はじめに
前回の記事「JavaScriptのイベントループ基礎」では、ブラウザを中心にマクロ・マイクロタスクの優先度をまとめました。
Node.jsのイベントループは異なり、libuvによって管理される6つのフェーズという段階的な処理フローを持っています。
本記事では各フェーズがどのような役割を持ち、どの順番で処理されるのかをまとめていきます。
libuv とは
libuv とは、Node.jsが複数のI/O処理を非同期かつ並行に処理できるようにするためのC言語のライブラリです。
JavaScriptエンジン(V8)は単一スレッドですが、libuvはOSのマルチスレッド(スレッドプールなど)を活用することで、ファイルI/Oやネットワーク通信といった時間のかかる操作をメインスレッドから切り離し、見かけ上のノンブロッキングな処理を実現しています。
┌─────────────────┐
│ V8 エンジン │
│ (シングル) │
└────────┬────────┘
│
│ 委譲
▼
┌──────────────────────────────┐
│ libuv (マルチスレッド) │
│ ┌────────┐┌────────┐ │
│ │Thread1 ││Thread2 │ ... │
│ │File I/O││Network │ │
│ └────────┘└────────┘ │
└────────┬─────────────────────┘
│
│ 完了通知
▼
┌─────────────────┐
│ V8 エンジン │ コールバック実行
│ (シングル) │
└─────────────────┘
Node.jsのイベントループ
Node.jsのイベントループは、libuvが管理する6つのフェーズを順番に処理していく仕組みです。各フェーズはFIFO(先入先出)キューを持ち、キューが空になるまでそのフェーズにとどまります。
┌──────────────┐
│ start/end │ (メインスクリプトの実行開始・終了)
└───────┬──────┘
│
┌───────▼───────┐
│ 1. timers │ → setTimeout(), setInterval() のコールバック
└───────┬───────┘
│
┌───────▼───────┐
│ 2. pending │ → 遅延された I/O や TCP/UDP エラーなどのシステムコールバック
│ callbacks │
└───────┬───────┘
│
┌───────▼───────┐
│ 3. idle, │ → libuv 内部処理用
│ prepare │
└───────┬───────┘
│
┌───────▼───────┐
│ 4. poll │ → 新しい I/O イベントの待機とコールバックの実行
└───────┬───────┘
│
┌───────▼───────┐
│ 5. check │ → setImmediate() のコールバック
└───────┬───────┘
│
┌───────▼───────┐
│ 6. close │ → socket.close() などのクローズイベントのコールバック
│ callbacks │
└───────┴───────┘
│
└→ (次のループへ)
1. timers
setTimeout() と setInterval() のコールバックを実行します。
コールバックが実行されるタイミングは、指定した時間(閾値)を経過した後の、イベントループがtimersフェーズに到達した際です。poll フェーズでI/O処理が長引いた場合、実際の実行時間は指定した時間よりも遅延する可能性があります。
2. pending callbacks
前回のループサイクルで処理が遅延されたシステムレベルの操作のコールバックを実行するフェーズです。主に、TCPやUDPのエラー、ファイルシステムのエラーなど、OSからの特殊な通知が該当します。通常、開発者が直接コールバックを登録することは稀です。
3. idle, prepare
libuvの内部処理用フェーズで、開発者は直接的に関わることはありません。
4. poll
イベントループの中心となるフェーズで、以下の2つの主な役割を担います。
- I/Oイベントの待機:キューにコールバックがない場合、ここでブロッキングしてI/Oイベントの完了を待機します。
- 完了したI/Oコールバックの実行:ファイル読み込み、ネットワーク接続、HTTPリクエストなど、ほとんどの非同期I/O処理の完了コールバックがここで実行されます。
動作フロー
- ポーリングキューにコールバックがある場合
- コールバックを順番に実行します(キューが空になるか、システム制限に達するまで)。
- ポーリングキューが空の場合
-
setImmediate()が予約されていれば、待機せずにcheckフェーズへ直ちに進みます。 - timers フェーズのタイマーが期限切れになっている場合は、待機せずにtimersフェーズへ戻ります。
- どちらもなければ、I/Oイベントが発生するまで待機(ブロッキング) します。
-
5. check
setImmediate() のコールバックを実行するフェーズです。
setImmediate() はpollフェーズの終了直後に実行されることが保証されており、この特性を利用して、I/O操作の完了直後に特定の処理を実行したい場合に用いられます。
6. close callbacks
ソケットやファイルハンドルなど、閉じられたリソースに関連するコールバック('close' イベントのハンドラ)を実行するフェーズです。
socket.destroy() や server.close() などの操作でリソースが正常にクローズされた際のイベント処理を担当し、イベントループの最後のフェーズとして機能しています。
マイクロタスク(Promise と process.nextTick)
Node.jsにおいて、マイクロタスクキューは各フェーズの完了後(次のフェーズへ移行する前)に処理されます。
マイクロタスクキューには、process.nextTick() のキューと、Promise.then() などのその他のマイクロタスクの2種類があります。
┌──────────────────┐
│ フェーズ n 実行 │
└────────┬─────────┘
│
┌────────▼────────┐
│ process.nextTick キュー全実行 │ (最高優先度)
└────────┬─────────┘
│
┌────────▼────────┐
│ Promise 他のマイクロタスク全実行 │
└────────┬─────────┘
│
┌──────────────────┐
│ フェーズ n+1 へ │
└──────────────────┘
-
process.nextTick():最高優先度で、現在のフェーズのコールバック処理が完了し、次のフェーズへ移行する直前に、他のマイクロタスクよりも先に全て実行されます。 -
Promise (.then(), .catch(), .finally()):process.nextTick()キューが空になった後、イベントループが次のフェーズへ移行する前に実行されます。
具体例
上記のルールに基づき、以下のコードの実行順序を追ってみましょう。
console.log('START');
setTimeout(() => {
console.log('1. setTimeout');
Promise.resolve().then(() => {
console.log('1-1. Promise in setTimeout');
});
}, 0);
setImmediate(() => {
console.log('2. setImmediate');
});
Promise.resolve()
.then(() => {
console.log('3. Promise 1');
process.nextTick(() => {
console.log('3-1. nextTick in Promise');
});
})
.then(() => {
console.log('3-2. Promise 2');
});
process.nextTick(() => {
console.log('4. nextTick');
setImmediate(() => {
console.log('4-1. setImmediate in nextTick');
});
});
console.log('END');
// 実行順
// 1. console.log('START') 実行
// 2. setTimeout をtimersフェーズへ登録
// 3. setImmediate をcheckフェーズへ登録
// 4. Promise.resolve().then をマイクロタスクキューに登録
// 5. process.nextTick をnextTickキューに登録
// 6. console.log('END') 実行
// 7. nextTick キュー(process.nextTick)全実行
// 8. console.log('4. nextTick') を実行
// 9. nextTick 内の setImmediate をcheckフェーズへ登録
// 10. マイクロタスクキュー(Promise.resolve().then)を実行
// 11. console.log('3. Promise 1') を実行
// 12. Promise.resolve().then 内の process.nextTick をnextTickキューに登録
// 13. console.log('3-2. Promise 2') を実行(Promise チェーン実行)
// 14. マイクロタスクキュー全実行完了
// 15. nextTick キュー(Promise 内の process.nextTick)全実行
// 16. console.log('3-1. nextTick in Promise') を実行
// 17. timers フェーズ(setTimeout)実行
// 18. console.log('1. setTimeout') を実行
// 19. setTimeout 内の Promise.resolve().then をマイクロタスクキューに登録
// 20. マイクロタスクキュー(setTimeout 内の Promise)を実行
// 21. console.log('1-1. Promise in setTimeout') を実行
// 22. check フェーズ(setImmediate)実行
// 23. console.log('2. setImmediate') を実行
// 24. nextTick 内の setImmediate を実行
// 25. console.log('4-1. setImmediate in nextTick') を実行
// 出力:
// START
// END
// 4. nextTick
// 3. Promise 1
// 3-2. Promise 2
// 3-1. nextTick in Promise
// 1. setTimeout
// 1-1. Promise in setTimeout
// 2. setImmediate
// 4-1. setImmediate in nextTick
まとめ
- Node.jsのイベントループはlibuvによって管理され、6つのフェーズを巡回して動作します。
- poll フェーズが非同期I/O処理の完了と待機(ブロッキング)の中心となります。
-
process.nextTick()は各フェーズ間のマイクロタスクチェックにおいて最高優先度で実行されます。 -
Promiseなどのマイクロタスクは、process.nextTick()の後に、各フェーズの完了後に処理されます。 -
setImmediate()はcheckフェーズで実行され、setTimeout(() => {}, 0)はtimersフェーズで実行されます。I/Oブロックがない場合は、setTimeoutとsetImmediateの実行順序は非確定的ですが、I/Oブロックがある場合はsetImmediateが優先される傾向があります。