了解 libuv 的各个阶段、setImmediate 与 setTimeout 之间的真正区别,以及如何检测被阻塞的事件循环。
在 Kodokon 中打开本课Node.js 把它的事件循环委托给 libuv,后者将其组织成若干个不同的阶段:timers(setTimeout、setInterval)、pending callbacks、idle/prepare(内部)、poll(I/O:套接字、文件)、check(setImmediate)和 close callbacks。在每一次 tick 中,循环都会按这个顺序走遍这些阶段。一个很少有开发者知道的细节:在每个回调之间,Node 会先清空 process.nextTick 队列,再清空 promise 微任务 - 而且从 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 毫秒,如果循环在不到一毫秒内就到达了 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 模块的顶层,这个顺序是反过来的 - promise 微任务会先于 process.nextTick 运行。原因在于:对一个 ES 模块的求值本身就是作为一个 promise 任务来执行的,所以当你的同步代码结束时,微任务队列已经在被清空了。而在 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);