JavaScriptにおけるイベントループの実践的理解

1. なぜイベントループが必要か

JavaScriptはシングルスレッド言語であり、同時に1つのタスクしか実行できません。この制約により、ブロッキング操作(例:重い計算や同期的I/O)が発生すると、UIの応答性が失われ、アプリケーション全体が一時停止します。イベントループは、この問題を解決するための非同期処理の基盤機構です。它は、コードの逐次的な実行順序を保ちながら、非同期処理(ネットワーク要求、タイマー、ユーザー入力など)を効率的に統合します。

2. 基本構造とフロー

イベントループは、以下の3つの主要なコンポーネントで構成されます:

  • コールスタック:現在実行中の関数を追跡するLIFO構造。
  • タスクキュー(マクロタスクキュー)setTimeoutsetInterval、UIレンダリング、I/O完了通知などが入るFIFOキュー。
  • マイクロタスクキューPromise.then/catch/finallyMutationObserverqueueMicrotask() などのコールバックが格納されるFIFOキュー。

実行フローは次の通りです:

  1. コールスタックが空になるまで同期コードを実行。
  2. マイクロタスクキューが空になるまで、すべてのマイクロタスクを連続して実行(スタック再帰なし)。
  3. 必要に応じてブラウザはUIレンダリングを実行(ただし、必ずしも毎回行われるとは限らない)。
  4. マクロタスクキューから1つ目のタスクを取り出し、コールスタックにプッシュして実行。
  5. 上記のステップを繰り返す。

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フリーズの回避:長時間実行される処理(例:配列の大量フィルタリング)をqueueMicrotasksetTimeout(..., 0)で分割し、レンダリングチャンスを確保。
  • マイクロタスクの過剰利用を避ける:無限ループ的なマイクロタスク生成(例:queueMicrotask(() => queueMicrotask(...)))はイベントループを「飢餓状態」に陥れ、マクロタスクが実行されなくなる。
  • DOM更新後の処理にはqueueMicrotaskを活用:VueのnextTickやReactのuseEffect同様、DOM変更後の読み取りを確実に行うために使用可能。
  • アニメーションにはrequestAnimationFrame:ブラウザの描画サイクルと同期し、不要なレイアウト計算を抑制。

6. Node.js固有のイベントループ段階

Node.jsでは、イベントループは6つの明確な段階(phases)で構成され、各段階で対応するマクロタスクが処理されます:

  1. timers:到達したsetTimeout/setIntervalコールバック。
  2. pending callbacks:OSレベルの非同期操作(例:TCPエラー)のコールバック。
  3. idle, prepare:内部使用(無視可)。
  4. poll:I/Oコールバックの取得と実行(キューが空なら待機)。
  5. checksetImmediateコールバック。
  6. close callbacks:ソケットのcloseイベントなど。

各段階の終了後、常にマイクロタスクキューがチェックされ、空になるまで実行されます。これはブラウザと同様の原則ですが、段階の粒度が細かい点が特徴です。

7. 注意すべきエッジケース

  • マイクロタスクのネスト深さ:再帰的なマイクロタスク登録はスタックオーバーフローを引き起こさないが、イベントループの進行を妨げる可能性がある。
  • タイマーの精度保証がないことsetTimeout(fn, 0)は「できるだけ早く」を意味し、実際の遅延はタスクキューの状態やシステム負荷に依存。
  • UIレンダリングの保証はない:マイクロタスクが大量にある場合、ブラウザはレンダリングをスキップすることがある(例:連続したPromise.then)。

タグ: javascript event-loop async-programming Promise microtask

7月4日 22:20 投稿