はじめに
JavaScriptで非同期処理を書く時になんとなくでasync/awaitやPromiseを使ったりしてしまっていてなぜそのように動くのかを理解していないなと思ったので挙動についてまとめてみました。
イベントループとは
JavaScriptはシングルスレッドのため、同時に1つのコードしか実行できません。
もしタイマーやファイル読み込みを同期的に実行してしまうと、メインスレッドがブロックされてブラウザがフリーズしてしまいます。それを解消するのがイベントループです。
イベントループとは、メインスレッドと並行して非同期タスクを管理する仕組みのことです。JavaScriptエンジン(V8など)とブラウザ・Node.jsのランタイム(Web API や libuv)が連携して、シングルスレッドでも複数のタスクを「並行処理」しているように見せています。
イベントループを構成する4つの要素
イベントループは以下の4つの要素から構成されます。
1. コールスタック
コールスタックとは、実行中の関数を管理する置き場です。関数を呼び出すと「スタック」に追加され、関数が終わると削除されます。このスタックが空になるまで、非同期タスク(Promise や setTimeout)は実行されません。
function a() {
b();
}
function b() {
console.log('b'); // ← ここで実行
}
a();
// スタックの変化:
// 1. a() 呼び出し → スタック: [a]
// 2. b() 呼び出し → スタック: [a, b]
// 3. console.log() → スタック: [a, b, console.log]
// 4. console.log() 終了 → スタック: [a, b]
// 5. b() 終了 → スタック: [a]
// 6. a() 終了 → スタック: [] ← 空になった!
// ここでイベントループが非同期タスクを処理
2. Web API(ブラウザ)/ libuv(Node.js)
Web API / libuv とは、ブラウザやNode.jsが提供する機能で、時間のかかる処理(タイマーやファイル読み込み)をJavaScriptエンジンの外で並行処理できるようにします。
console.log('Start'); // ← JavaScriptエンジン が実行
setTimeout(() => { // ← Web API に任せる
console.log('Timeout'); // ← 1秒後に JavaScriptエンジン で実行
}, 1000);
console.log('End'); // ← JavaScriptエンジン が実行
// 処理の流れ:
// 1. JavaScriptエンジン: console.log('Start') を実行
// 2. JavaScriptエンジン: setTimeout() を見つけて Web API に「1秒後にコールバックを実行キューに追加して」と依頼
// 3. Web API: タイマー開始(並行で動作)
// 4. JavaScriptエンジン: console.log('End') を実行 ← タイマーが 0.5 秒でも 10 秒でも関係なく実行される
// 5. JavaScriptエンジン: コールスタック空になった
// 6. (1秒経過)
// 7. Web API: コールバックをタスクキューに追加
// 8. イベントループ: タスクキューからコールバックを取り出して実行
// 9. JavaScriptエンジン: console.log('Timeout') を実行
3. タスクキュー(マクロタスクキュー)
タスクキューとは、 Web API の仕事が完了したコールバック関数が待つ列です。コールスタックが空になったときだけ、このキューから1件ずつ取り出して実行します。タスクキューは「列」なので、FIFO(先入先出)順に処理されます。
console.log('1'); // ← JavaScriptエンジン が実行
setTimeout(() => { // ← Web API に任せる
console.log('2'); // ← コールスタックが空になったら実行されるコールバック
}, 0);
console.log('3'); // ← JavaScriptエンジン が実行
// 出力:
// 1
// 3
// 2 ← setTimeout(0) でも同期処理が全て終わるまで実行されない
タスクキューに入るもの:
-
setTimeout/setIntervalのコールバック - I/O 完了コールバック(
fs.readFileなど) - UI イベント(クリック、スクロール等)
-
setImmediate(Node.js のみ)
4. マイクロタスクキュー
マイクロタスクキューとは、 タスクキューより優先度が高い、特別なタスク列です。タスクキューから1件実行した直後、マイクロタスクキューが完全に空になるまで、他のタスクには進みません。
console.log('1'); // ← JavaScriptエンジン が実行
setTimeout(() => {
console.log('3'); // タスクキュー
}, 0);
Promise.resolve().then(() => {
console.log('2'); // マイクロタスクキュー
});
// 出力:
// 1
// 2 ← Promise は setTimeout より先に実行される
// 3
マイクロタスクキューに入るもの:
-
Promise.then()/Promise.catch()/Promise.finally() queueMicrotask()-
MutationObserver(ブラウザ) -
process.nextTick()(Node.js のみ、最高優先度)
イベントループの実行順序(ブラウザ)
上記4要素の流れをまとめると以下のようになります。
基本的な例
console.log('1. 同期処理');
setTimeout(() => {
console.log('5. タスクキュー(setTimeout)');
}, 0);
Promise.resolve().then(() => {
console.log('4. マイクロタスク(Promise)');
});
console.log('2. 同期処理');
// 出力順序:
// 1. 同期処理
// 2. 同期処理
// ↓ ここまでがコールスタックの処理
// 4. マイクロタスク(Promise)
// ↓ マイクロタスクキューが空になった
// 5. タスクキュー(setTimeout)
// ↓ 全て完了
複数のタスクがある場合
console.log('1. 同期処理');
setTimeout(() => {
console.log('6. タスクキュー(setTimeout 1)');
}, 0);
Promise.resolve()
.then(() => {
console.log('4. マイクロタスク(Promise 1)');
})
.then(() => {
console.log('5. マイクロタスク(Promise 2)');
});
setTimeout(() => {
console.log('7. タスクキュー(setTimeout 2)');
}, 0);
console.log('2. 同期処理');
// 出力順序:
// 1. 同期処理
// 2. 同期処理
// ↓ コールスタックが空
// 4. マイクロタスク(Promise 1)
// 5. マイクロタスク(Promise 2)
// ↓ マイクロタスクキューが全て空になった!
// 6. タスクキュー(setTimeout 1)
// 7. タスクキュー(setTimeout 2)
// ↓ 全て完了
実行フロー:
┌─────────────────────────────────────────────────┐
│ スクリプト実行開始 │
└────────────────┬────────────────────────────────┘
│
全ての同期処理実行
(console.log('1')、console.log('2'))
│
▼
┌─────────────────────────────────────────────────┐
│ [LOOP START] イベントループ開始 │
└────────────────┬────────────────────────────────┘
│
1. コールスタック確認 → 空
│
▼
2. マイクロタスク全件実行
└→ Promise.then() 実行(全て空になるまで)
└→ console.log('4')、console.log('5')
└→ マイクロタスクキュー空
│
▼
3. タスクキューから1件取り出す
└→ setTimeout コールバック実行
└→ console.log('6')
└→ マイクロタスクをチェック(なし)
│
▼
4. 次のタスクを取り出す
└→ setTimeout コールバック実行
└→ console.log('7')
└→ マイクロタスクをチェック(なし)
│
▼
5. 画面再描画(必要に応じて)
│
▼
6. 「タスクキューに残りあり?」
NO → LOOP END
まとめ
- イベントループとはシングルスレッドで動くJavaScriptを、複数のタスクを「並行処理」しているように見せる仕組み
- コールスタック、Web API、タスクキュー、マイクロタスクキューの4つが連携して動く
- マイクロタスクはタスクより優先度が高く、先に実行される
- コールスタックが空になるまで、非同期タスクは一切実行されない