Node.js イベントループ

イベントループとは?

イベントループは、Node.js が非ブロッキング I/O 操作を実行できるようにするものです。デフォルトでは単一の JavaScript スレッドが使用されているにもかかわらず、可能な限りシステムカーネルに操作をオフロードすることでこれを実現します。

ほとんどの現代的なカーネルはマルチスレッドであるため、バックグラウンドで複数の操作を実行できます。これらの操作のいずれかが完了すると、カーネルは 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() を除くほとんどすべて)を実行します。node は適切な場合にここでブロックします。
  • check: setImmediate() のコールバックはここで呼び出されます。
  • close callbacks: socket.on('close', ...) のような、いくつかのクローズコールバック。

イベントループの各実行の間に、Node.js は非同期 I/O またはタイマーを待機しているかどうかを確認し、何もなければクリーンにシャットダウンします。

libuv 1.45.0 (Node.js 20) 以降、イベントループの動作が変更され、以前のバージョンのように poll フェーズの前後両方ではなく、poll フェーズの後にのみタイマーを実行するようになりました。この変更は、特定のシナリオにおいて setImmediate() コールバックのタイミングやタイマーとの相互作用に影響を与える可能性があります。

フェーズの詳細

timers

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

技術的には、pollフェーズがタイマーの実行タイミングを制御します。

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

const  = ('node:fs');

function () {
  // Assume this takes 95ms to complete
  .('/path/to/file', );
}

const  = .();

(() => {
  const  = .() - ;

  .(`${}ms have passed since I was scheduled`);
}, 100);

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

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

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

poll フェーズがイベントループを飢餓状態に陥らせるのを防ぐため、libuv(Node.js のイベントループとプラットフォームのすべての非同期動作を実装する C ライブラリ)は、さらなるイベントのポーリングを停止する前にハードな最大値(システム依存)も持っています。

pending callbacks

このフェーズでは、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 callbacks

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

setImmediate() vs setTimeout()

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

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

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

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

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

(() => {
  .('immediate');
});

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

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

.(, () => {
  (() => {
    .('timeout');
  }, 0);
  (() => {
    .('immediate');
  });
});

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 (, ) {
  if (typeof  !== 'string') {
    return .(
      ,
      new ('argument should be string')
    );
  }
}

このスニペットは引数チェックを行い、正しくない場合はコールバックにエラーを渡します。API は最近更新され、process.nextTick() に引数を渡すことができるようになりました。これにより、コールバックの後に渡された任意の引数をコールバックの引数として伝播させることができるため、関数をネストする必要がありません。

私たちがしていることは、ユーザーのコードの残りの部分を実行させたにのみ、ユーザーにエラーを返すことです。process.nextTick() を使用することで、apiCall() が常にユーザーのコードの残りの部分の、そしてイベントループが続行を許可されるにコールバックを実行することを保証します。これを実現するために、JS 呼び出しスタックがアンワインドされ、その後すぐに提供されたコールバックが実行されます。これにより、v8 からの RangeError: Maximum call stack size exceeded に達することなく、process.nextTick() への再帰的な呼び出しを行うことができます。

この思想は、いくつかの潜在的に問題のある状況につながる可能性があります。このスニペットを例にとってみましょう。

let  = null;

// this has an asynchronous signature, but calls callback synchronously
function () {
  ();
}

// the callback is called before `someAsyncApiCall` completes.
(() => {
  // since someAsyncApiCall hasn't completed, bar hasn't been assigned any value
  .('bar', ); // null
});

 = 1;

ユーザーは someAsyncApiCall() を非同期シグネチャを持つように定義しますが、実際には同期的に動作します。それが呼び出されると、someAsyncApiCall() は実際には何も非同期に行わないため、someAsyncApiCall() に提供されたコールバックはイベントループの同じフェーズで呼び出されます。その結果、コールバックは、スクリプトが完了まで実行できていないため、まだスコープ内にその変数がない可能性があるにもかかわらず、bar を参照しようとします。

コールバックを process.nextTick() に配置することで、スクリプトはまだ完了まで実行する能力を持ち、コールバックが呼び出される前にすべての変数、関数などが初期化されることを可能にします。また、イベントループの続行を許可しないという利点もあります。イベントループが続行を許可される前にユーザーにエラーを警告することが役立つ場合があります。以下は process.nextTick() を使用した前の例です。

let  = null;

function () {
  .();
}

(() => {
  .('bar', ); // 1
});

 = 1;

もう一つの実世界の例はこちらです。

const  = net.createServer(() => {}).listen(8080);

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

ポートのみが渡された場合、ポートはすぐにバインドされます。そのため、'listening' コールバックはすぐに呼び出される可能性があります。問題は、その時点では .on('listening') コールバックが設定されていないことです。

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

process.nextTick() vs setImmediate()

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

  • process.nextTick() は同じフェーズで即座に発火します。
  • setImmediate() はイベントループの次のイテレーションまたは「ティック」で発火します。

本質的に、名前は交換されるべきです。process.nextTick()setImmediate() よりも即座に発火しますが、これは過去の遺物であり、変更される可能性は低いです。この切り替えを行うと、npm 上のパッケージの大部分が壊れてしまいます。毎日新しいモジュールが追加されており、それは私たちが待つ毎日、より多くの潜在的な破損が発生することを意味します。それらは紛らわしいですが、名前自体は変更されません。

私たちは開発者がすべての場合において setImmediate() を使用することをお勧めします。なぜなら、その方が理解しやすいからです。

なぜ process.nextTick() を使うのか?

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

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

  2. 呼び出しスタックがアンワインドされた後、しかしイベントループが続行する前にコールバックを実行させることが必要な場合があります。

一つの例は、ユーザーの期待に合わせることです。簡単な例です。

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

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

listen() がイベントループの開始時に実行されるが、リスニングコールバックが setImmediate() に配置されているとします。ホスト名が渡されない限り、ポートへのバインドはすぐに行われます。イベントループが進行するためには、poll フェーズに到達する必要があり、これは接続イベントがリスニングイベントの前に発火する可能性のある接続が受信される可能性がゼロではないことを意味します。

別の例は、EventEmitterを拡張し、コンストラクタ内からイベントを発行することです。

const  = ('node:events');

class  extends  {
  constructor() {
    super();
    this.('event');
  }
}

const  = new ();
.('event', () => {
  .('an event occurred!');
});

コンストラクタからすぐにイベントを発行することはできません。なぜなら、スクリプトはユーザーがそのイベントにコールバックを割り当てる時点まで処理されていないからです。そのため、コンストラクタ自体の中で、process.nextTick()を使用して、コンストラクタが終了した後にイベントを発行するコールバックを設定することができます。これにより、期待される結果が得られます。

const  = ('node:events');

class  extends  {
  constructor() {
    super();

    // use nextTick to emit the event once a handler is assigned
    .(() => {
      this.('event');
    });
  }
}

const  = new ();
.('event', () => {
  .('an event occurred!');
});