早速ですが、以下のasync/await
やPromise
を使った3つの非同期処理コードについて、それぞれのconsole.log
の実行順を全て当てることができますか?
問1
exec();
function exec() {
console.log(1);
new Promise((resolve) => {
console.log(2);
resolve();
});
console.log(3);
}
問2
exec();
async function exec() {
console.log(1);
await func1();
console.log(3);
}
function func1() {
return new Promise((resolve) => {
console.log(2);
resolve();
});
}
問3
exec();
async function exec() {
console.log(1);
func1();
console.log(3);
}
function func1() {
return new Promise((resolve) => {
console.log(2);
resolve();
});
}
解答と、この記事の目的
いかがでしたでしょうか。それでは、回答です。
↓
↓
↓
↓
↓
↓
↓
↓
↓
出力順 | |
---|---|
問1 | 1, 2, 3 |
問2 | 1, 2, 3 |
問3 | 1, 2, 3 |
驚いたかもしれません。この3つのコードの出力結果は、すべて同じ 「1, 2, 3」です。
(全問正解できた方、素晴らしいです。)
しかし、「なぜnew Promiseの中の処理が先に実行されるのか?」、あるいは「awaitの有無で何が違うのか?」といった点に、少し戸惑いはありませんでしたか?
このような直感的ではない挙動こそ、 JavaScriptの非同期処理の「クセ」 とも言える部分です。
この記事では、あなたが今まで何気なく使っていた非同期処理について、その仕組みから深く理解することを目指します。
まずはその第一歩として、「非同期」という言葉そのものの意味から確認していきましょう。
そもそも非同期(asynchronous)とは
まずは言葉の定義から見ていきましょう。IT用語辞典 e-Wordsでは、以下のように説明されています。
複数の主体がタイミングを合わせずに通信や処理を行う方式。対義語は「同期」(synchronized/synchronous)。
引用:https://e-words.jp/w/非同期.html
つまり、 「ある処理が終わるのを待たずに、次の処理に進める」 のが非同期処理の基本です。
Web開発、特にフロントエンドではこの非同期処理が欠かせません。
例えば、外部サーバーからデータを取得する間もユーザーが画面を操作できるようにしたり、時間のかかる処理でUIが固まってしまうのを防いだりするために使われます。
これらはすべて、快適なユーザー体験を実現するための重要なテクニックです。
しかし、冒頭のクイズで体験した通り、JavaScriptの非同期処理はこの定義だけでは説明がつかない挙動をします。
この特有の「クセ」を理解していないと、 重い処理を非同期にした「つもり」 が、実は メインの処理を止めてしまっていた… という事態になりかねません。
そうならないためにも、JavaScriptがどのように非同期を実現しているのか、その仕組みを詳しく見ていきましょう。
new Promiseはいつ実行されるのか?
では早速、JavaScript非同期処理の最初の「クセ」を、冒頭の「問1」を例に解き明かしていきましょう。
// 問1. 再掲
exec();
function exec() {
console.log(1);
new Promise((resolve) => {
console.log(2);
resolve();
});
console.log(3);
}
このコードの出力順を「1, 3, 2」だと考えた方も少なくないかもしれません。
それは、以下のようにnew Promise
で囲んだブロック全体が、後で実行される非同期処理だとイメージされているからでしょう。
一般的な非同期処理のイメージとしては、それに近いものがあります。
しかし、JavaScriptの場合は異なります。実際の処理の流れは、このようになります。
図が示す通り、 全ての処理がメインスレッドで上から順に実行 されています。
ここが、JavaScriptの非同期処理を理解する上で最も重要で、そして最も多くの人が躓くポイントです。
結論から言うと、new Promise
のコンストラクタに渡した関数(executorと呼ばれます)は、 Promiseが生成されるその瞬間に、即座に、”同期的”に実行 されます。
「非同期処理」のためのPromiseなのに、なぜ同期的に実行される部分があるのか。これがJavaScriptが持つ、一つ目の大きな「クセ」なのです。
では、.then()
などで繋いだ本当の非同期処理は、一体いつ、どのような仕組みで実行されるのでしょうか。
その謎を解く鍵が、次のセクションで解説する 「イベントループ」 です。
JavaScript非同期処理の仕組み「イベントループ」
前のセクションでは、「new Promiseのexecutorは同期的だが、.then()などで繋いだ処理は非同期になる」という事実を確認しました。
そして、「その非同期処理は一体いつ、どのような仕組みで実行されるのか?」という新たな疑問が生まれました。
その疑問に答えるのが、まさにこの 「イベントループ (Event Loop)」 という仕組みです。
まず大前提として、JAVAやC#などの言語とは異なり、 JavaScriptはシングルスレッド で動作します。
これは、 「一度に一つのことしかできない」 ということを意味します。
では、どうやって処理を後回しにするなどの非同期的な振る舞いを実現しているのでしょうか。
その答えが、以下の図で示されるような 「イベントループ」 と 「キュー」 を用いた仕組みです。
この図の流れを、より具体的に説明すると以下のようになります。
-
処理の依頼(メッセージ追加)
メインスレッドで実行中の同期的な処理がすべて終わった後、非同期的に実行したい処理(例: .then()のコールバックなど)は、すぐには実行されません。
代わりに、「メッセージ」として キュー(Queue) という待機場所に追加されます。
キューは「FIFO (First-In, First-Out)」の原則に従い、先に追加されたメッセージから順番に並びます。 -
監視と取り出し(イベントループ)
イベントループは、このキューに新しいメッセージが追加されるのを常に監視しています。
そして、メインスレッドが現在実行中のタスクを終え、手が空くのを待っています。 -
実行(メッセージ返却)
メインスレッドの手が空くと、イベントループはキューの先頭からメッセージを一つ取り出し、メインスレッドに渡します。
メッセージを受け取ったメインスレッドが、そのメッセージに対応する処理(コールバック関数など)を実際に実行します。
この一連の流れを、キューにメッセージがなくなるまで繰り返します。
つまり、JavaScriptの非同期処理とは、別のスレッドが裏で並行して動いているわけではなく、 「実行すべき処理を一旦キューに並べておき、後でメインスレッドが順番に実行する」 ことで、実行タイミングをずらしているのです。
イベントループはあくまで処理の順番を管理するだけで、実際にコードを実行するのは常に単一のメインスレッドです。これが、JavaScriptはシングルスレッド であると言われる本質的な理由です。
補足:シングルスレッドとマルチスレッドの違い
前のセクションで「JavaScriptはシングルスレッドである」と説明しましたが、これが「マルチスレッド」とは具体的にどう違うのか、図で視覚的に比較してみましょう。
シングルスレッド(JavaScriptのモデル)
図の左側、シングルスレッドのモデルでは、処理を実行するためのレーンが一つしかありません。
すべてのタスクは、この一本道の上を順番に、一つずつ実行されていきます。
まさに、イベントループとキューで順番待ちをしながら処理を行うJavaScriptの姿がこれにあたります。
マルチスレッド
一方、右側のマルチスレッドのモデルでは、処理のレーンが複数存在します。
重い処理や時間のかかる処理を別のレーン(サブスレッド)に任せ、メインの処理と物理的に同時に実行することが可能です。
この違いにより、一般的にはマルチスレッドの方が、単位時間あたりにより多くのタスクを処理できるという利点があります。
これが、シングルスレッドとマルチスレッドの根本的な違いです。
補足:紛らわしい言葉「並列」と「並行」
シングルスレッドとマルチスレッドの話と関連して、よく混同されがちな「並列(Parallelism)」と「並行(Concurrency)」という言葉の違いについても整理しておきましょう。
この二つは似ていますが、異なる概念です。
並列 (Parallelism)
複数のCPUコアなどを使い、複数の処理を物理的に同時に実行すること。
並行 (Concurrency)
一つのCPUコアが処理を高速に切り替え(コンテキストスイッチ)ながら、あたかも同時に実行されているように見せること。
これを前のセクションの話と結びつけると、マルチスレッドは複数のCPUコアがあれば「並列処理」を実現できます。
一方、JavaScriptのシングルスレッドモデルは、イベントループによって一つのスレッド上で複数のタスクを切り替えながら進めるため、「並行処理」にあたります。
日常のタスク管理で例えると…
この違いを、私たちの日々のタスク管理に例えてみましょう。ここに2つの開発タスク(タスクA, タスクB)があるとします。
「並列」とは
あなたがタスクAを担当し、同僚にタスクBを任せ、 2人で物理的に同時に作業を進める ことです。
アウトプットが2倍の速度 で生み出されます。
「並行」とは
あなたが一人で、集中力が続く限りタスクAを進め、少し気分転換にタスクBに切り替え、またタスクAに戻る…といったように、 二つのタスクを少しずつ切り替えながら進める ことです。
一人しかいませんが、 両方のタスク が少しずつ進行していきます。
「同期」とは
あなたが タスクAを完全に終わらせてから、初めてタスクBに取り掛かること です。
一つのタスクが終わるまで、もう一方は全く進行しません。
このように整理すると、 JavaScriptの非同期処理が「並行」モデル であることが、より明確にイメージできるのではないでしょうか。
Promiseの同期部分と非同期部分の境界線
さて、イベントループの仕組みを理解したところで、最初の疑問に戻りましょう。
「なぜ new Promise に渡した関数は、キューに入らずに即座に実行されるのか?」
改めて結論を整理すると、以下のようになります。
- 同期的に実行される部分
new Promise(executor)
のexecutor
関数 - 非同期に実行される部分(キューに追加される)
.then()
.catch()
.finally()
に渡すコールバック関数
これが、Promiseにおける同期と非同期の明確な境界線です。
なぜこのような設計になっているのでしょうか?
非同期処理のための機能なのに、同期的に実行される部分があるのは不思議に思うかもしれません。
この背景には、主に以下のような目的があると考えられています。
1. 非同期処理を「即時」に開始するため
Promiseは、非同期処理の状態を管理するためのオブジェクトです。
例えば、サーバーからデータを取得するfetch
APIは、呼び出された瞬間にPromiseを返し、 同時に通信処理を開始 します。
もしnew Promise
のexecutor
の実行自体が非同期(後で実行)だと、一体いつ通信が開始されるのか分からず、制御が困難になってしまいます。
Promiseが作られた瞬間に非同期処理を開始できるよう、executorは同期的に実行される必要があるのです。
2. 同期的なエラーをハンドリングするため
もう一つの重要な理由は、エラーハンドリングです。
もしexecutor
内で、非同期処理とは関係のないプログラミングミス(例:未定義の変数を参照する)が起きた場合を考えてみてください。executor
が同期的に実行されることで、Promiseはそのエラーを即座にキャッチし、Promise自身を「rejected(失敗)」状態にすることができます。
これにより、私たちは.catch()で後からエラーを処理できます。
もしexecutorが非同期だと、この種のエラーは誰にも捕捉されずに消えてしまう可能性があります。
このように、Promiseの設計は、非同期処理を安全かつ予測可能に開始するために、あえて同期的な部分を含んでいます。
この「同期と非同期の境界線」を意識することが、Promiseを正しく使いこなすための鍵となります。
実践:重い処理を「本当に」非同期にする方法
さて、ここまでの解説で「new Promiseのexecutor
は同期的に実行される」と学んできました。
これが実践においてどのような問題を引き起こすのか、そしてどう解決すればよいのかを具体的なコードで見ていきましょう。
NG例:UIをブロックしてしまうコード
まずは、意図せずUIをブロックしてしまう可能性のあるコードです。
exec();
function exec() {
console.log(1);
new Promise((resolve) => {
console.log(2);
// 重い処理
for (var i = 0; i 100000; i++) {
// something.
}
console.log(3);
resolve();
})
.then(() => {
console.log(4);
});
console.log(5);
return 'completed';
}
このコードでは、Promiseのexecutor内で重いループ処理を実行しています。executor
は同期的に実行されるため、このループ処理とconsole.log(3)が完了するまで、後続のconsole.log(5)やreturn ‘completed’は実行されません。
もしブラウザ上での操作であれば、画面が固まってしまう(UIブロッキング)原因となります。
OK例:setTimeoutで処理をキューに送る
では、この重い処理をメインスレッドから切り離し、後で実行させるにはどうすればよいでしょうか。
ここで登場するのがsetTimeout
です。
イベントループのセクションで説明した通り、setTimeout
に渡したコールバック関数は「キュー」に追加され、メインスレッドの同期処理がすべて完了した後に実行されます。
この仕組みを利用して、コードを以下のように修正します。
exec();
function exec() {
console.log(1);
new Promise((resolve) => {
console.log(2);
setTimeout(() => {
// 重い処理
for (var i = 0; i 100000; i++) {
// something.
}
console.log(3);
resolve();
});
})
.then(() => {
console.log(4);
});
console.log(5);
return 'completed';
}
実行結果の比較
このOK例のコードをブラウザのコンソールなどで実行すると、以下のような結果になります。
結果を見ると、まず同期処理である1
, 2
, 5
が出力され、その後に関数の返り値である’completed’が表示されています。
そして、その後にsetTimeoutで遅延させた3
と、.then()
の4
が出力されていることがわかります。
これは、重い処理を含むコールバックがsetTimeout
によってキューに送られ、即座には実行されなかったことを明確に示しています。
これによりメインスレッドはブロックされず、関数は先に'completed'
を返すことができました。
このように、Promiseのexecutor
内でさらにsetTimeout
を使うことで、 重い処理を「本当に」非同期化し、実行タイミングを遅らせることが可能 になるのです。
外部API呼び出しと「並列処理」
ここまで、setTimeout
などJavaScriptエンジン内部の非同期処理、すなわち「並行処理」について見てきました。
では、Web開発で最も一般的な非同期処理である、 外部APIの呼び出し はどのように扱われるのでしょうか。
結論から言うと、この場合は 「並列処理」 と考えることができます。
これまでの解説の通り、JavaScriptエンジン自体はシングルスレッドで動作するため、それ単体では「並行処理」しかできません。
しかし、API呼び出しの文脈では、 JavaScriptが動作するブラウザ(クライアント) と、APIを提供するサーバーは、それぞれが独立して動作する別のコンピュータです。
そのため、この両者の関係においては、以下のような「並列処理」が実現されています。
- ブラウザが
fetch
関数などを使って、サーバーにAPIリクエストを送信します。 - リクエストを受け取ったサーバーは、データベースへの問い合わせなどの重い処理を実行します。
- その間、ブラウザ側のJavaScriptはサーバーの応答を待つことなく、UIの操作を受け付けたり、アニメーションを動かしたりと、全く別の処理を並行して進めることができます。
- サーバーでの処理が完了し、応答が返ってきたタイミングで、イベントループの仕組みを通じて
.then()
やawait
以降のコールバック処理が実行されます。
このように、ブラウザとサーバーがそれぞれ独立して処理を進めるため、システム全体として見れば並列に動作していると言えます。
この「ブラウザとサーバーの役割分担」という考え方は非常に重要です。
アプリケーションの応答性を保つために、意図的に重い処理をサーバーサイドに委譲(オフロード)する、という設計は広く行われています。
async/await: Promiseをより直感的に
ここまでPromiseの仕組みと、その背景にあるイベントループについて見てきました。
現代のJavaScript開発では、これらの非同期処理をより直感的に記述できる async/await 構文が広く使われています。
async/await
は、Promise
を置き換える全く新しい仕組みではなく、内部的にはPromise
を操作しています。
そのため、 「シンタックスシュガー(糖衣構文)」 と呼ばれます。
これは、複雑な処理をよりシンプルで読みやすい見た目(構文)で書けるようにしたもの、という意味です。
では、実際に.then()
チェーンを使ったコードをasync/await
で書き換えて、そのメリットと挙動を確認しましょう。
Promiseを使った場合
exec();
function exec() {
console.log(1);
something().then((res) => {
console.log(res);
console.log(3);
});
console.log(4);
}
function something() {
return new Promise((resolve) => {
console.log(2);
resolve('test');
})
}
async/awaitを使った場合
exec();
console.log(4); // exec()がasyncなので4の出力は外に出す
async function exec() { // asyncを付与
console.log(1);
const res = await something(); // awaitで待つ
console.log(res);
console.log(3);
}
async function something() { // asyncを付与
console.log(2);
return 'test'; // resolve('test')と書かずにそのままreturnする
}
(注: async関数は必ずPromiseを返すため、console.log(4)はトップレベルで実行しています)
実行結果と解説
この2つのコードを実行すると、出力結果はどちらも以下のようになります。
async/await
を使うことで、ネストが解消され、まるで同期処理のように上から下に処理が流れるように記述できるため、可読性が劇的に向上します。
しかし、なぜ4
が先に表示されるのでしょうか? ここでイベントループの知識が役立ちます。
Promise版の動き
-
exec()
が呼ばれ、1
が出力されます。 -
something()
が呼ばれ、2
が出力され、Promiseが返されます。
3,.then()
に渡されたコールバック関数は、すぐには実行されず 「キュー」に追加されます。 - メインの処理は止まらないため、
4
が先に出力されます。 - 同期処理が終わり、イベントループがキューから
.then()
のコールバックを取り出して実行し、test
と3
が出力されます。
async/await版の動き
-
exec()
が呼ばれ、1
が出力されます。 -
await something()
に到達し、まずsomething()
が呼ばれ2
が出力されます。 -
await
キーワードは、something()
が返すPromiseの解決を 待つ間、exec
関数の実行を一時中断 します。そして、 残りの処理(const res = ...以降)
を「キュー」に登録 します。 -
exec
関数の実行が中断されたため、処理はexec
の呼び出し元に戻り、次の行の4
が出力されます。 - 同期処理が終わり、イベントループがキューから
exec
の残りの処理を取り出して実行し、test
と3
が出力されます。
このように、async/await
はPromise
の挙動を隠蔽して書きやすくしてくれますが、内部では同じイベントループの仕組みが動いています。
どの部分が同期的で、どこからが非同期(キューに入る)処理になるのかを意識することが、意図しないバグを防ぐ鍵となります。
async/awaitで重い処理を非同期にする
以前、「実践:重い処理を『本当に』非同期にする方法」のセクションで、setTimeout
を使ったテクニックを紹介しました。
あの処理を、async/await
を使ってよりクリーンに呼び出すにはどうすればよいでしょうか。
ここで一つ課題があります。await
キーワードは、Promiseが返されるのを待つための構文ですが、setTimeout
関数自体はPromiseを返しません。そのため、setTimeout
を直接await
することはできません。
そこで、 setTimeout
を含む処理をnew Promise
でラップ(包み込み)し、await可能な関数
を作成します。
exec();
console.log(4);
async function exec() {
console.log(1);
const res = await something(); // Promiseを返すのでawaitできる
console.log(res);
console.log(3);
}
// setTimeoutをPromiseでラップすることでawaitできるようになる
function something() {
return new Promise(resolve => {
setTimeout(() => {
// ここで重い処理を実行
console.log(2);
resolve('test'); // 処理完了後にresolveを呼ぶ
}, 0);
});
}
コードのポイント
something関数
この関数の役割は、コールバック形式のsetTimeout
を、Promiseを返す形式に変換することです。setTimeout
のコールバック内で処理が完了したタイミングでresolve()
を呼び出すことで、Promiseの状態を「解決済み(resolved)」にし、結果を待っているawait
に値を渡します。
exec関数
こちらはasync
関数なので、something
関数が返すPromiseをawait
でシンプルに待つことができます。
呼び出し側のコードからは、something
関数の内部でsetTimeout
が使われていることを意識する必要がなくなり、可読性が大きく向上します。
このように、 setTimeoutのような古いコールバック形式の非同期APIをawaitで扱いたい場合、new Promiseでラップする(Promise化する) のは、非常に一般的で強力なテクニックです。
「すべてを async/await の構文だけで書きたい」と思うかもしれませんが、 Promise を返さない処理を await 可能にするための「変換アダプター」として new Promise が活躍する、と覚えておくと良いでしょう。
このパターンは、MDNの公式ドキュメントでも紹介されている標準的な手法です。
Tips: 即時実行される非同期アロー関数
小さな非同期処理のために、都度名前付きの関数を定義するのは少し冗長に感じることがあります。
そのような場面では、 即時実行される非同期アロー関数(async IIFE – Immediately Invoked Function Expression) というテクニックが役立ちます。
exec();
console.log(4);
async function exec() {
console.log(1);
const res = await (async () => {
// この部分は即座に実行されるasyncな無名関数
return 'test';
})(); // ← ()で即時実行!
// さらに短く書くこともできます
// const res = await (async () => 'test')();
console.log(res);
console.log(3);
}
【この構文の解説】
これは、async () => { ... }
という非同期アロー関数を定義し、それを()で囲み、直後にもう一つ()
を付けてその場で実行している構文です。
async
関数は必ずPromise
を返すため、この即時実行関数全体が一つのPromiseとなり、await
でその結果を待つことができます。
これにより、他の部分に影響を与えない独立したスコープで、 その場限りの非同期処理 を簡潔に記述することが可能になります。
まとめ
最後に、この記事で解説してきたJavaScript非同期処理の重要なポイントを振り返りましょう。
- JavaScriptはシングルスレッド であり、 イベントループ と キュー の仕組みを用いて非同期(並行処理)を実現しています。
-
new Promise
のexecutor
関数は、Promiseが生成される瞬間に”同期的”に実行 されます。 -
.then()
のコールバックやawait
以降の処理が、 キューに追加される”非同期”処理 となります。これが同期と非同期の明確な境界線です。 -
async/await
はPromise
のシンタックスシュガー であり、非同期処理の可読性を劇的に向上させますが、内部では同じイベントループの仕組みが動いています。 - UIブロッキングを避けるには、
setTimeout
を使い、重い処理をキューに送るのが有効なテクニック です。
これらの「クセ」とも言える挙動の裏側にある仕組みを理解することで、非同期処理のコードを自信を持って読み書きできるようになったはずです。
ぜひもう一度、冒頭の理解度確認テストに挑戦し、自身の理解が深まったことを確かめてみてください。
きっと全問正解できるはずです。
Appendix (参考資料)
Views: 0