2. 事件循环机制

Node.js 能在单线程上处理高并发 I/O,靠的不是多线程,而是事件循环(Event Loop)配合 libuv 的异步能力。理解事件循环,关键不是背下六个阶段的名字,而是搞清楚三件事:每个阶段负责什么回调、微任务在什么时机被清空、一次循环里回调的执行顺序由谁决定。把这三点弄明白,异步代码的输出顺序就不再是"玄学"。

一、事件循环的六个阶段

Node.js 的事件循环由 libuv 驱动,一次完整的循环(tick)按固定顺序经过六个阶段,每个阶段维护一个自己的回调队列:

  • timers(定时器):执行到期的 setTimeout 与 setInterval 回调。注意这里判断的是"是否到期",而不是"注册了多久"。
  • pending callbacks(待处理回调):执行上一轮循环中被推迟的、与系统操作相关的回调,例如某些 TCP 错误回调。
  • idle, prepare:仅供 Node 内部使用,业务代码几乎不会直接接触。
  • poll(轮询):最核心的阶段。它负责等待并执行 I/O 回调(文件读写、网络数据到达等),并在没有其他任务时决定是否在此阻塞等待。
  • check(检查):专门执行 setImmediate 的回调。
  • close callbacks(关闭回调):执行 socket.on('close') 这类关闭事件的回调。

需要特别记住:微任务(microtask)不属于这六个阶段中的任何一个。它们由两个队列组成——process.nextTick 队列和 Promise 队列,二者在每个阶段切换之间、以及每个回调执行完毕后都会被清空。这就是为什么微任务总是"插队"在下一个宏任务之前执行。

Node.js 事件循环:六阶段 + 微任务插队 timers setTimeout / setInterval pending callbacks 系统级延迟回调 idle, prepare Node 内部使用 poll I/O 回调 · 等待/阻塞 核心阶段 check setImmediate 回调 close callbacks socket.on('close') 微任务队列(每个阶段切换 / 每个回调结束后立即清空) process.nextTick 队列 优先级最高,先于 Promise Promise 微任务队列 .then / .catch / await 后续 宏任务按阶段顺序执行;微任务在每个宏任务之间"插队"清空

二、宏任务与微任务:谁先谁后

把回调分成两类,顺序问题就清晰了:

  • 宏任务(macrotask):setTimeout、setInterval、setImmediate、I/O 回调、close 回调。它们分别挂在事件循环的不同阶段上。
  • 微任务(microtask):process.nextTick 与 Promise 回调。它们不占阶段,而是在阶段之间被"清空"。

执行规则可以概括为两条:第一,每执行完一个宏任务回调,就立即清空微任务队列;第二,process.nextTick 队列优先于 Promise 队列。这意味着微任务永远比下一个宏任务先执行,而且如果微任务里又产生微任务,会一直清空到队列为空——这也是"微任务递归"可能饿死事件循环的原因。

三、代码实验:观察真实执行顺序

下面这段代码把各类回调放在一起,先在心里预测输出,再运行对照:

const fs = require('fs');

console.log('1 script start');

setTimeout(() => console.log('2 setTimeout'), 0);
setImmediate(() => console.log('3 setImmediate'));

Promise.resolve().then(() => console.log('4 promise then'));
process.nextTick(() => console.log('5 nextTick'));

fs.readFile(__filename, () => {
  console.log('6 fs.readFile callback');
  setTimeout(() => console.log('7 setTimeout in I/O'), 0);
  setImmediate(() => console.log('8 setImmediate in I/O'));
  process.nextTick(() => console.log('9 nextTick in I/O'));
});

console.log('10 script end');

一次典型运行结果如下(示例输出,具体顺序在顶层 setTimeout 与 setImmediate 之间可能互换):

1 script start
10 script end
5 nextTick
4 promise then
2 setTimeout
3 setImmediate
6 fs.readFile callback
9 nextTick in I/O
8 setImmediate in I/O
7 setTimeout in I/O

逐段解读:同步代码先跑完,所以 1、10 最先;随后清空微任务,nextTick 优先于 Promise,得到 5、4;进入 timers 阶段执行 2;再到 check 阶段执行 3。文件读取回调 6 在 poll 阶段执行,执行完立即清空微任务得到 9;接着进入 check 阶段执行 8;而 7 这个 setTimeout 因为注册时已经过了当前 timers 阶段,只能等到下一轮循环的 timers 阶段,所以排在最后。

在 I/O 回调内部,setImmediate 总是先于 setTimeout(fn, 0) 执行,因为 I/O 回调在 poll 阶段,紧接着就是 check 阶段;而 setTimeout 要等下一轮 timers。但在顶层代码中,两者的顺序取决于进程启动耗时,并不确定。

四、把顺序判断变成可复用的方法

面对任意一段异步代码,可以按下面的判断路径快速推导输出顺序:

  1. 先执行所有同步代码,遇到异步注册就按类型归入对应队列。
  2. 同步代码结束后,清空微任务:先 nextTick,再 Promise,且循环清空直到为空。
  3. 进入事件循环,按 timers → pending → poll → check → close 的顺序执行各阶段宏任务。
  4. 每执行完一个宏任务回调,回到第 2 步清空微任务。
  5. 遇到 I/O 回调时,记住它处于 poll 阶段,其后的 setImmediate 会在本轮 check 执行,setTimeout 则要等下一轮。
常见误区:认为 setTimeout(fn, 0) 会"立刻"执行。实际上 0 毫秒只是最小延迟,回调仍需等到下一轮 timers 阶段,且受前面微任务和其他阶段影响。另一个误区是把 process.nextTick 当成普通异步——它优先级极高,递归调用会阻塞事件循环,应改用 setImmediate 拆分。

五、实践建议

理解事件循环的最终目的是写出可预测的异步代码。几点经验:需要"尽快但可让出控制权"时用 setImmediate 而非递归 nextTick;在 I/O 回调后希望优先执行的任务,用 setImmediate 能稳定排在 setTimeout 之前;排查顺序问题时,用 console.log 打上阶段标签,比凭记忆推断更可靠。掌握这套机制后,后面要讲的异步性能优化、避免阻塞事件循环,才有坚实的判断基础。