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 队列,二者在每个阶段切换之间、以及每个回调执行完毕后都会被清空。这就是为什么微任务总是"插队"在下一个宏任务之前执行。
二、宏任务与微任务:谁先谁后
把回调分成两类,顺序问题就清晰了:
- 宏任务(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 阶段,所以排在最后。
setImmediate 总是先于 setTimeout(fn, 0) 执行,因为 I/O 回调在 poll 阶段,紧接着就是 check 阶段;而 setTimeout 要等下一轮 timers。但在顶层代码中,两者的顺序取决于进程启动耗时,并不确定。四、把顺序判断变成可复用的方法
面对任意一段异步代码,可以按下面的判断路径快速推导输出顺序:
- 先执行所有同步代码,遇到异步注册就按类型归入对应队列。
- 同步代码结束后,清空微任务:先
nextTick,再 Promise,且循环清空直到为空。 - 进入事件循环,按 timers → pending → poll → check → close 的顺序执行各阶段宏任务。
- 每执行完一个宏任务回调,回到第 2 步清空微任务。
- 遇到 I/O 回调时,记住它处于 poll 阶段,其后的
setImmediate会在本轮 check 执行,setTimeout则要等下一轮。
setTimeout(fn, 0) 会"立刻"执行。实际上 0 毫秒只是最小延迟,回调仍需等到下一轮 timers 阶段,且受前面微任务和其他阶段影响。另一个误区是把 process.nextTick 当成普通异步——它优先级极高,递归调用会阻塞事件循环,应改用 setImmediate 拆分。五、实践建议
理解事件循环的最终目的是写出可预测的异步代码。几点经验:需要"尽快但可让出控制权"时用 setImmediate 而非递归 nextTick;在 I/O 回调后希望优先执行的任务,用 setImmediate 能稳定排在 setTimeout 之前;排查顺序问题时,用 console.log 打上阶段标签,比凭记忆推断更可靠。掌握这套机制后,后面要讲的异步性能优化、避免阻塞事件循环,才有坚实的判断基础。