この記事で伝えること
JavaScriptはシングルスレッドで動く言語です。それなのに、ネットワーク通信やタイマーを「非同期」に処理できるのはなぜでしょうか。
この記事では、コールスタック・タスクキュー・イベントループという3つの仕組みを丁寧に解説します。setTimeout や Promise の挙動が「なんとなく動いている」から「なぜこう動くのか理解できる」に変われば目標達成です。
JavaScriptはシングルスレッドとはどういうことか
「シングルスレッド」とは、一度に1つの処理しか実行できないということです。
マルチスレッド言語(JavaやGoなど)では、複数の処理を本当に同時並行で走らせられます。一方JavaScriptのエンジン(V8など)は、スレッドが1本しかありません。
console.log("A");
console.log("B");
console.log("C");
// → A, B, C の順に1つずつ実行される
では、HTTPリクエストのような「時間がかかる処理」をブロッキングで書くと何が起きるでしょうか。
// 仮にfetchが同期的だったら…
const data = fetchSync("https://api.example.com/data"); // ここで数秒フリーズ
console.log(data);
ブラウザもNode.jsも、この数秒間は完全に止まります。UIは操作不能になり、他のコードも何も動きません。これは実用に耐えません。
そこで登場するのがイベントループです。
3つの登場人物
イベントループを理解するには、3つの概念を押さえる必要があります。
1. コールスタック(Call Stack)
関数呼び出しを管理するスタックです。関数が呼ばれると積まれ(push)、完了すると取り除かれます(pop)。
function greet(name) {
console.log("Hello, " + name);
}
greet("Alice");
このとき、コールスタックは次のように動きます。
[greet("Alice")] ← push
[console.log(...)] ← push(greetの中から呼ばれる)
[] ← console.logがpop、その後greetもpop
スタックが空になって初めて、次の処理が始まります。
2. タスクキュー(Task Queue)
非同期処理のコールバック関数が待機する場所です。
setTimeout のコールバック、クリックイベントのハンドラーなどが、ここに積まれます。
setTimeout(() => {
console.log("タイムアウト!");
}, 1000);
このコードを実行すると:
-
setTimeoutがWeb API(またはNode.jsのlibuvタイマー)に委譲される - 1秒後にコールバックがタスクキューに追加される
3. イベントループ(Event Loop)
イベントループは、次の繰り返しをひたすら続けます。
コールスタックが空?
→ Yes: タスクキューの先頭を取り出してコールスタックに積む
→ No: 何もしない(コールスタックの処理を待つ)
つまり、コールスタックが空になるまで、タスクキューのコールバックは絶対に実行されないのです。
動作イメージ(図解)
実際のコードで確認する
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
console.log("3");
setTimeout のディレイを 0 にしているので、「すぐ実行される」と思いたくなりますが、実際の出力は:
1
3
2
なぜなら setTimeout のコールバックは必ずタスクキューを経由するからです。コールスタックに console.log("3") が残っている間は、「2」は実行されません。
マイクロタスクキューとの違い(Promiseの優先度)
実は、タスクキューは2種類あります。
| 種類 | 例 | 優先度 |
|---|---|---|
| マクロタスクキュー |
setTimeout, setInterval, I/Oコールバック |
低 |
| マイクロタスクキュー |
Promise.then, queueMicrotask, MutationObserver
|
高 |
イベントループのルールは少し正確に言うと:
- コールスタックが空になる
- マイクロタスクキューをすべて空にする(何個あっても全部処理)
- マクロタスクキューから1つだけ取り出して実行する
- 1に戻る
console.log("1");
setTimeout(() => console.log("setTimeout"), 0); // マクロタスク
Promise.resolve().then(() => console.log("Promise")); // マイクロタスク
console.log("2");
出力:
1
2
Promise
setTimeout
Promise は setTimeout より先に実行されます。
注意点: 長いループでUIがフリーズする
イベントループを理解すると、次のコードが「なぜ危ないか」もわかります。
// 悪い例: 重い同期処理でスタックを占有
for (let i = 0; i < 1_000_000_000; i++) {
// 何か重い計算
}
// この間、ブラウザのUIは完全にフリーズする
コールスタックが空にならないと、タスクキューのコールバックも実行されません。「UIのレンダリング」もタスクキューを通るため、画面が固まります。
重い処理は Web Worker や分割実行(小さな setTimeout チェーン)で対処します。
筆者の考え
イベントループを初めて理解したとき、「なるほど、これはうまい設計だ」と素直に感心しました。
シングルスレッドという制約を「欠点」としてではなく、「I/O待ちの間に別のことをする」という非同期モデルに昇華させているのが面白いです。特に、マイクロタスクがマクロタスクより先に実行されるという優先度の設計は、Promise チェーンが直感的に「続けて処理される感覚」を持てるように工夫されていると感じます。
個人的には、async/await が広まるまで Promise.then チェーンが長く続くコードを読むのが少し辛く感じていましたが、イベントループを理解してからは「これはマイクロタスクのキューイングをコードで表現しているんだ」と読めるようになり、スッキリしました。
Node.jsでバックエンドを書くときも、「CPUバウンドな処理は別スレッドに逃がし、I/Oバウンドな処理はイベントループに任せる」という設計判断の根拠が明確になります。
まとめ
- JavaScriptはシングルスレッドだが、非同期処理はWeb API / libuvに委譲することでブロッキングを避けている
- イベントループはコールスタックが空になったタイミングで、タスクキューからコールバックを取り出して実行する
-
PromiseなどのマイクロタスクはsetTimeoutなどのマクロタスクより優先度が高い - 長い同期処理でコールスタックを占有すると、他の処理やUIが完全にブロックされる