JavaScript イベントループ完全理解|非同期処理の裏側を図解で解説
JavaScriptのイベントループを図解で完全解説。Call Stack・Task Queue・Microtaskの動作原理、setTimeout・Promise・async/awaitの実行順序を実例で学ぶ。
「JavaScript はシングルスレッドなのに、なぜ非同期処理ができるのか?」「setTimeout と Promise の実行順序はどう決まるのか?」これらの疑問に答えるのが イベントループ(Event Loop) です。この記事では、JavaScript の非同期処理の裏側を図解で徹底解説します。
JavaScript はシングルスレッド
JavaScript エンジン(V8、SpiderMonkey 等)は 1 つのメインスレッドで動作します。これは「同時に 1 つの処理しか実行できない」ことを意味します。
function loop() {
while (true) {} // 無限ループ
}
loop();
console.log('ここには到達しない');
上記のコードは、ブラウザがフリーズします。シングルスレッドだからです。
では、なぜ非同期処理ができるのか? 答えは「JavaScript エンジンだけでは非同期処理はできない」です。ブラウザまたは Node.js が提供する Web API / Node API と イベントループの協力で実現されています。
実行環境の構成要素
┌─────────────────────────────────────┐
│ JavaScript Engine │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Call Stack │ │ Heap │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────┘
↕ ↕
┌─────────────────────────────────────┐
│ Web API (provided by browser) │
│ setTimeout, fetch, DOM, etc. │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Task Queue │
│ (Callback Queue / Macrotask Queue) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Microtask Queue │
└─────────────────────────────────────┘
↑
[ Event Loop ]
Call Stack(コールスタック)
関数呼び出しは Call Stack に積まれ、実行が終わると取り除かれます(LIFO)。
function a() { b(); }
function b() { c(); }
function c() { console.log('hello'); }
a();
実行の流れ:
1. a() が push
Stack: [a]
2. b() が push
Stack: [a, b]
3. c() が push
Stack: [a, b, c]
4. console.log が push → 実行 → pop
Stack: [a, b, c]
5. c() が pop
Stack: [a, b]
6. b() が pop
Stack: [a]
7. a() が pop
Stack: []
Web API と非同期処理
setTimeout や fetch などの非同期関数は Web API に委譲されます。
console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');
実行の流れ:
1. console.log('1') → Call Stack で実行 → "1" 出力
2. setTimeout(() => ..., 0) → Web API に委譲
3. console.log('3') → Call Stack で実行 → "3" 出力
4. Call Stack が空に
5. setTimeout の 0ms 経過後、コールバックが Task Queue に
6. Event Loop が Task Queue のコールバックを Call Stack に移動
7. console.log('2') → "2" 出力
出力順序: 1 → 3 → 2
これが「setTimeout 0ms でも同期コードの後に実行される」理由です。
Task と Microtask の違い
キューは実は 2 種類あります。
Task Queue(Macrotask Queue)
setTimeoutsetIntervalsetImmediate(Node.js)- DOM イベント
- I/O 操作
Microtask Queue
Promise.then/.catch/.finallyqueueMicrotaskMutationObserverasync/await(Promise 扱い)
重要なルール: Call Stack が空になると、Microtask Queue を全て処理してから、Task Queue の次の1つを取り出します。
実行順序の例
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
実行の流れ:
1. "1" 出力
2. setTimeout → Web API → 0ms 後 Task Queue へ
3. Promise.then → Microtask Queue へ
4. "4" 出力
5. Call Stack 空 → Microtask 処理
→ "3" 出力
6. Microtask Queue 空 → Task Queue 処理
→ "2" 出力
出力順序: 1 → 4 → 3 → 2
Microtask は Task より優先度が高いことがわかります。
async/await の実行順序
async/await は Promise のシンタックスシュガーです。動作原理を理解すれば、出力順序も予測できます。
async function foo() {
console.log('foo start');
await bar();
console.log('foo end');
}
async function bar() {
console.log('bar start');
console.log('bar end');
}
console.log('script start');
foo();
console.log('script end');
実行の流れ:
1. "script start"
2. foo() 呼び出し
→ "foo start"
→ bar() 呼び出し
→ "bar start"
→ "bar end"
→ bar() の戻り値 Promise を await
→ foo() の残りは Microtask へ
3. "script end"
4. Call Stack 空 → Microtask 処理
→ "foo end"
出力順序: script start → foo start → bar start → bar end → script end → foo end
重要: await は「その行以降を Microtask に入れる」という動作です。
よくあるトラップ
setTimeout(fn, 0) は本当に 0ms ではない
HTML 仕様上、ネストした setTimeout の最小遅延は 4ms です。
setTimeout(() => {
setTimeout(() => {
setTimeout(() => {
console.log('実際は 12ms 以上経過');
}, 0);
}, 0);
}, 0);
0ms を期待したい場合は queueMicrotask を使いましょう。
queueMicrotask(() => {
console.log('次のイベントループで即実行');
});
Promise.resolve() は非同期
console.log('1');
Promise.resolve().then(() => console.log('2'));
console.log('3');
// 1 → 3 → 2
Promise.resolve() は同期的に解決されますが、.then のコールバックは常に非同期的(Microtask)に呼ばれます。
長時間タスクがブロッキング
// この間、画面は一切更新されない
for (let i = 0; i < 1000000000; i++) {
// 重い計算
}
対策: タスクを分割して Event Loop に制御を返す。
async function heavyTask(items) {
for (let i = 0; i < items.length; i++) {
process(items[i]);
if (i % 100 === 0) {
// 100件ごとに Event Loop に制御を返す
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
Web Worker で並列処理
重い処理は Web Worker でメインスレッドから切り離します。
// main.js
const worker = new Worker('./worker.js');
worker.postMessage({ cmd: 'start', data: largeArray });
worker.addEventListener('message', (e) => {
console.log('Result:', e.data);
});
// worker.js
self.addEventListener('message', (e) => {
if (e.data.cmd === 'start') {
const result = heavyComputation(e.data.data);
self.postMessage(result);
}
});
Web Worker は別スレッドで動作するため、メインスレッドをブロックしません。
requestAnimationFrame
アニメーション用の特別なタイミングで、ブラウザの描画前に実行されます。
function animate() {
element.style.transform = `translateX(${x}px)`;
x += 2;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
setTimeout(fn, 16) より正確で、バッテリー消費も少なくなります。
requestIdleCallback
アイドル時間に実行したい非緊急タスクに使います。
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
performTask(tasks.shift());
}
});
実行順序チェック: 応用編
async function main() {
console.log('1');
setTimeout(() => console.log('2'), 0);
const p = new Promise(resolve => {
console.log('3');
resolve();
});
p.then(() => console.log('4'));
await p;
console.log('5');
}
main();
console.log('6');
答え: 1 → 3 → 6 → 4 → 5 → 2
解説:
main()実行- “1” 出力
- setTimeout → Web API
- new Promise のコールバック即実行 → “3” 出力
- p.then → Microtask Queue
await p→ main() の残りを Microtask に- main() 一旦終了 → “6” 出力
- Microtask: p.then → “4” 出力
- Microtask: await 後 → “5” 出力
- Task: setTimeout → “2” 出力
まとめ
JavaScript のイベントループを理解すると、以下が明確になります。
- シングルスレッドでも非同期できる仕組み: Web API への委譲
- Microtask と Task の優先順位: Microtask が先
- async/await の裏側: Promise と Microtask の組み合わせ
- パフォーマンスボトルネックの特定: 長時間タスクの識別
実務では「なぜこの順序で実行されるのか?」を予測できるようになることが、デバッグ能力の向上につながります。