前言
一年多以前我写过一篇 深入浅出 Event Loop【1】—— 基本原理 & 浏览器 runtime 篇, 那时候就说过这是一系列的文章, 会在后续介绍 Node.js runtime 的 EventLoop 的机制。 也是没想到🐦鸽了这么久,最近开始复习八股,且有了一点点🤏的Node.js 的编程经验, 于是乎来填上这个坑。
参考:
NodeJS 和 浏览器 runtime 对比
Node.js 的事件循环机制虽然在概念上与浏览器相似,但在实现层级、阶段划分以及任务优先级上具有显著的特殊性。
Node.js 事件循环的特殊性在于其基于 libuv 库实现的多阶段结构 (浏览器的事件循环则遵循 HTML 规范),这种设计允许它在单线程环境下通过阶段化管理(如专门的 Poll 和 Check 阶段)和极高优先级的微任务(process.nextTick)来精细化调度服务器端繁重的 I/O 和计算任务,而 Nodejs 无 DOM 树结构,也无需处理渲染任务, 也就是没有【1】中提到的 主线程和渲染线程互斥的问题, 无需考虑浏览器端的 UI 渲染一致性问题。
Node.js 与浏览器的核心对比
| 特性 | 浏览器 | Node.js |
|---|---|---|
| 底层实现 | 遵循 HTML 规范,由渲染引擎控制。 | 基于 libuv 库,面向服务器端 I/O。 |
| 阶段划分 | 通常只有宏任务和微任务队列,每轮循环处理一个宏任务。 | 具有 6 个明确阶段,每轮循环按阶段顺序执行。 |
| 微任务优先级 | 所有微任务(如 Promise)具有相同优先级。 | process.nextTick 优于 Promise。 |
| UI 渲染 | 每轮事件循环结束后可能执行 UI 渲染。 | 无 UI 渲染阶段,侧重于系统内核 I/O 交互。 |
| 特有 API | requestAnimationFrame(在渲染前执行)。 | setImmediate(在 Poll 阶段后、定时器前执行)。 |
Libuv 事件循环的六个阶段
libuv 将每一轮循环(称为一个 Tick)划分为离散的阶段,每个阶段都维护着一个先进先出(FIFO)的回调队列:
- Timers(定时器阶段):执行 setTimeout() 和 setInterval() 的回调
- Pending Callbacks(待定回调阶段):执行延迟到下一个循环迭代的 I/O 回调,例如某些 TCP 错误
- Idle / Prepare(空闲/准备阶段):仅供 Node.js 内部使用
- Poll(轮询阶段):这是最核心的阶段,负责检索新的 I/O 事件并执行回调。Node.js 会在此阶段计算阻塞和轮询的时间
- Check(检查阶段):专门执行由 setImmediate() 调度的回调
- Close Callbacks(关闭回调阶段):执行如 socket.on('close', ...) 这种关闭请求的回调
Idle阶段
这里面最黑箱的其实就是这个 Idle 阶端,因为不对外暴露标准的异步 API, 它不处理任何用户级别的业务逻辑回调
在这一阶段是 Node.js 及其底层异步 I/O 库 libuv 的私有阶段,事件循环主要执行注册在 uv_idle_t 句柄上的回调函数
其具体职能包括:
- 每一轮循环的固定动作:尽管名字叫 idle,但实际上,只要该句柄是活跃的,它的回调在事件循环的每一轮(tick)中都会被调用一次
- 处理低优先级活动:系统利用这个阶段来处理一些非常低优先级的内部维护任务,这些任务不需要立即完成,也不依赖于外部 I/O 事件
- 状态同步与准备:它与随后的 prepare 阶段协同工作,在系统进入最繁重、最可能导致阻塞的 poll(轮询 I/O)阶段之前,执行必要的内部状态更新和准备工作
你可以把 idle 阶段理解为工厂流水线在正式开启重型机器(处理 I/O)之前的设备空转检查。它不生产具体的产品(不执行你的 JavaScript 业务逻辑),但它确保了流水线在没有外部订单时也不会完全停摆,并且在每一轮循环中都完成必要的内部维护
Node.js 事件循环 vs. libuv 事件循环
Node.js 的事件循环虽然完全基于 libuv 实现,但它在 JavaScript 层面进行了重要的增强和逻辑扩展。
1. 核心差异:微任务(Microtasks)的引入
libuv 的原生循环并不包含“微任务”的概念,而 Node.js 在各个阶段之间穿插了微任务队列的处理。
- Node.js 独有的微任务:包括
process.nextTick()回调和Promise的then/catch/finally回调。 - 执行时机:在 Node.js 中,每当一个阶段的回调执行完毕,或者在切换到下一个阶段之前,Node.js 都会尝试清空所有的微任务队列。
- 优先级:微任务具有“插队”特权。在 Node.js 中,process.nextTick 的优先级最高,它甚至优于 Promise 微任务和任何 libuv 的宏任务阶段。
2. 关键 API 的执行顺序对比
- setImmediate() vs setTimeout(0):
- 在 libuv 层面,两者分别属于 Check 阶段和 Timers 阶段。
- 在 Node.js 中,如果它们在主模块(Main Script)中被调用,执行顺序取决于进程性能,是不确定的。
- 但如果它们在 I/O 回调内部被调用,setImmediate() 总是先执行,因为它紧随 Poll 阶段之后的 Check 阶段。
- process.nextTick():这在 Node.js 中不属于事件循环的任何特定阶段,它会在当前 JavaScript 操作完成后立即执行,因此比
setImmediate()触发快得多。
3. 对比表
| 特性 | libuv 事件循环 | Node.js 事件循环 |
|---|---|---|
| 底层驱动 | C 语言实现,直接与 OS 交互 | 基于 libuv,向上连接 V8 引擎 |
| 任务分类 | 仅处理宏观的阶段性回调 | 区分宏任务(阶段回调)与微任务 |
| 微任务支持 | 原生不支持,由上层语言实现 | 深度集成 nextTick 和 Promise 队列 |
| 优先级管理 | 按固定的 6 个阶段顺序循环 | 宏任务阶段切换间隙会“清空”所有微任务 |
| 目标场景 | 高效处理跨平台异步 I/O | 在单线程环境下通过事件调度支持高并发 JS |
promise vs process.nextTick
在 Node.js 的运行机制中,process.nextTick 的优先级高于 Promise.then,这主要源于 Node.js 任务队列的分级调度逻辑以及其底层架构的设计。
1. 不同的存放队列与处理顺序
虽然在广义的分类中,两者都被视为微任务,但在 Node.js 内部,它们被存放在两个不同的队列中,且有着严格的先后处理顺序:
nextTickQueue:专门存放process.nextTick()的回调。microTaskQueue:由 V8 引擎维护,主要存放已解决(resolved)的Promise回调。
在每一轮事件循环的阶段切换间隙,或者在每一个宏任务回调执行完毕后,Node.js 引擎都会尝试清空这些队列。其执行逻辑是:引擎会首先排空 nextTickQueue 中的所有任务,只有当该队列彻底清空后,才会开始处理 microTaskQueue(即 Promise 回调)。
2. process.nextTick 不属于事件循环阶段
从架构上看,process.nextTick() 具有极高的紧迫性,它在语义上不属于 libuv 事件循环的任何特定阶段。
- 事件循环的阶段(如 Timers、Poll、Check)是由底层的 libuv 库驱动的宏观调度。
process.nextTick则是一个在当前 JavaScript 堆栈展开(unwind)后、且在允许事件循环继续走向下一个动作之前的“立即执行”指令。- 它被设计为在“当前操作”完成后立即触发,这里执行的优先级甚至优于任何宏任务阶段的切换。
3. 设计初衷与应用场景
这种优先级差异是出于 Node.js 的设计哲学:让异步操作在尽可能早的未来执行,同时确保脚本能运行至完毕。
- 同步预警与错误处理:允许用户在事件循环继续之前处理错误、清理资源,或者在当前操作之后立即重试请求。
- 保持预期的执行顺序:例如在构造函数中通过
process.nextTick发出事件,可以确保该事件在构造函数执行完毕后触发,此时用户已经有时间通过同步代码为该对象绑定事件监听器。 - 确保非阻塞一致性:通过
nextTick将本应同步执行的代码异步化,可以保证 API 的一致性,即回调函数始终在当前调用栈清空后才运行,避免了某些变量未初始化的潜在风险。
4. 潜在风险:事件循环饥饿
由于 process.nextTick 的这种“插队”特权及其递归处理机制,如果开发者滥用递归的 process.nextTick 调用,会导致引擎不断地处理该队列而无法进入事件循环的 Poll 阶段处理 I/O 任务,从而导致 “事件循环饥饿” (Starvation)。因此,在现代 Node.js 开发中,官方更推荐在大多数情况下使用 setImmediate,因为它在 Check 阶段公平地排队,不容易造成系统阻塞。
后记
不知道还有没机会学到 Deno、Bun 这些 Runtime, 有的话这系列也会做相应的更新的😋