Kodokon kodokon.com

Node 事件循环:各阶段与陷阱

了解 libuv 的各个阶段、setImmediate 与 setTimeout 之间的真正区别,以及如何检测被阻塞的事件循环。

10 分钟 · 3 题

在 Kodokon 中打开本课

Node.js 把它的事件循环委托给 libuv,后者将其组织成若干个不同的阶段:timerssetTimeoutsetInterval)、pending callbacksidle/prepare(内部)、poll(I/O:套接字、文件)、checksetImmediate)和 close callbacks。在每一次 tick 中,循环都会按这个顺序走遍这些阶段。一个很少有开发者知道的细节:在每个回调之间,Node 会先清空 process.nextTick 队列,再清空 promise 微任务 - 而且从 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 毫秒,如果循环在不到一毫秒内就到达了 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 模块的顶层,这个顺序是反过来的 - promise 微任务会先于 process.nextTick 运行。原因在于:对一个 ES 模块的求值本身就是作为一个 promise 任务来执行的,所以当你的同步代码结束时,微任务队列已经在被清空了。而在 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 会按什么顺序清空优先级队列?
    • promise 微任务先执行
    • 两个队列严格交替清空
    • process.nextTick 队列先被清空,然后是微任务
  3. 哪个原生工具能在生产环境中精确测量事件循环的停顿?
    • 在每个请求周围用 console.time
    • 来自 node:perf_hooks 的 monitorEventLoopDelay
    • 在一个 setInterval 内调用 process.memoryUsage
    • node:trace_events 模块