引子
你大概已经用 AI 写过不少 Node.js 代码了。
让 Copilot 帮你生成一个 Express 路由,几秒钟的事。让它写一段文件读取逻辑,async/await 包得整整齐齐。大多数时候,这些代码能跑,甚至跑得不错。
但总有一些时刻,事情开始变得诡异:
setTimeout和Promise的执行顺序,跟你在浏览器里的经验对不上setImmediate和process.nextTick,两个你在前端从没见过的 API,文档说的和实际跑出来的不一样- 一个看起来没问题的异步操作,在高并发下突然表现异常
你去问 AI,它会给你一个看起来合理的解释。
但如果你追问细节
——"为什么在 I/O 回调里 setImmediate 一定比 setTimeout 先执行?"
——它大概率会开始含糊其辞,甚至给出错误的答案。
这不怪 AI。事件循环的行为高度依赖运行时上下文,同一段代码在不同位置执行,结果可能完全不同。这不是靠模式匹配能答对的问题。
作为前端开发者,你对"异步"并不陌生。 你知道 Promise,知道 async/await,知道"不要阻塞主线程"。但这些认知建立在浏览器的事件循环模型上——而那个模型,在 Node.js 里会产生误导。
这篇文章不会从零讲 JavaScript 异步。它假设你已经熟悉浏览器环境下的异步编程,然后带你看清:Node.js 的事件循环,到底和你以为的有什么不同。
你以为你懂的事件循环
在浏览器里,事件循环的模型相对简单。你可能已经内化了这个流程:
- 从宏任务队列取一个任务执行(比如一个
setTimeout回调) - 清空所有微任务(
Promise.then、queueMicrotask) - 如果需要,执行渲染(重绘、重排)
- 回到第 1 步
这个模型足够应付绝大多数前端场景。
但当你带着这个心智模型走进 Node.js,会发现好几个地方对不上:
Node.js 没有"渲染"这回事。
浏览器事件循环的节奏很大程度上被屏幕刷新率(通常 60fps)驱动,每一帧大约 16.6ms。Node.js 没有 UI 线程,事件循环的驱动力是 I/O 事件和定时器,不存在"一帧"的概念。
不是一个队列,而是六个阶段。
浏览器的事件循环可以简化为"一个宏任务队列 + 一个微任务队列"。
Node.js 的事件循环是一个由 6 个阶段(phase)组成的循环,每个阶段有自己的任务队列,事件循环按固定顺序依次经过这些阶段。
微任务的执行时机不同。
在浏览器中,每执行完一个宏任务就清空微任务队列。在 Node.js(v11+)中,微任务在每个阶段切换时清空——也就是说,一个阶段内可能连续执行多个回调,然后才轮到微任务。
多了两个你没见过的 API。
setImmediate 和 process.nextTick 是 Node.js 独有的。它们的执行时机和优先级,是理解 Node.js 事件循环的关键拼图。
来看一段代码,分别在浏览器和 Node.js 里跑一下:
// 在浏览器控制台和 Node.js 中分别运行,对比输出顺序
console.log('1: script start');
setTimeout(() => {
console.log('2: setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('3: Promise.then');
});
queueMicrotask(() => {
console.log('4: queueMicrotask');
});
console.log('5: script end');
在浏览器和 Node.js 中,这段代码的输出顺序是一样的:
1: script start
5: script end
3: Promise.then
4: queueMicrotask
2: setTimeout
看起来没区别?那是因为这段代码太简单了。差异藏在更复杂的场景里——当 setImmediate、process.nextTick、I/O 回调同时出现时,浏览器的心智模型就不够用了。
接下来,我们看看 Node.js 事件循环的完整面貌。
Node.js 事件循环的全貌
Node.js 的事件循环由 libuv 驱动,每一轮循环(通常称为一个 tick)依次经过 6 个阶段:
┌───────────────────────────┐
┌─>│ timers │ 执行 setTimeout / setInterval 回调
│ └─────────────┬─────────────┘
│ │
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ 执行系统级回调(如 TCP 错误)
│ └─────────────┬─────────────┘
│ │
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ Node.js 内部使用
│ └─────────────┬─────────────┘
│ │
│ ┌─────────────┴─────────────┐
│ │ poll │ 获取新的 I/O 事件,执行 I/O 回调
│ └─────────────┬─────────────┘
│ │
│ ┌─────────────┴─────────────┐
│ │ check │ 执行 setImmediate 回调
│ └─────────────┬─────────────┘
│ │
│ ┌─────────────┴─────────────┐
│ │ close callbacks │ 执行关闭事件回调(如 socket.on('close'))
│ └─────────────┬─────────────┘
│ │
└────────────────┘
关键认知:事件循环不是"有事就做",而是"按阶段轮询"。
每个阶段都有一个 FIFO 队列,存放该阶段需要执行的回调。当事件循环进入某个阶段时,它会执行该阶段队列中的回调(直到队列清空或达到执行上限),然后进入下一个阶段。
在每两个阶段之间,Node.js 会检查并清空两个特殊队列:
process.nextTick队列(nextTickQueue)- Promise 微任务队列(microtask queue)
这意味着微任务不属于任何阶段,而是在阶段切换的"间隙"执行。这一点非常重要,后面会详细展开。
现在,让我们逐个阶段看看它们各自在做什么。
六个阶段,逐个击破
1. timers 阶段
这是事件循环的第一个阶段,负责执行 setTimeout 和 setInterval 的回调。
但有一个关键细节:定时器的延迟时间是"最小延迟",不是"精确延迟"。
setTimeout(fn, 100) 的意思不是"100ms 后执行 fn",而是"至少 100ms 后,当事件循环走到 timers 阶段时,执行 fn"。如果事件循环正忙于其他阶段的回调,定时器的实际触发时间会晚于预期。
// timer-precision.js
const start = Date.now();
setTimeout(() => {
const delay = Date.now() - start;
console.log(`实际延迟: ${delay}ms(期望: 100ms)`);
}, 100);
// 模拟一个耗时操作,阻塞事件循环
const blockUntil = start + 200;
while (Date.now() < blockUntil) {
// 忙等待 200ms
}
console.log('同步代码执行完毕');
运行 node timer-precision.js,你会看到实际延迟大约是 200ms 而不是 100ms——因为同步代码阻塞了事件循环,定时器只能等到同步代码执行完、事件循环重新转起来之后才能触发。
这在浏览器里也是一样的道理,但在 Node.js 服务端场景下,这个特性的影响更大:一个 CPU 密集的操作可能让所有定时器集体延迟。
另一个容易忽略的细节:setTimeout(fn, 0) 在 Node.js 中实际上等价于 setTimeout(fn, 1)。Node.js 内部会将延迟为 0 的定时器强制设为 1ms。这个看似无关紧要的细节,在后面讨论 setTimeout vs setImmediate 的执行顺序时会变得关键。
2. pending callbacks 阶段
这个阶段执行的是被推迟到下一轮循环的系统级回调,比如某些 TCP 错误(ECONNREFUSED)的回调。
3. idle, prepare 阶段
这是 Node.js 内部使用的阶段,不暴露给用户代码。跳过。
4. poll 阶段(重点)
poll 是事件循环中最重要的阶段,也是事件循环"停留时间最长"的阶段。它做两件事:
- 计算应该阻塞多久来等待 I/O 事件
- 处理 poll 队列中的回调
几乎所有的 I/O 回调都在这个阶段执行:文件读写完成的回调、网络请求返回的回调、数据库查询结果的回调……这些都是 poll 阶段的"客人"。
poll 阶段的行为取决于当前状态:
-
如果 poll 队列不为空: 依次执行队列中的回调,直到队列清空或达到系统限制
-
如果 poll 队列为空:
- 如果有
setImmediate回调等待执行 → 结束 poll 阶段,进入 check 阶段 - 如果没有
setImmediate→ 事件循环会在这里"等待",直到有新的 I/O 事件到来,或者有定时器到期
- 如果有
这就是为什么一个简单的 HTTP 服务器不会退出——它一直在 poll 阶段等待新的连接请求。
// poll-demo.js
const fs = require('fs');
console.log('1: script start');
// 这个回调会在 poll 阶段执行
fs.readFile(__filename, () => {
console.log('2: file read complete (poll phase)');
// 在 I/O 回调内部注册的 setTimeout
setTimeout(() => {
console.log('3: setTimeout inside I/O callback');
}, 0);
// 在 I/O 回调内部注册的 setImmediate
setImmediate(() => {
console.log('4: setImmediate inside I/O callback');
});
});
console.log('5: script end');
运行 node poll-demo.js,输出:
1: script start
5: script end
2: file read complete (poll phase)
4: setImmediate inside I/O callback
3: setTimeout inside I/O callback
注意第 4 行和第 3 行的顺序:在 I/O 回调内部,setImmediate 总是比 setTimeout(fn, 0) 先执行。原因很简单——I/O 回调在 poll 阶段执行,poll 之后紧接着就是 check 阶段(setImmediate),而 setTimeout 要等到下一轮循环的 timers 阶段才能执行。
这是一个非常实用的规律,记住它。
5. check 阶段
check 阶段专门用来执行 setImmediate 的回调。
setImmediate 是 Node.js 独有的 API(浏览器没有)。它的语义是:"在当前 poll 阶段完成后立即执行"。这让它成为一个非常有用的工具——当你想在 I/O 操作完成后尽快执行某段代码,但又不想阻塞当前的 I/O 处理时,setImmediate 是最佳选择。
现在来看一个经典问题:setTimeout(fn, 0) 和 setImmediate,谁先执行?
// order-main.js — 在主模块中执行
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
多运行几次 node order-main.js,你会发现输出顺序不确定——有时 setTimeout 先,有时 setImmediate 先。
为什么?因为 setTimeout(fn, 0) 实际上是 setTimeout(fn, 1)。当主模块代码执行完毕,事件循环开始第一轮时,1ms 的定时器是否已经到期取决于系统当时的状态。如果到期了,timers 阶段会先执行它;如果还没到期,事件循环会跳过 timers,一路走到 check 阶段执行 setImmediate。
但在 I/O 回调内部,顺序是确定的:
// order-io.js — 在 I/O 回调中执行
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
运行多少次 node order-io.js,结果都是:
setImmediate
setTimeout
原因在上一节已经解释过:I/O 回调在 poll 阶段执行,poll 之后是 check(setImmediate),然后才是下一轮的 timers(setTimeout)。
6. close callbacks 阶段
这个阶段处理关闭事件的回调,比如 socket.on('close', ...)。
如果一个 socket 或 handle 被突然关闭(比如 socket.destroy()),close 事件会在这个阶段触发。如果是通过正常流程关闭的,close 事件通常通过 process.nextTick 触发。
对于大多数应用开发场景,你不需要特别关注这个阶段。知道它是事件循环的最后一站就够了。
微任务:不属于任何阶段的"插队者"
到目前为止,我们讲的 6 个阶段都是事件循环的"正式成员"。但还有两类回调,它们不属于任何阶段,却拥有最高的执行优先级:
process.nextTick回调(nextTickQueue)- Promise 微任务(microtask queue,包括
Promise.then、queueMicrotask)
在 Node.js v11+ 中,每当事件循环准备从一个阶段切换到下一个阶段时,它会先清空这两个队列。顺序是:先清空 nextTickQueue,再清空 microtask queue。
// microtask-order.js
console.log('1: script start');
process.nextTick(() => {
console.log('2: process.nextTick');
});
Promise.resolve().then(() => {
console.log('3: Promise.then');
});
setTimeout(() => {
console.log('4: setTimeout');
}, 0);
setImmediate(() => {
console.log('5: setImmediate');
});
console.log('6: script end');
运行 node microtask-order.js,输出:
1: script start
6: script end
2: process.nextTick
3: Promise.then
4: setTimeout
5: setImmediate
解析这个输出:
- 同步代码先执行:输出 1 和 6
- 同步代码执行完毕,事件循环开始。在进入第一个阶段(timers)之前,先清空微任务队列
process.nextTick优先级高于Promise.then,所以 2 在 3 前面- 微任务清空后,进入 timers 阶段,执行
setTimeout回调:输出 4 - 继续走到 check 阶段,执行
setImmediate回调:输出 5
现在来看一个更复杂的例子,理解微任务在阶段切换时的行为:
// microtask-between-phases.js
const fs = require('fs');
fs.readFile(__filename, () => {
// 我们现在在 poll 阶段
setTimeout(() => {
console.log('1: setTimeout');
process.nextTick(() => {
console.log('2: nextTick inside setTimeout');
});
}, 0);
setImmediate(() => {
console.log('3: setImmediate');
process.nextTick(() => {
console.log('4: nextTick inside setImmediate');
});
});
process.nextTick(() => {
console.log('5: nextTick');
});
Promise.resolve().then(() => {
console.log('6: Promise.then');
});
});
运行 node microtask-between-phases.js,输出:
5: nextTick
6: Promise.then
3: setImmediate
4: nextTick inside setImmediate
1: setTimeout
2: nextTick inside setTimeout
逐步解析:
- I/O 回调在 poll 阶段执行完毕后,准备切换到 check 阶段
- 切换前先清空微任务:
nextTick(5)→Promise.then(6) - 进入 check 阶段,执行
setImmediate(3) - check 阶段结束,切换前清空微任务:
nextTick inside setImmediate(4) - 下一轮循环进入 timers 阶段,执行
setTimeout(1) - timers 阶段结束,清空微任务:
nextTick inside setTimeout(2)
process.nextTick 的危险面
process.nextTick 的高优先级是一把双刃剑。如果你在 nextTick 回调中递归调用 nextTick,微任务队列永远清不空,事件循环永远无法进入下一个阶段:
// starvation.js — 不要在生产环境运行!
function recursiveNextTick() {
process.nextTick(() => {
console.log('nextTick 执行');
recursiveNextTick(); // 永远不会让出控制权
});
}
recursiveNextTick();
setTimeout(() => {
console.log('这行永远不会执行');
}, 0);
这就是所谓的"饿死"事件循环。setTimeout 的回调永远等不到 timers 阶段,因为事件循环被困在了微任务队列里。
实际建议: 除非你明确需要在当前操作完成后、事件循环继续之前执行某段代码,否则优先使用 setImmediate 而不是 process.nextTick。Node.js 官方文档也给出了同样的建议。
实战中的三个坑
理论讲完了,来看看这些知识在实际开发中会以什么形式"咬"你一口。
坑 1:setTimeout vs setImmediate 的顺序"薛定谔"
这个问题在前面已经提到过,但值得单独拎出来强调,因为它是 Node.js 事件循环中最常被问到的问题。
规律很简单:
- 在主模块中: 顺序不确定(取决于 1ms 定时器是否已到期)
- 在 I/O 回调中:
setImmediate总是先于setTimeout(fn, 0)
// pitfall-1.js
const fs = require('fs');
// 场景 1:主模块 — 顺序不确定
setTimeout(() => console.log('主模块: setTimeout'), 0);
setImmediate(() => console.log('主模块: setImmediate'));
// 场景 2:I/O 回调内 — 顺序确定
fs.readFile(__filename, () => {
setTimeout(() => console.log('I/O 内: setTimeout'), 0);
setImmediate(() => console.log('I/O 内: setImmediate'));
});
多运行几次,你会发现场景 1 的顺序会变,但场景 2 永远是 setImmediate 先。
坑 2:连续 await 的微任务陷阱
async/await 是 Promise 的语法糖,每个 await 后面的代码都会被包装成微任务。当多个 async 函数"交错"执行时,执行顺序可能出乎意料:
// pitfall-2.js
async function taskA() {
console.log('A-1');
await Promise.resolve();
console.log('A-2');
await Promise.resolve();
console.log('A-3');
}
async function taskB() {
console.log('B-1');
await Promise.resolve();
console.log('B-2');
await Promise.resolve();
console.log('B-3');
}
taskA();
taskB();
运行 node pitfall-2.js,输出:
A-1
B-1
A-2
B-2
A-3
B-3
两个函数在每个 await 处"交替"执行。这是因为每个 await 都会让出控制权,把后续代码放入微任务队列。当 A-1 执行完遇到 await,控制权交给 B,B-1 执行完也遇到 await,然后微任务队列按顺序执行 A-2、B-2、A-3、B-3。
在实际开发中,如果你有两个 async 函数操作同一份数据,这种交错执行可能导致竞态条件——即使 Node.js 是单线程的。
坑 3:CPU 密集任务"冻住"事件循环
Node.js 的单线程模型意味着:如果你的代码在做 CPU 密集的计算,整个事件循环都会被阻塞。所有的定时器、I/O 回调、网络请求处理都得等着。
// pitfall-3.js
const http = require('http');
const server = http.createServer((req, res) => {
if (req.url === '/slow') {
// 模拟 CPU 密集操作:计算斐波那契数列
const fib = (n) => (n <= 1 ? n : fib(n - 1) + fib(n - 2));
const result = fib(42);
res.end(`Result: ${result}`);
} else {
res.end('Hello!');
}
});
server.listen(3000);
console.log('Server running on http://localhost:3000');
当有人访问 /slow 时,fib(42) 大约需要 1-2 秒。在这段时间里,所有其他请求(包括访问 / 的请求)都会被阻塞——因为事件循环被困在了这个同步计算中,无法进入 poll 阶段处理新的网络事件。
这就是"Node.js 不适合 CPU 密集型任务"这句话的真正含义。不是说 Node.js 算不了,而是算的时候整个服务都停了。
解决方案: 对于 CPU 密集的操作,使用 worker_threads 把计算放到独立线程中,不阻塞主事件循环。这超出了本文的范围,但知道这个出口很重要。
写在最后
事件循环是 Node.js 的心跳。理解它,你就能听懂 Node.js 在告诉你什么。
从浏览器到 Node.js,最大的跨越不是学新的 API,而是更新你对"异步"的理解。 希望这篇文章帮你完成了这一步。