非同期プログラミングの徹底解説:イベントループとタスクキュー
1. 事例:200ミリ秒でブロックされたサービス
ボブのAPIサービスは通常、わずか5ミリ秒で応答するが、ピーク時には応答時間が2,000ミリ秒にまで急上昇した。ユーザーからの苦情が相次ぎ、当初彼はデータベースの処理が遅いのだと考えたが、インデックスを追加しても改善されなかった。その後、console.time を使って段階的にトラブルシューティングを行ったところ、大規模な同期型 JSON 配列のソートに 200 ms かかっていることが判明しました。この 200 ms の処理がイベントループをブロックしており、キューに蓄積されたすべてのリクエストがその処理が完了するのを待たされていたのです。イベントループの仕組みを理解した後、ボブはsetImmediateを使用してソート処理を小さなチャンクに分割し、順次実行するようにしました。その結果、ピーク時の応答時間を50ミリ秒まで短縮することができました。
学習内容:
- イベントループの6つのフェーズにおける役割と流れ
- マイクロタスクキューの優先順位(process.nextTick / Promise.then)
- マクロタスクキューのスケジューリング(setTimeout / setImmediate / I/O)
- setTimeout、setImmediate、process.nextTick の実行順序
- I/O コンテキストにおいて、なぜ
setImmediateがsetTimeoutより優先されるのか - イベントループをブロックすることのリスクと、それを分割するための戦略
2. イベントループの6つの段階
Node.jsのイベントループはlibuvによって駆動され、6つのフェーズを順に経過します。各ループサイクルは「タイマー」から始まり、「クローズコールバック」で終了し、その後「タイマー」に戻って次のサイクルを開始します。
flowchart TD
A["timers<br/>setTimeout / setInterval"] --> B["pending callbacks<br/>System-Level Callbacks"]
B --> C["idle, prepare<br/>For internal use only"]
C --> D["poll<br/>I/O callbacks / new I/O polling"]
D --> E["check<br/>setImmediate"]
E --> F["close callbacks<br/>socket.on('close')"]
F --> A
| フェーズ | コールバックの種類 | 説明 |
|---|---|---|
| タイマー | setTimeout / setInterval | タイマーの期限が切れた時点でコールバックを実行する(最低1ミリ秒の遅延が必要) |
| 保留中のコールバック | システムレベルのコールバック | 次のループに延期されたI/Oコールバック(例:TCPエラー) |
| アイドル、準備中 | 内部使用 | libuvによる内部使用。開発者は通常、これとやり取りすることはない |
| ポーリング | I/O コールバック | 新しい I/O イベントの有無を確認し、I/O 関連のコールバックを実行します。タイマーがない場合、プロセスはこの段階でブロックされます |
| check | setImmediate | この段階で setImmediate のコールバックが実行されます |
| クローズコールバック | クローズイベント | 例:socket.on('close') クローズコールバック |
3. マイクロタスクとマクロタスク
(1) (3.1) マイクロタスクキュー
マイクロタスクは、現在のフェーズが終了した後、次のフェーズが始まる前に、まとめて実行されます。マイクロタスクのキューには、2つの優先度レベルがあります:
| マイクロタスク | 優先度 | 説明 |
|---|---|---|
| process.nextTick | 最大値 | 各フェーズの終了後に nextTick キューのクリアを優先する |
| Promise.then / catch / finally | 2番目に多い | nextTickキューがクリアされた後に実行される |
▶ 例:nextTick は Promise.then より優先されます
process.nextTick(() => {
console.log('nextTick 1');
});
Promise.resolve().then(() => {
console.log('promise.then 1');
});
process.nextTick(() => {
console.log('nextTick 2');
});
Promise.resolve().then(() => {
console.log('promise.then 2');
});
nextTick 1
nextTick 2
promise.then 1
promise.then 2
(2) (3.2) マクロタスクキュー
マクロタスクはイベントループの各フェーズごとにスケジュールされ、各フェーズはそれに対応するマクロタスクを実行します。
| マクロタスク | フェーズ | 説明 |
|---|---|---|
| setTimeout / setInterval | タイマー | 最小遅延は 1 ms。期限が切れるとタイマーキューに入る |
| setImmediate | check | 次の反復の「check」フェーズ中に実行される |
| I/O コールバック | poll | ファイルの読み書きやネットワーク操作などの I/O 操作が完了した際のコールバック |
| コールバックの終了 | コールバックの終了 | イベントの終了コールバック |
4. setTimeout 対 setImmediate 対 process.nextTick
▶ 例:外側のコンテキストの実行順序は未定義です
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
I/O 以外のコンテキストでは、setTimeout と setImmediate の実行順序は未定義です。これはシステムのプロセススケジューリングに依存し、どちらが先に実行されるかは定かではありません。
▶ 例:I/O コンテキストでは、setImmediate が常に最初に実行されます
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
setImmediate
setTimeout
I/O コールバックはポーリングフェーズ中に実行されます。これらが完了すると、処理はチェックフェーズ(setImmediate)に移行し、その後に初めてタイマーフェーズの次のラウンド(setTimeout)に進みます。
▶ 例:process.nextTick は常に先頭にくる
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
process.nextTick(() => {
console.log('nextTick');
});
nextTick
setTimeout (or setImmediate, depends on scheduling)
setImmediate
| 機能 | setTimeout(fn, 0) | setImmediate | process.nextTick |
|---|---|---|---|
| キュー | マクロタスク(タイマー) | マクロタスク(チェック) | マイクロタスク(最優先) |
| 実行フェーズ | タイマー | チェック | 現在のフェーズが終了した直後 |
| 最小レイテンシ | 1 ms | なし | なし |
| I/O コンテキストの順序 | setImmediate の後 | setTimeout の前 | 最初 |
| 外部コンテキストの順序 | 未定義 | 未定義 | 最も早い |
5. マイクロタスクとマクロタスクの比較
| 次元 | マイクロタスク | マクロタスク |
|---|---|---|
| 代表例 | process.nextTick、Promise.then | setTimeout、setImmediate、I/O コールバック |
| 実行タイミング | 現在のフェーズが終了した後、次のフェーズが始まる前 | イベントループの対応するフェーズ中 |
| 実行戦略 | すべてを一括でクリア | フェーズごとに1つずつ実行し、その後マイクロタスクを確認 |
| ネスト | 実行中に生成されたマイクロタスクもこのラウンドでクリアされる | 実行を後の段階に先送りされる |
| 優先度 | すべてのマクロタスクより高い | マイクロタスクより低い |
▶ 例:マクロタスクの合間に挟まれるマイクロタスク
setTimeout(() => {
console.log('timeout 1');
Promise.resolve().then(() => {
console.log('promise between 1 and 2');
});
}, 0);
setTimeout(() => {
console.log('timeout 2');
}, 0);
timeout 1
promise between 1 and 2
timeout 2
各マクロタスクが完了した後、次のマクロタスクが実行される前に、マイクロタスクのキューはクリアされます。
6. イベントループをブロックすることの危険性と、それを回避する方法
▶ 例:イベントループのブロック
const http = require('http');
const server = http.createServer((req, res) => {
const start = Date.now();
while (Date.now() - start < 200) {}
res.end('done');
});
server.listen(3000, () => {
console.log('Server running on port 3000');
});
各リクエストはイベントループを200ミリ秒間ブロックし、その間、他のリクエストは処理されません。
▶ 例:setImmediate を使った長時間実行タスクの分割
function chunkedSort(arr, compareFn, callback) {
const CHUNK = 1000;
let offset = 0;
function sortChunk() {
const end = Math.min(offset + CHUNK, arr.length);
for (let i = offset; i < end; i++) {
for (let j = i + 1; j < end; j++) {
if (compareFn(arr[i], arr[j]) > 0) {
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
}
offset = end;
if (offset < arr.length) {
setImmediate(sortChunk);
} else {
callback(arr);
}
}
setImmediate(sortChunk);
}
chunkedSort(
[5, 3, 8, 1, 9, 2, 7, 4, 6],
(a, b) => a - b,
(result) => {
console.log('Sorted:', result);
}
);
| 次元 | ブロッキングコード | ノンブロッキングコード |
|---|---|---|
| 計算方法 | 単一の同期ループで完了 | setImmediate を使用して小さなチャンクに分割し、順次実行 |
| イベントループ | 停止中;他のリクエストは保留中 | スライス間で制御が譲渡される;他のコールバックが実行可能 |
| 応答時間 | 累積遅延 | 遅延のばらつき(ユーザーが許容できる範囲) |
| CPU使用率 | シングルコア 100%(急上昇) | 使用率が分散しており、I/O処理の時間が確保されている |
| ユースケース | 非常に短い計算 | 長い計算、高並行性サービス |
7. 総合的な例:イベントループの実行順序を確認するスクリプト
以下のスクリプトは、setTimeout → process.nextTick → setImmediate → Promise.then → I/O コールバックという完全な実行順序を検証します。
const fs = require('fs');
console.log('--- Script start ---');
setTimeout(() => {
console.log('1. setTimeout (macrotask - timers)');
}, 0);
setImmediate(() => {
console.log('2. setImmediate (macrotask - check)');
});
process.nextTick(() => {
console.log('3. process.nextTick (microtask - highest)');
});
Promise.resolve().then(() => {
console.log('4. Promise.then (microtask)');
});
fs.readFile(__filename, () => {
console.log('5. I/O callback (poll phase)');
setTimeout(() => {
console.log('6. inner setTimeout');
}, 0);
setImmediate(() => {
console.log('7. inner setImmediate');
});
process.nextTick(() => {
console.log('8. inner nextTick');
});
Promise.resolve().then(() => {
console.log('9. inner Promise.then');
});
});
console.log('--- Script end ---');
--- Script start ---
--- Script end ---
3. process.nextTick (microtask - highest)
4. Promise.then (microtask)
1. setTimeout (macrotask - timers)
2. setImmediate (macrotask - check)
5. I/O callback (poll phase)
8. inner nextTick
9. inner Promise.then
7. inner setImmediate
6. inner setTimeout
実行順序の分析:
- 同期コードが最初に実行され、「Script start」と「Script end」が出力される
- 現在のフェーズが終了したらマイクロタスクをクリアする:nextTick → Promise.then
- 「タイマー」の段階に入る:setTimeout
- チェックフェーズに入る:setImmediate
- I/Oが完了した後、ポーリングフェーズ中にコールバックを実行する
- I/O コールバックが完了したら、マイクロタスクをクリアする:inner nextTick → inner Promise.then
- 投票が終了したら、次のコードを入力してください:inner setImmediate
- 次のタイマーの実行:内部の setTimeout
❓ よくある質問
setTimeout(fn, 0) は本当に 0 ms 後に実行されるのですか?process.nextTick と Promise.then のどちらが先に実行されますか?process.nextTick が先に実行されます。nextTick はマイクロタスクキュー内で最優先であり、nextTick キューは常に Promise キューよりも先に処理されます。setImmediateがsetTimeoutよりも先に実行されるのですか?pollフェーズ中に実行されます。そのフェーズが終了すると、プロセスは直接checkフェーズ(setImmediate)に移行し、その後、次のtimersフェーズ(setTimeout)へと進みます。したがって、I/Oのコンテキストでは、setImmediateは常にsetTimeoutよりも先に実行されます。setImmediate を使用して段階的に実行します。また、ワーカースレッドを使用して、CPU負荷の高いタスクを別のスレッドに移します。さらに、同期I/Oの代わりに非同期I/Oを使用し、ストリームを活用して大規模なデータセットを分割して処理します。process.nextTick を無制限に再帰的に呼び出すことはできますか?process.maxTickDepth によって設定されたデフォルトの制限があります。nextTick への再帰的な呼び出しは、イベントループが次のフェーズに進むのを妨げ、I/O スターベーションを引き起こします。再帰的な nextTick の呼び出しの代わりに、setImmediate を使用すべきです。📖 まとめ
- ストーリー:200ミリ秒でブロックされるサービスの基本概念と使い方
- イベントループの6つのフェーズに関する主要な概念と使い方
- マイクロタスクとマクロタスクの基本概念と活用方法
- setTimeout、setImmediate、および process.nextTick の主要な概念と使い方
- マイクロタスクとマクロタスクの主要な概念と活用法
- イベントループをブロックするリスク、それを回避するための重要な概念、およびその方法
- 包括的な例:イベントループの実行順序の確認――スクリプトの基本概念と使い方
📝 練習問題
- このレッスンにあるすべてのコード例を完成させ、それぞれが正しく動作することを確認してください。
- 包括的な例を修正し、独自の拡張機能を追加する
- 公式ドキュメントを確認し、このレッスンで扱われていないAPIを1~2つ見つけ、それらのテストコードを作成してください。
- 振り返り:このレッスンで学んだことを、実際のプロジェクトにどのように活かしますか?
- このレッスンで学んだことと、これまでのレッスンの内容を組み合わせて、小さなプロジェクトを作成してみてください。



