Node.jsでのイベントループの仕組みとタイマーについて
イベントループ
イベントループとは?
イベントループとは、JavaScriptがシングルスレッドなのにもかかわらず、効率よくノンブロッキングI/Oを実行できるようにする仕組みです。
イベントループはメインスレッドで実行されます。
ブラウザのイベントループとは異なるので注意が必要です。
Node.jsのイベントループはlibuvに基づきます。
ブラウザのイベントループはhtml5に基づきます。
libuv
Node.jsの非同期はカーネルと会話するためにlibuvを使います。
もともと、Node.jsのために作られたものですが、今は様々なところで使われています。
libuvとは、非同期I/Oに強く、クロスプラットフォーム対応の抽象化ライブラリです。
基本的には、イベントループと非同期処理を行います。
libuvは、Node.jsにイベントループ機能全体を提供しています。
デフォルトでは、デフォルトサイズが4のスレッドプールを作ります。
Design overview — libuv documentation
イベントループのコードは以下を参照してください。
タスク
タスクは、同期タスクと非同期タスクの2種類存在します。
setTimeout(() => console.log(1)); setImmediate(() => console.log(2)); process.nextTick(() => console.log(3)); Promise.resolve().then(() => console.log(4)); (() => console.log(5))();
- 同期タスク
(() => console.log(5))();
- 非同期タスク
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));
同期タスクは常に非同期タスクよりも早く実行されます。
また、EventEmitter
で発生するイベントはタスクとは呼びません。
このコードの出力は以下の通りになります。
5 3 4 1 2
なぜこのような順番で出力されるかは、次のイベントループの説明でわかるはずです。
イベントループの仕組み
Node.jsが起動すると以下のイベントループが初期化されます。
http://voidcanvas.com/nodejs-event-loop
https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/#event-loop-explained
初期化はされますが、開始前に行われることがあります。
- タイマーのスケジュール設定
process.nextTick
等の実行- 同期タスクの実行
- 非同期APIの呼び出し
上記が終わり次第、イベントループが開始されます。
注意点として、イベントループは複数のtaskを同時に処理することはできないため、キューに入れられ順次処理をされるようになっています。
つまり、一つのタスクが完了する時間が長いと健全ではない(イベントループに遅延が出る)ということになります。
また、Node.jsではタスクキューの処理にOSのカーネルが依存しているため、タスクを受け取った瞬間を判断することは不可能で、準備ができている場合のみを知っています。
フェーズ
イベントループには6つのフェーズが存在します。
- timers
- pending callbacks
- idle, prepare
- poll
- check
- close callbacks
JavaScriptの実行は、idle, prepare を除くどこかのフェーズで実行されます。
それぞれフェーズには、実行するコールバックのFIFOジョブキューを持ちます。
そのフェーズに入るとそのフェーズの処理が実行され、キューが処理されます。
そして、キューがemptyになるかコールバックの上限に達したらイベントループは次のフェーズへ遷移します。
libuvとの関係図です。
https://jsblog.insiderattack.net/handling-io-nodejs-event-loop-part-4-418062f917d1
libuvは、各フェーズ毎にフェーズの結果をNodeに伝達する必要があります。
このときにnextTickQueueとmicroTaskQueueに入れられたイベントのキューをチェックします。
もし、キューが空ではない場合は空になるまでキューの処理を行い、メインのイベントループのフェーズへ移行します。
つまり、各フェーズ後(フェーズが移行する前)にnextTickQueueとmicroTaskQueueが実行されるということです。
フローは以下の図のような感じになります。
イベントキュー
libuvから提供されるキューとNodeが提供するキューの6種類があります。
- libuv
- Expired timers / intervals queue
- IO Events Queue
- Immediates Queue
- Close Handlers Queue
- Node
- nextTick Queue
- microTask Queue
nextTickQueue / microTaskQueue
code: ib/internal/bootstrap/node.js#L77-L78
code: lib/internal/process/next_tick.js
先にnextTickQueueとmicroTaskQueueの説明をしたいと思います。
この2つはlibuvによる提供ではなく、Nodeにより実装されています。
先程の説明の通り、イベントループの各フェーズの後にnextTickQueueとmicroTaskQueueに入れられたイベントのキューをチェックし、空になるまで実行します。
また、この2つはイベントループのフェーズの一部ではないことに注意してください。
nextTickQueue
process.nextTick
を使用して登録されたコールバックを保持します。
すべての非同期タスクの中で最速となります。
nextTickは再帰的に呼び出すとNodeをブロックする可能性があるため注意です。
microTaskQueue
Promises
オブジェクトのコールバックはここに所属します。
microTaskQueueに入っているPromiseはV8によって提供されるネイティブのみが適用対象とされます。
イベントループと同様にnextTickQueueが空になり次第、実行となります。
process.nextTick(() => console.log(1)); Promise.resolve().then(() => console.log(2)); process.nextTick(() => console.log(3)); Promise.resolve().then(() => console.log(4)); process.nextTick(() => console.log(5));
1 3 5 2 4
先にnextTickQueueが消費されているのがわかります。
ここまでの説明で、以下がなぜこの順番になるのか半分ぐらいわかるかと思います。
setTimeout(() => console.log(1)); setImmediate(() => console.log(2)); process.nextTick(() => console.log(3)); // 2 Promise.resolve().then(() => console.log(4)); // 3 (() => console.log(5))(); // 1
次からは各フェーズの説明を行っていきます。
Timers Phase
code: src/timer.c#L147-L164
イベントループの開始フェーズです。
このフェーズでは、setTimeout
や setInterval
のタイマーのコールバックを実行します。
タイマーを最小ヒープに保持し、Nodeは有効期限が切れたタイマーを確認し、コールバックを呼びます。
複数の有効期限が切れたタイマーが存在する場合、登録した順番に実行されます。(FIFO)
OSのスケジューリングや他のコールバックの実行により遅延が発生する可能性があり、Node.jsではコールバックの実行する正確なタイミングや順序付けは保証されません。
指定された時間のできるだけ近い時間で呼び出されます。
https://nodejs.org/api/timers.html#timers_settimeout_callback_delay_args
const start = process.hrtime(); setTimeout(() => { const end = process.hrtime(start); console.log(`timeout callback executed after ${end[0]}s and ${end[1]/Math.pow(10,9)}ms`); }, 1000);
# output timeout callback executed after 1s and 0.0070209ms timeout callback executed after 1s and 0.004651383ms timeout callback executed after 1s and 0.001348922ms
毎回異なる結果となり、0ms
となることはありません。
Pending Callbacks Phase
code: src/unix/core.c#L765-L784
イベントループのpending_queue
に存在するコールバックを実行するフェーズです。
完了、エラーのI/O操作のコールバックが実行されます。
pollフェーズの最後のラウンドで実行されるコールバックは実行できず、このラウンドのpending callbacksフェーズまで延期となります。
Idle, Prepare Phase
code: src/unix/loop-watcher.c#L48-L60
libuvによって内部的に呼び出されるフェーズです。
次のフェーズであるPoll Phaseが開始されるたびにPrepareも実行されます。
Poll Phase
code: src/unix/posix-poll.c#L134
このフェーズは、サーバの応答、まだ返されていないI/Oイベントを待機するために使用されるポーリング時間です。
新しいソケットコネクトやファイルの読み込みなどの新しいI/Oイベントを取得し、実行します。
このフェーズでは、以下の2つのことを行います。
- I / Oをブロックしてポーリングする時間を計算する
- キュー内のイベントを処理する
ポーリングする時間を計算します。(これは様々な状態によって結果が変わります)
I/Oの処理をシステムコールの epoll のキューに全て登録します。
epoll_waitシステムコールを呼び、ポーリングを行います。
完了したら、コールバックを呼びます。
キューになにか存在する場合、キューがemptyになるかシステム依存の限度に達するまで順次同期実行を行います。
キューが空の場合、以下の2つのうち1つが実行されます。
- スケジューリングされている場合、イベントループはこのフェーズを終了し、次のcheckフェーズへ進みスケジュールされたスクリプトを実行する
- スケジュールされていない場合、イベントループはコールバックがキューへ追加されるのを待ち実行する
Check Phase
setImmediate
のコールバック専用フェーズです。
setImmediate
で登録されたすべてのコールバックを実行します。
timerフェーズのものとは異なり、専用のフェーズがあるため、必ず実行が保証されます。
つまり、pollフェーズで実行されていたコールバック内にsetImmediate
が存在すれば、setTimeout
よりも先に呼ばれることが保証されます。
Close Callbacks Phase
code: src/unix/core.c#L293-L305
すべての close
イベントのコールバックが処理されます。(e.g. readable.on('close', () => {})
)
もし、キューに処理するものがなければ、ループが終了となります。
存在すれば、timerフェーズへ遷移します。
例
const { readFile } = require('fs'); const timeoutScheduled = Date.now(); setTimeout(() => { console.log(`delay: ${Date.now() - timeoutScheduled}ms`); }, 100); readFile(__filename, () => { const startCallback = Date.now(); while (Date.now() - startCallback < 500) {} });
# output
delay: 502ms
このコードをぱっと見た時に、100ms後にdelay: 100ms
と出力されるだろうと思うかもしれません。
このコードのフローを説明します。
第一ラウンド
スクリプトが最初のイベントループに入ったときには、まだ有効期限が切れたタイマーが存在しておらず、実行可能なI/Oコールバックも存在しません。
つまり、この第一ラウンドはポーリングフェーズに入り、カーネルからのファイル読み込み結果を待ちます。
このときは、ファイルの読み込みが軽量であり、タイマーよりも早く結果を取得します。
例えば、setTimeout
に時間を100ではなく0や1にしていた場合、ファイルの結果よりも先にタイマーの有効期限が切れるため、次のループで結果が変わります。
第二ラウンド
今回は、100msでやっていて、ファイルの読み込みのほうが速く、まだtimerの有効期限が切れてません。
もし、0や1であれば、delay
が出力されていたでしょう。
すでに、ファイルは取得できているため、pending callbacksフェーズに入ります。
このコールバック内では、500msの同期処理を実行させています。
そして、このコールバックはジョブキューに入っており、次のフェーズへ移行するには、キューを空にする必要があります。
なので、ここで500msの遅延(500msを停止させた)が発生したということになります。
無事、500msの実行が終わったらキューが空になるため、次のイベントループへ移行します。
第三ラウンド
すでに第二ラウンドの遅延により、タイマーの有効期限が切れるため、setTimeout
はタイマーフェーズ中に実行され、delay
を出力し終了します。
The Node.js Event Loop, Timers, and process.nextTick() | Node.js
まとめ
setTimeout(() => console.log(1)); setImmediate(() => console.log(2)); process.nextTick(() => console.log(3)); Promise.resolve().then(() => console.log(4)); (() => console.log(5))();
5 3 4 1 2
なぜこの順番の出力になるかは上記のイベントループの流れでわかるかと思います。
1. 同期タスク: (() => console.log(5))(); 2. 非同期タスク::nextTickQueue : process.nextTick(() => console.log(3)); 3. 非同期タスク::microTaskQueue: Promise.resolve().then(() => console.log(4)); 4. 非同期タスク::timers phase: setTimeout(() => console.log(1)); 5. 非同期タスク::check phase: setImmediate(() => console.log(2));
- イベントループはメインスレッドで実行される
- イベントループは複数のタスクを実行できず、キューに入れられたのを順次処理する
- イベントループには6つのフェーズが存在する
- フェーズが遷移する前にnextTickQueueとmicroTaskQueueが実行される
Timer
Node.jsで使えるタイマーは以下の4つとなります。
setImmediate
と process.nextTick
はNode.js固有でありブラウザにはないことに注意してください。
setTimeout setInterval setImmediate process.nextTick
setTimeout(() => {}, 0)
setTimeout(() => console.log('setTimeout')); setImmediate(() => console.log('setImmediate')); process.nextTick(() => console.log('nextTick'));
上記の出力は以下のようになります。
nextTick setTimeout setImmediate
nextTickが一番最初に来るのは最初に説明したとおりです。
そして、timersフェーズが来て、checkフェーズなのでこのような出力となります。
しかし、この出力は保証された出力ではありません。
timerフェーズに入ったときに有効期限が切れたかわからないためです。
さて、setTimeout
が0
の時の遅延はどれぐらいなのでしょうか?
第二引数の範囲は、1msから2147483647msと決められており、範囲外の指定をしたときには、1msとなるように規定されています。
つまり 0
のときは1msより大きい値となります。
Timers | Node.js v19.0.1 Documentation
ちなみに、setTimeout
を 4msに指定したら自分のPCではsetImmediate
が先に出力されるようになりました。
setImmediate
は、pollフェーズ後に保証された実行ができるため、使う場面によっては、有用な使い方が可能となります。
順番を操作する
const { readFile } = require('fs'); readFile(__filename, () => { setTimeout(() => console.log('setTimeout')); setImmediate(() => console.log('setImmediate')); });
setImmediate setTimeout
上の例だと必ず setImmediate
が先に出力されるようになります。
それは、最初にpending callbacksフェーズに入り、その次がcheckフェーズだからです。
timersフェーズは過ぎてしまっており、次のループなため出力が遅れるのです。
まとめ
ブラウザとは違う部分がありますが、macroTasksやmicroTasksの考えなどは同じ部分があります。
ちなみにブラウザはこの記事がわかりやすいです。
動画はこちら
What the heck is the event loop anyway? | Philip Roberts | JSConf EU - YouTube
イベントループは理解するまで難しいコンセントではありますが、一度理解すればコードの理解が深まったり、最適化できたりします。
(よく言われる「わからないでnextTick
を使うのは危険」っていう話とか)
なにかあれば、Twitterまでどうぞ!
リファレンス
- The Node.js Event Loop, Timers, and process.nextTick() | Node.js
- 不要混淆nodejs和浏览器中的event loop - CNode技术社区
- Node.js挖掘之四:从libuv到JS,一个TCP连接事件引发的一系列callback的逆向调用过程 - CNode技术社区
- Design overview — libuv documentation
- Node 定时器详解 - 阮一峰的网络日志
- https://jsblog.insiderattack.net/handling-io-nodejs-event-loop-part-4-418062f917d1