在 1.2 节中我们已经了解了事件循环作为 Node.js 并发模型中枢的核心地位。事件循环就像一个永不停歇的调度器,在单线程上管理着成千上万的异步任务。然而“事件循环”并不是一个模糊的概念,它内部有明确划分的阶段,每个阶段都维护着一个回调队列,不同类型的异步操作会在不同阶段被处理。同时,在阶段之间还存在微任务等高优先级的“插队”队列。只有把这些细节理清,我们才能写出真正健壮、高性能的 Node.js 代码,也才能理解那些令人困惑的输出顺序。
3.3.1 事件循环六大阶段详解
Node.js 的事件循环在每次迭代(即一个 tick)中,会依次经过以下几个阶段:
┌───────────────────────────┐
┌─>│ timers │ 执行 setTimeout、setInterval 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ 执行推迟到下一轮的 I/O 回调(如某些 TCP 错误)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ (内部使用)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ 获取新的 I/O 事件;执行 I/O 相关回调(除 close、timer、setImmediate 外)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ 执行 setImmediate 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ └─── close callbacks ───────┘ 执行 close 事件回调(如 socket.on('close'))
各阶段维护着一个 FIFO(先进先出) 的回调队列。事件循环进入某一阶段时,会从该阶段的队列中依次取出所有回调执行,直到队列为空或达到该阶段的最大回调限制。然后进入下一阶段。
① timers 阶段
管理 setTimeout 和 setInterval 注册的定时器。Node.js 会检查当前时间,将已经到期的定时器回调推入队列执行。需要注意的是,定时器的实际执行时间可能晚于设定的延迟时间:因为只有当事件循环进入 timers 阶段时才会检查,如果前一个 tick 耗时过长,或者 poll 阶段阻塞了太久,定时器就无法准时执行。
setTimeout(() => {
console.log('setTimeout 回调执行');
}, 100);
② pending callbacks 阶段
执行上一轮事件循环中未处理的回调,比如某些操作系统级别的 I/O 错误回调(例如 TCP 连接突然断开时产生的错误事件)。日常的应用逻辑很少在这里被触发,大部分开发者不需要特别关注此阶段。
③ idle、prepare 阶段
仅由 Node.js 内部使用,不暴露给用户代码。无需关心。
④ poll 阶段(核心阶段)
这是事件循环中的“心脏”。它有两个主要任务:
- 计算应该阻塞并等待 I/O 的时间;
- 处理 poll 队列中的与 I/O 相关的事件回调(如文件读取完成、HTTP 请求响应返回等)。
当事件循环进入 poll 阶段且 poll 队列不为空时,会同步执行队列中的所有回调,直到队列清空或达到系统上限。如果 poll 队列为空,事件循环会检查是否有 setImmediate 回调需要执行。如果有,则结束 poll 阶段,进入 check 阶段执行 setImmediate;如果没有,则继续检查是否有到期的定时器回调。如果定时器队列也为空,poll 阶段就会阻塞等待,直到有新的 I/O 事件到来,然后立刻执行其回调。阻塞等待的时间取决于最近的定时器到期时间。
这个机制的精妙之处在于:在没有任务时,事件循环是“休眠”的,不会占用 CPU;一旦有新连接或 I/O 完成,又能立刻被唤醒处理。
⑤ check 阶段
专属于 setImmediate() 的回调。setImmediate 是一个执行时机非常确定的 API,无论 poll 阶段如何阻塞,它的回调都会在当前 tick 的 poll 阶段结束后立即执行(前提是 poll 阶段结束时不因其他原因跳过)。
setImmediate(() => {
console.log('setImmediate 回调执行');
});
⑥ close callbacks 阶段
处理 close 事件,如 socket.on('close', ...) 或 readStream.on('close', ...)。注意,这些不是通过 process.on('exit') 触发的回调。
3.3.2 宏任务与微任务的执行顺序与优先级
事件循环的六大阶段执行的都是 宏任务(macrotask),包括 setTimeout、setInterval、setImmediate、I/O 回调等。但在 Node.js 中,还存在另一类更高优先级、会“插队”的任务——微任务(microtask)。
微任务主要包括:
process.nextTick()注册的回调Promise.then()、async/await以及queueMicrotask()
它们的执行规则非常关键:
每执行完一个宏任务,都会立即清空当前的微任务队列。在六大阶段之间的过渡和切换时,也会清空微任务队列。
具体到 Node.js 事件循环中,微任务分为两个子队列:nextTick 队列和Promise 队列(即其它微任务队列)。执行时,会先完整清空 nextTick 队列,再完整清空 Promise 队列。也就是说,process.nextTick 比 Promise.then 优先级更高。
由于 process.nextTick 会在当前“操作”完成后立刻触发,滥用它会造成“迭代饥饿”——如果递归调用 nextTick,会阻止事件循环进入下一阶段,I/O 回调将永远无法执行。
我们通过一个经典示例来观察执行顺序:
setTimeout(() => console.log('1: setTimeout'), 0);
setImmediate(() => console.log('2: setImmediate'));
Promise.resolve().then(() => console.log('3: Promise'));
process.nextTick(() => console.log('4: nextTick'));
console.log('5: 主线程同步代码');
输出结果依赖于这段代码是在一个什么时机被运行的。如果直接作为主模块通过 node script.js 运行,则输出通常是:
5: 主线程同步代码
4: nextTick
3: Promise
1: setTimeout
2: setImmediate
这是为什么呢?
- 主线程同步代码最先执行,输出
5; - 主线程结束后,当前“操作”完成,触发微任务队列:先清空 nextTick,输出
4,再清空 Promise,输出3; - 进入事件循环,当前 tick 在判断定时器之前会检查是否已存在
setTimeout和setImmediate。由于两个函数在同一个主模块中被先后调用,当定时器阈值(0ms,但受 clamps 影响可能为 1ms)与setImmediate竞争时,如果在主模块中调用,两者之间的顺序受性能影响并不总是确定(在 Node.js 内部某些版本中可能setTimeout在前),但在上面场景下,多数情况会先输出setTimeout再setImmediate。实际上,若放到fs.readFile的 I/O 回调中,二者的顺序完全确定:I/O 回调属于 poll 阶段回调,执行完后必然进入 check 阶段执行setImmediate,而定时器要等到下一个 tick 才会被检查,因此setImmediate一定先于setTimeout。
为了看清楚微任务的介入时机,再看一个更复杂的例子:
fs.readFile(__filename, () => {
setTimeout(() => console.log('A: setTimeout'), 0);
setImmediate(() => console.log('B: setImmediate'));
Promise.resolve().then(() => console.log('C: Promise'));
process.nextTick(() => console.log('D: nextTick'));
});
输出顺序:
D: nextTick
C: Promise
B: setImmediate
A: setTimeout
解释:
- I/O 回调是 poll 阶段的一个宏任务。该宏任务执行时,依次注册了
setTimeout、setImmediate、Promise 和 nextTick 回调。 - 宏任务结束后,立即清空微任务:nextTick
D先输出,然后 PromiseC输出。 - 接着判断 check 队列是否有
setImmediate,有则执行B。 - 当前 tick 结束,下一个 tick 的 timers 阶段执行
setTimeout输出A。
这个例子非常清晰地展示了微任务在宏任务结束后的“雷霆清空”动作,也验证了 process.nextTick > Promise 的优先级。
3.3.3 与浏览器事件循环的核心差异
尽管 Node.js 和浏览器都使用事件循环机制处理异步,但二者在细节上存在不少差异,了解它们对全栈开发非常重要。
1. 事件循环结构与阶段划分
- 浏览器(以 Chrome 为代表):事件循环比较简单,主要分为宏任务队列和微任务队列。宏任务包括:整体脚本、setTimeout、setInterval、UI 渲染、I/O(如 fetch);微任务包括:Promise.then、MutationObserver。事件循环每次从宏任务队列取出一个执行,然后清空所有微任务。之后可能渲染页面。
- Node.js:有明确的多阶段划分(timers、poll、check 等),不同的异步 API 在不同阶段被处理,微任务在每个宏任务之后以及阶段切换时清空。这种设计针对服务器场景做了更细粒度的分类,提升 I/O 处理效率。
2. 宏任务类型的归属
setImmediate是 Node.js 特有的宏任务,浏览器没有。浏览器的类似行为可以用setTimeout(fn, 0)模拟,但setImmediate在 Node.js 中的调度时机更稳定,尤其是在 I/O 回调内部。- 浏览器的
requestAnimationFrame和 UI 渲染是特殊的宏任务,但 Node.js 显然没有。
3. 微任务的清空时机
- 浏览器:一个宏任务执行完,清空所有微任务,包括 nextTick(浏览器中 queueMicrotask 相当于默认微任务)。但浏览器没有
process.nextTick。 - Node.js:一个宏任务执行完,清空所有微任务,并且先清空 nextTick 队列,再清空 Promise 队列。这意味着
process.nextTick回调比 Promise 更快执行,可能导致 Promise 一直得不到执行。
4. process.nextTick 的特殊存在process.nextTick 是 Node.js 特有的,它不属于事件循环的任何一个阶段,而是在当前操作结束后、下一轮事件循环之前立即执行。严格来说,它甚至不算一个任务,因为它不会让出执行权给回调。它的存在使得开发者可以“插队”执行代码,但过度使用会引起 I/O 饥饿。浏览器中没有对应的 API。
5. 定时器精度的差异
Node.js 中 setTimeout(fn, 0) 实际上会被强制转换为 1ms(Node.js 早期版本),最近版本则在 0 和 1ms 之间“clamp”处理,但总之不会是真的 0ms。浏览器通常也是 4ms 或更高(对于嵌套定时器)。这导致两者在细微顺序上可能不同,不过这些细节只在极端竞争条件下才体现。
6. 全局上下文
浏览器的事件循环与页面生命周期绑定,关闭页签后循环终止;Node.js 的事件循环则只要还有未退出的异步任务(定时器、监听端口、待处理的回调等),就会一直运行。process.exit() 可以强制终止,unref() 方法可以标记一个定时器/套接字不阻止退出。
3.3.4 实用总结:如何正确运用事件循环
- 应该避免在事件循环的单一阶段执行过长时间的任务。用分解(
setImmediate分片)或 Worker 分流 CPU 密集计算,否则会阻塞 poll 阶段 I/O。 - 优先使用
Promise和async/await,让错误处理和流程控制更清晰。除非有明确的时序需求,否则不要使用process.nextTick。 - 理解
setTimeout和setImmediate的时机差异。在 I/O 回调中,setImmediate总是先于setTimeout,可以放心利用这个特性做事件循环内的两步调度。 - 避免在递归中使用
process.nextTick,使用setImmediate可以避免饥饿。 - 调试异步顺序时,善用队列模型,画出宏任务与微任务队列的状态变化,往往比单纯“猜”要准确得多。
掌握了事件循环的完整机制,你就拥有了一份 Node.js 异步世界的精确地图。这不仅是面试的高频考点,更是写出稳定、高性能服务端代码的根基。接下来的章节中,我们将进一步剖析 libuv 的底层实现,理解 Node.js 是如何利用系统调用和线程池实现真正的非阻塞 I/O。