人人都会AI编程

3.3 事件循环完整机制

更新时间:2026-07-11

在 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 阶段
管理 setTimeoutsetInterval 注册的定时器。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),包括 setTimeoutsetIntervalsetImmediate、I/O 回调等。但在 Node.js 中,还存在另一类更高优先级、会“插队”的任务——微任务(microtask)

微任务主要包括:

  • process.nextTick() 注册的回调
  • Promise.then()async/await 以及 queueMicrotask()

它们的执行规则非常关键:

每执行完一个宏任务,都会立即清空当前的微任务队列。在六大阶段之间的过渡和切换时,也会清空微任务队列。

具体到 Node.js 事件循环中,微任务分为两个子队列:nextTick 队列Promise 队列(即其它微任务队列)。执行时,会先完整清空 nextTick 队列,再完整清空 Promise 队列。也就是说,process.nextTickPromise.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 在判断定时器之前会检查是否已存在 setTimeoutsetImmediate。由于两个函数在同一个主模块中被先后调用,当定时器阈值(0ms,但受 clamps 影响可能为 1ms)与 setImmediate 竞争时,如果在主模块中调用,两者之间的顺序受性能影响并不总是确定(在 Node.js 内部某些版本中可能 setTimeout 在前),但在上面场景下,多数情况会先输出 setTimeoutsetImmediate。实际上,若放到 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 阶段的一个宏任务。该宏任务执行时,依次注册了 setTimeoutsetImmediate、Promise 和 nextTick 回调。
  • 宏任务结束后,立即清空微任务:nextTick D 先输出,然后 Promise C 输出。
  • 接着判断 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。
  • 优先使用 Promiseasync/await,让错误处理和流程控制更清晰。除非有明确的时序需求,否则不要使用 process.nextTick
  • 理解 setTimeoutsetImmediate 的时机差异。在 I/O 回调中,setImmediate 总是先于 setTimeout,可以放心利用这个特性做事件循环内的两步调度。
  • 避免在递归中使用 process.nextTick,使用 setImmediate 可以避免饥饿。
  • 调试异步顺序时,善用队列模型,画出宏任务与微任务队列的状态变化,往往比单纯“猜”要准确得多。

掌握了事件循环的完整机制,你就拥有了一份 Node.js 异步世界的精确地图。这不仅是面试的高频考点,更是写出稳定、高性能服务端代码的根基。接下来的章节中,我们将进一步剖析 libuv 的底层实现,理解 Node.js 是如何利用系统调用和线程池实现真正的非阻塞 I/O。