T-CREATOR

【保存版】JavaScript のイベントループとタスクキューを図解で理解する

【保存版】JavaScript のイベントループとタスクキューを図解で理解する

JavaScript を使って開発していると、「あれ?なんでこのコードの実行順序が思った通りにならないんだろう?」と疑問に思ったことはありませんか?特に setTimeout や Promise を使った非同期処理で、予想と違う実行結果に戸惑った経験がある方も多いでしょう。

実は、この現象の背景には「イベントループ」と「タスクキュー」という JavaScript の核心的な仕組みが関わっています。これらを理解することで、非同期処理の動作を正確に予測できるようになり、より効率的でバグの少ないコードを書けるようになりますね。

背景

JavaScript のシングルスレッドの特性

JavaScript は シングルスレッド で動作する言語です。これは、一度に一つの処理しか実行できないことを意味します。

javascript// 同期処理の例
console.log('処理1'); // 最初に実行
console.log('処理2'); // 次に実行
console.log('処理3'); // 最後に実行

上記のコードは順番通りに実行され、「処理 1」「処理 2」「処理 3」の順で出力されますね。しかし、実際の Web アプリケーションでは、API 呼び出しやユーザーのクリックイベント、タイマー処理など、様々な非同期処理を扱う必要があります。

非同期処理が必要な理由

シングルスレッドの JavaScript で同期処理のみを使った場合、以下のような問題が発生します。

javascript// 時間のかかる同期処理の例
function heavyCalculation() {
  let result = 0;
  for (let i = 0; i < 1000000000; i++) {
    result += i;
  }
  return result;
}

console.log('開始');
heavyCalculation(); // この処理中はブラウザが固まる
console.log('終了');

この例では、重い計算処理が実行されている間、ブラウザの UI が完全に固まってしまいます。ユーザーはボタンをクリックすることも、スクロールすることもできなくなってしまうんです。

非同期処理を使うことで、このような問題を解決できます。

javascript// 非同期処理の例
console.log('開始');
setTimeout(() => {
  console.log('非同期処理完了');
}, 0);
console.log('終了');

ブラウザと Node.js での違い

イベントループの基本的な仕組みは同じですが、実行環境によって細かな違いがあります。

ブラウザ環境での特徴:

  • DOM イベント処理
  • Web APIs(fetch、setTimeout など)
  • レンダリングと連携した処理

Node.js 環境での特徴:

  • ファイルシステム操作
  • ネットワーク処理
  • より詳細なタスクキューの分類

どちらの環境でも、イベントループの基本概念を理解していれば、適切に対応できますよ。

課題

同期処理の限界

同期処理だけでは、現代の Web アプリケーションに求められる要件を満たすことができません。

javascript// 問題のあるコード例
function fetchUserData() {
  // この処理に3秒かかると仮定
  const userData = syncApiCall('/api/user');
  return userData;
}

// UI更新処理
function updateUI() {
  console.log('UIを更新中...');
}

fetchUserData(); // 3秒間ブロック
updateUI(); // 3秒後にようやく実行

この例では、API 呼び出しが完了するまで UI の更新が一切できず、ユーザー体験が大幅に悪化してしまいます。

コールバック地獄の問題

非同期処理を扱う初期の方法として「コールバック関数」がありましたが、複雑な処理になると可読性が著しく低下します。

javascript// コールバック地獄の例
getData(function (a) {
  getMoreData(a, function (b) {
    getEvenMoreData(b, function (c) {
      getFinalData(c, function (d) {
        // やっと処理完了
        console.log('最終結果:', d);
      });
    });
  });
});

このような「コールバック地獄」は、コードの保守性を大幅に損ねてしまいますね。

パフォーマンスのボトルネック

イベントループの仕組みを理解せずにコードを書くと、意図しないパフォーマンスの問題が発生することがあります。

javascript// パフォーマンス問題のある例
for (let i = 0; i < 1000; i++) {
  setTimeout(() => {
    console.log(i);
  }, 0);
}

この例では、1000 個のタイマーが作成され、すべてタスクキューに積まれます。メモリ使用量の増加や処理の遅延につながる可能性があるんです。

解決策

イベントループの仕組み

以下の図は、JavaScript のイベントループの基本的な構造を示しています。

mermaidflowchart LR
    code[JavaScript コード] -->|関数呼び出し| stack[コールスタック]
    stack -->|Web API 呼び出し| api[Web APIs]
    api -->|コールバック| queue[タスクキュー]
    queue -->|イベントループ| stack

    subgraph browser[ブラウザ環境]
        api
        queue
        loop[イベントループ]
    end

    loop -.->|監視| stack
    loop -.->|監視| queue

イベントループは、コールスタックが空になったときにタスクキューから次の処理を取り出して実行する役割を担っています。

JavaScript エンジンの主要コンポーネントは以下の通りです:

1. コールスタック(Call Stack) 現在実行中の関数を管理するスタック構造です。

javascriptfunction first() {
  console.log('first');
  second();
}

function second() {
  console.log('second');
  third();
}

function third() {
  console.log('third');
}

first(); // コールスタック: third -> second -> first

2. ヒープメモリ(Heap) オブジェクトや変数が格納されるメモリ領域です。

3. Web APIs ブラウザが提供する非同期機能(setTimeout、fetch、DOM イベントなど)です。

タスクキューの種類と優先順位

JavaScript には複数のタスクキューが存在し、それぞれ異なる優先順位を持っています。

mermaidflowchart TD
    start[コールスタック空?] -->|Yes| micro[マイクロタスクキュー確認]
    micro -->|あり| exec1[マイクロタスク実行]
    exec1 --> micro
    micro -->|なし| macro[マクロタスクキュー確認]
    macro -->|あり| exec2[マクロタスク実行]
    exec2 --> start
    macro -->|なし| render[レンダリング処理]
    render --> start
    start -->|No| wait[待機]
    wait --> start

優先順位(高い順):

順位タスクキューの種類
1マイクロタスクキューPromise.then()、queueMicrotask()
2マクロタスクキューsetTimeout()、setInterval()、I/O 処理
3レンダリングrequestAnimationFrame()

マイクロタスクとマクロタスクの違い

この優先順位の違いを理解することが、非同期処理を正確に予測する鍵となります。

マイクロタスクの特徴:

  • Promise の then/catch/finally
  • async/await の完了時
  • queueMicrotask()
javascript// マイクロタスクの例
Promise.resolve().then(() => {
  console.log('マイクロタスク1');
});

Promise.resolve().then(() => {
  console.log('マイクロタスク2');
});

マクロタスクの特徴:

  • setTimeout/setInterval
  • DOM イベント
  • I/O 処理
javascript// マクロタスクの例
setTimeout(() => {
  console.log('マクロタスク1');
}, 0);

setTimeout(() => {
  console.log('マクロタスク2');
}, 0);

具体例

setTimeout と Promise の実行順序

実際のコード例で、イベントループの動作を確認してみましょう。

javascriptconsole.log('1: 同期処理開始');

setTimeout(() => {
  console.log('4: マクロタスク(setTimeout)');
}, 0);

Promise.resolve().then(() => {
  console.log('3: マイクロタスク(Promise)');
});

console.log('2: 同期処理終了');

実行順序の詳細解説:

  1. console.log('1: 同期処理開始') - 即座に実行
  2. setTimeout() - Web API に登録、コールバックはマクロタスクキューへ
  3. Promise.resolve().then() - コールバックはマイクロタスクキューへ
  4. console.log('2: 同期処理終了') - 即座に実行
  5. コールスタックが空になる
  6. マイクロタスクキューを確認 → console.log('3: マイクロタスク(Promise)')実行
  7. マクロタスクキューを確認 → console.log('4: マクロタスク(setTimeout)')実行

async/await の内部動作

async/await は Promise の syntactic sugar ですが、その内部動作を理解することが重要です。

javascriptasync function example() {
  console.log('1: async関数開始');

  await Promise.resolve();
  console.log('3: await後の処理');

  return '完了';
}

console.log('0: 処理開始');
example().then((result) => {
  console.log('4: async関数の戻り値:', result);
});
console.log('2: async関数呼び出し後');

実行フローの解説:

javascript// await は以下のように変換されます
function example() {
  console.log('1: async関数開始');

  return Promise.resolve().then(() => {
    console.log('3: await後の処理');
    return '完了';
  });
}

await は内部的に Promise の then() と同じ動作をするため、マイクロタスクキューに登録されるんです。

DOM イベントとタスクキューの関係

DOM イベントもマクロタスクキューを通じて処理されます。

javascript// HTML要素の取得
const button = document.getElementById('myButton');

// イベントリスナーの設定
button.addEventListener('click', () => {
  console.log('2: ボタンクリック処理');

  Promise.resolve().then(() => {
    console.log('3: クリック内のマイクロタスク');
  });
});

// 同期的な処理
console.log('1: イベント設定完了');

// プログラムからクリックを発火
button.click();
console.log('4: プログラム処理継続');

実際のコード例とステップ実行

より複雑な例で、イベントループの理解を深めてみましょう。

javascriptconsole.log('=== 開始 ===');

setTimeout(() => console.log('タイマー1'), 0);

Promise.resolve()
  .then(() => console.log('Promise1'))
  .then(() => console.log('Promise2'));

setTimeout(() => console.log('タイマー2'), 0);

Promise.resolve().then(() => {
  console.log('Promise3');
  setTimeout(() => console.log('Promise内のタイマー'), 0);
});

console.log('=== 終了 ===');

予想される出力順序:

diff=== 開始 ===
=== 終了 ===
Promise1
Promise2
Promise3
タイマー1
タイマー2
Promise内のタイマー

ステップバイステップの実行過程:

ステップ処理内容コールスタックマイクロタスクマクロタスク
1console.log('=== 開始 ===')実行emptyemptyempty
2setTimeout()登録emptyempty[タイマー 1]
3Promise.resolve().then()登録empty[Promise1][タイマー 1]
42 個目のsetTimeout()登録empty[Promise1][タイマー 1, タイマー 2]
52 個目のPromise.resolve().then()登録empty[Promise1, Promise3][タイマー 1, タイマー 2]
6console.log('=== 終了 ===')実行empty[Promise1, Promise3][タイマー 1, タイマー 2]

まとめ

イベントループ理解のポイント

JavaScript のイベントループとタスクキューを理解する上で重要なポイントをまとめます。

1. 実行順序の優先順位

  • 同期処理 → マイクロタスク → マクロタスク の順で実行される
  • マイクロタスクは完全に空になるまで連続実行される
  • マクロタスクは 1 つずつ実行される

2. 非同期処理の分類

javascript// マイクロタスク(高優先度)
Promise.resolve().then(() => {});
async function example() {
  await Promise.resolve();
}
queueMicrotask(() => {});

// マクロタスク(低優先度)
setTimeout(() => {}, 0);
setInterval(() => {}, 1000);
setImmediate(() => {}); // Node.js のみ

3. デバッグ時の確認方法

  • ブラウザの開発者ツールで実行順序を確認
  • console.log で処理の流れを可視化
  • Performance タブでタスクの実行時間を測定

実際の開発での活用方法

パフォーマンス最適化:

javascript// 悪い例:UIをブロックする処理
function processLargeData(data) {
  data.forEach((item) => {
    // 重い処理
    processItem(item);
  });
}

// 良い例:処理を分割してUIをブロックしない
function processLargeDataAsync(data) {
  const batchSize = 100;
  let index = 0;

  function processBatch() {
    const endIndex = Math.min(
      index + batchSize,
      data.length
    );

    for (let i = index; i < endIndex; i++) {
      processItem(data[i]);
    }

    index = endIndex;

    if (index < data.length) {
      setTimeout(processBatch, 0); // 次のバッチを非同期で処理
    }
  }

  processBatch();
}

エラーハンドリングの改善:

javascript// Promise チェーンでのエラーハンドリング
fetchUserData()
  .then((userData) => processUserData(userData))
  .then((processedData) => updateUI(processedData))
  .catch((error) => {
    console.error('処理エラー:', error);
    showErrorMessage();
  });

テストでの活用:

javascript// 非同期処理のテスト
async function testAsyncFunction() {
  const result = await myAsyncFunction();

  // マイクロタスクの完了を待つ
  await new Promise((resolve) => setTimeout(resolve, 0));

  expect(result).toBe(expectedValue);
}

これらの知識を活用することで、より効率的で予測可能な JavaScript コードを書けるようになります。イベントループの理解は、現代の Web 開発において必須のスキルと言えるでしょう。

関連リンク