はじめに
JavaScriptを学んでいると、「なぜsetTimeoutで0秒を指定しても、他のコードより後に実行されるのか」「Promiseの.then()はいつ実行されるのか」といった疑問を持つことがあります。
これらの疑問を解決する鍵となるのが、JavaScriptのイベントループという仕組みです。イベントループを理解すると、非同期処理の実行順序を正確に予測できるようになり、予期せぬバグを防ぐことができます。
本記事では、以下の内容を初心者向けにわかりやすく解説します。
- JavaScriptがシングルスレッドである理由
- コールスタックの役割と動作
- タスクキューとマイクロタスクキューの違い
- イベントループの仕組み
setTimeoutとPromiseの実行順序
JavaScriptはシングルスレッド
シングルスレッドとは
JavaScriptはシングルスレッドで動作するプログラミング言語です。シングルスレッドとは、一度に1つのタスクしか実行できないことを意味します。
|
|
上記のコードは、必ず上から順番に実行されます。これがシングルスレッドの特徴です。
なぜシングルスレッドなのか
JavaScriptは元々、ブラウザ上でDOM(HTMLの要素)を操作するために設計されました。もしマルチスレッドでDOMを同時に操作すると、予期しない競合状態(Race Condition)が発生する可能性があります。シングルスレッドであることで、このような問題を回避し、プログラムの動作を予測しやすくしています。
シングルスレッドの課題
シングルスレッドには課題もあります。時間のかかる処理(API通信、ファイル読み込みなど)を実行すると、その間は他の処理が止まってしまい、ユーザーインターフェースがフリーズしたように見えます。
|
|
この課題を解決するのが非同期処理です。JavaScriptは非同期処理を使うことで、時間のかかるタスクを待っている間も他の処理を続けることができます。
コールスタックの仕組み
コールスタックとは
コールスタック(Call Stack) は、現在実行中の関数を追跡するためのデータ構造です。スタック(Stack)という名前の通り、後入れ先出し(LIFO: Last In, First Out)の構造を持ちます。
| 用語 | 説明 |
|---|---|
| プッシュ(Push) | スタックの一番上に新しい要素を追加する |
| ポップ(Pop) | スタックの一番上の要素を取り出す |
コールスタックの動作例
以下のコードでコールスタックがどのように動作するか見てみましょう。
|
|
このコードを実行すると、コールスタックは以下のように変化します。
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関数が呼び出されるとスタックにプッシュされ、処理が完了するとポップされます。
スタックオーバーフロー
コールスタックには容量の上限があります。無限再帰などでスタックが溢れると、スタックオーバーフローエラーが発生します。
|
|
タスクキューとマイクロタスクキュー
Web APIと非同期処理
ブラウザは、JavaScriptエンジンとは別にWeb APIを提供しています。setTimeout、fetch、DOMイベントリスナーなどは、このWeb APIによって処理されます。
|
|
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つタスクを取り出して実行する
- 手順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 --> EventLoopsetTimeoutの実行順序を理解する
setTimeout(fn, 0)の挙動
setTimeoutの遅延時間を0ミリ秒に設定しても、コールバックは即座には実行されません。
|
|
なぜこのような順序になるのでしょうか。ステップごとに見ていきましょう。
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") 実行console.log("1")がコールスタックに追加され、即座に実行されるsetTimeoutがWeb APIにタイマー処理を委譲するconsole.log("3")がコールスタックに追加され、即座に実行される- 0ms経過後、コールバックがタスクキューに追加される
- コールスタックが空になり、イベントループがタスクキューからコールバックを取り出す
console.log("2")が実行される
setTimeoutの遅延時間は最小保証
setTimeoutの第2引数は、「少なくともこの時間が経過した後に実行する」という最小保証を意味します。他のタスクが実行中の場合、実際の遅延はより長くなります。
|
|
Promiseとマイクロタスクの実行順序
Promiseの基本
Promiseの.then()、.catch()、.finally()で登録されたコールバックは、マイクロタスクキューに追加されます。
|
|
この挙動はsetTimeout(fn, 0)と似ていますが、実行順序が異なる場合があります。
setTimeoutとPromiseの比較
|
|
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
6
4
2
3
5
解説:
console.log("1")→ 同期処理で即座に実行- 最初の
setTimeoutコールバックがタスクキューへ - 最初の
Promise.thenコールバックがマイクロタスクキューへ console.log("6")→ 同期処理で即座に実行- コールスタックが空になり、マイクロタスクキューを処理
console.log("4")→ マイクロタスクとして実行- 2番目の
setTimeoutがタスクキューへ - マイクロタスクキューが空になり、タスクキューを処理
console.log("2")→ タスクとして実行Promise.thenがマイクロタスクキューへ- タスク完了後、マイクロタスクを処理
console.log("3")→ マイクロタスクとして実行- 次のタスクを処理
console.log("5")→ タスクとして実行
async/awaitとイベントループ
async/awaitの内部動作
async/awaitは、Promiseをより読みやすく書くための構文糖衣(シンタックスシュガー)です。awaitキーワードは、その後の処理をマイクロタスクとして登録します。
|
|
await以降のコードは、Promise.then()の中に書かれているのと同じ挙動になります。
async/awaitを使った実行順序
|
|
実践的なイベントループのデバッグ
実行順序の予測方法
イベントループの理解を活かして、コードの実行順序を予測する手順をまとめます。
- 同期コードを特定する: 即座に実行される処理を洗い出す
- マイクロタスクを特定する:
Promise.then、await以降の処理を洗い出す - タスクを特定する:
setTimeout、setInterval、イベントハンドラを洗い出す - 優先度に従って実行順序を決定する: 同期 → マイクロタスク → タスク
よくある間違いと対処法
| 間違い | 対処法 |
|---|---|
setTimeout(fn, 0)は即座に実行される |
タスクキューを経由するため、同期コードの後に実行される |
| Promiseのコンストラクタ内は非同期 | コンストラクタ内は同期的に実行される。.then()が非同期 |
awaitは処理を完全に止める |
現在の関数の実行を一時停止するが、他の処理は続行される |
|
|
まとめ
本記事では、JavaScriptのイベントループと非同期処理の仕組みについて解説しました。
主なポイントを振り返ります。
- JavaScriptはシングルスレッドで動作し、一度に1つのタスクしか実行できない
- コールスタックは、現在実行中の関数を追跡する後入れ先出しのデータ構造
- タスクキューは、
setTimeoutなどのWeb APIからのコールバックを格納する - マイクロタスクキューは、Promiseの
.then()などのコールバックを格納し、タスクキューより優先度が高い - イベントループは、コールスタックが空になるたびにキューからタスクを取り出して実行する
- 実行順序は「同期コード → マイクロタスク → タスク」の順番
イベントループを理解することで、setTimeoutやPromiseの実行タイミングを正確に予測できるようになります。非同期処理でのバグを減らし、より効率的なJavaScriptコードを書くために、ぜひこの知識を活用してください。