JavaScript イベントループと非同期プログラミング徹底解説:マクロタスクからマイクロタスクまでの完全な流れ
あるバグから始まった話
昨年、同僚がポーリング関数を書きました。30 秒後、ブラウザが完全にフリーズしました:
function pollServer() {
while (true) {
const response = fetch('/api/status'); // 毎秒ポーリングするつもり
// ...
}
}
彼はすぐに問題に気づけませんでした——fetch は非同期なのに、なぜ while(true) がブロックするのか?これが「非同期をマルチスレッドと勘違いする」典型的な罠です。while(true) はメインスレッドで同期的に実行されるため、イベントループが fetch のコールバックを実行する機会を得られません。イベントループを理解することは、JavaScript のシングルスレッド並行モデルを理解することです。
コールスタック(Call Stack)
JavaScript エンジンにはコールスタックが 1 つだけあります。関数呼び出しはプッシュされ、戻りはポップされます:
function multiply(a, b) {
return a * b;
}
function square(n) {
const result = multiply(n, n);
return result;
}
function main() {
const value = square(5);
console.log(value);
}
main();
スタックオーバーフローは RangeError: Maximum call stack size exceeded——通常は無限再帰です。Chrome のコールスタック上限は約 10,000 フレームです。
タスクキュー(Task Queue)
コールスタックが空になると、イベントループはタスクキューから 1 つのタスクを取得して実行します。タスクキュー内の各項目は**マクロタスク(MacroTask)**と呼ばれます。
マクロタスクを生成するもの:
| 発生源 | 例 |
|---|---|
setTimeout / setInterval |
setTimeout(() => {}, 0) |
| I/O 操作 | ファイル読み書き、ネットワークリクエストコールバック |
| UI レンダリング | requestAnimationFrame、ブラウザレンダーフレーム |
| ユーザー操作 | click、keydown イベントコールバック |
setImmediate |
Node.js 専用 |
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
console.log('3');
// 出力:1 → 3 → 2
// setTimeout のコールバックはマクロタスクキューに入り、
// 現在のコールスタックが空になるまで待つ必要がある
マイクロタスク(MicroTask)
マイクロタスクは ECMAScript 仕様の概念です。各マクロタスクの実行後、イベントループはマイクロタスクキュー全体を空にしてから次のマクロタスクを取得します。
マイクロタスクを生成するもの:
| 発生源 | 例 |
|---|---|
Promise.then/catch/finally |
Promise.resolve().then(fn) |
async/await(await 以降のコード) |
await something |
queueMicrotask |
queueMicrotask(fn) |
MutationObserver |
DOM 変更コールバック |
これが面接問題の核心です:マイクロタスクは次のマクロタスクより先に実行されます。
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. 同期コード:1, 6
// 2. マイクロタスクキュー:[then(()=>4)] → 4 を出力、setTimeout(5) をマクロタスクに
// 3. マクロタスクキュー:[setTimeout(2), setTimeout(5)]
// 4. 最初のマクロタスク:2 を出力、then(()=>3) がマイクロタスクに
// 5. マイクロタスクを空にする:3 を出力
// 6. 2 番目のマクロタスク:5 を出力
完全なイベントループモデル
┌──────────────────────────────────────┐
│ コールスタック(同期) │
│ ↓ スタックが空 │
│ マイクロタスクキュー(全消去) │
│ ↓ マイクロタスクが空 │
│ マクロタスクキュー(1 つ実行) │
│ ↓ コールスタックに戻る │
│ ループ... │
└──────────────────────────────────────┘
1 つの完全な tick:
- マクロタスクを 1 つ取得して実行
- 実行中に生成されたマイクロタスクをすべて同じ tick 内で実行
- マイクロタスクから生成されたマイクロタスクも同じ tick で実行(再帰的に空にする)
- レンダリング(必要な場合)
- 次のマクロタスクを取得
async/await の内部動作
async/await は Generator + Promise の糖衣構文です。
// 私たちが書くコード:
async function fetchUser() {
const response = await fetch('/api/user');
const user = await response.json();
return user;
}
// 等価なコード:
function fetchUser() {
return Promise.resolve().then(() => {
return fetch('/api/user');
}).then((response) => {
return response.json();
});
}
重要な詳細:await の後の式は Promise.resolve() でラップされます。値がすでに Promise でない場合は、自動的に解決済み Promise に変換されます。つまり await 42 は await Promise.resolve(42) と等価です。
Node.js のイベントループ
Node.js のイベントループはブラウザより複雑で、6 つのフェーズがあり、それぞれに独自の FIFO キューがあります:
┌───────────────────────┐
┌─>│ timers │ setTimeout, setInterval コールバック
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ pending callbacks │ 遅延 I/O コールバック
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │ 内部使用
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ poll │ 新しい I/O イベントの取得(コアフェーズ)
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ check │ setImmediate コールバック
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ close callbacks │ socket.on('close', ...)
│ └──────────┬────────────┘
└──────────────┘
各フェーズ終了後 → マイクロタスクキューを空にする
process.nextTick の罠
console.log('1');
process.nextTick(() => {
console.log('2');
process.nextTick(() => console.log('3'));
});
Promise.resolve().then(() => console.log('4'));
console.log('5');
// 出力:1 → 5 → 2 → 3 → 4
// nextTick は Promise マイクロタスクより優先される!
実践的チューニング:長いタスクの回避
問題:10,000 件のデータを一度にレンダリング
// ❌ メインスレッドが数秒間フリーズ
function renderAll(items) {
items.forEach(item => {
const div = document.createElement('div');
div.textContent = item.name;
container.appendChild(div);
});
}
解決策 1:タイムスライシング
function renderChunked(items, chunkSize = 100) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
const div = document.createElement('div');
div.textContent = items[index].name;
container.appendChild(div);
}
if (index < items.length) {
setTimeout(processChunk, 0);
}
}
processChunk();
}
解決策 2:Web Worker(真の並列処理)
// main.js
const worker = new Worker('/workers/data-processor.js');
worker.postMessage({ data: rawData, action: 'transform' });
worker.onmessage = (event) => { renderData(event.data); };
関連ツール
- JSON フォーマッター — 非同期 API レスポンスデータのデバッグ
- Base64 エンコード/デコード — 非同期ファイル読み取りとエンコーディング変換
ブラウザローカルツールを無料で試す →