シングルスレッドだけど、順番通りとは限らない
JavaScript はシングルスレッドで動きます。コールスタックは1本だけで、ある瞬間に実行されている関数はたった1つ。同じ realm の中で、あなたのコードが2行同時に走ることは絶対にありません。
こう聞くと制約だらけに感じるかもしれませんが、JavaScript が実際にやっていることを思い出してみてください。データの取得、ユーザーのクリック待ち、ファイルの読み込み——「仕事」のほとんどは「待つこと」なんです。ここで登場するのがイベントループという仕組みで、待ち時間のコストをほぼゼロにしてくれます。あなたのコードはブラウザや Node にタスクを渡したら、別のことを進めていて、結果が準備できた頃に通知してもらう、というわけです。
1番目 と 2番目 は順番どおりに出力され、3番目 はその後に続きます。タイムアウトを 0 にしているのにです。この時間差こそがイベントループの仕業で、その仕組みを理解することがこのページの目的です。
コールスタックとは
関数を呼び出すたびに、コールスタックにフレームが積まれます。関数が return するとそのフレームは取り除かれます。スタックはあくまでスタック、つまり後入れ先出し(LIFO)です。
outer() を呼び出すと、Node はまず outer をスタックに積み、続いて inner を積みます。inner が "done" を返した時点でポップされ、次に outer もポップされて、スタックは再び空になります。この「スタックが空になった瞬間」こそが、イベントループが待ち構えているタイミングです。
同期コードはスタック上で最初から最後まで一気に走り切ります。途中で非同期処理が割り込む余地はありません。もし while (true) のような無限ループを書けば、スタックは永遠に空にならず、ページはフリーズします。クリックもタイマーも Promise のコールバックも、一切反応しなくなります。イベントループは出番が回ってこないので、何もできないというわけです。
非同期処理が実際に動いている場所
実を言うと、JavaScript 自体はネットワークリクエストを投げる方法も、100 ミリ秒待つ方法も知りません。これらの API はホスト側、つまりブラウザや Node が提供しているものです。setTimeout(fn, 100) を呼び出したとき、裏側では次のようなことが起きています。
- タイマーがホストに登録される。
setTimeoutはすぐに return する。スタック上のコードはそのまま走り続ける。- 100ms 経過すると、ホストが
fnをキューに積む。 - コールスタックが空になったタイミングで、イベントループがキューから
fnを取り出して実行する。
タイマーのコールバックは、for ループと console.log("終了") が終わるまで動けません。スタックがまだ空になっていないからです。タイマーの遅延時間はあくまで「最低これだけ待つ」という意味で、きっちりその時間で実行される保証ではありません。
2つのキュー:タスクとマイクロタスク
キューは1種類ではなく、2種類あります。この違いを押さえておくと、イベントループで「あれ?」と思う挙動の大半は説明がつきます。
- タスクキュー(マクロタスクキューとも呼ばれます):
setTimeout、setInterval、I/O コールバック、UI イベントなど。 - マイクロタスクキュー: Promise のコールバック(
.then、.catch、.finally)、awaitの継続処理、そしてqueueMicrotaskでスケジュールしたものすべて。
イベントループが従うルールはこうです。
- タスクキューからタスクを1つ取り出して実行する。
- マイクロタスクキューを_全部_空にする。処理中に新しく積まれたマイクロタスクも含めて、残らず実行する。
- 必要ならレンダリングする(ブラウザの場合)。
- 1 に戻る。
マイクロタスクは、次のタスクより必ず先に実行されます。だから、次のコードの結果に驚く人が多いのです。
出力順は 同期 1、同期 2、promise、timeout です。まず同期コードが実行され、コールスタックが空になります。続いてイベントループがマイクロタスクをすべて処理し (promise)、その後にようやくタイマータスク (timeout) が取り出されます。
マイクロタスクがタスクを飢餓状態にする
マイクロタスクキューは次のタスクに進む前に完全に空になるまで処理されます。そのため、マイクロタスクの中でさらにマイクロタスクを登録し続けると、タスクキューは永遠にブロックされてしまいます。
タイマーは永遠に発火しません。マイクロタスクが次のマイクロタスクを積み続け、キューが空になる瞬間が来ないからです。Promise チェーンが安全なのは、.then ごとに継続が 1 つしか積まれないため。ただし、自作のマイクロタスクループは地雷になりがちなので覚えておいて損はありません。
await はマイクロタスクのシンタックスシュガー
Promise を await すると、関数はそこで一時停止し、残りの処理は Promise が確定したタイミングで走るマイクロタスクとしてスケジューリングされます。特別な仕組みは何もなく、裏側は .then そのものです。
出力は A、C、B の順です。await で呼び出し元に制御が戻るので、まず現在のスタック上で console.log("C") が実行されます。その後マイクロタスクキューが消化され、demo の続きが再開して B が出力される、という流れです。
非同期コードを読むときはここを意識しておきましょう。await はブロックしているのではなく、制御を譲っている(yield している)だけです。
実行順序を総まとめ:全部入りサンプル
ここまでの要素を全部盛りにしてみます。
実行順序はこうなります。
1: sync— スタック上で実行。6: sync— こちらもまだスタック上。- スタックが空になり、マイクロタスクキューが消化される:
3: promise、5: microtask、そして4: nested microtask(消化中に追加されたものも、そのまま拾われる)。 - 次のタスクへ:
2: timeout。
最終的な出力は 1, 6, 3, 5, 4, 2。これを追えたなら、イベントループの仕組みはもう自分のものです。
Node のイベントループにはもっとフェーズがある
Node のイベントループは、ブラウザのモデルを拡張したものです。timers、pending I/O callbacks、poll、check、close といった フェーズ に分かれていて、マイクロタスクは各フェーズの合間に毎回消化されます。setImmediate は check フェーズで動き、process.nextTick は通常のマイクロタスクよりも 先 に実行されます(さらに優先度の高い専用キューを持っているため)。
フェーズの一覧を初日から丸暗記する必要はありません。押さえておきたいポイントはブラウザと同じで、「同期コードが最後まで走り切る → マイクロタスクが消化される → ループが次のキュー済みコールバックを拾う」という流れです。
なぜこれが重要なのか
このモデルが腑に落ちると、謎だった非同期コードの挙動が一気にスッキリします。
- 長い
forループで UI が固まるのは、イベントループに順番が回ってこないから。 setTimeout(fn, 0)は、現在のタスクとマイクロタスクが終わった後まで処理を遅らせるためのテクニック。- すでに解決済みの Promise に対する
.thenコールバックは「すぐ実行される」ように見えても、現在実行中の同期コードが終わるまでちゃんと待つ。 - ループの中で
awaitを使うと処理が直列化されるのは、イテレーションごとにマイクロタスクキューに一度処理を譲ってから次へ進むから。
非同期コードのデバッグは、結局のところ「今スタックに何が乗っていて、何がキューに並んでいるのか?」を問うことに尽きます。そしてその答えを教えてくれるのがイベントループです。
次は: コールバック
Promise や async/await が登場する前、JavaScript の非同期処理で使える唯一の道具がコールバック、つまり「あとで呼んでね」と API に渡す関数でした。コールバックは今でも至るところで使われていますし(イベントリスナー、Node のコア API など)、その理解はこの章のこれから先すべての土台になります。
よくある質問
JavaScriptのイベントループとは何ですか?
シングルスレッドのJavaScriptが、処理を止めずに非同期タスクをこなすための仕組みです。イベントループはコールスタックを監視していて、スタックが空になったタイミングでキューから次のコールバックを取り出して実行します。タイマー、I/O、Promiseの続きの処理はすべてキューに並び、イベントループが1つずつ拾っていく、というイメージです。
なぜJavaScriptはシングルスレッドなのですか?
言語仕様として、1つのレルムにつきコールスタックは1本と決まっているので、コードは常に単一スレッドで動きます。並行処理に見えるのは、ブラウザやNodeといったホスト側がタイマー・ネットワーク・ファイルI/Oといった作業をバックグラウンドAPIに任せ、終わったタイミングでコールバックをキューに積んでくれるからです。同じコンテキスト内で2つのJSコードが同時に走ることはありません。
マイクロタスクとマクロタスクの違いは?
マイクロタスクはPromise(.thenやawait)やqueueMicrotaskから生まれます。マクロタスクはsetTimeout、setInterval、I/O、UIイベントなどですね。ポイントは、1つのマクロタスクが終わるたびにイベントループはマイクロタスクキューを空になるまで全部流し切ってから、次のマクロタスクに移ること。だから同じタイミングで積んだPromise.resolve().then(...)とsetTimeout(..., 0)なら、必ずPromiseのほうが先に動きます。
setTimeoutに0msを指定してもすぐに実行されないのはなぜ?
setTimeout(fn, 0)は「いますぐ実行して」ではなく、「0ms経過後、最速でマクロタスクとしてキューに積む」という意味です。まず現在同期的に走っているコードが終わり、マイクロタスクキューがすべて流れ切って、ようやくイベントループがタイマーのコールバックを拾います。つまり0は最短待ち時間であって、即時実行の保証ではないんです。