概要

JavaScriptのイベントループは、シングルスレッドの実行環境において非同期処理を実現するための中核的な仕組みである。

JavaScriptエンジンは任意の時点で1つのコードのみを実行するシングルスレッドモデルを採用している。
しかし、ネットワークリクエストやタイマ処理等の非同期操作が完了した時に、適切なタイミングでコールバック関数を実行する必要がある。

イベントループは、コールスタック、マクロタスクキュー、マイクロタスクキューを監視・調整することにより、この要求に応える。

  • コールスタックの動作原理とシングルスレッドの特性
  • イベントループの動作サイクル
  • マクロタスクとマイクロタスクの違いと優先順位
  • 具体的なコード例による実行順序
  • 非同期処理が"並列"ではない理由
  • Web Workersによる真の並列処理との比較
  • requestAnimationFrame と レンダリングの関係


Promiseの詳細はJavaScriptの基礎 - Promise、async/awaitの詳細はJavaScriptの基礎 - async await、Fetch APIの詳細はJavaScriptの基礎 - Fetch APIのページを参照すること。


JavaScriptの実行モデル

JavaScriptはシングルスレッドで動作し、コールスタックを使用して処理の実行順序を管理する。

シングルスレッド

JavaScriptエンジンは任意の時点で1つのステートメントのみを実行する設計となっている。

下表に、この設計の特性を示す。

シングルスレッドモデルの特性
特性 説明
予測可能性 任意の時点で実行されるコードが1つに限定されるため、
複数スレッドが同じデータを同時に変更するという問題が発生しない。
スレッド管理の不要性 マルチスレッド環境で必要となるロック機構や競合状態 (Race Condition) の
管理を考慮する必要がない。
ブロッキングの問題 重い処理が実行中は他の処理が待機状態になるため、
UIの応答性に影響を与える可能性がある。


コールスタック

コールスタック (Call Stack) は、現在実行中の関数を追跡するデータ構造である。
LIFO (Last In, First Out) 方式で動作し、後から追加されたスタックフレームが先に処理される。

コールスタックの動作規則を以下に示す。

  • 関数呼び出し時
    新しいスタックフレームが最上部に追加される。スタックフレームはローカル変数、パラメータ、実行コンテキスト情報を含む。
  • 関数完了時
    最上部のスタックフレームが削除され、呼び出し元の関数に制御が戻る。
  • コールスタック空の状態
    実行すべき同期コードがなくなった状態。イベントループがキューからタスクを取り出すタイミングとなる。


コールスタックの遷移例を以下に示す。

 function a() { b(); }
 function b() { c(); }
 function c() { console.log('c'); }
 a();


上記コードの実行におけるスタック遷移を以下に示す。

 ステップ1: a() 呼び出し  -> スタック = [a]
 ステップ2: b() 呼び出し  -> スタック = [a, b]
 ステップ3: c() 呼び出し  -> スタック = [a, b, c]
 ステップ4: c() 完了      -> スタック = [a, b]
 ステップ5: b() 完了      -> スタック = [a]
 ステップ6: a() 完了      -> スタック = []


再帰呼び出しが無限に続く場合、スタックフレームが際限なく積み重なりスタックオーバーフローが発生する。

 function infiniteRecursion() {
    infiniteRecursion();
 }

 infiniteRecursion();
 // RangeError: Maximum call stack size exceeded



イベントループの仕組み

イベントループは、コールスタックとタスクキューを監視し、適切なタイミングでタスクを実行する役割を担う。

イベントループの動作サイクル

イベントループは、以下のサイクルを繰り返す。

+-------------------------------------+
| 1. マクロタスク 1つを実行              |
|    (setTimeout, setInterval, I/O等) |
+-------------------------------------+
| 2. マイクロタスク 全てを実行            |
|    (Promise.then, queueMicrotask等) |
+-------------------------------------+
| 3. レンダリング (必要な場合)           |
+-------------------------------------+
| 4. 次のサイクルへ                     |
+-------------------------------------+


下表に、各ステップの詳細を示す。

イベントループの各ステップの詳細
ステップ 説明
ステップ1: マクロタスクの実行 マクロタスクキューから1つのタスクを取り出して実行する。
コールスタックが空になるまで処理を続ける。
ステップ2: マイクロタスクチェックポイント マイクロタスクキュー内の全てのマイクロタスクを順番に実行する。
実行中に新しいマイクロタスクが追加された場合も全て処理される。
ステップ3: レンダリング ブラウザが必要と判断した場合、レイアウト計算、ペイント、合成を行う。
ステップ4: 次のサイクルへ マクロタスクキューに次のタスクがあれば、ステップ1に戻る。


タスクキュー (マクロタスクキュー)

マクロタスクキューには、ブラウザや実行環境から発行される非同期タスクが格納される。

下表に、マクロタスクキューに格納されるタスクを示す。

マクロタスクキューに格納されるタスク
タスク 説明
setTimeout() / setInterval() タイマコールバック
指定した遅延時間経過後にキューへ追加される。
I/Oコールバック ファイル読み込みやネットワークリクエスト完了時のコールバック
ユーザインタラクション クリックやキー入力等のイベントハンドラ
setImmediate() Node.js環境固有のタスク
現在の反復の終了後に実行される。


重要な特性として、1ループサイクルにつき最大1つのマクロタスクのみ実行されることが挙げられる。

マイクロタスクキュー

マイクロタスクキューは、マクロタスクより高い優先度で処理されるタスクを格納する。

下表に、マイクロタスクキューに格納されるタスクを示す。

マイクロタスクキューに格納されるタスク
タスク 説明
Promise.then() / Promise.catch() / Promise.finally() Promiseの状態変化に伴うコールバック
queueMicrotask() 明示的にマイクロタスクを登録する関数
async/awaitawait 以降 await 式の後続処理はマイクロタスクとして実行される。
MutationObserver DOM変更を監視するコールバック


マイクロタスクキューの特性を以下に示す。

  • マクロタスク完了後、次のマクロタスクを実行する前に、キュー内の全てのマイクロタスクが実行される。
  • 実行中に新しいマイクロタスクが追加されても、全て処理されてからマクロタスクに移行する。
  • マイクロタスクはマクロタスクより常に優先される。



タスクとマイクロタスクの優先順位

イベントループにおける実行優先順位を理解することは、非同期コードの動作を正確に把握するために不可欠である。

実行順序のルール

下表に、実行順序の優先度を示す。

実行順序の優先度
優先度 種別 代表例
1 (最高) 同期コード 通常のJavaScript文
2 マイクロタスク Promise.then, queueMicrotask, await以降
3 レンダリング 画面描画 (Webブラウザのみ)
4 (最低) マクロタスク setTimeout, setInterval, I/Oコールバック


同期コードが全て完了した後、マイクロタスクキューが空になるまでマイクロタスクが実行される。
その後、必要であればレンダリングが行われ、次のマクロタスクが実行される。

setTimeout vs queueMicrotask vs Promise

下表に、setTimeoutqueueMicrotaskPromise.resolve().then() の違いを示す。

非同期APIの比較
API キュー種別 エラーハンドリング 主な用途
setTimeout(fn, 0) マクロタスク try / catchで捕捉可能 処理の遅延実行、UIブロッキング回避
Promise.resolve().then(fn) マイクロタスク Promise chainで捕捉可能 非同期処理の後続処理
queueMicrotask(fn) マイクロタスク 捕捉できない場合がある シンプルなマイクロタスク登録


queueMicrotask()Promise.resolve().then() は機能的にほぼ同等だが、エラーハンドリングの挙動に違いがある。
エラーの捕捉が必要な場面では Promise.resolve().then() の使用を推奨する。


実行順序の例

基本的な実行順序

  • console.logsetTimeoutPromise.then を組み合わせた基本的な例
     console.log('開始');
     
     setTimeout(() => {
        console.log('setTimeout');
     }, 0);
     
     Promise.resolve()
        .then(() => console.log('Promise1'))
        .then(() => console.log('Promise2'));
     
     console.log('終了');
    

  • 出力結果
     開始
     終了
     Promise1
     Promise2
     setTimeout
    


下表に、実行順序の説明を示す。

実行順序
ステップ 説明
ステップ1 console.log('開始') が同期コードとして実行される。
ステップ2 setTimeout のコールバックがマクロタスクキューに登録される。
ステップ3 Promise.resolve().then() の最初のコールバックがマイクロタスクキューに登録される。
ステップ4 console.log('終了') が同期コードとして実行される。
ステップ5 コールスタックが空になり、マイクロタスクチェックポイントが実行される。
Promise1 が実行され、続いて Promise2 がマイクロタスクキューに追加される。
ステップ6 Promise2 が実行される。
ステップ7 マイクロタスクキューが空になった後、マクロタスクの setTimeout コールバックが実行される。


複雑な実行順序

  • ネストした setTimeoutPromise を組み合わせた複雑な例
     console.log('1');
     
     setTimeout(() => {
        console.log('2');
        Promise.resolve().then(() => console.log('3'));
     }, 0);
     
     Promise.resolve()
        .then(() => {
           console.log('4');
           setTimeout(() => console.log('5'), 0);
        })
        .then(() => console.log('6'));
     
     console.log('7');
    

  • 出力結果
     1
     7
     4
     6
     2
     3
     5
    


下表に、実行順序の説明を示す。

実行順序の説明
フェーズ 説明
同期コード実行フェーズ 1 が出力される。
setTimeout のコールバックがマクロタスクキューに追加される。(タスクA)
Promise.then のコールバックがマイクロタスクキューに追加される。(マイクロA)
7 が出力される。
マイクロタスク実行フェーズ (1回目) マイクロAが実行され、4 が出力される。
setTimeout(() => console.log('5'), 0) がマクロタスクキューに追加される。(タスクB)
次の .then(() => console.log('6')) がマイクロタスクキューに追加される。(マイクロB)
マイクロBが実行され、6 が出力される。
マクロタスク実行フェーズ (1回目) タスクAが実行され、2 が出力される。
Promise.resolve().then(() => console.log('3')) がマイクロタスクキューに追加される。(マイクロC)
マイクロタスク実行フェーズ (2回目) マイクロCが実行され、3 が出力される。
マクロタスク実行フェーズ (2回目) タスクBが実行され、5 が出力される。



非同期処理が"並列"ではない理由

JavaScriptの非同期処理は並列処理ではなく並行処理である。

この違いを理解することが重要である。

シングルスレッドと非同期の関係

3つの概念の違いを以下に示す。

並行と並列の違い
概念 説明 JavaScriptとの関係
非同期 (Asynchronous) 処理の実行がブロッキングされない。 JavaScriptの非同期モデルの基本
並行 (Concurrent) 複数のタスクが交互に実行される。 JavaScriptはこのモデルを採用
並列 (Parallel) 複数の処理が同時に異なるCPUコアで実行される。 JavaScriptのメインスレッドは非対応


JavaScriptの非同期処理は 並行 であり、並列 ではない。
任意の時点で実行されるのは1つのコードに限られるが、タスクを小さな単位に分けてイベントループが切り替えることにより、複数の処理を進行させる。

長時間かかる同期処理がUIをブロッキングする例を以下に示す。

 setTimeout(() => console.log('タイムアウト'), 100);
 
 // 重い処理 (UIをブロック)
 for (let i = 0; i < 1000000000; i++) {}
 // この処理が完了するまで、setTimeoutのコールバックは実行されない


上記の例では、setTimeout で100[ms]後の実行を指定しても、その後のforループが完了するまでコールバックは実行されない。
これは、コールスタックが空にならない限り、イベントループがタスクキューからタスクを取り出せないためである。

Web Workersによる真の並列処理

真の並列処理が必要な場合は、Web Workersを使用する。
Web WorkersはJavaScriptのメインスレッドとは独立したスレッドでコードを実行できる。

Web Workersの使用例を以下に示す。

 // main.js
 
 const worker = new Worker('worker.js');
 worker.postMessage({ data: 1000000000 });
 worker.onmessage = (event) => {
    console.log('結果:', event.data);
 };


 // worker.js
 
 self.onmessage = (event) => {
    let result = 0;
    for (let i = 0; i < event.data; i++) result += i;
 
    self.postMessage(result);
 };


下表に、Web Workersの制限事項を示す。

Web Workersの制限事項
制限事項 説明
DOM操作 Web WorkersはDOMにアクセスできないため、UI操作はメインスレッドで行う必要がある。
window / document へのアクセス Webブラウザのグローバルオブジェクトにはアクセスできない。
メインスレッドとの通信 postMessage() を使用したメッセージパッシングで通信する。



レンダリングとの関係

ブラウザのレンダリングはイベントループのサイクルの中で実行される。

レンダリングのタイミングを理解することは、スムーズなアニメーションやUI更新に重要である。

requestAnimationFrame

requestAnimationFrame() は、Webブラウザの次の描画フレーム直前に実行されるコールバックを登録する関数である。

イベントループにおける実行順序を以下に示す。

[マクロタスク] -> [マイクロタスク] -> [requestAnimationFrame] -> [レンダリング]


requestAnimationFramesetTimeout の違いを以下に示す。

requestAnimationFrame と setTimeout の違い
項目 requestAnimationFrame setTimeout(fn, 0)
実行タイミング 次のフレーム描画直前 (通常 60[fps] = 約16.67[ms]) 指定した遅延後 (精度は保証されない)
描画との同期 常に同期する 描画タイミングと合わない可能性がある
タブが非アクティブ時 コールバックが停止する 実行され続ける
バッテリー効率 効率的 比較的非効率


requestAnimationFrame を使用したアニメーションの基本的な例を以下に示す。

 const element = document.getElementById('box');
 let position = 0;
 
 function animate() {
    element.style.left = position + 'px';
    position++;
    if (position < 300) {
       requestAnimationFrame(animate);
    }
 }
 
 requestAnimationFrame(animate);


requestAnimationFrame を使用することにより、Webブラウザの描画サイクルに合わせたスムーズなアニメーションを実現できる。
setTimeout を使用した場合と比較して、フレーム落ちや不必要な描画が発生しにくい。


関連情報