Node.js イベントループ
イベントループとは何か?
イベントループは、Node.js が非ブロッキング I/O 操作を実行できるようにする仕組みです。これは、デフォルトで単一の JavaScript スレッドを使用しているにもかかわらず、可能な限りシステムカーネルに操作をオフロードすることで実現されています。
最新のカーネルのほとんどはマルチスレッドであるため、バックグラウンドで複数の操作を処理できます。これらの操作の 1 つが完了すると、カーネルは Node.js に通知し、適切なコールバックを poll キューに追加して、最終的に実行できるようにします。これは、このトピックの後半で詳しく説明します。
イベントループの説明
Node.js が起動すると、イベントループが初期化され、提供された入力スクリプトが処理されます(または REPL にドロップしますが、このドキュメントでは扱いません)。これにより、非同期 API 呼び出しが行われたり、タイマーがスケジュールされたり、process.nextTick()が呼び出されたりすることがあります。その後、イベントループの処理が始まります。
次の図は、イベントループの操作順序の概要を簡略化して示したものです。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
TIP
各ボックスは、イベントループの「フェーズ」と呼ばれます。
各フェーズには、実行するコールバックの FIFO キューがあります。各フェーズはそれぞれ固有の方法で特殊ですが、一般的に、イベントループが特定のフェーズに入ると、そのフェーズに固有の操作を実行し、その後、そのフェーズのキュー内のコールバックを実行します。キューが空になるか、最大コールバック数が実行されるまで実行されます。キューが空になるか、コールバックの上限に達すると、イベントループは次のフェーズに進み、以降同様に処理されます。
これらの操作のいずれかがさらに操作をスケジュールし、pollフェーズで処理される新しいイベントはカーネルによってキューに入れられるため、polling イベントの処理中に poll イベントがキューに入れられる可能性があります。その結果、長時間実行されるコールバックにより、poll フェーズの実行時間がタイマーのしきい値よりも大幅に長くなる可能性があります。詳細については、タイマーと poll のセクションを参照してください。
TIP
Windows の実装と Unix/Linux の実装にはわずかな違いがありますが、このデモでは重要ではありません。最も重要な部分はここにあります。実際には 7 つまたは 8 つのステップがありますが、Node.js が実際に使用するステップ(私たちが気にするステップ)は上記のものだけです。
フェーズの概要
- 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 またはタイマーを待機しているかどうかをチェックし、何も待機していない場合はクリーンにシャットダウンします。
フェーズの詳細
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()
は完了していません)。そのため、最も早いタイマーの閾値に達するまでの残りの ms 数だけ待機します。95ms 待機している間に、fs.readFile()
がファイルの読み取りを完了し、10ms かかるそのコールバックが poll キューに追加されて実行されます。コールバックが完了すると、キューにコールバックがなくなります。そのため、イベントループは最も早いタイマーの閾値に達したことを確認し、timers フェーズに戻ってタイマーのコールバックを実行します。この例では、タイマーのスケジュールとコールバックの実行間の合計遅延は 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 つ以上のタイマーの準備ができている場合、イベントループはタイマーフェーズに戻って、それらのタイマーのコールバックを実行します。
check
このフェーズでは、pollフェーズの完了直後にコールバックをすぐに実行できます。pollフェーズがアイドル状態になり、setImmediate()
でスクリプトがキューイングされている場合、イベントループは待機せずにチェックフェーズに進む可能性があります。
setImmediate()
は実際には、イベントループの別のフェーズで実行される特別なタイマーです。これは、pollフェーズの完了後に実行されるコールバックをスケジュールする libuv API を使用します。
一般的に、コードが実行されると、イベントループは最終的にpollフェーズに到達し、そこで着信接続、リクエストなどを待ちます。ただし、setImmediate()
でコールバックがスケジュールされており、pollフェーズがアイドル状態になると、pollイベントを待機せずに終了してチェックフェーズに進みます。
close コールバック
ソケットまたはハンドルが突然閉じられた場合(例:socket.destroy()
)、このフェーズで'close'
イベントが発行されます。それ以外の場合は、process.nextTick()
を介して発行されます。
setImmediate()
対 setTimeout()
setImmediate()
とsetTimeout()
は似ていますが、呼び出されたタイミングによって動作が異なります。
setImmediate()
は、現在のpollフェーズが完了した後に一度スクリプトを実行するように設計されています。setTimeout()
は、最小閾値(ms)が経過した後にスクリプトを実行するようにスケジュールします。
タイマーが実行される順序は、それらが呼び出されたコンテキストによって異なります。両方ともメインモジュール内で呼び出された場合、タイミングはプロセスのパフォーマンスによって制限されます(これは、マシン上で実行されている他のアプリケーションの影響を受ける可能性があります)。
たとえば、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
ただし、I/O サイクル内で 2 つの呼び出しを移動した場合、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
setImmediate()
をsetTimeout()
よりも使用する主な利点は、I/O サイクル内でスケジュールされた場合、存在するタイマーの数に関係なく、setImmediate()
は常にすべてのタイマーの前に実行されることです。
process.nextTick()
process.nextTick()
の理解
非同期 API の一部であるにもかかわらず、図にprocess.nextTick()
が表示されていなかったことに気付いたかもしれません。これは、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 呼び出しスタックはアンワインドされ、すぐに提供されたコールバックが実行されます。これにより、RangeError: Maximum call stack size exceeded from v8
に達することなく、process.nextTick()
への再帰呼び出しを行うことができます。
この設計思想は、潜在的に問題のある状況につながる可能性があります。例えば、このスニペットを見てください。
let bar
// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback) {
callback()
}
// the callback is called before `someAsyncApiCall` completes.
someAsyncApiCall(() => {
// since someAsyncApiCall hasn't completed, bar hasn't been assigned any value
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()
ユーザーにとって似たような呼び出しが 2 つありますが、名前が紛らわしいです。
process.nextTick()
は同じフェーズで即座に実行されますsetImmediate()
は次のイテレーションまたはイベントループの'tick'
で実行されます
本質的に、名前を入れ替えるべきです。process.nextTick()
は setImmediate()
よりもさらに即座に実行されますが、これは過去のアーティファクトであり、変更される可能性は低いです。これを変更すると、npm 上のパッケージの大部分が壊れてしまいます。毎日新しいモジュールが追加されているため、待つ日数が増えるほど、潜在的な破損の可能性が高まります。紛らわしいものの、名前自体は変更されません。
TIP
開発者には、推論しやすいsetImmediate()
を常に使用することをお勧めします。
なぜprocess.nextTick()
を使うのか?
主な理由は 2 つあります。
ユーザーがエラーを処理したり、不要になったリソースをクリーンアップしたり、イベントループが継続する前にリクエストを再試行したりできるようにします。
コールスタックがアンワインドされた後、イベントループが継続する前にコールバックを実行する必要がある場合があります。
1 つの例は、ユーザーの期待に合わせるということです。簡単な例:
const server = net.createServer()
server.on('connection', conn => {})
server.listen(8080)
server.on('listening', () => {})
listen()
がイベントループの先頭で実行されると仮定しますが、リスニングコールバックはsetImmediate()
に配置されています。ホスト名が渡されない限り、ポートへのバインディングはすぐに実行されます。イベントループが続行するには、ポーリングフェーズに到達する必要があり、接続イベントがリスニングイベントの前に発生する可能性がゼロではないことを意味します。
もう 1 つの例は、EventEmitter
を拡張し、コンストラクター内でイベントを emit することです。
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!')
})
スクリプトが、ユーザーがそのイベントにコールバックを割り当てる時点まで処理されていないため、コンストラクターからイベントをすぐに emit することはできません。そのため、コンストラクター自体内でprocess.nextTick()
を使用して、コンストラクターが終了した後にイベントを emit するコールバックを設定でき、期待される結果が得られます。
const EventEmitter = require('node:events')
class MyEmitter extends EventEmitter {
constructor() {
super()
// use nextTick to emit the event once a handler is assigned
process.nextTick(() => {
this.emit('event')
})
}
}
const myEmitter = new MyEmitter()
myEmitter.on('event', () => {
console.log('an event occurred!')
})