Node.js のイベントループ
イベントループとは?
イベントループは、可能な限りオペレーションをシステムカーネルにオフロードすることで、Node.jsが(デフォルトでは単一のJavaScriptスレッドを使用しているにもかかわらず)ノンブロッキングI/Oオペレーションを実行できるようにするものです。
ほとんどの最新のカーネルはマルチスレッドであるため、バックグラウンドで実行される複数のオペレーションを処理できます。これらのオペレーションのいずれかが完了すると、カーネルはNode.jsに通知し、適切なコールバックが最終的に実行されるようにポーリングキューに追加されます。これについては、このトピックで後ほど詳しく説明します。
イベントループの説明
Node.jsが起動すると、イベントループを初期化し、提供された入力スクリプトを処理(またはREPLにドロップ。これはこのドキュメントでは扱いません)します。これにより、非同期API呼び出し、タイマーのスケジュール、またはprocess.nextTick()の呼び出しが行われ、その後、イベントループの処理が開始されます。
次の図は、イベントループのオペレーション順序の簡略化された概要を示しています。
┌───────────────────────────┐
┌─>│ タイマー │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ 保留中のコールバック │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ アイドル、準備 │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ 受信: │
│ │ ポーリング │<─────┤ 接続、 │
│ └─────────────┬─────────────┘ │ データなど │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ チェック │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ クローズコールバック │
└───────────────────────────┘
TIP
各ボックスは、イベントループの「フェーズ」と呼ばれます。
各フェーズには、実行するコールバックのFIFOキューがあります。各フェーズはそれぞれ独自の方法で特別ですが、一般的に、イベントループが特定のフェーズに入ると、そのフェーズに固有のオペレーションを実行し、キューが空になるか、コールバックの最大数が実行されるまで、そのフェーズのキュー内のコールバックを実行します。キューが空になるか、コールバックの制限に達すると、イベントループは次のフェーズに移動し、以下同様に続きます。
これらのオペレーションのいずれかがより多くのオペレーションをスケジュールし、ポーリングフェーズで処理された新しいイベントがカーネルによってキューに入れられるため、ポーリングイベントが処理されている間にポーリングイベントをキューに入れることができます。その結果、実行時間の長いコールバックにより、ポーリングフェーズがタイマーのしきい値よりもはるかに長く実行される可能性があります。詳細については、タイマーおよびポーリングのセクションを参照してください。
TIP
WindowsとUnix/Linuxの実装にはわずかな違いがありますが、このデモンストレーションでは重要ではありません。最も重要な部分はここにあります。実際には7つまたは8つのステップがありますが、Node.jsが実際に使用する、私たちが気にするものは上記のものだけです。
Phases Overview
- timers: このフェーズでは、
setTimeout()
およびsetInterval()
によってスケジュールされたコールバックを実行します。 - pending callbacks: 次のループイテレーションに延期された I/O コールバックを実行します。
- idle, prepare: 内部でのみ使用されます。
- poll: 新しい I/O イベントを取得します。I/O 関連のコールバックを実行します(クローズコールバック、タイマーによってスケジュールされたコールバック、および
setImmediate()
を除くほぼすべて)。ノードは、必要に応じてここでブロックします。 - check:
setImmediate()
コールバックがここで呼び出されます。 - close callbacks: いくつかのクローズコールバック。例:
socket.on('close', ...)
。
イベントループの各実行の間で、Node.js は非同期 I/O またはタイマーを待機しているかどうかを確認し、存在しない場合は正常にシャットダウンします。
Phases in Detail
timers
タイマーは、提供されたコールバックが実行される正確な時間ではなく、実行される可能性がある閾値を指定します。タイマーのコールバックは、指定された時間が経過した後、スケジュールできる限り早く実行されます。ただし、オペレーティングシステムのスケジューリングまたは他のコールバックの実行によって遅延する場合があります。
TIP
厳密には、poll フェーズがタイマーの実行を制御します。
たとえば、100ms の閾値後に実行されるタイムアウトをスケジュールし、スクリプトが 95ms かかるファイルの非同期読み取りを開始するとします。
const fs = require('node:fs');
function someAsyncOperation(callback) {
// これが完了するまでに 95ms かかると仮定します
fs.readFile('/path/to/file', callback);
}
const timeoutScheduled = Date.now();
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}ms have passed since I was scheduled`);
}, 100);
// 完了するまでに 95ms かかる someAsyncOperation を実行します
someAsyncOperation(() => {
const startCallback = Date.now();
// 10ms かかる何かを実行します...
while (Date.now() - startCallback < 10) {
// 何もしません
}
});
イベントループが poll フェーズに入ると、キューは空です(fs.readFile()
は完了していません)。そのため、最も近いタイマーの閾値に到達するまでに残りのミリ秒数を待ちます。 95ms が経過する間、fs.readFile()
はファイルの読み取りを完了し、完了までに 10ms かかるコールバックが poll キューに追加され、実行されます。コールバックが完了すると、キューにコールバックはもうありません。したがって、イベントループは、最も近いタイマーの閾値に達したことを確認し、タイマーのコールバックを実行するためにタイマーフェーズに戻ります。この例では、タイマーのスケジュールからコールバックの実行までの合計遅延が 105ms になることがわかります。
TIP
poll フェーズがイベントループを枯渇させないようにするために、libuv (Node.js イベントループとプラットフォームのすべての非同期動作を実装する C ライブラリ)には、より多くのイベントのポーリングを停止する前に、ハード最大値(システムに依存)もあります。
保留中のコールバック
このフェーズでは、TCPエラーの種類など、いくつかのシステム操作に対するコールバックを実行します。たとえば、TCPソケットが接続を試みるときにECONNREFUSED
を受信した場合、一部の*nixシステムでは、エラーの報告を待機したい場合があります。これは保留中のコールバックフェーズで実行されるようにキューに入れられます。
poll
pollフェーズには、主に2つの機能があります。
- ブロックしてI/Oをポーリングする時間を計算し、
- pollキューのイベントを処理します。
イベントループがpollフェーズに入り、スケジュールされたタイマーがない場合、次の2つのいずれかが発生します。
pollキューが空でない場合、イベントループはキューのコールバックを反復処理し、キューがなくなるか、システム依存のハードリミットに達するまで同期的に実行します。
pollキューが空の場合、さらに次の2つのいずれかが発生します。
スクリプトが
setImmediate()
によってスケジュールされている場合、イベントループはpollフェーズを終了し、チェックフェーズに進んで、スケジュールされたスクリプトを実行します。スクリプトが
setImmediate()
によってスケジュールされていない場合、イベントループはコールバックがキューに追加されるのを待ち、すぐに実行します。
pollキューが空になると、イベントループは時間しきい値に達したタイマーを確認します。1つ以上のタイマーの準備ができている場合、イベントループはtimersフェーズに戻って、それらのタイマーのコールバックを実行します。
check
このフェーズでは、pollフェーズが完了した直後にコールバックを実行できます。pollフェーズがアイドル状態になり、スクリプトがsetImmediate()
でキューに入れられている場合、イベントループは待機せずにチェックフェーズに進む場合があります。
setImmediate()
は、実際にはイベントループの別のフェーズで実行される特別なタイマーです。これは、pollフェーズが完了した後にコールバックを実行するようにスケジュールするlibuv APIを使用します。
一般に、コードが実行されると、イベントループは最終的にpollフェーズに到達し、そこで受信接続、リクエストなどを待ちます。ただし、コールバックがsetImmediate()
でスケジュールされており、pollフェーズがアイドル状態になると、終了し、pollイベントを待機せずにcheckフェーズに進みます。
close コールバック
ソケットまたはハンドルが突然閉じられた場合(例:socket.destroy()
)、'close'
イベントはこのフェーズで発生します。そうでない場合は、process.nextTick()
を介して発生します。
setImmediate()
vs setTimeout()
setImmediate()
と setTimeout()
は似ていますが、呼び出されるタイミングによって動作が異なります。
setImmediate()
は、現在の poll フェーズが完了するとすぐにスクリプトを実行するように設計されています。setTimeout()
は、指定された最小時間(ミリ秒)が経過した後にスクリプトが実行されるようにスケジュールします。
タイマーが実行される順序は、呼び出されるコンテキストによって異なります。両方がメインモジュール内から呼び出された場合、タイミングはプロセスのパフォーマンスによって制限されます(これは、マシン上で実行されている他のアプリケーションの影響を受ける可能性があります)。
たとえば、I/Oサイクル内にない(つまり、メインモジュールである)次のスクリプトを実行すると、2つのタイマーが実行される順序は非決定的であり、プロセスのパフォーマンスによって制限されます。
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
ただし、2つの呼び出しをI/Oサイクル内に移動すると、immediate コールバックは常に最初に実行されます。
// timeout_vs_immediate.js
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout
setTimeout()
よりも setImmediate()
を使用する主な利点は、I/Oサイクル内でスケジュールされている場合、タイマーの数に関係なく、setImmediate()
は常に他のタイマーよりも先に実行されることです。
process.nextTick()
process.nextTick()
を理解する
process.nextTick()
は非同期 API の一部であるにもかかわらず、図に表示されていないことに気づいたかもしれません。これは、process.nextTick()
が厳密にはイベントループの一部ではないためです。代わりに、nextTickQueue
は、イベントループの現在のフェーズに関係なく、現在の操作が完了した後に処理されます。ここで、操作は、基盤となる C/C++ ハンドラーからのトランジションと、実行する必要がある JavaScript の処理として定義されます。
図を振り返ると、特定のフェーズで process.nextTick()
を呼び出すたびに、process.nextTick()
に渡されたすべてのコールバックは、イベントループが続行する前に解決されます。これにより、再帰的な process.nextTick()
呼び出しを行うことで I/O を「飢餓状態」にし、イベントループが poll フェーズに到達できなくなるため、いくつかの悪い状況が発生する可能性があります。
なぜ許可されるのか?
なぜこのようなものが Node.js に含まれるのでしょうか?その一部は、API は必要がない場合でも常に非同期であるべきだという設計思想です。次のコードスニペットを例に取ります。
function apiCall(arg, callback) {
if (typeof arg !== 'string')
return process.nextTick(
callback,
new TypeError('argument should be string')
);
}
このスニペットは引数チェックを行い、正しくない場合は、エラーをコールバックに渡します。API はごく最近更新され、process.nextTick()
に引数を渡せるようになり、コールバックの後に渡された引数をコールバックへの引数として伝播させることができるため、関数をネストする必要がなくなりました。
私たちが行っていることは、ユーザーにエラーを返すことですが、ユーザーのコードの残りの部分の実行を許可した後のみです。process.nextTick()
を使用することで、apiCall()
は常にユーザーのコードの残りの部分を実行した後、イベントループが続行する前にコールバックを実行することを保証します。これを実現するために、JS コールスタックはアンワインドされ、すぐに提供されたコールバックを実行することが許可され、これにより、v8
から RangeError: Maximum call stack size exceeded
に到達することなく、process.nextTick()
を再帰的に呼び出すことができます。
この哲学は、潜在的に問題のある状況につながる可能性があります。次のスニペットを例に取ります。
let bar;
// これは非同期シグネチャを持ちますが、コールバックを同期的に呼び出します
function someAsyncApiCall(callback) {
callback();
}
// コールバックは `someAsyncApiCall` が完了する前に呼び出されます。
someAsyncApiCall(() => {
// someAsyncApiCall が完了していないため、bar にはまだ値が割り当てられていません
console.log('bar', bar); // undefined
});
bar = 1;
ユーザーは someAsyncApiCall()
が非同期シグネチャを持つように定義しますが、実際には同期的に動作します。呼び出されると、someAsyncApiCall()
は実際には非同期的に何も行わないため、someAsyncApiCall()
に提供されるコールバックはイベントループの同じフェーズで呼び出されます。その結果、コールバックは、スクリプトが完了まで実行できなかったため、まだスコープ内にその変数がない可能性があるにもかかわらず、bar を参照しようとします。
コールバックを process.nextTick()
に配置することで、スクリプトは依然として完了まで実行する能力を持ち、すべての変数、関数などをコールバックが呼び出される前に初期化できます。また、イベントループが続行できないという利点もあります。イベントループが続行する前に、ユーザーにエラーを警告することが役立つ場合があります。次に、process.nextTick()
を使用した前の例を示します。
let bar;
function someAsyncApiCall(callback) {
process.nextTick(callback);
}
someAsyncApiCall(() => {
console.log('bar', bar); // 1
});
bar = 1;
以下は別の現実世界の例です。
const server = net.createServer(() => {}).listen(8080);
server.on('listening', () => {});
ポートのみが渡されると、ポートはすぐにバインドされます。したがって、'listening'
コールバックはすぐに呼び出される可能性があります。問題は、その時点で .on('listening')
コールバックが設定されていないことです。
これを回避するために、'listening'
イベントは nextTick()
でキューに入れられ、スクリプトが完了まで実行できるようになります。これにより、ユーザーは必要なイベントハンドラーを設定できます。
process.nextTick()
vs setImmediate()
ユーザーにとって両者は似たようなものですが、名前が紛らわしいです。
process.nextTick()
は同じフェーズで即座に実行されますsetImmediate()
はイベントループの次のイテレーションまたは「tick」で実行されます
本質的に、名前は入れ替えるべきです。process.nextTick()
は setImmediate()
よりも早く実行されますが、これは過去の遺物であり、変更される可能性は低いでしょう。この変更を行うと、npm 上のパッケージの大部分が壊れてしまいます。新しいモジュールは日々追加されており、待てば待つほど、潜在的な破損が増えることになります。紛らわしいですが、名前自体は変わらないでしょう。
TIP
開発者は、理解しやすい setImmediate()
を常に使用することをお勧めします。
なぜ process.nextTick()
を使うのか?
主な理由は2つあります。
ユーザーがエラーを処理したり、不要なリソースをクリーンアップしたり、イベントループが続行する前にリクエストを再試行したりできるようにするため。
コールスタックが巻き戻された後、イベントループが続行する前にコールバックを実行する必要がある場合があるため。
例としては、ユーザーの期待に応えることがあります。簡単な例:
const server = net.createServer();
server.on('connection', conn => {});
server.listen(8080);
server.on('listening', () => {});
listen()
がイベントループの最初に実行され、listening コールバックが setImmediate()
に配置されたとします。ホスト名が渡されない限り、ポートへのバインドは即座に行われます。イベントループが続行するには、poll フェーズに到達する必要があります。つまり、listening イベントが発火する前に接続イベントが発火する可能性がゼロではありません。
別の例として、EventEmitter
を拡張し、コンストラクター内からイベントを発行する場合があります。
const EventEmitter = require('node:events');
class MyEmitter extends EventEmitter {
constructor() {
super();
this.emit('event');
}
}
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});
スクリプトがユーザーがそのイベントにコールバックを割り当てるポイントまで処理されていないため、コンストラクターからイベントを即座に発行することはできません。したがって、コンストラクター自体の中で、process.nextTick()
を使用して、コンストラクターが終了した後にイベントを発行するコールバックを設定することで、期待される結果が得られます。
const EventEmitter = require('node:events');
class MyEmitter extends EventEmitter {
constructor() {
super();
// ハンドラが割り当てられたらイベントを発行するために nextTick を使用する
process.nextTick(() => {
this.emit('event');
});
}
}
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});