Node.jsでのイベントループの仕組みとタイマーについて

2018 / 09 / 26

Edit
🚨 This article hasn't been updated in over a year
💁‍♀️ This post was copied from Hatena Blog

イベントループ

イベントループとは?

イベントループとは、JavaScript がシングルスレッドなのにもかかわらず、効率よくノンブロッキング I/O を実行できるようにする仕組みです。 イベントループはメインスレッドで実行されます。

ブラウザのイベントループとは異なるので注意が必要です。

Node.js のイベントループは libuv に基づきます。 ブラウザのイベントループはhtml5に基づきます。

libuv

Node.js の非同期はカーネルと会話するために libuv を使います。 もともと、Node.js のために作られたものですが、今は様々なところで使われています。

libuv とは、非同期 I/O に強く、クロスプラットフォーム対応の抽象化ライブラリです。 基本的には、イベントループと非同期処理を行います。

libuv は、Node.js にイベントループ機能全体を提供しています。 デフォルトでは、デフォルトサイズが 4 のスレッドプールを作ります。

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

libuv/core.c at v1.x · libuv/libuv Cross-platform asynchronous I/O. Contribute to libuv/libuv development by creating an account on Git...

タスク

タスクは、同期タスクと非同期タスクの 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 が起動すると以下のイベントループが初期化されます。

Node.js event loop workflow & lifecycle in low level A year back while describing the differences between setImmediate & process.nextTick, I wrote a bit ...

The Node.js Event Loop, Timers, and process.nextTick() | Node.js Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.

初期化はされますが、開始前に行われることがあります。

  • タイマーのスケジュール設定
  • 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 Node.js is an event-based platform. This means that everything that happens in Node is the reaction ...

イベントキュー

libuv から提供されるキューと Node が提供するキューの 6 種類があります。

  • libuv
    • Expired timers / intervals queue
    • IO Events Queue
    • Immediates Queue
    • Close Handlers Queue
  • Node
    • nextTick Queue
    • microTask Queue

nextTickQueue / microTaskQueue

先に 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

イベントループの開始フェーズです。 このフェーズでは、setTimeoutsetInterval のタイマーのコールバックを実行します。 タイマーを最小ヒープに保持し、Node は有効期限が切れたタイマーを確認し、コールバックを呼びます。 複数の有効期限が切れたタイマーが存在する場合、登録した順番に実行されます。(FIFO)

OS のスケジューリングや他のコールバックの実行により遅延が発生する可能性があり、Node.js ではコールバックの実行する正確なタイミングや順序付けは保証されません。 指定された時間のできるだけ近い時間で呼び出されます。


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

イベントループのpending_queueに存在するコールバックを実行するフェーズです。 完了、エラーの I/O 操作のコールバックが実行されます。

poll フェーズの最後のラウンドで実行されるコールバックは実行できず、このラウンドの pending callbacks フェーズまで延期となります。

Idle, Prepare Phase

libuv によって内部的に呼び出されるフェーズです。 次のフェーズである Poll Phase が開始されるたびに Prepare も実行されます。

Poll Phase

このフェーズは、サーバの応答、まだ返されていない 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

すべての 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 Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.

まとめ

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 より大きい値となります。

ちなみに、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 の考えなどは同じ部分があります。

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

動画はこちら

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

リファレンス


The Node.js Event Loop, Timers, and process.nextTick() | Node.js Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.