一文吃透 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 阶段有两个主要功能:
- 计算它应该阻塞多久,然后轮询 I/O
- 轮询队列中的事件处理
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 事件循环,记住这五个核心要点:
- Node.js 是单线程的,但 I/O 不是 ------ 真正的 I/O 在操作系统层面完成(epoll/kqueue/IOCP),JavaScript 线程始终不阻塞
- 微任务强制插队 ------ 每个宏任务执行完后,必须先彻底清空 nextTickQueue,再彻底清空 Promise 队列,才能继续
- poll 阶段最复杂 ------ 有回调时同步执行,无回调时根据 setImmediate 决定是阻塞还是跳转
- setImmediate 的上下文差异 ------ 主模块中顺序不确定,I/O 回调中永远先于 setTimeout
- Node 20+ 变更 ------ 移除 poll 后的 timer 检查,在 I/O 回调中
setImmediate永远先于setTimeout(0),这是 libuv 1.45.0 带来的确定性改进