イベント ループは、JavaScript がシングルスレッドであっても、可能な場合には操作をシステム カーネルにオフロードすることで、ノンブロッキング I/O 操作を処理するための Node.js のメカニズムです。
現在のほとんどのコアはマルチスレッドであるため、バックグラウンドでさまざまな操作を処理できます。いずれかの操作が完了すると、カーネルは Node.js に適切なコールバック関数をポーリング キューに追加し、実行の機会を待つように通知します。この記事の後半で詳しく紹介します。
Node.js が開始されると、イベント ループが初期化され、提供された入力スクリプトが処理されます (または、この記事では取り上げませんが、REPL にスローされます)。いくつかの非同期 API、スケジュール タイマー、または、 process.nextTick()
を呼び出して、イベント ループの処理を開始します。
以下の図は、イベント ループの一連の操作の概要を簡略化して示しています。
┌─────────────────┐ ┌─>│ タイマー │ │ └─────────┬─────────┘ │ ┌─────────┴─────────┐ │ │ 保留中のコールバック │ │ └─────────┬─────────┘ │ ┌─────────┴─────────┐ │ │ アイドル、準備 │ │ ━─────────┬───────┘ ┌───────┐ │ ┌─────────┴───────┐ │ 受信: │ │ │ ポーリング │<─────┤ 接続、 │ │ └─────────┬─────────┘ │ データ等 │ │ ┌─────────┴───────┐ ━━━━━━┘ │ │ 確認する │ │ └─────────┬─────────┘ │ ┌─────────┴─────────┐ └─┤ コールバックを閉じる │ ━━━━━━━━━━━┘
注: 各ボックスは、イベント ループ機構のステージと呼ばれます。
各ステージにはコールバックを実行するための FIFO キューがあります。各ステージは特殊ですが、通常、イベント ループが特定のステージに入ると、そのステージに固有の操作が実行され、キューが使い果たされるか最大数のコールバックが実行されるまで、そのステージのキュー内のコールバックが実行されます。キューが使い果たされるか、コールバック制限に達すると、イベント ループは次のフェーズに移行します。
これらの操作のいずれかが、ポーリングフェーズ中に処理されるカーネルによって追加の操作と新しいイベントがキューに入れられるようにスケジュールする可能性があるため、ポーリング フェーズでイベントを処理している間、ポーリング イベントがキューに入れられる可能性があります。したがって、コールバックの実行時間が長いと、ポーリング フェーズがタイマーのしきい値時間を超えて実行される可能性があります。詳細については、 「タイマーとポーリング」セクションを参照してください。
注: Windows と Unix/Linux の実装には微妙な違いがありますが、これはデモンストレーションの目的では重要ではありません。最も重要な部分はここです。実際には 7 つまたは 8 つのステップがありますが、ここで重要なのは、Node.js が実際に上記のステップの一部を使用しているということです。
setTimeout()
およびsetInterval()
であるスケジュール コールバック関数を実行します。setImmediate()
によってスケジュールされたコールバック関数を除きます)。その他の場合、ノードは必要に応じてここでブロックされます。setImmediate()
コールバック関数が実行されます。socket.on('close', ...)
などのいくつかのクローズ コールバック関数。イベント ループの各実行の間に、Node.js は非同期 I/O またはタイマーを待機しているかどうかを確認し、待機していない場合は完全にシャットダウンします。
フェーズタイマーは、ユーザーが実行を希望する正確な時間ではなく、提供されたコールバックを実行できるしきい値を指定します。指定された間隔が経過すると、タイマー コールバックができるだけ早く実行されます。ただし、オペレーティング システムのスケジューリングや他の実行中のコールバックによって遅延する可能性があります。
注:ポーリングフェーズは、タイマーをいつ実行するかを制御します。
たとえば、100 ミリ秒後にタイムアウトするタイマーをスケジュールし、スクリプトが 95 ミリ秒かかるファイルの非同期読み取りを開始するとします。
const fs = require('fs'); 関数 someAsyncOperation(callback) { // 完了までに 95 ミリ秒かかると仮定します fs.readFile('/path/to/file', callback); } const timeoutScheduled = Date.now(); setTimeout(() => { const late = Date.now() - timeoutScheduled; console.log(`スケジュールされてから ${lay}ms が経過しました`); }, 100); // 完了までに 95 ミリ秒かかる someAsyncOperation を実行します someAsyncOperation(() => { const startCallback = Date.now(); // 10ms かかる処理を実行します... while (Date.now() - startCallback < 10) { // 何もしない }
イベント ループがポーリングフェーズに入ると、キューが空になる ( fs.readFile()
まだ完了していない) ため、最速のタイマーしきい値に達するまで残りのミリ秒間待機します
。
fs.readFile()
がファイルの読み取りを完了するまで 95 ミリ秒待機すると、完了までに 10 ミリ秒かかるコールバックがポーリングキューに追加されて実行されます。コールバックが完了すると、キューにはコールバックがなくなり、イベント ループ メカニズムは最も早くしきい値に到達したタイマーを調べ、タイマーフェーズに戻ってタイマーのコールバックを実行します。この例では、スケジュールされたタイマーとそのコールバックが実行されるまでの合計遅延が 105 ミリ秒であることがわかります。
注:ポーリングフェーズでイベント ループが枯渇するのを防ぐために、libuv (Node.js イベント ループとプラットフォームのすべての非同期動作を実装する C ライブラリ) にもハード最大値があります (システムに依存します)。
このフェーズでは、特定のシステム操作 (TCP エラー タイプなど) のコールバックを実行します。たとえば、一部の *nix システムは、接続しようとしたときに TCP ソケットがECONNREFUSED
受信した場合、エラーを報告するまで待機します。これは、保留中のコールバックフェーズ中に実行するためにキューに入れられます。
ポーリングフェーズには、
I/O をブロックしてポーリングする時間を計算するという 2 つの重要な機能があります。
次に、ポーリングキュー内のイベントを処理します。
イベント ループがポーリングフェーズに入り、タイマーがスケジュールされていない場合、次の 2 つのいずれかが起こります。
ポーリングキューが空でない場合
、イベント ループはコールバック キューを反復処理し、キューが空になるまで同期的に実行します。 、またはシステム関連のハード制限に達しました。
ポーリングキューが空の場合は、さらに 2 つのことが起こります。
スクリプトがsetImmediate()
によってスケジュールされている場合、イベント ループはポーリングフェーズを終了し、チェックフェーズを継続して、スケジュールされたスクリプトを実行します。
スクリプトがsetImmediate()
によってスケジュールされていない場合、イベント ループはコールバックがキューに追加されるのを待ち、すぐに実行します。
ポーリングキューが空になると、イベント ループは時間しきい値に達したタイマーをチェックします。 1 つ以上のタイマーの準備ができている場合、イベント ループはタイマー フェーズに戻り、それらのタイマーのコールバックを実行します。
このフェーズでは、ポーリングフェーズが完了した直後にコールバックを実行できます。 setImmediate()
の使用後にポーリング フェーズがアイドル状態になり、スクリプトがキューに入れられた場合、イベント ループは待機せずにチェックフェーズに進む可能性があります。
setImmediate()
は実際には、イベント ループの別のフェーズで実行される特別なタイマーです。 libuv API を使用して、ポーリングフェーズの完了後に実行されるコールバックをスケジュールします。
通常、コードを実行すると、イベント ループは最終的にポーリング フェーズに突入し、受信接続やリクエストなどを待ちます。ただし、 setImmediate()
を使用してコールバックがスケジュールされており、ポーリング フェーズがアイドル状態になると、ポーリング イベントを待ち続けるのではなく、このフェーズが終了してチェック フェーズに進みます。
ソケットまたはハンドラーが突然閉じられた場合 (たとえば、 socket.destroy()
)、この段階で'close'
イベントが発行されます。それ以外の場合は、 process.nextTick()
経由で出力されます。
setImmediate()
とsetTimeout()
は非常に似ていますが、いつ呼び出されるかに基づいて動作が異なります。
setImmediate()
は、現在のポーリングフェーズが完了するとスクリプトを実行するように設計されています。setTimeout()
最小しきい値 (ミリ秒単位) が経過した後にスクリプトを実行します。タイマーが実行される順序は、タイマーが呼び出されるコンテキストによって異なります。両方がメイン モジュール内から呼び出された場合、タイマーはプロセスのパフォーマンスによって制限されます (コンピューター上で実行されている他のアプリケーションの影響を受ける可能性があります)。
たとえば、I/O サイクル (つまり、メイン モジュール) 内ではない次のスクリプトを実行する場合、2 つのタイマーの実行順序はプロセスのパフォーマンスによって制限されるため、決定的ではありません。
// timeout_vs_immediate.js setTimeout(() => { console.log('タイムアウト'); }, 0); setImmediate(() => { console.log('即時'); }); $ ノードタイムアウト_vs_immediate.js タイムアウト すぐに $ ノードタイムアウト_vs_immediate.js すぐに timeout
ただし、これら 2 つの関数を I/O ループに入れて呼び出すと、常に setImmediate が最初に呼び出されます。
// timeout_vs_immediate.js const fs = require('fs'); fs.readFile(__ファイル名, () => { setTimeout(() => { console.log('タイムアウト'); }, 0); setImmediate(() => { console.log('即時'); }); }); $ ノードタイムアウト_vs_immediate.js すぐに タイムアウト $ ノードタイムアウト_vs_immediate.js すぐにsetTimeout() よりも
タイムアウトに setImmediate() を使用する主な利点は、入出力サイクル中に setImmediate() がスケジュールされている場合、
setTimeout()
setImmediate()
setImmediate()
ないタイマーの数に応じて、その中のタイマーよりも先に実行されることです。
process.nextTick()
に気づいたかもしれません。これは、 process.nextTick()
が技術的にはイベント ループの一部ではないためです。代わりに、イベント ループの現在の段階に関係なく、現在の操作が完了した後にnextTickQueue
処理します。ここでの操作は、基礎となる C/C++ プロセッサからの遷移とみなされ、実行する必要がある JavaScript コードが処理されます。
図を振り返ると、特定のフェーズでprocess.nextTick()
が呼び出されるたびに、 process.nextTick()
に渡されたすべてのコールバックはイベント ループが継続する前に解決されます。これにより、再帰的なprocess.nextTick()
呼び出しによって I/O が「枯渇」し、イベント ループがポーリングステージに到達できなくなるため、いくつかの悪い状況が発生する可能性があります。
なぜこのようなものが Node.js に含まれているのでしょうか?その一部は、たとえそうする必要はないとしても、API は常に非同期であるべきであるという設計哲学です。このコード スニペットを例として取り上げます。
function apiCall(arg, callback) { if (引数の型 !== '文字列') return process.nextTick( 折り返し電話、 new TypeError('引数は文字列である必要があります') );
パラメータチェック用のコードスニペット
。
正しくない場合は、エラーがコールバック関数に渡されます。 API は最近更新され、 process.nextTick()
に引数を渡すことができるようになりました。これにより、コールバック関数の位置以降の引数を受け入れ、その引数をコールバック関数の引数としてコールバック関数に渡すことができるようになります。関数をネストします。
私たちが行っていることはエラーをユーザーに返すことですが、それはユーザーのコードの残りの部分が実行された後でのみです。 process.nextTick()
使用することにより、 apiCall()
ユーザー コードの残りの部分の後、イベント ループを続行する前に常にそのコールバック関数を実行することが保証されます。これを実現するために、JS コール スタックをアンワインドし、提供されたコールバックをすぐに実行できるようにします。これにより、 RangeError: 超过V8 的最大调用堆栈大小
状態にならずにprocess.nextTick()
への再帰呼び出しが可能になります。
この設計原則は、いくつかの潜在的な問題を引き起こす可能性があります。 次のコード スニペットを例として取り上げます
。 // これには非同期シグネチャがありますが、コールバックは同期的に呼び出されます 関数 someAsyncApiCall(callback) { 折り返し電話(); } // コールバックは `someAsyncApiCall` が完了する前に呼び出されます。 someAsyncApiCall(() => { // someAsyncApiCall が完了したため、bar には値が割り当てられていません console.log('bar', bar); // 未定義 }); bar = 1;
ユーザーはsomeAsyncApiCall()
非同期シグネチャを持つものとして定義しますが、実際には同期的に実行されます。 someAsyncApiCall()
実際には何も非同期的に実行しないため、呼び出されると、 someAsyncApiCall()
に提供されたコールバックがイベント ループの同じフェーズ内で呼び出されます。その結果、コールバック関数はbar
を参照しようとしますが、スクリプトの実行がまだ終了していないため、変数はまだスコープ内にない可能性があります。
process.nextTick()
にコールバックを配置することで、スクリプトは完了まで実行することができ、コールバックが呼び出される前にすべての変数、関数などを初期化できます。また、イベントループを継続させない利点もあり、エラー発生時にイベントループを継続させる前にユーザーに警告するのに適しています。 process.nextTick()
使用した前の例を次に示します
。 関数 someAsyncApiCall(callback) { process.nextTick(コールバック); } someAsyncApiCall(() => { console.log('バー', バー); // 1 }); bar = 1;
これは別の実際の例です:
const server = net.createServer(() => {}).listen(8080); server.on('listening', () => {});
ポートが渡された場合のみ、ポートはすぐにバインドされます。したがって、 'listening'
コールバックをすぐに呼び出すことができます。問題は、その時点で.on('listening')
のコールバックが設定されていないことです。
この問題を回避するには、 'listening'
イベントをnextTick()
内のキューに入れて、スクリプトが最後まで実行できるようにします。これにより、ユーザーは任意のイベント ハンドラーを設定できるようになります。
ユーザーに関する限り、2つの同様の呼び出しがありますが、その名前は混乱しています。
process.nextTick()
同じステージで即時に実行されます。setImmediate()
イベント ループの次の反復または「ティック」で起動されます。process.nextTick()
setImmediate()
よりも高速に起動するため、基本的に 2 つの名前を交換する必要がありますが、これは過去の遺産であるため、変更される可能性は低いです。名前の交換を無謀に行うと、npm 上のほとんどのパッケージが壊れてしまいます。新しいモジュールが毎日追加されているため、毎日待たなければならないほど、より多くの潜在的な損害が発生する可能性があります。これらの名前は紛らわしいですが、名前自体は変わりません。
開発者は理解しやすいため、あらゆる状況でsetImmediate()
を使用することをお勧めします。
理由は次の 2 つです。
ユーザーがエラーを処理できるようにするため、不要なリソースをクリーンアップするため、またはイベント ループが続行する前にリクエストを再試行できるようにするためです。
場合によっては、スタックが展開された後、イベント ループが継続する前にコールバックを実行する必要があります。
ユーザーの期待に応える簡単な例を次に示します。
const server = net.createServer(); server.on('接続', (conn) => {}); サーバー.リッスン(8080); server.on('listening', () => {});
listen()
はイベント ループの先頭で実行されますが、listen コールバックはsetImmediate()
に配置されているとします。ホスト名が渡されない限り、ポートはすぐにバインドされます。イベント ループを続行するには、ポーリングフェーズに到達する必要があります。これは、接続が受信され、リッスン イベントの前に接続イベントが発生している可能性があることを意味します。
別の例では、 EventEmitter
から継承する関数コンストラクターを実行し、コンストラクターを呼び出します。
const EventEmitter = require('events'); const util = require('util'); 関数 MyEmitter() { EventEmitter.call(this); this.emit('イベント'); } util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter(); myEmitter.on('イベント', () => { console.log('イベントが発生しました!');
ユーザーがコールバック関数をイベントに割り当てる時点までスクリプトがまだ処理されていないため、コンストラクターからイベントをすぐにトリガーすることはできません
。
したがって、コンストラクター自体でprocess.nextTick()
使用してコールバックを設定し、コンストラクターの完了後にイベントが発行されるようにすることができます。これは期待どおりです
。 const util = require('util'); 関数 MyEmitter() { EventEmitter.call(this); // ハンドラーが割り当てられたら、nextTick を使用してイベントを発行します process.nextTick(() => { this.emit('イベント'); }); } util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter(); myemitter.on( 'event'、()=> { console.log('イベントが発生しました!'); });
出典: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/