Node.js イベントループ:マクロタスク、マイクロタスク、process.nextTick の解明
Daniel Hayes
Full-Stack Engineer · Leapcell

Node.js における非同期の理解
Node.js は、I/O バウンドな操作を処理する上で信じられないほど効率的な、ノンブロッキングで非同期な性質で知られています。この効率性の中核にあるのは、Node.js がメイン実行スレッドをブロックすることなく長時間実行されるタスクを実行できるようにする、中心的な原則であるイベントループです。
開発者は、setTimeout
、setImmediate
、Promises、async/await
、process.nextTick
を使用する際に、しばしば予測不能に見える実行順序に遭遇します。この一見カオスな現象は、イベントループによって管理されるさまざまなタスクキューの洗練されたオーケストレーションに起因しています。
マクロタスク、マイクロタスク、process.nextTick
がどのように相互作用するかを理解することは、堅牢でパフォーマンスの高い Node.js アプリケーションを作成するために不可欠であり、レースコンディションのデバッグや、非常に応答性の高いシステムの設計に役立ちます。この記事では、Node.js イベントループの複雑さを解き明かし、これらの基本的な概念を説明し、実践的な例でその動作を実証します。
Node.js イベントループの内部動作
Node.js イベントループは、コールバックを処理する連続的なサイクルです。これは独立したスレッドではなく、Node.js が非同期操作を効率的に処理できるようにするメカニズムです。
Node.js が起動すると、イベントループを初期化し、スクリプトの実行を開始します。スクリプトが非同期操作に遭遇すると、そのコールバックを登録し、実際の処理を基盤となるシステム(例:I/O のオペレーティングシステムカーネル)にオフロードします。これらの操作が完了すると、そのコールバックはさまざまなキューに入れられ、イベントループによる実行の順番を待ちます。
イベントループは、特定の順序でこれらのキューからコールバックを選択し、それを駆動するのは個別のフェーズです。
マクロタスク
マクロタスクは、イベントループの個別のフェーズで処理される、より大きく、より時間のかかる操作を表します。各フェーズには独自の макроタスクキューがあります。
イベントループがあるフェーズから次のフェーズに移行する際に、次のフェーズに移動する前に、そのフェーズのマクロタスクキュー内のすべての保留中のコールバックを処理します。マクロタスクの一般的な例は次のとおりです。
- タイマー (
setTimeout
、setInterval
): これらのコールバックは、タイマーフェーズキューに入れられます。 - I/O コールバック (ファイルシステム、ネットワーク): これらは I/O コールバックフェーズキューに入れられます。これには、
fs.readFile
、http.get
などからのコールバックが含まれます。 setImmediate
: これらのコールバックは、I/O コールバックの後、イベントループの次のティックの前に実行されるように特別に設計されています。これらはチェックフェーズキューに配置されます。
例を挙げて説明しましょう。
console.log('Start'); setTimeout(() => { console.log('setTimeout callback'); }, 0); setImmediate(() => { console.log('setImmediate callback'); }); console.log('End');
このコードを実行すると、通常は次のように表示されます。
Start
End
setTimeout callback
setImmediate callback
ただし、setTimeout
が I/O 操作内にある場合、それぞれのフェーズの性質により、setTimeout
と setImmediate
の順序は予測不可能になる可能性があります。
const fs = require('fs'); console.log('Start'); fs.readFile(__filename, () => { setTimeout(() => { console.log('setTimeout inside I/O'); }, 0); setImmediate(() => { console.log('setImmediate inside I/O'); }); }); console.log('End');
この場合、fs.readFile
コールバック自体が I/O マクロタスクです。完了すると、イベントループは I/O コールバックフェーズに入ります。それを処理した後、setImmediate
が通常処理されるチェックフェーズに移動し、その後、setTimeout
のタイマーフェーズに移動します。したがって、次のように表示される可能性が高いです。
Start
End
setImmediate inside I/O
setTimeout inside I/O
マイクロタスク
マイクロタスクは、現在実行中のマクロタスクが完了した後、イベントループが次のフェーズに進む前に実行される、より小さい、より緊急性の高いタスクです。
これは、マイクロタスクキュー内のすべてのマイクロタスクが、イベントループが続行する前に完全に消費されることを意味します。これにより、マイクロタスクは後続のマクロタスクよりも優先度が高くなります。マイクロタスクの主な例は次のとおりです。
- Promise コールバック (
.then()
、.catch()
、.finally()
): Promise が解決または拒否されると、関連する.then()
または.catch()
コールバックがマイクロタスクとしてキューイングされます。 async/await
:await
キーワードはasync
関数を効果的に一時停止し、待機中の Promise が settled されたら、関数の残りの部分をマイクロタスクとしてスケジューリングします。queueMicrotask
API: マイクロタスクをキューイングするための直接的な方法です。
以下を検討してください。
console.log('Start'); setTimeout(() => { console.log('setTimeout macrotask'); }, 0); Promise.resolve().then(() => { console.log('Promise microtask'); }); console.log('End');
出力は次のようになります。
Start
End
Promise microtask
setTimeout macrotask
ここで、Promise.resolve().then()
はマイクロタスクをキューイングします。同期的な console.log('End')
が完了した後、マイクロタスクキューがチェックされ、イベントループがタイマーフェーズに進んで setTimeout macrotask
を処理する前に Promise microtask
が実行されます。
別のマイクロタスクを追加する場合。
console.log('Start'); setTimeout(() => { console.log('setTimeout macrotask'); }, 0); Promise.resolve().then(() => { console.log('First Promise microtask'); }); Promise.resolve().then(() => { console.log('Second Promise microtask'); }); console.log('End');
出力は、新しいマクロタスクが処理される前に、すべてのマイクロタスクが消費されることを示しています。
Start
End
First Promise microtask
Second Promise microtask
setTimeout macrotask
process.nextTick
の特殊ケース
process.nextTick
は Node.js におけるユニークな構成要素であり、優先度の点ではマクロタスクとマイクロタスクの両方から区別されます。
process.nextTick
に渡されたコールバックは、イベントループの現在のフェーズにおける他のどのマイクロタスクまたはマクロタスクよりも前に実行されます。これらは、Node.js が他のキューを処理しようとする直前の、現在の C++ スタックフレームの末尾で効果的に実行されます。
これにより、setTimeout(fn, 0)
の遅延を導入することなく、アクションを延期したいが、それがほとんどすぐに発生することを確認したい状況、通常はエラー処理や同期コードの分割に process.nextTick
が理想的になります。
その優先度を実際に見てみましょう。
console.log('Start'); setTimeout(() => { console.log('setTimeout macrotask'); }, 0); Promise.resolve().then(() => { console.log('Promise microtask'); }); process.nextTick(() => { console.log('process.nextTick callback'); }); console.log('End');
出力は process.nextTick
の支配を明確に示しています。
Start
End
process.nextTick callback
Promise microtask
setTimeout macrotask
process.nextTick
コールバックは、すべての同期コードが完了した直後に実行され、次にマイクロタスクが実行され、最後にイベントループがマクロタスクフェーズを進めます。
無限ループで process.nextTick
キューをブロックするとイベントループが枯渇し、他のコールバックが実行できなくなる可能性があるため、process.nextTick
を慎重に使用することが重要です。
イベントループフェーズの再訪
フローを要約すると、次のようになります。
- タイマーフェーズ:
setTimeout
およびsetInterval
コールバックを実行します。 - 保留中コールバックフェーズ: ほとんどのシステムコールバック(例:TCP エラー)を実行します。
- アイドル、準備フェーズ: Node.js 内部用です。
- ポーリングフェーズ:
- 新しい I/O イベントを取得します。
- I/O イベントのコールバックを実行します。
- ポーリングキューが空の場合、イベントループはここで、新しい I/O イベントが到着するまでブロックするか、setImmediate コールバックがある場合はチェックフェーズに移行する可能性があります。
- チェックフェーズ:
setImmediate
コールバックを実行します。 - クローズコールバックフェーズ:
close
ハンドラ(例:socket.on('close', ...)
)を実行します。
これらの各フェーズ間、および同期実行が完了した後、Node.js はこれらの内部キューを次の順序でチェックして消費します。
process.nextTick
キュー- マイクロタスクキュー (Promises、
queueMicrotask
)
この連続的なサイクルは、Node.js の並行モデルのバックボーンを形成し、従来のマルチスレッドに頼ることなく、多数の同時接続を効率的に処理できるようにします。
結論
Node.js イベントループは、マクロタスク、マイクロタスク、および優先度の高い process.nextTick
との相互作用により、Node.js の非同期機能を支えるエンジンです。
それぞれの優先度とイベントループフェーズの循環的な性質を理解することで、開発者はより予測可能で、パフォーマンスが高く、回復力のある Node.js アプリケーションを作成し、そのノンブロッキングアーキテクチャを真に活用できます。
これらの概念を習得することは、並行処理を効果的に管理し、複雑な非同期フローをデバッグするための鍵となります。