一文吃透 Node.js 事件循环:从原理到 Node 20+ 重大变更

一文吃透 Node.js 事件循环:从原理到 Node 20+ 重大变更(完整版)

本文基于 Node.js 官方文档,逐阶段拆解事件循环,包含 poll 阶段的双分支逻辑、微任务检查点的强制插入机制、setImmediate 与 setTimeout 的上下文差异,以及 Node.js 20 引入的 libuv 1.45.0 行为变更。建议收藏反复阅读。

一、为什么需要事件循环?

Node.js 运行在单线程中,却要处理大量并发请求:文件读取、网络请求、数据库查询......如果每个 I/O 操作都同步等待,线程会被阻塞,性能极差。

事件循环的核心价值在于实现异步非阻塞

  • 发起 I/O 操作时,Node.js 将其委托给底层(libuv 线程池或操作系统异步接口),自己继续执行后续代码
  • 当操作完成,回调函数被放入事件循环的某个阶段队列,等待时机执行
  • 每次事件循环之间,Node.js 检查是否还有等待的异步 I/O 或定时器,如果没有则干净地关闭

二、事件循环的启动与整体机制

Node.js 启动时,初始化事件循环,处理提供的输入脚本(或直接进入 REPL),期间可以进行异步 API 调用、调度计时器或调用 process.nextTick(),然后开始处理事件循环。

核心规则:

每个异步回调(宏任务)执行完毕后,Node.js 会立即清空 nextTick 队列,然后再清空 Promise 队列,最后才会去执行事件循环中的下一个宏任务或进入下一阶段。

更精确地说:

在 Node.js 的事件循环中,每当一个异步回调(或主模块代码)执行完毕,准备执行下一个回调、或准备切换到下一个阶段之前,事件循环会强制插入一个**"微任务检查点"**。在该检查点中,必须先将 nextTickQueue 彻底清空,再将 Promise 队列彻底清空,然后才能放行。


三、六个阶段逐个拆解

每个阶段都有一个 FIFO 队列等待执行回调。当事件循环进入某个阶段时,会执行该阶段特定的操作,然后在该阶段的队列中执行回调,直到队列用尽或回调次数达到最大。当队列耗尽或回调限制达到时,事件循环将进入下一阶段,依此类推。

1. timers 阶段

此阶段执行由 setTimeout() 排程的回调以及 setInterval() 的回调。

注意: 这些回调可以在进入事件循环前执行。这有时会发生在 setTimeout(() => { ... }, 0) 在 I/O 循环之外时。

timer 的延迟是最小阈值,不是精确执行时间。实际执行时间受进程性能影响。

2. pending callbacks 阶段

处理上一轮循环中推迟的系统回调,例如 TCP 的 ECONNREFUSED 错误。

3. idle, prepare 阶段

仅供 libuv 内部使用,开发者无需关注。

4. poll 阶段 ------ 最复杂的阶段

poll 阶段有两个主要功能:

  1. 计算它应该阻塞多久,然后轮询 I/O
  2. 轮询队列中的事件处理

poll 阶段的执行逻辑非常精细:

情况 A:poll 队列未空
  • 事件循环会同步迭代其回调队列,从队列头部取出一个回调,执行它
  • 执行完该回调后,立即清空微任务队列(process.nextTick 和 Promise.then)
  • 重复上述步骤,直到:
    • 队列变空,或者
    • 已经执行的回调数量达到系统硬限制(防止一直卡在 poll 阶段,忽略其他阶段)

这里的关键是:poll 队列有回调时,事件循环不会等待,而是立即开始执行它们。每执行一个回调后都会插入微任务检查点。

情况 B:poll 队列为空

此时还会发生以下两种情况之一:

B1:如果脚本已被 setImmediate() 调度

  • 事件循环将结束轮询阶段,并继续进入 check 阶段以执行这些调度脚本
  • 如果代码中调用了 setImmediate(callback),并且该回调尚未执行,那么事件循环不会 在 poll 阶段阻塞等待 I/O,而是立即退出 poll 阶段,进入下一个阶段 ------ check 阶段

B2:如果 setImmediate() 还没有调度脚本

  • 事件循环会等待回调加入队列,然后立即执行
  • 此时既没有待执行的 I/O 回调,也没有 setImmediate 回调
  • 事件循环会阻塞(进入休眠),等待操作系统通知"有新的 I/O 事件到达"(例如新的网络请求、文件读取完成)
  • 一旦有事件发生,其回调会被加入 poll 队列,事件循环立即被唤醒,然后执行这个回调(以及可能后续的其他回调)

poll 队列为空时的额外检查:

一旦轮询队列为空,事件循环会检查计时器时间阈值是否已达。如果一个或多个计时器准备好,事件循环将回绕到定时器阶段,执行这些计时器的回调。(Node.js 19 之前

5. check 阶段

  • 该阶段允许事件循环在 poll 阶段完成后执行 setImmediate() 回调
  • 如果轮询阶段变得空闲且脚本已被 setImmediate() 排队,事件循环可能会继续进入检查阶段,而不必等待
  • setImmediate() 实际上是一个特殊的计时器,运行在事件循环的另一个阶段。它使用 libuv API,在轮询阶段结束后安排回调执行
  • setImmediate 可以让事件循环在 poll 阶段空闲时不等待 I/O,而是立刻去执行它

通常,代码执行后,事件循环最终会进入 poll 阶段,在那里等待新连接、新请求等 I/O 事件。但是,如果已经有 setImmediate() 注册的回调在排队,并且 poll 阶段变得空闲(没有任何 I/O 回调要执行),那么事件循环不会在 poll 阶段傻等 I/O 事件,而是直接结束 poll 阶段,进入 check 阶段去执行那些 setImmediate 回调。

6. close callbacks 阶段

处理关闭事件,如 socket.on('close', ...)


四、setImmediate vs setTimeout ------ 上下文决定一切

setImmediate()setTimeout() 类似,但根据调用时间不同表现不同:

  • setImmediate() 设计用于在当前轮询阶段完成后执行脚本
  • setTimeout() 在运行脚本的最低阈值(MS)后进行安排

场景1:主模块中(非 I/O 周期)

javascript 复制代码
// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);

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

// 执行顺序是非确定性的

原因: 计时器的执行顺序会根据调用的上下文而有所不同。如果两者都从主模块内调用,那么时序将受进程性能的限制(而进程可能会受到机器上其他应用程序的影响)。

场景2:I/O 周期内

javascript 复制代码
import fs from 'node:fs';

fs.readFile(import.meta.filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});

结果:立即回调总是先执行。

使用 setImmediate() 而非 setTimeout() 的主要优势是:如果在 I/O 周期内排程,setImmediate() 总是在任何计时器之前执行,且无关于计时器数量。


五、process.nextTick() ------ 事件循环的"插队王"

尽管 process.nextTick() 也是异步 API 的一部分,但它在事件循环的示意图中并没有出现。

原因: 从技术上讲,process.nextTick() 并不属于事件循环的一部分。

执行机制

nextTickQueue 将在当前操作完成后被处理,无论事件循环的当前阶段如何。这里,"操作"定义为从底层 C/C++ 处理器过渡,并处理需要执行的 JavaScript。

事件循环的阶段图(timers → pending → ... → close)展示的是宏任务(macrotasks) 的处理顺序。而 process.nextTick 是一种微任务(microtask) ,但它比 Promise.then 优先级更高。它并不隶属于任何一个阶段,而是在每个阶段内部、每两个操作之间插入执行。

具体示例

  • 在 timers 阶段 :执行一个定时器回调,回调内部调用 process.nextTick。那么这个 nextTick 回调会在该定时器回调执行完后、timers 阶段尚未结束、尚未进入 pending 阶段之前就被执行。
javascript 复制代码
setTimeout(() => {
  console.log('timer');
  process.nextTick(() => {
    console.log('nextTick');
  });
}, 0);
// 输出:timer → nextTick
// nextTick 在 timers 阶段尚未结束时就执行
  • 在 poll 阶段 :执行一个 I/O 回调,回调内部调用 process.nextTick。同样,nextTick 回调会在该 I/O 回调结束后、poll 阶段继续处理下一个 I/O 回调之前被执行。
javascript 复制代码
fs.readFile('file', () => {
  console.log('I/O done');
  process.nextTick(() => {
    console.log('nextTick');
  });
});
// 输出:I/O done → nextTick
// nextTick 在 poll 阶段继续下一个 I/O 回调前执行

由于 nextTickQueue 是在每个操作(每个 JavaScript 执行块)结束后立即处理的,它不等待当前事件循环的阶段完成。


六、循环机制更新:Node.js 20+ 的重大变更 ⚠️

从 libuv 1.45.0(Node.js 20)开始,事件循环行为发生了关键变化

事件循环行为改为仅在轮询阶段后使用运行计时器 ,而非早期版本中同时运行前后计时器。这种变化会影响 setImmediate() 回调的时机以及它们在特定场景下与定时器的交互。

Node.js 19 之前

sql 复制代码
┌────────────────────────────┐
│         timers             │  ← 第1次 timer 检查:执行到期的 setTimeout/setInterval
└─────────────┬──────────────┘
              ↓
┌────────────────────────────┐
│     pending callbacks      │
└─────────────┬──────────────┘
              ↓
┌────────────────────────────┐
│     idle, prepare          │
└─────────────┬──────────────┘
              ↓
┌────────────────────────────┐
│         poll               │  ← 等待 I/O 事件,执行 I/O 回调
└─────────────┬──────────────┘
              ↓
┌────────────────────────────┐
│   ** 第2次 timer 检查 **    │  ← ⚠️ 关键:在 poll 之后、check 之前再次检查并执行到期的 timer
└─────────────┬──────────────┘
              ↓
┌────────────────────────────┐
│         check              │  ← 执行 setImmediate 回调
└─────────────┬──────────────┘
              ↓
┌────────────────────────────┐
│     close callbacks        │
└─────────────┬──────────────┘
      │
      └──────→ 回到 timers (下一轮)

"同时运行前后计时器" 就是指:

  • :在 timers 阶段(每轮循环开始处)执行一次
  • :在 poll 阶段之后、check 阶段之前 再执行一次

两次 timer 检查,意味着同一个循环中,到期的 timer 可能被提前执行(甚至在 setImmediate 之前)。

Node.js 20+

新版本移除了 poll 阶段之后、check 阶段之前的那次 timer 检查

现在事件循环顺序变为:

sql 复制代码
┌────────────────────────────┐
│         timers             │  ← 第1次 timer 检查(仍然保留,在每轮循环开始处)
└─────────────┬──────────────┘
              ↓
...(中间阶段不变)...
              ↓
┌────────────────────────────┐
│         poll               │
└─────────────┬──────────────┘
              ↓
┌────────────────────────────┐
│         check              │  ← setImmediate 回调
└─────────────┬──────────────┘
              ↓
┌────────────────────────────┐
│   ** 没有 timer 检查了 **   │  ← 原来的第2次 timer 检查被移除
└─────────────┬──────────────┘
              ↓
┌────────────────────────────┐
│     close callbacks        │
└─────────────┬──────────────┘
              ↓
        回到 timers (下一轮)

timers 阶段仍然存在,但它现在只在新一轮循环的开始执行一次。

关键区别

新版本中,setTimeout(cb, 0) 的回调绝对不会 在当前循环的 poll 结束后立即执行,而必须等到下一轮循环的 timers 阶段。

对照表

场景 Node.js 19 (旧) Node.js 20+ (新)
事件循环中 timer 检查次数 2 次(循环开始 + poll 之后) 1 次(仅循环开始处)
I/O 回调内的相对顺序 setTimeout(0) 通常先于 setImmediate setImmediate 永远 先于 setTimeout(0)
主模块(不在 I/O 回调)中顺序 不确定(可能取决于系统负载) 仍然不确定(因为事件循环尚未启动,timer 可能提前执行,但官方建议不要依赖)

七、完整对照速查表

特性 说明
单线程 JavaScript 执行在单线程,但 I/O 通过 libuv 多线程/异步接口处理
宏任务阶段 timers → pending callbacks → idle, prepare → poll → check → close callbacks
微任务检查点 每个宏任务执行完后强制插入:先清空 nextTickQueue,再清空 Promise 队列
nextTick 优先级 高于 Promise.then,不属于事件循环阶段,在"每个操作"结束后立即执行
poll 阶段双分支 队列未空时同步执行回调(每执行一个后插入微任务);队列为空时根据 setImmediate 决定阻塞或跳转
poll 队列为空 + 有 setImmediate 立即退出 poll,进入 check
poll 队列为空 + 无 setImmediate 阻塞等待 I/O 事件(操作系统层面休眠)
timer 延迟 最小阈值,非精确保证
setImmediate 设计目标 在 poll 阶段完成后执行,I/O 周期内永远先于 setTimeout
Node 20+ 变更 移除 poll 后的第2次 timer 检查,setImmediate 在 I/O 中确定性先于 setTimeout(0)

八、总结

理解 Node.js 事件循环,记住这五个核心要点:

  1. Node.js 是单线程的,但 I/O 不是 ------ 真正的 I/O 在操作系统层面完成(epoll/kqueue/IOCP),JavaScript 线程始终不阻塞
  2. 微任务强制插队 ------ 每个宏任务执行完后,必须先彻底清空 nextTickQueue,再彻底清空 Promise 队列,才能继续
  3. poll 阶段最复杂 ------ 有回调时同步执行,无回调时根据 setImmediate 决定是阻塞还是跳转
  4. setImmediate 的上下文差异 ------ 主模块中顺序不确定,I/O 回调中永远先于 setTimeout
  5. Node 20+ 变更 ------ 移除 poll 后的 timer 检查,在 I/O 回调中 setImmediate 永远先于 setTimeout(0),这是 libuv 1.45.0 带来的确定性改进

相关推荐
Alson_Code2 小时前
人机协作项目文档--HITL-AgentScope
后端·aigc·ai编程
IT_陈寒2 小时前
Java 并行流把我坑惨了,这6小时加班值了
前端·人工智能·后端
葫芦和十三3 小时前
图解 MongoDB 03|CRUD 全链路:一条 find 怎么穿过 WiredTiger
后端·mongodb·agent
葫芦和十三11 小时前
图解 MongoDB 04|索引模型:每建一个索引,就是在 B+-tree 森林里多栽一棵
后端·mongodb·agent
用户479492835691512 小时前
claude Fable用不了?把Gpt 5.5pro接到你的claude code里
前端·后端
JieE21212 小时前
LeetCode 101. 对称二叉树|JS 递归 + 迭代双解法,彻底搞懂镜像判断
javascript·算法
GetcharZp14 小时前
告别 Nginx 复杂配置!这款带 Web 面板的万能代理神器,让端口转发变得如此简单
后端
冬奇Lab14 小时前
AI Workflow 定义的四次演进:从 Markdown 到 JS 脚本,再到分布式多 Agent
javascript·人工智能·agent
IT_陈寒16 小时前
React的useState居然还有这种坑?我差点删库跑路
前端·人工智能·后端