技術探し

JavaScriptを中心に記事を書いていきます :;(∩´﹏`∩);:

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

イベントループのコードは以下を参照してください。

github.com

タスク

タスクは、同期タスクと非同期タスクの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が実行されるということです。

フローは以下の図のような感じになります。

What you should know to really understand the Node.js Event Loop | by Daniel Khan | Node.js Collection | Medium

イベントキュー

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

イベントループの開始フェーズです。
このフェーズでは、setTimeoutsetInterval のタイマーのコールバックを実行します。
タイマーを最小ヒープに保持し、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つとなります。
setImmediateprocess.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フェーズに入ったときに有効期限が切れたかわからないためです。

さて、setTimeout0の時の遅延はどれぐらいなのでしょうか?
第二引数の範囲は、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の考えなどは同じ部分があります。

ちなみにブラウザはこの記事がわかりやすいです。

jakearchibald.com

動画はこちら
What the heck is the event loop anyway? | Philip Roberts | JSConf EU - YouTube

イベントループは理解するまで難しいコンセントではありますが、一度理解すればコードの理解が深まったり、最適化できたりします。
(よく言われる「わからないでnextTickを使うのは危険」っていう話とか)

なにかあれば、Twitterまでどうぞ!

リファレンス