メインコンテンツへスキップ
Code & Craft
Web制作 約7分で読めます

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 と非同期処理

setTimeoutfetch などの非同期関数は 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)

  • setTimeout
  • setInterval
  • setImmediate(Node.js)
  • DOM イベント
  • I/O 操作

Microtask Queue

  • Promise.then / .catch / .finally
  • queueMicrotask
  • MutationObserver
  • async/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

解説:

  1. main() 実行
  2. “1” 出力
  3. setTimeout → Web API
  4. new Promise のコールバック即実行 → “3” 出力
  5. p.then → Microtask Queue
  6. await p → main() の残りを Microtask に
  7. main() 一旦終了 → “6” 出力
  8. Microtask: p.then → “4” 出力
  9. Microtask: await 後 → “5” 出力
  10. Task: setTimeout → “2” 出力

まとめ

JavaScript のイベントループを理解すると、以下が明確になります。

  • シングルスレッドでも非同期できる仕組み: Web API への委譲
  • Microtask と Task の優先順位: Microtask が先
  • async/await の裏側: Promise と Microtask の組み合わせ
  • パフォーマンスボトルネックの特定: 長時間タスクの識別

実務では「なぜこの順序で実行されるのか?」を予測できるようになることが、デバッグ能力の向上につながります。

参考リンク

#JavaScript #非同期処理 #Promise #async/await
シェア: