一、事件循环机制总览:Node.js事件循环的六个阶段详解

说到Node.js,绕不开的就是事件循环。我刚开始接触Node.js时,总觉得这玩意儿挺玄乎的——明明是个单线程,怎么就能处理那么多并发请求?后来啃了源码才明白,事件循环才是真正的幕后英雄。

今天咱们就来拆解一下Node.js事件循环的六个阶段。嗯,这六个阶段就像一条流水线,每个阶段都有自己的活要干。

1.1 六个阶段概览

Node.js的事件循环,说白了就是一个无限循环。它按照固定的顺序,依次处理六个队列中的任务。我习惯把这六个阶段记成一张表:

阶段名称 主要任务 典型API
timers 执行到期的定时器回调 setTimeout, setInterval
pending callbacks 处理上一轮遗留的I/O回调 某些TCP错误回调
idle/prepare 内部使用,咱们一般碰不到 libuv内部机制
poll 轮询I/O事件,执行回调 文件读写、网络请求
check 执行setImmediate回调 setImmediate
close callbacks 处理关闭事件回调 socket.on('close')

你想想看,这六个阶段就像六道工序。Node.js每转一圈,就把这六道工序走一遍。走完一圈,看看还有没有活要干,有的话继续转,没有就退出。

1.2 timers阶段——定时器的家

timers阶段负责处理setTimeout和setInterval的回调。这里有个坑,我踩过不止一次——定时器并不保证精确执行。

关键点:定时器的延迟时间是最小延迟,不是精确延迟。

举个例子:

const start = Date.now();

setTimeout(() => {
  console.log(`实际延迟:${Date.now() - start}ms`);
}, 100);

// 假设这里有个耗时操作
while (Date.now() - start < 200) {
  // 空转200ms
}

你猜结果是多少?不是100ms,而是200ms以上。为什么?因为事件循环在timers阶段检查定时器时,发现还没到时间,就继续往下走了。等它转完一圈再回来,已经过了200ms。

注意:千万不要用setTimeout来做精确的时间控制。我曾经在一个实时数据采集项目里这么干过,结果数据时间戳全乱了。后来改用专门的定时库才解决。

1.3 pending callbacks阶段——收拾烂摊子

这个阶段处理的是上一轮循环中没来得及处理的I/O回调。说白了,就是一些系统级别的错误回调,比如TCP连接失败时的错误处理。

我个人习惯把这个阶段叫做「善后阶段」。它处理的事情不多,但很重要。没有它,一些底层错误就没人管了。

1.4 idle/prepare阶段——幕后工作者

这个阶段是libuv内部使用的,咱们写业务代码基本碰不到。你可以把它理解成「准备工作阶段」——Node.js在进入poll阶段前,需要做一些内部状态更新。

嗯,这里就不深挖了,知道有这回事就行。

1.5 poll阶段——最忙的阶段

poll阶段是事件循环的核心。它干三件事:

  1. 计算还有多少I/O事件需要处理
  2. 处理这些I/O事件的回调
  3. 如果没有事件了,就等一会儿

我在项目中遇到过一个问题:一个HTTP服务,请求量一大就卡死。查了半天,发现是poll阶段被一个同步操作阻塞了。你想想看,poll阶段是处理I/O的地方,它被卡住了,新的请求就进不来。

小技巧:如果发现Node.js应用响应变慢,先看看poll阶段有没有被阻塞。常见的罪魁祸首是JSON.parse、加密解密这类同步操作。

1.6 check阶段——setImmediate的地盘

check阶段专门执行setImmediate的回调。为什么要有这个阶段?因为setImmediate比setTimeout更「急」——它会在当前poll阶段结束后立即执行。

看个对比:

setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});

这段代码的执行结果不一定。在大多数情况下,setImmediate会先执行。但如果在I/O回调里调用,setTimeout反而可能先执行。为什么会这样?因为事件循环进入timers阶段时,如果定时器还没到期,就会先跳过,等到check阶段执行setImmediate,再回来处理到期的定时器。

记住:setImmediate不是立即执行,而是在当前poll阶段结束后、下一轮事件循环开始前执行。

1.7 close callbacks阶段——最后的告别

最后一个阶段,处理关闭事件的回调。比如socket.on('close')、stream.destroy()的回调都在这里执行。

这个阶段执行完,事件循环就完成了一轮。然后它会检查:

  • 还有没有活跃的定时器?
  • 还有没有待处理的I/O?
  • 还有没有活跃的连接?

如果都没有,Node.js就退出了。否则继续下一轮循环。

1.8 实战中的思考

理解了这六个阶段,很多问题就迎刃而解了。比如:

  • 为什么process.nextTick比setTimeout先执行?因为它不属于事件循环的任何一个阶段,而是在每个阶段切换时执行。
  • 为什么setImmediate比setTimeout(0)快?因为setImmediate在check阶段执行,而setTimeout(0)在timers阶段执行,但timers阶段有1ms的最小延迟。

我曾经接手过一个老项目,里面到处都是setTimeout(0)和process.nextTick混用,代码逻辑乱成一团。后来我把它们统一成setImmediate,性能反而提升了。原因就是setImmediate的执行时机更可控,不会因为定时器延迟而打乱顺序。

我的建议:日常开发中,能用setImmediate就别用setTimeout(0)。前者语义更清晰,执行时机也更可靠。

好了,事件循环的六个阶段就讲到这里。下一章咱们深入看看process.nextTick和微任务,这两个家伙在事件循环里扮演着特殊的角色。