1. なぜイベントループが必要か
JavaScriptはシングルスレッド言語であり、同時に1つのタスクしか実行できません。この制約により、ブロッキング操作(例:重い計算や同期的I/O)が発生すると、UIの応答性が失われ、アプリケーション全体が一時停止します。イベントループは、この問題を解決するための非同期処理の基盤機構です。它は、コードの逐次的な実行順序を保ちながら、非同期処理(ネットワーク要求、タイマー、ユーザー入力など)を効率的に統合します。
2. 基本構造とフロー
イベントループは、以下の3つの主要なコンポーネントで構成されます:
- コールスタック:現在実行中の関数を追跡するLIFO構造。
- タスクキュー(マクロタスクキュー):
setTimeout、setInterval、UIレンダリング、I/O完了通知などが入るFIFOキュー。 - マイクロタスクキュー:
Promise.then/catch/finally、MutationObserver、queueMicrotask()などのコールバックが格納されるFIFOキュー。
実行フローは次の通りです:
- コールスタックが空になるまで同期コードを実行。
- マイクロタスクキューが空になるまで、すべてのマイクロタスクを連続して実行(スタック再帰なし)。
- 必要に応じてブラウザはUIレンダリングを実行(ただし、必ずしも毎回行われるとは限らない)。
- マクロタスクキューから1つ目のタスクを取り出し、コールスタックにプッシュして実行。
- 上記のステップを繰り返す。
3. マクロタスク vs マイクロタスク:優先度の違い
重要な区別は「実行タイミング」にあります:
- マクロタスクはイベントループの各反復(iteration)で1つだけ実行されます。
- マイクロタスクは、マクロタスクの終了直後、かつ次のマクロタスク開始前という「狭間」で、キューが空になるまで貪欲に実行されます。
これは、Promiseがコールバックの信頼性と一貫性を提供する根拠でもあります。
4. 実装例による検証
例1:基本的な優先順位確認
console.log('① Start');
setTimeout(() => console.log('④ setTimeout'), 0);
Promise.resolve()
.then(() => console.log('③ Promise resolved'))
.then(() => console.log('⑤ Chained promise'));
console.log('② End');
出力順序:
① Start → ② End → ③ Promise resolved → ⑤ Chained promise → ④ setTimeout
理由:同期コード → マイクロタスク(2回連続)→ 次のマクロタスク。
例2:Promiseコンストラクタの即時実行性
console.log('① Start');
new Promise(resolve => {
console.log('② Constructor sync');
resolve();
}).then(() => console.log('④ Then handler'));
console.log('③ End');
出力順序:
① Start → ② Constructor sync → ③ End → ④ Then handler
ポイント:Promiseコンストラクタの引数関数はマクロタスク内での即時実行であり、「then」はその直後のマイクロタスクに登録される。
例3:async/awaitとsetTimeoutの相互作用
console.log('① Start');
async function runAsync() {
console.log('② Inside async');
await new Promise(resolve => {
console.log('③ Promise created');
setTimeout(resolve, 0); // ← マクロタスク登録
});
console.log('⑥ After await');
}
runAsync();
console.log('④ End');
出力順序:
① Start → ② Inside async → ③ Promise created → ④ End → ⑥ After await
解説:
・awaitは非同期関数を一時中断し、制御を呼び出し元へ戻す。
・setTimeoutのコールバックはマクロタスクキューへ。
・その後、マイクロタスク(awaitの続き)が実行されるのは、マクロタスク(setTimeoutのresolve)完了後、かつその直後のマイクロタスクフェーズで発火。
5. パフォーマンス最適化戦略
- UIフリーズの回避:長時間実行される処理(例:配列の大量フィルタリング)を
queueMicrotaskやsetTimeout(..., 0)で分割し、レンダリングチャンスを確保。 - マイクロタスクの過剰利用を避ける:無限ループ的なマイクロタスク生成(例:
queueMicrotask(() => queueMicrotask(...)))はイベントループを「飢餓状態」に陥れ、マクロタスクが実行されなくなる。 - DOM更新後の処理には
queueMicrotaskを活用:VueのnextTickやReactのuseEffect同様、DOM変更後の読み取りを確実に行うために使用可能。 - アニメーションには
requestAnimationFrame:ブラウザの描画サイクルと同期し、不要なレイアウト計算を抑制。
6. Node.js固有のイベントループ段階
Node.jsでは、イベントループは6つの明確な段階(phases)で構成され、各段階で対応するマクロタスクが処理されます:
- timers:到達した
setTimeout/setIntervalコールバック。 - pending callbacks:OSレベルの非同期操作(例:TCPエラー)のコールバック。
- idle, prepare:内部使用(無視可)。
- poll:I/Oコールバックの取得と実行(キューが空なら待機)。
- check:
setImmediateコールバック。 - close callbacks:ソケットの
closeイベントなど。
各段階の終了後、常にマイクロタスクキューがチェックされ、空になるまで実行されます。これはブラウザと同様の原則ですが、段階の粒度が細かい点が特徴です。
7. 注意すべきエッジケース
- マイクロタスクのネスト深さ:再帰的なマイクロタスク登録はスタックオーバーフローを引き起こさないが、イベントループの進行を妨げる可能性がある。
- タイマーの精度保証がないこと:
setTimeout(fn, 0)は「できるだけ早く」を意味し、実際の遅延はタスクキューの状態やシステム負荷に依存。 - UIレンダリングの保証はない:マイクロタスクが大量にある場合、ブラウザはレンダリングをスキップすることがある(例:連続した
Promise.then)。