运行模型已经把角色分清了:JavaScript 在主线程执行,I/O 可以交给系统或工作线程,完成结果再回到队列。但“回到队列”并不等于“立刻执行”。当定时器、I/O 回调、process.nextTick() 和 Promise 微任务同时就绪时,Node.js 必须决定谁先拿到执行权。
这个顺序不是面试题里的冷知识。只要用错一种“让步”方式,服务就可能在 CPU 和数据库都正常的情况下照样超时。
那次报警看起来很像数据库出了问题。
一个批量导入接口上线后,请求的 p99 从两百毫秒涨到几秒,连健康检查都偶尔超时。奇怪的是,数据库侧的查询耗时仍然只有几十毫秒,连接池没有打满,进程内存也很平稳。我先怀疑网络抖动,又盯了一阵 GC,甚至把慢查询日志翻了一遍,什么都没找到。
真正让我改方向的是一条很不起眼的日志:数据库早已返回,Node.js 却隔了六百多毫秒才进入结果处理回调。不是 I/O 没完成,而是已经就绪的回调一直没有拿到 JavaScript 主线程。
新代码里有一个“主动让步”的循环,大意是这样:
async function normalizeRows(rows) {
for (let index = 0; index < rows.length; index += 1) {
normalize(rows[index]);
if (index % 1000 === 0) {
await Promise.resolve();
}
}
}作者本意很好:每处理一千行就 await 一次,免得独占线程。问题在于,await Promise.resolve() 只把循环的后半段塞进微任务队列;微任务又会在事件循环进入 timers、poll、check 等阶段前被继续清空。循环不断补充新的微任务,看起来“异步”了,实际上 I/O 还是插不进来。
把这段让步改成真正跨过事件循环阶段的 setImmediate() 后,请求延迟立刻恢复。这个事故也迫使我把一个模糊问题问得更准确:不要问“代码是不是异步的”,要问“当前这段 JavaScript 什么时候归还主线程,归还之后谁先拿到执行机会”。
V8 每调用一个函数,就把对应栈帧压入调用栈;函数返回,栈帧才弹出。当前正在执行的永远是栈顶那一帧。
function parse() {
console.log('3. parse');
}
function loadUser() {
console.log('2. loadUser');
parse();
console.log('4. loadUser 返回前');
}
console.log('1. 全局脚本');
loadUser();
console.log('5. 全局脚本结束');这条调用链开始以后,必须等 parse() 返回,loadUser() 才能继续;也必须等 loadUser() 返回,全局脚本才能走到最后一行。执行期间即使 socket 已经可读、定时器已经到期,对应回调也不能从中间挤进来。
这就是 run-to-completion:一段已经开始执行的 JavaScript 会一直跑到返回,事件循环不会在中途抢占它。所谓“回调就绪”,只代表它具备了排队资格,不代表它能打断当前调用栈。

下面这个例子很短,却包含了调度问题的核心:
console.log('开始');
setTimeout(() => {
console.log('定时器回调');
}, 0);
console.log('结束');输出一定先是“开始”“结束”,最后才是“定时器回调”。调用 setTimeout() 时,当前脚本只是登记定时器,然后继续向下执行。延迟阈值满足后,回调获得执行资格,但仍要等调用栈清空,并等事件循环走到处理定时器的位置。
排查异步问题时,我会把一项工作强制拆成两段看:
两段之间不是空白。主线程正是在这里处理其他请求、微任务或阶段回调。那次事故的关键,也正是第二段虽然已就绪,却被源源不断的微任务挡在门外。
事件循环调度的是“下一段可以执行的 JavaScript”。它不会打断正在运行的 JavaScript,也不会让同一条主线程同时执行两个回调。
Node.js 通过 libuv 驱动底层事件循环。为了处理不同类型的事件,一轮循环被划分为多个阶段。循环没有天然的起点;从最能解释 I/O 的 poll 阶段看,现代 Node.js 的主线可以记成:
走完后又回到 poll,因此这张表应该读成一个环,而不是从第一行到最后一行只走一次。自 libuv 1.45(Node.js 20 使用的版本)起,定时器只在 poll 之后运行,不再像更早的实现那样同时在 poll 前后各处理一次。这会影响极端边界下 setImmediate() 与定时器的相对时机,却不改变一个更可靠的判断原则:先确认回调属于哪个阶段,再确认它是在什么位置被登记的。

我以前把事件循环想成“每轮取一个宏任务”,这个模型一碰到真实服务就不够用了。事件循环进入一个阶段后,通常会处理该阶段中已经就绪的多个回调,直到队列暂时清空或达到内部上限,才向前推进。
因此,一轮循环可能在 poll 中处理多个 I/O 回调,也可能在 check 中处理多个 setImmediate() 回调。而且每段 JavaScript 回调结束后,Node.js 还会处理 process.nextTick() 与 V8 微任务。它们并不是等整个阶段彻底跑完才统一出现。
poll 阶段一边处理已经到达的 I/O 事件,一边决定最多可以等待 I/O 多久。
setImmediate() 时,循环会前往 check,而不是继续长时间等待。这也解释了 Node.js 空闲时为什么不会靠一个 JavaScript while (true) 把 CPU 跑满:等待发生在底层通知机制上,有事件到来才唤醒。那次导入事故刚好相反——底层早已唤醒,JavaScript 却一直在更高优先级的队列里打转。
await 让出了调用栈,却没让给 I/O把定时器回调、I/O 回调和 setImmediate() 回调统称为“宏任务”,适合快速沟通,但 Node.js 内部并不存在一条囊括所有宏任务的总队列。它们分别落在 timers、poll、check 等位置。
V8 微任务常见于:
.then()、.catch() 与 .finally();queueMicrotask();await 后的续体——被等待的 Promise 落定后,后续代码以微任务继续。Node.js 另外维护 nextTick 队列。process.nextTick() 不属于事件循环的任何阶段,通常比 V8 微任务更早处理。这条队列优先级很高,也更容易被滥用。
在普通 CommonJS 顶层脚本或 JavaScript 回调结束后,可以用下面这条顺序推导大多数问题:
queueMicrotask() 使用的微任务队列;如果微任务执行期间又登记了 process.nextTick(),Node.js 会在继续事件循环前再处理它。

我把下面的最小复现存成 order.cjs,用它确认自己没有被日志出现的先后顺序骗到:
console.log('1. 同步开始');
process.nextTick(() => {
console.log('3. nextTick');
Promise.resolve().then(() => {
console.log('6. nextTick 里登记的 Promise');
});
});
Promise.resolve().then(() => {
console.log(
输出是:
1. 同步开始
2. 同步结束
3. nextTick
4. Promise.then
5. queueMicrotask
6. nextTick 里登记的 Promise
7. Promise 里登记的 nextTick3 最早出现,因为顶层脚本结束后先处理 nextTick。随后微任务按登记顺序执行,所以是 4、5;3 中追加的 Promise 排到微任务队尾,于是输出 6。运行 4 时登记的 nextTick 不会穿透当前微任务回调,而是在微任务队列清空后、事件循环继续前输出 7。
如果把相似代码直接放进 .mjs,顶层模块求值本身处在微任务语境中,顶层登记的 Promise 或 queueMicrotask() 可能先于 process.nextTick()。所以“nextTick 永远比 Promise 早”不是可以脱离上下文背诵的定律。遇到顺序题,我会先确认三件事:它在 CommonJS 顶层、ES 模块顶层,还是普通回调内部。
process.nextTick() 目前属于兼容性仍然存在、但业务代码应谨慎使用的机制;大多数只需要微任务语义的场景,用 queueMicrotask() 更直观。危险之处在于,nextTick 回调可以在事件循环继续前不断追加自己:
const { performance } = require('node:perf_hooks');
let remaining = 1_000_000;
const startedAt = performance.now();
function repeat() {
remaining -= 1;
if (remaining > 0) process.nextTick(repeat);
}
repeat();
setTimeout(() =>
把 remaining 调得越大,定时器通常等得越久;如果无限递归,poll、check 和 timers 都拿不到机会,形成 I/O 饥饿。持续补充 Promise 微任务也能造成类似结果。那段 await Promise.resolve() 循环就是更隐蔽的版本:语法上反复 await,调度上却一直留在微任务检查点。
await Promise.resolve() 只把后续代码放进微任务,并没有把机会真正让给 timers 或 I/O。需要跨事件循环轮次分片时,可以使用 setImmediate();需要大量 CPU 计算时,应考虑 worker_threads 或独立进程。
setTimeout(fn, 20) 承诺的是最早阈值20 毫秒不是预约好的执行时刻,而是回调可以被调度的最早阈值。阈值到达以后,它仍要等待:
操作系统调度也可能继续增加误差。在现代 Node.js 中,延迟小于 1、等于 NaN 或超出支持范围时,会被调整为 1 毫秒。因此 setTimeout(fn, 0) 连字面意义上的“零毫秒”都不是。
const { performance } = require('node:perf_hooks');
const startedAt = performance.now();
setTimeout(() => {
const elapsed = Math.round(performance.now() - startedAt);
console.log(`定时器实际等待了 ${elapsed}ms`);
}, 20);
// 用同步循环占住主线程约 80ms
这段代码通常打印接近 80ms,而不是 20ms。定时器在约 20ms 时已经满足阈值,可调用栈仍被同步循环占着,它只能等。

setImmediate() 和 setTimeout(0) 谁先这个问题必须带上登记位置。
setImmediate() 会进入当前轮后续的 check 阶段;setTimeout(0) 至少要等待定时器阈值和后续 timers 机会,因此 immediate 先执行。const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});输出稳定为:
immediate
timeout
setImmediate() 不是“现在立刻执行”,而是登记到 check 队列,至少要等当前回调返回。process.nextTick() 会在事件循环继续前运行,但同样不能打断当前调用栈。
async 不是免阻塞通行证事件循环能隐藏 I/O 的等待时间,却不会自动拆开耗时 JavaScript。解析超大 JSON、灾难性回溯的正则、密码学计算、图片处理、同步文件 API 和普通死循环,都会长时间占住调用栈。
函数前加 async 不会改变这一点:await 之前的代码照常同步执行;await 之后恢复的代码也会一直运行,直到返回或再次让出。那次批量导入中,每批数据的标准化都在主线程执行,而 await Promise.resolve() 又只在微任务之间切换。于是 CPU 工作被切成了很多块,却没有给 I/O 阶段留出口。
当一个回调连续占用主线程 200ms,这段时间内到达的 socket 数据、到期的定时器和其他请求回调只能排队。服务的典型症状不是所有请求一起匀速变慢,而是尾延迟突然抬高:多数请求仍然很快,少数请求刚好撞在长任务后面。

如果总计算量不大,只是单次连续占用主线程太久,可以每处理一小批就用 setImmediate() 归还控制权:
function sumInChunks(limit, chunkSize = 100_000) {
return new Promise((resolve) => {
let index = 1;
let sum = 0;
function runChunk() {
const end = Math.min(index + chunkSize, limit + 1);
while
分片不减少总计算量,还会增加调度开销;它改善的是“每次连续占用主线程多久”。批次也不是越小越好,应该用可接受的事件循环延迟和整体吞吐量一起调节。工作本身很重,或确实需要多核并行时,应交给 worker_threads、子进程或独立计算服务。
我最终没有靠“体感很卡”结案,而是把事件循环延迟纳入了监控。node:perf_hooks 的 monitorEventLoopDelay() 可以采样延迟直方图:
const {
monitorEventLoopDelay,
performance,
} = require('node:perf_hooks');
const delay = monitorEventLoopDelay({ resolution: 10 });
delay.enable();
setTimeout(() => {
const startedAt = performance.now();
while (performance.now() - startedAt < 120
直方图原始单位是纳秒,所以要除以 1e6 转成毫秒。线上更适合持续采样,并把事件循环延迟与请求延迟、CPU、GC、数据库和外部服务指标放在同一时间轴上。事件循环延迟高只证明主线程没有及时推进;要定位元凶,还得结合 CPU profile、慢操作日志或诊断报告找到占住它的代码。
假设请求 A 到达 Node.js HTTP 服务。socket 可读后,相关 I/O 事件沿 poll 路径被处理,Node.js 解析请求并调用处理函数。此时处理函数位于调用栈顶。
处理函数发起数据库查询,然后很快返回。网络等待由数据库驱动、libuv 和操作系统协作处理,JavaScript 主线程于是可以执行请求 B。数据库 socket 收到请求 A 的结果后,I/O 回调再次获得调度机会;驱动解析结果并让 Promise 落定,await 后的逻辑以微任务恢复,最后调用 res.end() 提交响应。
请求 A 并没有从头到尾占着一条线程,它更像几段短暂的 JavaScript 被 I/O 等待隔开:
请求对象、响应对象和请求级状态会被闭包、Promise 链或框架上下文继续引用,所以跨过多个调度点仍然可用。反过来,当前用户、请求 ID 等状态不该放进普通全局变量:请求 A 与 B 会交错推进,全局可变状态很容易被另一条请求覆盖。
Node.js 处理高并发 I/O 的关键,不是让一条 JavaScript 线程同时做很多事,而是让每个请求只在需要执行 JavaScript 时短暂占用主线程,等待 I/O 时及时归还执行权。
定位事故时,我常把可疑顺序缩成一个 CommonJS 脚本。下面这段把同步代码、I/O、nextTick、微任务、immediate 和定时器放在同一现场:
const fs = require('node:fs');
console.log('1. 同步开始');
fs.readFile(__filename, () => {
console.log('4. I/O 回调');
process.nextTick(() => {
console.log('5. nextTick');
});
Promise.resolve().then(() =>
顶层脚本先按调用栈运行,所以先输出 1 和 2。读文件操作已经发起,但回调不能打断当前脚本。
顶层脚本返回后,微任务队列里已有 Promise 回调,于是输出 3。文件 I/O 即使已经完成,也要等这个检查点结束。
文件读取完成并获得 poll 调度机会后,I/O 回调进入调用栈,输出 4。回调内部只是登记四项后续工作,它们不会插进当前回调中间。
I/O 回调返回后,CommonJS 语境先处理 nextTick 队列,输出 5;再处理 Promise 微任务,输出 6。
setTimeout(fn, 0) 就是立刻执行”它至少要等当前调用栈清空,并等到 timers 的处理机会;Node.js 还会把过小的延迟调整为 1ms。准确说法是“达到最早阈值后,等待未来某次定时器调度”。
异步 API 可以把 I/O 等待移出主线程,回调里的 JavaScript 仍在主线程执行。async 函数连续计算 500ms,照样挡住其他回调;一个已经落定的 Promise 只会把续体放进微任务,不会把 CPU 工作搬到其他线程。
timers、poll、check、nextTick 和 V8 微任务不在同一条队列里。比较执行顺序时,必须同时看回调类型、登记位置和当时所处阶段。
process.nextTick() 就是下一轮事件循环”它不属于事件循环阶段,而是在当前 JavaScript 操作结束后、事件循环继续前运行。递归追加 nextTick,反而可能让新一轮循环迟迟无法开始。
JavaScript 回调遵守 run-to-completion。就绪只表示可以排队,不能中断调用栈。这也是 20ms 定时器可能在 80ms 后才执行的根本原因。
我后来把这次踩坑压缩成六个问题,每次遇到“Node.js 莫名卡住”就按顺序问:
await Promise.resolve()、queueMicrotask() 和 process.nextTick() 都不能保证 I/O 获得机会。setImmediate() 控制单次占用时间,重计算移出主线程。事件循环最值得记住的不是一张箭头很多的环形图,而是一条判断线:正在运行的 JavaScript 不会被抢占;它返回以后,nextTick 和微任务通常先得到处理;随后 libuv 才继续推进各阶段。定时器只给最早阈值,I/O 就绪也只是获得排队资格。真正稳定的 Node.js 服务,靠的是让每段主线程工作足够短,并用指标确认它确实按时把执行权还了回去。
到这里,我们已经能解释“回调为什么晚了”,却还不能解释“回调准备好以前发生了什么”。文件读取、DNS、加密和网络请求都叫异步 I/O,它们背后使用的却不是同一种机制,也不共享同一套容量。继续往系统一侧追,才能分清事件循环堵塞、线程池排队和外部依赖变慢这三种完全不同的故障。
事件循环随后进入 check,运行刚登记的 setImmediate,输出 7。setTimeout(0) 要等待后续 timers 机会,最后输出 8。