libuvのフェーズ、setImmediateとsetTimeoutの本当の違い、そしてブロックされたイベントループを検出する方法を学びましょう。
このレッスンを Kodokon で開くNode.jsはイベントループをlibuvに委ねており、libuvはそれをはっきりと区別されたフェーズに分けて構成します。timers(setTimeout、setInterval)、pending callbacks、idle/prepare(内部用)、poll(I/O:ソケット、ファイル)、check(setImmediate)、そしてclose callbacksです。各ティックで、ループはこれらのフェーズをこの順番でたどります。ほとんどの開発者が知らない細かい点があります。各コールバックのあいだで、Nodeはまずprocess.nextTickのキューを、続いてプロミスのマイクロタスクを空にします。しかもこれはNode 11以降、フェーズの切れ目だけでなくすべてのコールバックのあとで行われるようになっています。
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"));
});スクリプトの起動時、setTimeout(fn, 0)とsetImmediate(fn)のあいだの順序は非決定的です。setTimeout(fn, 0)は実際には1ミリ秒に切り上げられ、ループが1ミリ秒未満でtimersフェーズに到達すると、そのタイマーはまだ実行対象になりません。しかしI/Oコールバックの中では、あなたはpollフェーズにいます。checkフェーズ(setImmediate)は、timersフェーズに戻る前に必ず来ます。したがってio immediateは一貫してio timeoutより先に表示されます。
setTimeout(() => {
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
queueMicrotask(() => console.log("microtask"));
console.log("sync");
}, 0);
// sync, nextTick, promise, microtask微妙な落とし穴があります。ESモジュールのトップレベルでは、この順序が逆転し、プロミスのマイクロタスクがprocess.nextTickより先に実行されます。理由はこうです。ESモジュールの評価そのものがプロミスのジョブとして実行されるため、あなたの同期コードが終わる時点ですでにマイクロタスクのキューが空にされている最中なのです。CommonJSでは、nextTickはトップレベルでもその優先度を保ちます。この種の順序は、実際にデプロイするモジュール形式で必ず確認しましょう。
イベントループは単一のスレッドで動きます。長い同期計算、たとえば大きなペイロードのJSON.parse、破滅的な正規表現、同期的な暗号化などは、処理中のすべてのリクエストをフリーズさせます。本番環境では、monitorEventLoopDelayでループのレイテンシを測定しましょう。p99が数十ミリ秒を超えると、それは停滞の兆候です。次のコードは、わざと停滞を引き起こしてから、それを測定します。
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);