Kodokon kodokon.com

Nodeのイベントループ:フェーズと落とし穴

libuvのフェーズ、setImmediateとsetTimeoutの本当の違い、そしてブロックされたイベントループを検出する方法を学びましょう。

10 分 · 3 問

このレッスンを Kodokon で開く

Node.jsはイベントループをlibuvに委ねており、libuvはそれをはっきりと区別されたフェーズに分けて構成します。timerssetTimeoutsetInterval)、pending callbacksidle/prepare(内部用)、poll(I/O:ソケット、ファイル)、checksetImmediate)、そしてclose callbacksです。各ティックで、ループはこれらのフェーズをこの順番でたどります。ほとんどの開発者が知らない細かい点があります。各コールバックのあいだで、Nodeはまずprocess.nextTickのキューを、続いてプロミスのマイクロタスクを空にします。しかもこれはNode 11以降、フェーズの切れ目だけでなくすべてのコールバックのあとで行われるようになっています。

JAVASCRIPT
import { readFile } from "node:fs";

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

readFile(new URL(import.meta.url), () => {
  setTimeout(() => console.log("io timeout"), 0);
  setImmediate(() => console.log("io immediate"));
});
この順序が保証されるのはI/Oコールバックの中だけ

スクリプトの起動時、setTimeout(fn, 0)setImmediate(fn)のあいだの順序は非決定的です。setTimeout(fn, 0)は実際には1ミリ秒に切り上げられ、ループが1ミリ秒未満でtimersフェーズに到達すると、そのタイマーはまだ実行対象になりません。しかしI/Oコールバックの中では、あなたはpollフェーズにいます。checkフェーズ(setImmediate)は、timersフェーズに戻る前に必ず来ます。したがってio immediateは一貫してio timeoutより先に表示されます。

JAVASCRIPT
setTimeout(() => {
  process.nextTick(() => console.log("nextTick"));
  Promise.resolve().then(() => console.log("promise"));
  queueMicrotask(() => console.log("microtask"));
  console.log("sync");
}, 0);
// sync, nextTick, promise, microtask
コールバックの中では、nextTickはマイクロタスクより先に実行される

微妙な落とし穴があります。ESモジュールのトップレベルでは、この順序が逆転し、プロミスのマイクロタスクがprocess.nextTickより先に実行されます。理由はこうです。ESモジュールの評価そのものがプロミスのジョブとして実行されるため、あなたの同期コードが終わる時点ですでにマイクロタスクのキューが空にされている最中なのです。CommonJSでは、nextTickはトップレベルでもその優先度を保ちます。この種の順序は、実際にデプロイするモジュール形式で必ず確認しましょう。

イベントループは単一のスレッドで動きます。長い同期計算、たとえば大きなペイロードのJSON.parse、破滅的な正規表現、同期的な暗号化などは、処理中のすべてのリクエストをフリーズさせます。本番環境では、monitorEventLoopDelayでループのレイテンシを測定しましょう。p99が数十ミリ秒を超えると、それは停滞の兆候です。次のコードは、わざと停滞を引き起こしてから、それを測定します。

JAVASCRIPT
import { monitorEventLoopDelay }
  from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();

const end = Date.now() + 200;
while (Date.now() < end) {}

setTimeout(() => {
  h.disable();
  const ms = h.max / 1e6;
  console.log(`max delay: ${ms.toFixed(1)} ms`);
}, 300);
ヒストグラムが200ミリ秒の停滞を明らかにする

理解度チェック

このレッスンの要点をしっかり覚えているか確認しましょう。

  1. I/Oコールバック(たとえばfs.readFile)の中で、setTimeout(fn, 0)とsetImmediate(fn)をスケジュールします。どちらが先に実行されますか?
    • 常にsetTimeout
    • 常にsetImmediate
    • 順序はランダムで、スクリプト起動時と同じ
  2. コールバックのあと、Nodeはどのような順序で優先キューを空にしますか?
    • プロミスのマイクロタスクが先
    • 両方のキューが厳密に交互に空にされる
    • まずprocess.nextTickのキューが空にされ、次にマイクロタスク
  3. 本番環境でイベントループの停滞を正確に測定するネイティブなツールはどれですか?
    • 各リクエストを囲むconsole.time
    • node:perf_hooksのmonitorEventLoopDelay
    • setIntervalの中で呼ばれるprocess.memoryUsage
    • node:trace_eventsモジュール