はじめに

JavaScriptを学んでいると、「なぜsetTimeoutで0秒を指定しても、他のコードより後に実行されるのか」「Promiseの.then()はいつ実行されるのか」といった疑問を持つことがあります。

これらの疑問を解決する鍵となるのが、JavaScriptのイベントループという仕組みです。イベントループを理解すると、非同期処理の実行順序を正確に予測できるようになり、予期せぬバグを防ぐことができます。

本記事では、以下の内容を初心者向けにわかりやすく解説します。

  • JavaScriptがシングルスレッドである理由
  • コールスタックの役割と動作
  • タスクキューとマイクロタスクキューの違い
  • イベントループの仕組み
  • setTimeoutPromiseの実行順序

JavaScriptはシングルスレッド

シングルスレッドとは

JavaScriptはシングルスレッドで動作するプログラミング言語です。シングルスレッドとは、一度に1つのタスクしか実行できないことを意味します。

1
2
3
4
5
6
7
console.log("1番目");
console.log("2番目");
console.log("3番目");
// 出力:
// 1番目
// 2番目
// 3番目

上記のコードは、必ず上から順番に実行されます。これがシングルスレッドの特徴です。

なぜシングルスレッドなのか

JavaScriptは元々、ブラウザ上でDOM(HTMLの要素)を操作するために設計されました。もしマルチスレッドでDOMを同時に操作すると、予期しない競合状態(Race Condition)が発生する可能性があります。シングルスレッドであることで、このような問題を回避し、プログラムの動作を予測しやすくしています。

シングルスレッドの課題

シングルスレッドには課題もあります。時間のかかる処理(API通信、ファイル読み込みなど)を実行すると、その間は他の処理が止まってしまい、ユーザーインターフェースがフリーズしたように見えます。

1
2
3
// この処理が5秒かかると仮定すると、画面が5秒間フリーズする
const data = fetchDataSync(); // 同期的なデータ取得(仮想的な例)
console.log(data);

この課題を解決するのが非同期処理です。JavaScriptは非同期処理を使うことで、時間のかかるタスクを待っている間も他の処理を続けることができます。

コールスタックの仕組み

コールスタックとは

コールスタック(Call Stack) は、現在実行中の関数を追跡するためのデータ構造です。スタック(Stack)という名前の通り、後入れ先出し(LIFO: Last In, First Out)の構造を持ちます。

用語 説明
プッシュ(Push) スタックの一番上に新しい要素を追加する
ポップ(Pop) スタックの一番上の要素を取り出す

コールスタックの動作例

以下のコードでコールスタックがどのように動作するか見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  const result = square(n);
  console.log(result);
}

printSquare(4);

このコードを実行すると、コールスタックは以下のように変化します。

sequenceDiagram
    participant Stack as コールスタック
    Note over Stack: 1. printSquare(4) を追加
    Note over Stack: 2. square(4) を追加
    Note over Stack: 3. multiply(4, 4) を追加
    Note over Stack: 4. multiply完了 → 取り出し
    Note over Stack: 5. square完了 → 取り出し
    Note over Stack: 6. console.log(16)を追加
    Note over Stack: 7. console.log完了 → 取り出し
    Note over Stack: 8. printSquare完了 → 取り出し
    Note over Stack: スタックが空になる

コールスタックの状態を時系列で表すと、以下のようになります。

block-beta
  columns 4
  
  block:step1:1
    columns 1
    s1["printSquare"]
  end
  
  block:step2:1
    columns 1
    s2a["square"]
    s2b["printSquare"]
  end
  
  block:step3:1
    columns 1
    s3a["multiply"]
    s3b["square"]
    s3c["printSquare"]
  end
  
  block:step4:1
    columns 1
    s4a["square"]
    s4b["printSquare"]
  end

関数が呼び出されるとスタックにプッシュされ、処理が完了するとポップされます。

スタックオーバーフロー

コールスタックには容量の上限があります。無限再帰などでスタックが溢れると、スタックオーバーフローエラーが発生します。

1
2
3
4
5
6
function recursive() {
  recursive(); // 自分自身を無限に呼び出す
}

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

タスクキューとマイクロタスクキュー

Web APIと非同期処理

ブラウザは、JavaScriptエンジンとは別にWeb APIを提供しています。setTimeoutfetch、DOMイベントリスナーなどは、このWeb APIによって処理されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
console.log("開始");

setTimeout(() => {
  console.log("タイマー完了");
}, 1000);

console.log("終了");
// 出力:
// 開始
// 終了
// タイマー完了(1秒後)

setTimeoutはWeb APIにタイマー処理を委譲し、JavaScriptエンジンは次の処理に進みます。タイマーが完了すると、コールバック関数がキューに追加されます。

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

タスクキュー(Task Queue) は、Web APIからのコールバックを格納するキューです。マクロタスクキューとも呼ばれます。

以下の処理がタスクキューに追加されます。

処理 説明
setTimeout / setInterval タイマー完了後のコールバック
DOM イベント クリック、スクロールなどのイベントハンドラ
requestAnimationFrame アニメーションフレームのコールバック

マイクロタスクキュー

マイクロタスクキュー(Microtask Queue) は、タスクキューよりも優先度の高いキューです。

以下の処理がマイクロタスクキューに追加されます。

処理 説明
Promise.then() / .catch() / .finally() Promiseの解決後のコールバック
queueMicrotask() 明示的なマイクロタスクの追加
MutationObserver DOM変更の監視コールバック

タスクキューとマイクロタスクキューの違い

両者の最も重要な違いは優先度です。イベントループは、各タスクの完了後に必ずマイクロタスクキューを空にしてから、次のタスクに進みます。

flowchart TD
    A[コールスタックが空になる] --> B{マイクロタスクキューに<br/>タスクがあるか?}
    B -->|はい| C[マイクロタスクを実行]
    C --> B
    B -->|いいえ| D{タスクキューに<br/>タスクがあるか?}
    D -->|はい| E[タスクを1つ実行]
    E --> A
    D -->|いいえ| F[待機]
    F --> A

イベントループの動作原理

イベントループとは

イベントループ(Event Loop) は、コールスタック、タスクキュー、マイクロタスクキューを監視し、適切な順序でタスクを実行する仕組みです。

イベントループは以下の手順を繰り返します。

  1. コールスタック内のすべての同期コードを実行する
  2. コールスタックが空になったら、マイクロタスクキューのすべてのタスクを実行する
  3. マイクロタスクキューが空になったら、タスクキューから1つタスクを取り出して実行する
  4. 手順1に戻る

イベントループの全体像

flowchart LR
    subgraph JSEngine[JavaScript エンジン]
        Stack[コールスタック]
        Heap[ヒープ<br/>オブジェクト格納]
    end
    
    subgraph Browser[ブラウザ環境]
        WebAPI[Web API<br/>setTimeout<br/>fetch<br/>DOM Events]
    end
    
    subgraph Queues[キュー]
        Micro[マイクロタスクキュー<br/>Promise.then<br/>queueMicrotask]
        Task[タスクキュー<br/>setTimeout callback<br/>Event handlers]
    end
    
    EventLoop((イベント<br/>ループ))
    
    Stack --> WebAPI
    WebAPI --> Task
    WebAPI --> Micro
    EventLoop --> Stack
    Micro --> EventLoop
    Task --> EventLoop

setTimeoutの実行順序を理解する

setTimeout(fn, 0)の挙動

setTimeoutの遅延時間を0ミリ秒に設定しても、コールバックは即座には実行されません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
console.log("1");

setTimeout(() => {
  console.log("2");
}, 0);

console.log("3");
// 出力:
// 1
// 3
// 2

なぜこのような順序になるのでしょうか。ステップごとに見ていきましょう。

sequenceDiagram
    participant CS as コールスタック
    participant WA as Web API
    participant TQ as タスクキュー
    participant EL as イベントループ
    
    Note over CS: console.log("1") 実行
    CS->>WA: setTimeout登録
    Note over CS: console.log("3") 実行
    WA->>TQ: 0ms後にコールバックを追加
    Note over CS: スタックが空になる
    EL->>CS: タスクキューからコールバック取得
    Note over CS: console.log("2") 実行
  1. console.log("1")がコールスタックに追加され、即座に実行される
  2. setTimeoutがWeb APIにタイマー処理を委譲する
  3. console.log("3")がコールスタックに追加され、即座に実行される
  4. 0ms経過後、コールバックがタスクキューに追加される
  5. コールスタックが空になり、イベントループがタスクキューからコールバックを取り出す
  6. console.log("2")が実行される

setTimeoutの遅延時間は最小保証

setTimeoutの第2引数は、「少なくともこの時間が経過した後に実行する」という最小保証を意味します。他のタスクが実行中の場合、実際の遅延はより長くなります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const start = Date.now();

setTimeout(() => {
  console.log(`実際の遅延: ${Date.now() - start}ms`);
}, 100);

// 重い処理をシミュレート
while (Date.now() - start < 500) {
  // 500ms間ブロック
}

console.log("同期処理完了");
// 出力:
// 同期処理完了
// 実際の遅延: 500ms以上

Promiseとマイクロタスクの実行順序

Promiseの基本

Promiseの.then().catch().finally()で登録されたコールバックは、マイクロタスクキューに追加されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
console.log("1");

Promise.resolve().then(() => {
  console.log("2");
});

console.log("3");
// 出力:
// 1
// 3
// 2

この挙動はsetTimeout(fn, 0)と似ていますが、実行順序が異なる場合があります。

setTimeoutとPromiseの比較

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
console.log("1");

setTimeout(() => {
  console.log("2 - setTimeout");
}, 0);

Promise.resolve().then(() => {
  console.log("3 - Promise");
});

console.log("4");
// 出力:
// 1
// 4
// 3 - Promise
// 2 - setTimeout

Promiseの.then()がマイクロタスクキューに、setTimeoutのコールバックがタスクキューに追加されます。マイクロタスクキューが優先されるため、Promiseのコールバックが先に実行されます。

sequenceDiagram
    participant CS as コールスタック
    participant MQ as マイクロタスクキュー
    participant TQ as タスクキュー
    
    Note over CS: console.log("1") 実行
    CS->>TQ: setTimeoutコールバック追加
    CS->>MQ: Promise.thenコールバック追加
    Note over CS: console.log("4") 実行
    Note over CS: スタック空 → マイクロタスク確認
    MQ->>CS: Promiseコールバック実行
    Note over CS: console.log("3 - Promise")
    Note over CS: マイクロタスク空 → タスク確認
    TQ->>CS: setTimeoutコールバック実行
    Note over CS: console.log("2 - setTimeout")

複合的な実行順序の例

より複雑な例で理解を深めましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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);
});

console.log("6");

実行順序を予測してみてください。

正解は以下の通りです。

1
6
4
2
3
5

解説:

  1. console.log("1") → 同期処理で即座に実行
  2. 最初のsetTimeoutコールバックがタスクキューへ
  3. 最初のPromise.thenコールバックがマイクロタスクキューへ
  4. console.log("6") → 同期処理で即座に実行
  5. コールスタックが空になり、マイクロタスクキューを処理
  6. console.log("4") → マイクロタスクとして実行
  7. 2番目のsetTimeoutがタスクキューへ
  8. マイクロタスクキューが空になり、タスクキューを処理
  9. console.log("2") → タスクとして実行
  10. Promise.thenがマイクロタスクキューへ
  11. タスク完了後、マイクロタスクを処理
  12. console.log("3") → マイクロタスクとして実行
  13. 次のタスクを処理
  14. console.log("5") → タスクとして実行

async/awaitとイベントループ

async/awaitの内部動作

async/awaitは、Promiseをより読みやすく書くための構文糖衣(シンタックスシュガー)です。awaitキーワードは、その後の処理をマイクロタスクとして登録します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
async function example() {
  console.log("1");
  await Promise.resolve();
  console.log("2");
}

console.log("3");
example();
console.log("4");
// 出力:
// 3
// 1
// 4
// 2

await以降のコードは、Promise.then()の中に書かれているのと同じ挙動になります。

async/awaitを使った実行順序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
async function asyncFunc() {
  console.log("async 1");
  await Promise.resolve();
  console.log("async 2");
}

console.log("script 1");

setTimeout(() => {
  console.log("setTimeout");
}, 0);

asyncFunc();

console.log("script 2");
// 出力:
// script 1
// async 1
// script 2
// async 2
// setTimeout

実践的なイベントループのデバッグ

実行順序の予測方法

イベントループの理解を活かして、コードの実行順序を予測する手順をまとめます。

  1. 同期コードを特定する: 即座に実行される処理を洗い出す
  2. マイクロタスクを特定する: Promise.thenawait以降の処理を洗い出す
  3. タスクを特定する: setTimeoutsetInterval、イベントハンドラを洗い出す
  4. 優先度に従って実行順序を決定する: 同期 → マイクロタスク → タスク

よくある間違いと対処法

間違い 対処法
setTimeout(fn, 0)は即座に実行される タスクキューを経由するため、同期コードの後に実行される
Promiseのコンストラクタ内は非同期 コンストラクタ内は同期的に実行される。.then()が非同期
awaitは処理を完全に止める 現在の関数の実行を一時停止するが、他の処理は続行される
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Promiseコンストラクタ内は同期的
new Promise((resolve) => {
  console.log("1 - 同期的に実行");
  resolve();
}).then(() => {
  console.log("2 - 非同期的に実行");
});

console.log("3 - 同期的に実行");
// 出力:
// 1 - 同期的に実行
// 3 - 同期的に実行
// 2 - 非同期的に実行

まとめ

本記事では、JavaScriptのイベントループと非同期処理の仕組みについて解説しました。

主なポイントを振り返ります。

  • JavaScriptはシングルスレッドで動作し、一度に1つのタスクしか実行できない
  • コールスタックは、現在実行中の関数を追跡する後入れ先出しのデータ構造
  • タスクキューは、setTimeoutなどのWeb APIからのコールバックを格納する
  • マイクロタスクキューは、Promiseの.then()などのコールバックを格納し、タスクキューより優先度が高い
  • イベントループは、コールスタックが空になるたびにキューからタスクを取り出して実行する
  • 実行順序は「同期コード → マイクロタスク → タスク」の順番

イベントループを理解することで、setTimeoutやPromiseの実行タイミングを正確に予測できるようになります。非同期処理でのバグを減らし、より効率的なJavaScriptコードを書くために、ぜひこの知識を活用してください。

参考リンク