Node.js のイベントループの仕組みを整理する

目次

はじめに

こんにちは、 @okarin です。弊社に入社してから Node.js を使って開発する機会が増えており、イベントループという用語をよく聞くようになりました。しかしながらその中身はどうなっているのか自分にとってはブラックボックスだったので、イベントループに関して調べてみました。

Node.js の非同期処理の仕組み

Node.js における非同期処理は、libuv というライブラリがその役割を担っています。 Node.js のランタイムである V8 が同期的な処理を担い、timer や I/O などの非同期処理は libuv に委譲されます。非同期処理が完了すると、非同期処理に対応する queue に callback が enqueue されます。この callback が実行されるタイミングはイベントループの仕組みによって決まります。

より詳しく知りたい方はアニメーションで分かりやすく解説している builder.io の記事をご参照ください。

イベントループの仕組み

全体像

まずは全体像を以下に示します。イベントループの各 phase はそれぞれに対応する queue を持っています。公式ドキュメントでは pending callbacks や idle, prepare などの phase が記述されていますが、今回は簡略化のため省略します。

Node.js のプログラムを実行すると、同期的な処理は逐次コールスタックに追加されて実行されます。一方でタイマーなどの非同期 API は別スレッドで実行されます。非同期 API の処理が完了すると、非同期 API に渡した callback は phase に応じた queue に追加(タイマーの場合は timer queue に追加)されます。

コールスタックが空になると、マイクロタスクキューに入っている callback を実行します。その後、イベントループ側の timer phase の queue (timer queue) に入っている callback を実行し、マイクロタスクキューの実行に移り、次は poll phase の queue (I/O queue) といったように処理が続いていきます。最後に close queue の処理が終わると、最初の timer queue に戻ってきて繰り返し処理が行われます。

こちらも上述した builder.io の記事の中で以下のような図を用いて解説されてるので参考にしてみてください。

マイクロタスク

さきほどマイクロタスクキューという言葉が出てきたので、マイクロタスクとは何か解説しておきます。MDN には以下のように記載されています。

マイクロタスクは、それを作成した関数やプログラムが終了した後、 JavaScript 実行スタックが空の場合にのみ実行され、ユーザーエージェントがスクリプトの実行環境を動かすために使用しているイベントループにコントロールを返す前に実行される短い関数です。

JavaScript 実行スタックが空の場合にマイクロタスクが実行される様子を以下のコードでみてみます。

console.log("1: start line");

Promise.resolve().then(() => console.log("3: promise"));

console.log("2: end line");

この結果は以下のようになります。このように、 Promise の then メソッドに渡した callback はマイクロタスクキューに enqueue されて、 JavaScript 実行スタックが空になった後に callback が実行されます。

1: start line
2: end line
3: promise

マイクロタスクキューの種類

Node.js の場合は、少しややこしいのですが通常のマイクロタスクキューとは別に、nextTick 専用の queue があります。分類すると以下のようになります。 queue の優先度としては、 nextTick 専用のものが優先されます。

マイクロタスクキューを理解するために、以下のコードを考えてみます。

console.log("1: start line");

queueMicrotask(() => console.log("4: queueMicrotask before promise"));

Promise.resolve().then(() => console.log("5: promise"));

queueMicrotask(() => console.log("6: queueMicrotask after promise"));

process.nextTick(() => console.log("3: nextTick"));

console.log("2: end line");

この結果は以下のようになります。まずは同期的な処理が全て完了した後に、nextTick に渡した callback が優先されています。その後、通常のマイクロタスクキューに enqueue された順番に実行されており、 Promise の then メソッドと queueMicrotask との間に優先度の差異はないことが分かります。

1: start line
2: end line
3: nextTick
4: queueMicrotask before promise
5: promise
6: queueMicrotask after promise

timer phase

次に、 timer phase についてみていきます。 timer phase の queue (= timer queue) に enqueue されるのはsetTimeoutsetInterval に渡した callback になります。

console.log("1: start line");

setTimeout(() => {
  console.log("4: start setTimeout");

  process.nextTick(() => console.log("6: nextTick inner setTimeout"));

  console.log("5: end setTimeout");
}, 0);

process.nextTick(() => console.log("3: nextTick"));

console.log("2: end line");

この結果は以下のようになります。同期的な処理(1 と 2)が完了した後、マイクロタスクキューに enqueue された callback (3)が実行されます。その後、 timer queue に enqueue した callback (4 と 5)が実行されます。 その中でさらにマイクロタスクキューに callback (6)を追加しており、これは最後に実行されます。

1: start line
2: end line
3: nextTick
4: start setTimeout
5: end setTimeout
6: nextTick inner setTimeout

マイクロタスクキューを空にする → timer queue を空にする → マイクロタスクキューを空にする、といった流れがイメージできたでしょうか? 次は poll phase をみていきます。難しくなってきたら全体像の図を見直してみてください。

poll phase

poll phase はファイル読み込みなどの I/O 処理になります。例えば、以下のように fs.readFile に渡した callback は I/O queue に enqueue されます。

const fs = require("fs");

console.log("1: start line");

fs.readFile(__filename, () => {
  console.log("?: start readFile");

  process.nextTick(() => console.log("?: nextTick inner readFile"));

  console.log("?: end readFile");
});

setTimeout(() => {
  console.log("4: start setTimeout");

  process.nextTick(() => console.log("6: nextTick inner setTimeout"));

  console.log("5: end setTimeout");
}, 0);

process.nextTick(() => console.log("3: nextTick"));

console.log("2: end line");

この結果は以下のようになります。ここで意外に思われた方もいるかと思いますが、timer queue が実行されるタイミングが一意に定まりません。これは setTimeout の仕様に起因します。ドキュメントにも記載されていますが、2147483647 より大きい、または 1 より小さい値が渡された時は 1 に上書きされます。したがって、0 を指定しても 1ms 待ってから実行されるようになります。そのため、場合によっては先に readFile が完了し、 I/O queue の callback が先に処理されるようになり、次のループで timer queue の処理が実行されます。

1: start line
2: end line
3: nextTick
4: start setTimeout
5: end setTimeout
6: nextTick inner setTimeout
?: start readFile
?: end readFile
?: nextTick inner readFile

または
1: start line
2: end line
3: nextTick
?: start readFile
?: end readFile
?: nextTick inner readFile
4: start setTimeout
5: end setTimeout
6: nextTick inner setTimeout

timer queue を実行するタイミングを一意に定めたい時は、以下の for 文のように長い同期的な処理を追加すると実現できます。

const fs = require("fs");

console.log("1: start line");

fs.readFile(__filename, () => {
  console.log("7: start readFile");

  process.nextTick(() => console.log("9: nextTick inner readFile"));

  console.log("8: end readFile");
});

setTimeout(() => {
  console.log("4: start setTimeout");

  process.nextTick(() => console.log("6: nextTick inner setTimeout"));

  console.log("5: end setTimeout");
}, 0);

process.nextTick(() => console.log("3: nextTick"));

for (let i = 0; i < 1_000_000_000; i++) {
  // do nothing
}

console.log("2: end line");

この処理の結果は以下のようになり、実行タイミングが一意に定まります。timer phase → poll phase の順に処理が実行され、phase が切り替わるタイミングでマイクロタスクキューが実行されていることが分かります。

1: start line
2: end line
3: nextTick
4: start setTimeout
5: end setTimeout
6: nextTick inner setTimeout
7: start readFile
8: end readFile
9: nextTick inner readFile

check phase

check phase の queue にはsetImmediate に渡した callback が追加されます。immediate という名前がついているのに timer queue などの後に実行されるのが少しややこしいですね。

setImmediate を使ったコード例を以下に示します。簡略化のため、前節よりもコードを少し削っています。

const fs = require("fs");

setImmediate(() => {
  console.log("2: setImmediate");
});

fs.readFile(__filename, () => {
  console.log("3: readFile");
});

setTimeout(() => {
  console.log("1: setTimeout");
}, 0);

for (let i = 0; i < 1_000_000_000; i++) {
  // do nothing
}

このコードの実行結果は以下のようになります。check phase は poll phase の後だから setImmediate に渡した callback は最後に実行されるのでは? と思った方も多いかと思います。これは自分自身も完全に理解できているわけではないのですが、イベントループは I/O 操作が完了したかどうかを確かめるために poll する必要があり、完了した操作の callback だけを I/O queue に enqueue するようです。したがって、制御が初めて poll phase にきた時には I/O queue は空となるため、次の check phase へと移行し、 setImmediate に渡した callback が先に実行されます。builder.io の記事にこのあたりのことが書かれているので、より詳細が気になる方は読んでみてください。

1: setTimeout
2: setImmediate
3: readFile

close callbacks phase

最後に、close callbacks phase となります。以下のように stream などを close したときに close イベントが close callbacks phase で発行されます。場合によっては、 process.nextTick() で発行されるケースもあります。

const fs = require("fs");

const readableStream = fs.createReadStream(__filename);
readableStream.close();

readableStream.on("close", () => {
  console.log("3: close readableStream");
});

setImmediate(() => {
  console.log("2: setImmediate");
});

setTimeout(() => {
  console.log("1: setTimeout");
}, 0);

for (let i = 0; i < 1000000_000; i++) {
  // do nothing
}

この結果は以下のようになります。 check phase の後に close callbacks phase に移行していることが分かります。

1: setTimeout
2: setImmediate
3: close readableStream

まとめ

ここまでイベントループの各 phase とマイクロタスクキューについて解説してきました。最後に、Node.js における非同期 API の fs.readFile と、同期 API の fs.readFileSync を比較してみます。

import * as fs from "fs";

console.log("1: start line");

fs.readFile("./sample.txt", (_, data) => {
  console.log(`5: readFile "${data.toString()}"`);
});

process.nextTick(() => console.log("4: nextTick"));

const data = fs.readFileSync("./sample.txt");

console.log(`2: readFileSync "${data.toString()}"`);

console.log("3: end line");

今回読み込むファイルの sample.txt の中身は以下になります。

hogehoge

この結果は以下のようになります。 readFileSync はマイクロタスクキューやイベントループの queue に入れられることはなく、同期的に実行されています。 readFile に渡した callback は I/O queue に入ってから実行されています。

1: start line
2: readFileSync "hogehoge"
3: end line
4: nextTick
5: readFile "hogehoge"

以上で本記事は終わりになります。この記事が少しでも Node.js の非同期処理やイベントループの解像度が上がる一助となれば嬉しいです。

参考文献