Node.jsイベントループ

イベントループとは何か?

イベントループは、JavaScriptがシングルスレッドであるにもかかわらず、可能な限り操作をシステムカーネルにオフロードすることにより、Node.jsがノンブロッキングI/O操作を実行できるようにするものです。

ほとんどの最新のカーネルはマルチスレッドであるため、バックグラウンドで実行される複数の操作を処理できます。これらの操作の1つが完了すると、カーネルはNode.jsに通知し、適切なコールバックが**poll**キューに追加され、最終的に実行されます。これについては、このトピックの後半で詳しく説明します。

イベントループの説明

Node.jsが起動すると、イベントループを初期化し、提供された入力スクリプトを処理します(または、このドキュメントでは説明していないREPLに入ります)。入力スクリプトは、非同期API呼び出しを行ったり、タイマーをスケジュールしたり、`process.nextTick()`を呼び出したりする可能性があり、その後、イベントループの処理を開始します。

次の図は、イベントループの操作順序を簡略化した概要を示しています。

   ┌───────────────────────────┐
┌─>│           timers          
  └─────────────┬─────────────┘
  ┌─────────────┴─────────────┐
       pending callbacks     
  └─────────────┬─────────────┘
  ┌─────────────┴─────────────┐
         idle, prepare       
  └─────────────┬─────────────┘      ┌───────────────┐
  ┌─────────────┴─────────────┐         incoming:   
             poll            │<─────┤  connections, 
  └─────────────┬─────────────┘         data, etc.  
  ┌─────────────┴─────────────┐      └───────────────┘
             check           
  └─────────────┬─────────────┘
  ┌─────────────┴─────────────┐
└──┤      close callbacks      
   └───────────────────────────┘

各ボックスは、イベントループの「フェーズ」と呼ばれます。

各フェーズには、実行するコールバックのFIFOキューがあります。各フェーズはそれぞれ独自の方法で特殊ですが、一般に、イベントループが特定のフェーズに入ると、そのフェーズに固有の操作を実行し、キューが空になるか、コールバックの最大数が実行されるまで、そのフェーズのキュー内のコールバックを実行します。キューが空になった場合、またはコールバックの制限に達した場合、イベントループは次のフェーズに移動します。

これらの操作のいずれも*さらに*多くの操作をスケジュールする可能性があり、**poll**フェーズで処理される新しいイベントはカーネルによってキューイングされるため、ポーリングイベントの処理中にポーリングイベントをキューイングできます。その結果、長時間実行されるコールバックにより、pollフェーズがタイマーのしきい値よりもはるかに長く実行される可能性があります。詳細については、**timers**セクションと**poll**セクションを参照してください。

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またはタイマーを待機しているかどうかを確認し、待機していない場合は正常にシャットダウンします。

フェーズの詳細

タイマー

タイマーは、指定された時間が経過した後、提供されたコールバックが*実行される可能性のある***しきい値*を指定します。人が*実行したい***正確な時間ではありません。タイマーコールバックは、指定された時間が経過した後、できるだけ早くスケジュールされます。ただし、オペレーティングシステムのスケジューリングまたは他のコールバックの実行によって遅延が発生する可能性があります。

技術的には、**poll**フェーズは、タイマーがいつ実行されるかを制御します。

たとえば、100ミリ秒のしきい値の後にタイムアウトを実行するようにスケジュールしてから、スクリプトが95ミリ秒かかるファイルの非同期読み取りを開始するとします。

const fs = require('node:fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  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);

// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

イベントループが**poll**フェーズに入ると、キューが空になります(`fs.readFile()`が完了していません)。そのため、最も早いタイマーのしきい値に達するまで残りのミリ秒数を待機します。95ミリ秒の待機中に、`fs.readFile()`はファイルの読み取りを終了し、完了までに10ミリ秒かかるコールバックが**poll**キューに追加され、実行されます。コールバックが終了すると、キューにコールバックがなくなるため、イベントループは最も早いタイマーのしきい値に達したことを確認し、**timers**フェーズに戻ってタイマーのコールバックを実行します。この例では、タイマーのスケジュールとコールバックの実行の間の合計遅延が105ミリ秒になることがわかります。

**poll**フェーズがイベントループをスタベーション状態にするのを防ぐために、libuv(Node.jsイベントループとプラットフォームのすべての非同期動作を実装するCライブラリ)にも、それ以上のイベントのポーリングを停止するまでのハード最大値(システム依存)があります。

保留中のコールバック

このフェーズでは、TCPエラーのタイプなど、一部のシステム操作のコールバックを実行します。たとえば、TCPソケットが接続を試みるときに`ECONNREFUSED`を受信した場合、一部の* nixシステムはエラーの報告を待機します。これは、**pending callbacks**フェーズで実行されるようにキューに入れられます。

poll

**poll**フェーズには、2つの主な機能があります。

  1. ブロックしてI/Oをポーリングする時間の長さを計算し、
  2. **poll**キュー内のイベントを処理します。

イベントループが**poll**フェーズに入り、*スケジュールされたタイマーがない*場合、次の2つのいずれかが発生します。

  • ***poll**キューが**空でない**場合*、イベントループはコールバックのキューを反復処理し、キューが空になるか、システム依存のハード制限に達するまで、コールバックを同期的に実行します。

  • ***poll**キューが**空の場合**、さらに次の2つのいずれかが発生します。

    • `setImmediate()`によってスクリプトがスケジュールされている場合、イベントループは**poll**フェーズを終了し、**check**フェーズに進んで、スケジュールされたスクリプトを実行します。

    • `setImmediate()`によってスクリプトがスケジュール**されていない**場合、イベントループはコールバックがキューに追加されるのを待ってから、すぐに実行します。

**poll**キューが空になると、イベントループは*時間しきい値に達した*タイマーを確認します。1つ以上のタイマーの準備ができている場合、イベントループは**timers**フェーズに戻って、それらのタイマーのコールバックを実行します。

check

このフェーズでは、**poll**フェーズの完了直後にコールバックを実行できます。 **poll**フェーズがアイドル状態になり、`setImmediate()`でスクリプトがキューイングされている場合、イベントループは待機するのではなく、**check**フェーズに進む場合があります。

`setImmediate()`は、実際にはイベントループの別のフェーズで実行される特別なタイマーです。 **poll**フェーズの完了後にコールバックを実行するようにスケジュールするlibuv APIを使用します。

一般的に、コードが実行されると、イベントループは最終的に **poll** フェーズに到達し、そこで受信接続、リクエストなどを待ちます。しかし、`setImmediate()` でコールバックがスケジュールされていて、**poll** フェーズがアイドル状態になると、**poll** イベントを待たずに終了し、**check** フェーズに進みます。

close コールバック

ソケットまたはハンドルが突然閉じられた場合 (例: `socket.destroy()`)、`'close'` イベントはこのフェーズで発生します。そうでない場合は、`process.nextTick()` を介して発生します。

`setImmediate()` 対 `setTimeout()`

`setImmediate()` と `setTimeout()` は似ていますが、呼び出されたタイミングによって動作が異なります。

  • `setImmediate()` は、現在の **poll** フェーズが完了した直後にスクリプトを実行するように設計されています。
  • `setTimeout()` は、ミリ秒単位の最小しきい値が経過した後にスクリプトを実行するようにスケジュールします。

タイマーが実行される順序は、呼び出されたコンテキストによって異なります。両方がメインモジュール内から呼び出された場合、タイミングはプロセスのパフォーマンス(マシン上で実行されている他のアプリケーションの影響を受ける可能性があります)に制限されます。

たとえば、I/Oサイクル内にない(つまり、メインモジュール内にある)次のスクリプトを実行すると、2つのタイマーが実行される順序は非決定論的になります。これは、プロセスのパフォーマンスに制限されるためです。

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});

ただし、2つの呼び出しをI/Oサイクル内に移動すると、即時コールバックは常に最初に実行されます。

// timeout_vs_immediate.js
const fs = require('node:fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});

`setTimeout()` より `setImmediate()` を使用する主な利点は、`setImmediate()` が I/O サイクル内でスケジュールされている場合、タイマーの数に関係なく、常にタイマーの前に実行されることです。

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'` コールバックはすぐに呼び出される可能性があります。問題は、その時点までに `'listening'` コールバックが設定されていないことです。`on('listening')`

これを回避するために、`'listening'` イベントは `nextTick()` にキューに入れられ、スクリプトが完了まで実行できるようになります。これにより、ユーザーは必要なイベントハンドラーを設定できます。

`process.nextTick()` 対 `setImmediate()`

ユーザーに関する限り、2つの呼び出しは似ていますが、名前が紛らわしいです。

  • `process.nextTick()` は同じフェーズで直ちに起動します。
  • `setImmediate()` は、イベントループの次の反復または「ティック」で起動します。

本質的に、名前は交換する必要があります。`process.nextTick()` は `setImmediate()` よりもすぐに起動しますが、これは変更される可能性の低い過去の遺物です。このスイッチを作成すると、npm のパッケージの大部分が壊れます。毎日、新しいモジュールが追加されているため、待つ日ごとに、潜在的な破損が発生します。紛らわしいですが、名前自体は変わりません。

開発者には、推論しやすい `setImmediate()` を常に使用することをお勧めします。

なぜ `process.nextTick()` を使用するのでしょうか?

主な理由は2つあります。

  1. ユーザーがエラーを処理したり、不要になったリソースをクリーンアップしたり、イベントループが続行される前にリクエストを再試行したりできるようにします。

  2. コールスタックが巻き戻された後、イベントループが続行される前にコールバックを実行できるようにする必要がある場合があります。

1つの例は、ユーザーの期待に応えることです。簡単な例

const server = net.createServer();
server.on('connection', conn => {});

server.listen(8080);
server.on('listening', () => {});

`listen()` がイベントループの開始時に実行されるとします。しかし、リスニングコールバックは `setImmediate()` に配置されます。ホスト名が渡されない限り、ポートへのバインドはすぐに発生します。イベントループが続行するには、**poll** フェーズに到達する必要があります。つまり、リスニングイベントの前に接続イベントが発生する可能性があり、接続が受信される可能性があります。

別の例は、`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();

    // 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!');
});
(前後のページへのリンク)