深入浅出 EventLoop【2】—— Node.js 和 Libuv 篇

2 阅读8分钟

前言

一年多以前我写过一篇 深入浅出 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 交互。
特有 APIrequestAnimationFrame(在渲染前执行)。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', ...) 这种关闭请求的回调

image.png

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() 回调和 Promisethen/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 引擎
任务分类仅处理宏观的阶段性回调区分宏任务(阶段回调)与微任务
微任务支持原生不支持,由上层语言实现深度集成 nextTickPromise 队列
优先级管理按固定的 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, 有的话这系列也会做相应的更新的😋