《Node.js 是怎样的 JavaScript 运行时?》中,我们把 V8、Node.js、libuv 与操作系统的职责分开;在《Node.js 模块机制:CommonJS 与 ESM》中,我们又讨论了程序入口和模块怎样被加载、初始化。不过,当模块开始读取文件、监听端口或安排定时器时,还有一个问题没有回答:

JavaScript 发起异步操作后,真正的工作在哪里进行?操作完成时,回调又为什么能够回到 JavaScript 中执行?

JavaScript 系列的事件循环文章主要从浏览器宿主中的任务与微任务出发。Node.js 同样会调度 Promise 后续逻辑,却还拥有 libuv 阶段、process.nextTick() 和服务端 I/O 生命周期。本文会复用已经建立的语言概念,但不会把浏览器的任务模型直接套在 Node.js 上。

先看一段 CommonJS 程序:

// order.cjs
const { readFile } = require("node:fs");

console.log("start");

readFile(__filename, () => {
  console.log("readFile");
});

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

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

Promise.resolve().then(() => {
  console.log("promise");
});

process.nextTick(() => {
  console.log("nextTick");
});

console.log("end");

输出的开头可以确定为:

start
end
nextTick
promise

但是,timeoutimmediatereadFile 的相对位置不能只根据它们在源码中的先后顺序推出。它们来自不同的异步来源,准备完成的时刻不同,进入事件循环的处理位置也不同。

这正是本文要建立的模型。本文以 Node.js v24.16.0 为主要验证环境,本机对应 libuv v1.52.1;示例使用 .cjs 明确 CommonJS 上下文,涉及 ESM 时会单独说明。我们不会把所有异步操作画进一条万能流水线,也不会只背诵 timers、poll、check 等阶段名称,而是沿着一次操作的完整往返,回答以下问题:

  1. 工作由 JavaScript、操作系统还是 Worker Pool 完成?
  2. 完成信号怎样被 Node.js 得知?
  3. 回调或 Promise 后续逻辑在什么边界重新进入 JavaScript?
  4. process.nextTick()、微任务与事件循环阶段怎样共同影响执行顺序?
  5. 当服务变慢或进程无法退出时,怎样用这张运行模型定位问题?

1. “异步”不是工作突然消失了

1.1 API 返回,只表示 JavaScript 不在原地等待

比较同步和异步文件读取:

const { readFileSync, readFile } = require("node:fs");

const text = readFileSync("config.json", "utf8");
console.log(text.length);

readFile("config.json", "utf8", (error, asyncText) => {
  if (error) throw error;
  console.log(asyncText.length);
});

readFileSync() 必须取得结果后才会返回,当前 JavaScript 调用栈因而停在这里。readFile() 则在登记操作后返回;当前函数可以结束,JavaScript 线程可以继续运行其他代码。

但“函数先返回”不等于“工作不需要做”。文件仍然要被打开、读取和关闭,完成结果仍然需要被保存,Node.js 仍然要在合适的时机执行回调。所谓异步,首先描述的是调用方与结果之间的时间关系,不是某种固定的底层实现。

同样,Promise 也不会自动把工作搬到后台:

const result = new Promise((resolve) => {
  let total = 0;

  for (let i = 0; i < 1_000_000_000; i += 1) {
    total += i;
  }

  resolve(total);
});

传给 new Promise() 的 executor 会立即、同步执行。这段循环仍会占用当前 JavaScript 线程。Promise 负责表达未来的状态和安排后续逻辑,却不是通用线程调度器。

因此,每当看到“异步 API”,先把它拆成三个问题:

问题 要观察的对象 常见答案
工作在哪里推进 CPU、Socket、文件系统、DNS 查询 当前 JavaScript 线程、操作系统、libuv Worker Pool、其他原生组件
完成怎样被通知 就绪事件、完成事件、工作线程结果 epollkqueue、IOCP、线程间通知等
JavaScript 何时继续 回调、Promise 后续逻辑、事件监听器 在相应事件循环阶段的回调处理中,或在 Node.js 回调边界的 next tick / 微任务检查点中

1.2 一次异步操作的完整往返

以普通的 fs.readFile() 为例,可以先建立如下抽象:

JavaScript 调用 fs.readFile()
    ↓
Node.js 校验参数并创建请求
    ↓
文件工作交给 libuv Worker Pool
    ↓
工作线程执行相应的阻塞式文件系统操作
    ↓
完成结果通知事件循环
    ↓
事件循环取得完成项
    ↓
Node.js 在 JavaScript 线程中调用回调

这里有两次容易混淆的“执行”:工作线程可以读取文件,却不会在后台替主线程执行用户传入的 JavaScript 回调。回调最终仍要回到拥有相应 V8 JavaScript 执行环境(isolate)的线程中运行。

一次异步操作离开并回到 JavaScript 的完整往返

这张图也解释了为什么“单线程”与“异步 I/O”并不矛盾:常规应用代码主要在一个 JavaScript 执行线程中运行,但 Node.js 进程、libuv 和操作系统可以使用其他线程或内核机制推进外部工作。单线程说的是用户 JavaScript 的默认执行位置,不是整个进程和操作系统里只能存在一条线程。

2. libuv:把平台差异藏在统一运行模型之后

2.1 libuv 为什么出现

Node.js 诞生早期主要运行在 Unix-like 系统上,曾使用 libev 处理事件循环。后来,为了支持 Windows 的 I/O Completion Ports(IOCP)模型,Node.js 需要一层能够同时适配 Unix 与 Windows 的跨平台基础设施。这个工作逐渐形成 libuv;随着 libuv 逐步接管 Unix 与 Windows 的事件循环实现,Node.js 最终不再依赖 libev。

今天,libuv 是一个跨平台的 C 库。它不执行 JavaScript,也不定义 fs.readFile() 这样的 JavaScript API;它为 Node.js 等上层程序提供事件循环、非阻塞 Socket、异步文件系统接口、DNS 辅助、线程池、进程与信号处理等底层能力。

可以把几层职责分成下面这样:

层次 负责什么
Node.js JavaScript API 暴露 fsnethttp、timers 等接口,定义参数、返回值与 JavaScript 行为
Node.js 原生层 在 JavaScript 对象与底层资源之间转换,建立回调和生命周期关系
libuv 提供跨平台事件循环、I/O 抽象、Worker Pool、线程间协调等基础设施
操作系统 管理线程、文件描述符、Socket、文件系统与网络协议栈,并提供平台 I/O 机制

libuv 很重要,但不能被理解成“Node.js 所有工作都会经过的后台线程”。对普通 JavaScript 计算,它没有必要介入;对非阻塞网络 Socket,它主要组织平台事件通知;对许多文件操作,它才会使用 Worker Pool。

2.2 handle 与 request:长期资源和一次操作

libuv 的设计文档把常见对象分成两类:

  • handle 通常是长期存在的资源,例如 TCP 服务端、定时器、信号观察者。handle 可以被启动和停止,也会影响事件循环是否仍然存活。
  • request 通常表示一次短期操作,例如一次文件读取、一次写入或一次 DNS 查询。request 完成后,对应的完成回调会被安排处理。

这不是 JavaScript 层每个对象都必须一一对应的公开承诺,却很适合帮助我们区分两种问题:

“为什么进程还不退出?”   → 先检查仍然活跃或被引用的长期资源
“为什么这次操作还没完成?” → 先检查具体请求在哪里等待

例如,一个 TCP 服务端持续监听端口,属于长期资源;这个服务端接收到的某次连接或读取,则是围绕该资源发生的一次具体活动。fs.watch() 返回的 fs.FSWatcher 也代表持续观察文件变化的长期对象,而不是一次完成后就消失的普通文件请求。

2.3 事件循环与 Worker Pool 不是同一个东西

事件循环运行在 JavaScript 所在的线程上,负责观察完成情况、选择可以处理的事件并进入相应回调。Worker Pool 则包含后台工作线程,用来执行无法直接交给平台非阻塞轮询机制、又不应让 JavaScript 线程原地等待的工作。

两者是协作关系,而不是上下级队列:

事件循环:我现在可以处理哪些完成事件?
Worker Pool:这些后台任务中,哪些已经真正做完?

线程池默认有 4 个工作线程,而且进程中的 event loop 会共享这个全局池;不仅文件系统操作会使用它,某些 DNS、加密和压缩操作也可能进入同一个池。因此,“JavaScript 线程没有阻塞”并不代表系统没有排队;排队可能已经移动到了 Worker Pool。

这里的 Worker Pool 也不等于 node:worker_threads、V8 的内部后台线程或 Windows IOCP 的并发执行。Worker Threads 是应用显式创建的 JavaScript 并行执行环境;libuv Worker Pool 是运行时为特定底层工作提供的共享设施。

libuv 本身足以成为一个独立专题,例如继续研究 uv_run()、watcher、线程间唤醒、平台后端和源码实现。但对应用开发者而言,不必先读完 libuv 源码才能理解 Node.js。本文保留一个更实用的边界:只展开会改变 API 行为判断、性能诊断和执行顺序的部分;内部数据结构和源码调用栈留给专门的底层实现文章。

3. 异步 I/O 没有一条统一的底层路径

先把本章涉及的五条典型路径放在一起观察。它们都向 JavaScript 提供“稍后交付结果”的接口,但底层工作、完成通知和资源生命周期并不相同。

文件、网络、DNS 等异步操作走向不同底层路径

3.1 网络 Socket:主要依赖平台的事件通知机制

Node.js 创建 TCP 服务端时,不会默认给每条连接分配一个 JavaScript 线程,也不会让一个 libuv 工作线程阻塞等待每个 Socket。Socket 会被设置为适合非阻塞处理的状态,libuv 把它登记到操作系统提供的 I/O 机制中;当 Socket 可读、可写,或一次异步操作完成时,平台把相应事件通知给 libuv,事件循环再处理它。

不同平台提供的机制并不相同:

平台家族 常见机制 核心观察方式
Linux epoll 观察多个文件描述符的就绪状态
BSD / macOS kqueue 订阅并取得内核事件,包括 Socket 就绪
Solaris / illumos event ports 从端口取得已发生的事件
Windows IOCP 异步操作完成后,把完成包放入完成端口

Unix-like 系统上的 epollkqueue 常被称为就绪模型:内核告诉程序“这个 Socket 现在可以读取”或“可以写入”,随后用户态代码继续执行相应的非阻塞操作。Windows IOCP 更接近完成模型:程序先发起重叠 I/O,操作完成后,系统把包含结果信息的完成包放入完成端口。

两类机制在细节上不是同一种 API。libuv 的价值正是把这些差异收敛到统一的事件循环接口之后,使 Node.js 的 nethttp 等上层 API 不必让应用分别实现四套平台逻辑。

Unix 就绪通知与 Windows IOCP 完成通知的概念差异

“网络 I/O 由操作系统推进”也需要再精确一步:

  1. 网卡、驱动与内核网络协议栈接收和处理数据包。
  2. 内核更新 Socket 的缓冲区或异步操作状态。
  3. 平台机制让等待者得知就绪或完成。
  4. libuv 收集事件,Node.js 随后在 JavaScript 线程中触发相应回调。
  5. 如果回调本身执行大量 JavaScript 计算,它仍然会阻塞后续事件的处理。

因此,非阻塞 Socket 解决的是“等待网络时不占住 JavaScript 线程”,并不能让收到数据后的业务计算自动并行。

就绪或完成通知也不等于应用消息边界。TCP 只提供有序字节流:一次 data 事件可能只包含一段消息,也可能合并多段数据;“一个事件等于一个 TCP 包”或“一个 TCP 包等于一个 JSON 对象”都不成立。协议解析仍要在应用层处理分帧、半包与粘连数据。

3.2 普通异步文件 API:通常使用 Worker Pool

跨平台操作系统并没有为所有普通文件操作提供一套像 Socket 轮询那样一致、适合 Node.js 使用的非阻塞接口。libuv 因而把文件系统操作包装成异步接口,并在内部使用全局 Worker Pool 执行相应的阻塞式文件操作。

fs.readFile() 为例,它还可能包含打开、读取、关闭等多个步骤。应用只看到一个最终回调,但这不意味着底层只有一次系统调用。这里真正稳定的应用层事实是:Node.js 的回调式和 Promise 式异步文件 API 使用线程池完成文件系统工作,完成结果再返回事件循环。

这带来三个直接后果:

  • 大量慢文件请求可能在 Worker Pool 中排队,而 JavaScript 线程此时仍然可以响应其他事件。
  • 增大线程池不是免费的;更多工作线程会增加内存和调度成本,也不能突破磁盘、文件系统或下游设备的真实瓶颈。
  • await readFile() 只改变 JavaScript 怎样等待结果,不会把文件读取改造成另一条底层 I/O 路径。

3.3 为什么 fs.FSWatcher 不能与普通异步文件请求混为一谈

Node.js 文档在说明线程池时,会把大多数异步 fs API 与 fs.FSWatcher 区分开。这里的“例外”不是说 fs.FSWatcher 完全不与 libuv 或操作系统交互,而是说它代表的工作类型不同。

fs.watch() 返回一个 fs.FSWatcher。它通常通过 libuv 的文件系统事件 handle,使用平台提供的变化通知机制,例如 Linux 的 inotify、BSD 的 kqueue、macOS 对文件和目录分别使用 kqueue 与 FSEvents、Windows 的 ReadDirectoryChangesW。它是一个持续订阅变化的长期资源,不是把“一次文件读取”提交到 Worker Pool、等待完成后结束的短期 request。

与它相对,fs.watchFile() 使用定期 stat 轮询来比较文件状态。轮询更容易跨越部分平台限制,但通常更慢、开销也更高;Node.js 文档建议在可以使用 fs.watch() 时优先使用它。

这个区别也解释了为什么 FSWatcher 默认可能让进程继续存活:只要观察者仍在工作并保持引用,Node.js 就有理由等待未来的文件变化。调用 watcher.close() 会停止监听,关闭后的 watcher 不能继续使用;设置 persistent: false 或调用 watcher.unref() 则不会停止监听,只表示不必单独为了这个 watcher 维持进程存活。如果还有其他活动资源,未关闭的 watcher 仍然可以继续接收变化事件。

文件变化通知还存在现实的平台边界:网络文件系统、容器或虚拟机中的映射文件系统可能不可靠;回调中的 filename 也不是所有平台都保证提供。编辑器的一次“保存”可能对应创建临时文件、重命名、删除和重建等多个底层动作,所以不能把一次 change 事件直接等同于一次用户操作。

3.4 DNS 为什么是一个很好的反例

node:dns 中两个看起来都在“查域名”的 API,会走明显不同的实现路径:

const dns = require("node:dns");

dns.lookup("example.com", (error, address) => {
  if (error) throw error;
  console.log("lookup:", address);
});

dns.resolve4("example.com", (error, addresses) => {
  if (error) throw error;
  console.log("resolve4:", addresses);
});

dns.lookup() 使用操作系统的名称解析设施,行为接近系统中的其他程序。它可能读取本机 hosts 配置,也会遵循所在平台的系统解析策略;在采用 Name Service Switch(NSS)的部分类 Unix 系统上,NSS 会选择并排序不同的名称服务来源。虽然函数名叫 DNS lookup,它甚至不一定真的发送 DNS 网络包。为了避免阻塞事件循环,Node.js 通过 libuv 线程池调用同步的 getaddrinfo()

dns.resolve*() 则总是通过网络执行 DNS 查询,不使用 getaddrinfo(),也不占用 libuv Worker Pool;它不会以同样方式读取本机 hosts 文件。

所以 DNS 是一个很好的反例:API 表面上的业务名称,不能证明底层使用哪一种异步机制。 “网络相关操作都不使用线程池”是错的,因为 dns.lookup() 可能使用;“DNS 查询都使用线程池”同样是错的,因为 dns.resolve*() 不使用。

3.5 回调、Promise 与 EventEmitter 是结果交付方式,不是底层 I/O 路径

同一个底层操作,可以通过不同 JavaScript 接口交付结果:

const fs = require("node:fs");
const fsPromises = require("node:fs/promises");

fs.readFile("config.json", (error, buffer) => {
  // 回调接口
});

async function readWithPromise() {
  const buffer = await fsPromises.readFile("config.json");
  // Promise 接口
}

Promise 版本没有凭空创造新的磁盘机制。这两个文件 API 都需要完成文件系统工作,区别主要在结果怎样返回 JavaScript:回调接口完成后,Node.js 会在相应的 I/O 回调边界调用用户 callback;Promise 接口则先兑现或拒绝 Promise,.then()await 的后续代码再通过 V8 微任务队列获得执行机会。两种接口也因此采用不同的错误传播与控制流组合方式。

EventEmitter 则是 Node.js 中常见的事件分发抽象。对象通过 on() 注册监听器,通过 emit() 发出事件:

const { EventEmitter } = require("node:events");

const bus = new EventEmitter();

bus.on("ready", () => {
  console.log("listener A");
});

bus.on("ready", () => {
  console.log("listener B");
});

bus.emit("ready");
console.log("after emit");

输出为:

listener A
listener B
after emit

emit() 会在当前调用栈中,按注册顺序同步调用普通监听器。某个 Socket 的数据确实可能在未来到达,并最终让 Node.js 发出 data 事件;但“数据何时到达”是异步 I/O 问题,“一次 emit() 怎样调用监听器”则是同步事件分发问题。EventEmitter 值得单独讨论监听器生命周期、error 事件、背压边界和异步迭代等内容;本文只需要先守住这条边界。

4. 事件循环的一次迭代怎样前进

4.1 事件循环解决的是协调问题

JavaScript 顶层代码执行完后,Node.js 不会机械地永远循环,也不会只要见过一个异步 API 就永久等待。事件循环会继续运行,是因为进程中仍有被引用的活动 handle、尚未完成的 request,或其他需要处理的工作。等这些条件都消失,进程就可以自然退出。

事件循环的核心任务可以概括成四步:

  1. 判断哪些事件已经满足处理条件。
  2. 在对应阶段取出可以执行的回调。
  3. 让 Node.js 回到 JavaScript,执行这些回调。
  4. 由 Node.js 调度的回调返回运行时边界后,处理 next tick 和微任务,并继续推进循环。

它不是后台工作的替代者。文件读取仍要由工作线程完成,网络仍要由操作系统推进;事件循环负责把这些完成事实与 JavaScript 的执行机会协调起来。

4.2 六个常见阶段分别做什么

Node.js 官方资料经常把阶段画成一个循环。若以 Node.js 24 使用的现代 libuv 为准,面向应用推理的主要顺序可按下面理解:

下面把阶段圆环从 pending callbacks 处切开。图中采用 Node.js 24 的阶段顺序;其中“默认模式”指让 libuv 持续运行到循环不再存活的 UV_RUN_DEFAULT。timer 的版本边界和图中的兼容性检查留到 4.4 节解释。

这张阶段表只回答“一个阶段完成后,下一个阶段是什么”,不表示两个阶段之间没有 Node.js 运行时工作。由 Node.js 调度的 JavaScript 回调返回后,运行时可能先处理 next tick 和微任务,再返回当前阶段的处理流程;当前阶段完成本次处理后,循环才继续进入下一阶段。

阶段 主要处理内容
pending callbacks 执行被延后到下一次循环迭代的某些 I/O 回调
idle, prepare libuv 内部使用
poll 取得新的 I/O 事件并执行许多 I/O 相关回调;必要时在这里等待事件
check 执行 setImmediate() 回调
close callbacks 执行部分 handle 的关闭回调,例如某些 Socket 的 close 事件
timers 执行已经达到时间阈值的 setTimeout()setInterval() 回调,然后进入下一轮

Node.js 事件循环的主要阶段与回调边界

这张阶段图是用于推理的简化模型,不是 Node.js 内部所有 watcher 和队列的完整源码图。几个边界尤其重要:

  • 阶段不是一种 API 的后台执行地点。 readFile() 的文件读取不会等到 poll 阶段才开始;调用时会先向 Worker Pool 提交首个底层请求,后续步骤再在前一步完成后继续提交。任一已经提交的请求都可能正在执行,也可能仍在队列中等待;最终结果返回事件循环后,用户回调才有机会被处理。
  • I/O 回调也不都放在同一个位置。 许多 I/O 回调在 poll 阶段执行;某些 I/O 回调不会在当轮 poll 后立即调用,而会被延后到下一轮的 pending callbacks 阶段。
  • 阶段箭头不等于直接跳转。 图中的 poll → check 表示阶段主线。poll 中的 JavaScript 回调返回后,Node.js 会先处理相应的 next tick 与微任务检查点,再返回 poll 的处理流程;如果还有可处理的 poll 回调,它们仍可能先于 check 执行。
  • 每次进入阶段都不是无限执行。 libuv 和 Node.js 会在适当边界继续推进循环,避免单一阶段永久垄断线程;具体限制属于实现细节,不适合拿一个固定数字当长期 API 契约。

4.3 timers 保存的是最早执行阈值,不是预约的精确时刻

const startedAt = Date.now();

setTimeout(() => {
  console.log(Date.now() - startedAt);
}, 100);

const blockUntil = Date.now() + 500;

while (Date.now() < blockUntil) {
  // 故意占用 JavaScript 线程
}

这个回调不会在大约 100 毫秒时穿透正在运行的 while 循环。它只能等当前 JavaScript 执行结束,再等事件循环有机会处理已经到期的 timer。实际输出会接近或大于 500,而不是 100。

因此,setTimeout(callback, 100) 更准确的含义是:至少经过相应阈值后,使回调具备被调度的资格。它不保证精确时间,也不保证到点后立即抢占当前回调。

Node.js 会先转换 delay;如果这个转换本身抛错,例如传入 SymbolBigInt 或自定义了异常转换逻辑的对象,setTimeout() 会同步抛出异常。只有成功完成数值转换后,小于 1、大于 2147483647 或得到 NaN 的 delay 才会使用 1 毫秒;非整数 delay 会被截断。即使写成 setTimeout(fn, 0),也不是“同步执行”或“零延迟立刻执行”。

4.4 poll 为什么是理解 I/O 与定时器关系的中心

进入 poll 阶段后,事件循环大致会做两类事情:

  1. 如果 poll 队列中已有可以处理的 I/O 回调,就执行它们。
  2. 如果没有立即可做的工作,就根据活动 handle、已经安排的 timer 等条件,决定是否以及等待多久,让操作系统阻塞等待新的 I/O 事件。

“事件循环会轮询”并不意味着它在用户态不停空转检查每个 Socket。epoll_wait()kevent()GetQueuedCompletionStatus() 等平台调用可以让线程高效等待,由内核在事件出现或等待时间到达时唤醒它。

poll 阶段调用某个 JavaScript 回调后,不会因为回调函数返回就直接跳到 check:Node.js 会先处理回调边界上的 next tick 和微任务,再返回 poll 的处理流程。等 poll 阶段完成——其中可能已经处理了不止一个回调——如果 check 阶段存在 setImmediate(),循环才会进入 check。若最近的 timer 已达到阈值,事件循环也需要及时进入 timer 处理。正是这些条件共同决定了 setTimeout(..., 0)setImmediate() 的相对顺序,而不是两者名称中的 “timeout” 或 “immediate”。

从 libuv 1.45.0(Node.js 20.3.0 起)开始,UV_RUN_DEFAULT 主循环中的常规 timer 检查由每轮 poll 前移到 poll 后;更早版本在每轮 poll 前处理 timer。现代版本另保留一次进入主循环前的兼容检查,见下段。

为兼容历史行为,每次 uv_run(loop, UV_RUN_DEFAULT) 在循环仍存活、尚未进入该次主循环前,还会先检查一次已经到期的 timer;这项入口检查不能画成每轮迭代中的 timers 阶段。当事件循环即将退出时,如果 processbeforeExit 事件监听器重新安排了工作,Node.js 可能再次运行 uv_run(),入口检查也会随新的调用再次发生。

因此,阅读旧文章或旧实验时必须先确认 Node.js 版本。把阶段圆环从任意位置切开,本来就可能得到不同的第一项;真正需要记住的是回调所处的上下文与相邻阶段,而不是“事件循环永远从 timers 开始”。

4.5 close callbacks 不等于所有资源清理

close callbacks 阶段处理的是部分 handle 的关闭事件。Node.js 官方给出的典型例子是:Socket 或其他 handle 被突然关闭,例如显式调用 socket.destroy(),对应的 close 事件会在这个阶段发出;其他关闭情形可能通过 process.nextTick() 发出。具体资源仍应以各自 API 的生命周期契约为准。

垃圾回收与 close callbacks 也不是一回事。V8 回收不可达的 JavaScript 对象,不代表所有外部资源都应依赖垃圾回收自动、及时地释放。服务器、文件观察者、流和数据库连接仍应该按照各自 API 明确结束生命周期。

5. 阶段之外:next tick 与微任务

5.1 process.nextTick() 不是事件循环阶段

process.nextTick() 的名字很容易让人误以为“下一次事件循环迭代才执行”。实际恰恰相反:它把回调放入 next tick queue;当前 JavaScript 操作完成、控制权准备返回事件循环时,Node.js 会先清空这个队列,然后才让事件循环继续前进。

所以,下面的回调会在 timer 之前运行:

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

process.nextTick(() => {
  console.log("nextTick");
});

console.log("sync");
sync
nextTick
timeout

next tick queue 不属于 timers、poll 或 check 中任何一个阶段。把它画成“优先级最高的事件循环阶段”虽然方便记忆,却会掩盖它是在回到事件循环之前处理的特殊队列。

5.2 Promise 后续逻辑与 queueMicrotask() 使用 V8 微任务队列

Promise 的 .then().catch().finally() 会登记 reaction(后续反应记录)。Promise 落定后,ECMAScript 会为相应 reaction 创建 Promise Reaction Job,V8 再把这个 job 放入微任务队列;await 之后的继续执行也借助这套 Promise job 调度。后文将 Promise 反应与 await 的后续执行统称为“Promise 后续逻辑”。queueMicrotask() 则是宿主提供的 API,用于显式安排微任务;它不依赖 Promise,也不属于 Promise reaction。

在 CommonJS 顶层代码中,可以观察到:

// queues.cjs
Promise.resolve().then(() => {
  console.log("promise");
});

queueMicrotask(() => {
  console.log("microtask");
});

process.nextTick(() => {
  console.log("nextTick");
});

console.log("sync");
sync
nextTick
promise
microtask

由 Promise reaction 产生的 job 与 queueMicrotask() 安排的微任务进入同一个微任务队列,因此两者按实际入队顺序执行。在本例中,Promise.resolve() 已经兑现,.then() 登记 reaction 时就把相应 job 排在随后调用的 queueMicrotask() 之前;如果 Promise 当时仍处于 pending,先调用 .then() 并不等于相应 job 已经入队。

对于由 Node.js 调度的 JavaScript,一次操作返回运行时边界后,Node.js 会先清空 next tick queue,再清空 V8 微任务队列。这是回调边界上的处理顺序,不是一张可以脱离执行上下文使用的全局优先级表。

微任务执行期间如果又加入微任务,新任务也会在进入下一阶段或下一个回调之前继续执行;如果微任务期间加入了 next tick,Node.js 会先完成当前微任务检查点,再在继续事件循环前回查 next tick queue。递归填充队列造成的饥饿问题会在 5.5 节继续讨论。

对于大多数用户代码,Node.js 24 文档建议使用可移植的 queueMicrotask(),而不是 process.nextTick()process.nextTick() 已被标记为 Legacy。我们仍然需要理解它,因为 Node.js 核心、既有库和历史代码中经常出现这个 API,而且它与微任务的先后关系会直接影响程序行为。

5.3 检查点会只处理“当前回调自己的任务”吗

不会。当前回调只是让 Node.js 到达检查点的执行边界,并不拥有一份私有的 next tick queue 或微任务队列。调度时,Node.js 处理的是当前 JavaScript 执行环境中已经入队的任务,不会先判断“这个任务是不是由刚返回的回调直接创建”。在主线程上,可以把它理解为该主线程当前运行环境所使用的队列;每个 Worker 拥有独立的 JavaScript 执行环境,不会由主线程的检查点代为清空。

下面把应用代码和同步调用的库代码放进同一个 I/O 回调:

// queue-scope.cjs
const { readFile } = require("node:fs");

function scheduleFromLibrary() {
  process.nextTick(() => {
    console.log("library nextTick");
  });

  queueMicrotask(() => {
    console.log("library microtask");
  });
}

readFile(__filename, () => {
  process.nextTick(() => {
    console.log("app nextTick");
  });

  scheduleFromLibrary();

  Promise.resolve().then(() => {
    console.log("app promise");
  });
});

输出为:

app nextTick
library nextTick
library microtask
app promise

两个 next tick 按进入 next tick queue 的顺序执行;随后,库代码安排的 queueMicrotask() 与应用代码安排的 Promise Reaction Job 按进入 V8 微任务队列的顺序执行。检查点不会把队列拆成“应用回调的任务”和“库函数的任务”。这里的库函数虽然不是事件循环直接调度的回调,却处在 readFile 回调的同步调用链中,它安排的任务会与外层代码安排的任务汇入同一批队列。

这也解释了为什么实践中经常觉得“检查点处理的就是当前回调产生的任务”:上一个回调边界通常已经把相关队列清空,而同一 JavaScript 线程不会在当前回调执行期间并发运行另一个用户回调。因此,检查点看到的任务往往来自当前回调、它同步调用的函数、它同步触发的事件监听器,以及这些代码在执行期间落定的 Promise。这个现象来自执行时序,不是队列提供了回调级所有权隔离。

EventEmitter 可以进一步说明边界在哪里。emit() 会同步、按登记顺序调用监听器;一个监听器安排的 next tick 或微任务,不会因为该监听器函数返回就自动插入下一个监听器之前。如果 emit() 本身发生在一个由 Node.js 调度的 I/O 回调里,这些监听器安排的任务通常会累积到外层 I/O 回调返回 Node.js 边界后再处理。普通的 JavaScript 函数返回,同样不等于到达一次 Node.js 回调边界。

检查点清空的也不是一张固定快照。处理 next tick 或微任务期间新加入的同类任务,会继续进入相应队列;只要任务持续自我补充,检查点就可能迟迟无法结束。下一阶段或下一个 I/O 回调因此得不到执行机会,这正是 5.5 节将讨论的饥饿。

调度不按来源筛选,不代表诊断时完全无法追踪因果关系。AsyncLocalStorage 可以让请求标识等状态沿回调和 Promise 链传播;更底层的 async_hooks 可以观察执行资源和触发资源之间的关系。不过,这些信息是上下文传播与诊断元数据,不会把共享队列分成回调私有队列,也不会改变任务的实际入队顺序。Promise 的底层 async ID 追踪还存在额外开销和启用条件,因此不能把诊断标识当成调度契约。

因此,图中的“当前 callback”表示触发这次检查点的 JavaScript 执行边界,而不是“接下来只执行属于它的任务”。

回调返回后清空当前运行环境的 next tick 与 V8 微任务队列

5.4 为什么 CommonJS 与 ESM 顶层顺序可能不同

把 5.2 节的 queues.cjs 原样改成 .mjs,顶层输出会发生变化:

sync
promise
microtask
nextTick

原因不是 ESM 为 process.nextTick() 定义了另一套优先级,而是 ESM 模块求值本身已经运行在微任务相关的异步求值上下文中。代码安排的 Promise Reaction Job 和 queueMicrotask() 微任务会进入当前微任务队列,next tick 要等这段模块求值退出到相应 Node.js 边界后才处理。

因此,“nextTick 永远早于 Promise”不是跨上下文的完整规则。比较执行顺序时,至少要说明代码位于:

  • CommonJS 顶层;
  • ESM 顶层;
  • timer、I/O 或其他回调内部;
  • 某个正在运行的微任务内部。

模块格式不只是语法差异,它也可能改变顶层代码所处的调度上下文。这正是上一篇模块文章与本文事件循环模型连接起来的地方。

5.5 持续填充检查点队列也可能造成饥饿

next tick 和微任务都允许在执行时继续把同类工作加入队列:

function repeat() {
  process.nextTick(repeat);
}

repeat();

setTimeout(() => {
  console.log("无法获得执行机会");
}, 0);

只要 repeat() 不停止,next tick queue 就无法真正清空,事件循环也无法前进到 timers、poll 或 check。类似地,无限递归安排微任务也会让 I/O 和 timer 饥饿。

这类问题不同于一段很长的同步循环:同步循环是一个回调久久不归还控制权;微任务饥饿则是每个小回调都很快结束,却不断在事件循环前方补充新工作。两者都会表现为 timer 延迟和服务失去响应,但诊断证据与修复方式不同。

6. 用小实验验证执行顺序

事件循环最容易被“记住一次输出,然后推广到所有场景”的方式误解。本节使用独立的 .cjs 文件,把每个实验限制在一个问题内。除特别说明外,示例都在 Node.js v24.16.0 中验证。

6.1 顶层的 setTimeout(0)setImmediate() 没有固定顺序

// top-level.cjs
setTimeout(() => {
  console.log("timeout");
}, 0);

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

可能得到:

timeout
immediate

也可能得到:

immediate
timeout

本文在同一台机器上启动 200 个独立的 Node.js v24.16.0 进程,实际观察到 195 次 immediate → timeout 和 5 次 timeout → immediate。这个比例只描述当时的实验环境,没有可推广性。

这不是 Node.js 随机打乱两个队列,而是程序启动、1 毫秒 timer 阈值、进入事件循环时的时钟状态等因素共同决定了第一次检查时谁已经具备处理条件。即使某台机器连续运行一百次都得到同一种结果,也不能把这次观察升级为跨机器、跨负载和跨版本的保证。

6.2 在 poll 阶段的 I/O 回调中,setImmediate() 先于新建的 timer

// in-io.cjs
const { readFile } = require("node:fs");

readFile(__filename, () => {
  setTimeout(() => {
    console.log("timeout");
  }, 0);

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

输出为:

immediate
timeout

readFile 回调执行时,事件循环正在处理这次 I/O 完成。回调中新建的 setImmediate() 会等待当前 poll 阶段完成后的 check 阶段;新建的 timer 至少要等时间阈值达到,并在后续 timer 处理机会中执行。因此,这个上下文提供了稳定的 immediate → timeout 关系。这里的“poll 阶段完成”已经包含回调返回后的 next tick 与微任务检查点,以及控制权返回 poll 处理流程;如果还有其他已经就绪的 poll 回调,它们也可能先于 check 执行。

注意,真正有解释力的不是“setImmediatesetTimeout 快”,而是“它们在当前回调所处的位置之后,分别要到哪个调度边界”。把相同两行代码搬回顶层,结论就不再成立。

6.3 怎样得到稳定的 timeout → immediate

有,而且不需要依赖机器速度:让 setTimeout() 回调负责登记 setImmediate(),两者之间就建立了明确的因果关系。

// timeout-first.cjs
setTimeout(() => {
  console.log("timeout");

  setImmediate(() => {
    console.log("immediate");
  });
}, 0);
timeout
immediate

setImmediate() 只有在 timer 回调已经运行后才会被创建,所以不可能反过来先执行。这个例子与顶层竞速的区别非常关键:稳定顺序来自显式依赖,不来自对调度速度的猜测。

不同上下文中 timeout 与 immediate 的可推导顺序

6.4 同一个 poll 阶段的 I/O 回调内部,可以推导哪些局部顺序

// mixed.cjs
const { readFile } = require("node:fs");

readFile(__filename, () => {
  console.log("I/O: start");

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

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

  Promise.resolve().then(() => {
    console.log("promise");
  });

  queueMicrotask(() => {
    console.log("microtask");
  });

  process.nextTick(() => {
    console.log("nextTick");
  });

  console.log("I/O: end");
});

输出为:

I/O: start
I/O: end
nextTick
promise
microtask
immediate
timeout

这段输出可以分三层推导:

  1. 当前回调中的同步语句先执行,因此 I/O: startI/O: end 包住登记动作。
  2. 回调返回到 Node.js 边界后,next tick queue 先于 V8 微任务队列;本例中的两个微任务按实际入队顺序运行,这里也恰好与登记顺序一致。
  3. 清空这些队列后,控制权先返回当前 poll 的处理流程;如果还有其他已经就绪的 poll 回调,它们可能在 check 之前执行。就本例列出的回调而言,poll 完成后 check 阶段的 immediate 先于这个 I/O 回调中新建的 timer。

因此,不能把局部路径压缩成“readFile 回调返回后直接进入 check”。更完整的路径是:readFile callback → next tick → V8 微任务 → 返回 poll → poll 完成后进入 check。阶段图描述前后两个 libuv 阶段,回调边界图描述夹在其中的 Node.js 运行时检查点。

不要把这七行输出背成一份全局优先级表。把代码移入 ESM 顶层会改变 next tick 与微任务的关系;把 timer 和 immediate 移到进程顶层,也会失去本例中的稳定顺序。若改在其他阶段的回调中登记,则必须从登记点之后的阶段关系重新推导:有些上下文仍能得到稳定顺序,有些上下文才会发生竞速。

6.5 主线程被占用时,timer 与 I/O 回调都只能等待

最后再把 timer、文件 I/O 与 CPU 阻塞放到同一个实验中:

// blocked.cjs
const { stat } = require("node:fs");
const { performance } = require("node:perf_hooks");

const startedAt = performance.now();

stat(__filename, (error) => {
  if (error) throw error;

  console.log(
    "stat callback:",
    Math.round(performance.now() - startedAt),
    "ms",
  );
});

setTimeout(() => {
  console.log("timer delay:", Math.round(performance.now() - startedAt), "ms");
}, 20);

const blockUntil = performance.now() + 200;

while (performance.now() < blockUntil) {
  // 模拟同步 CPU 工作
}

console.log("sync block ended:", Math.round(performance.now() - startedAt), "ms");

sync block ended 会先在 200 毫秒附近输出。timer 即使早已到期,也只能在这之后执行;stat callback 同样不能穿过正在运行的 while 循环。同步阻塞期间,线程池中的文件状态请求可能已经完成、仍在执行,也可能仍在队列中等待,但仅凭这段程序无法判断。timer 与文件回调的相对顺序不应由本例推广,它们各自的绝对时间也会受机器与文件系统影响。

仅观察 callback 的开始时间,不能精确证明底层 I/O 在哪一刻完成;这个实验能够证明的是:只要 JavaScript 线程仍被占用,已经到期的 timer 和文件 I/O 的用户回调都不能进入调用栈。

这给性能分析提供了一条重要分界:外部操作的完成时间JavaScript 真正开始处理结果的时间不是同一个指标。

7. 从浏览器模型回看 Node.js:共同的语言机制,不同的宿主循环

理解了 Node.js 中异步结果的返回路径,再回看浏览器模型,可以从四个维度区分共同机制与宿主差异:

  1. 语言基础相同。 同步 JavaScript 都遵循 run-to-completion,Promise 后续逻辑与 queueMicrotask() 回调都通过微任务机制继续执行;异步结果不能打断当前同步代码。
  2. 外部工作组织不同。 浏览器使用任务源与一个或多个任务队列,每次选取一个可运行任务;Node.js 由 libuv 按阶段处理回调,并通过 handle、request 协调不同 I/O 完成路径。
  3. 后续逻辑的检查点不同。 浏览器在规范规定的边界执行微任务检查点;Node.js 调度的 JavaScript 回调返回到运行时边界后,先处理 next tick queue,再处理 V8 微任务队列。process.nextTick() 不属于浏览器的微任务清单,也不能用这个顺序概括 ESM 顶层求值。
  4. 宿主生命周期不同。 浏览器需要协调页面、Worker,以及页面的渲染机会与 requestAnimationFrame();Node.js 需要协调 setImmediate()、进程与活动资源。两边的 timer 延迟都不保证回调准点执行。

因此,浏览器的一个 task 不等于 Node.js 的一个阶段:前者是一项待执行工作,后者是处理某一类回调的位置,一个阶段内可以出现多个回调和多个回调边界检查点。

下面按事件循环需要完成的处理环节进行对照。表格不是两套模型的固定执行时间表,同一行也不表示两个步骤或阶段完全等价。

处理环节 浏览器 Node.js
选取下一项工作 从含有可运行任务的任务队列中选择一个,取其中最早的可运行 task 按 libuv 阶段组织处理;同一阶段可能执行多个回调
进入 JavaScript 执行 task 的处理步骤时,可能调用脚本或事件回调;一个 task 不一定只对应一个函数 运行时在处理相关事件时调用 JavaScript;一个回调不等于一个阶段
处理后续队列 在 task 结束及规范规定的其他边界执行微任务检查点,task 内部也可能出现检查点 普通回调返回运行时边界后,先处理 next tick,再处理 V8 微任务;必要时回查队列,不是整个阶段结束后才统一处理
处理定时器与 I/O 通知 定时器和网络活动按各自规则安排任务;不同来源之间没有统一的全局 FIFO 顺序 timers 处理达到时间条件的定时器回调;许多 I/O 回调在 poll 处理,部分延后到 pending callbacks
更新页面渲染 受渲染机会和文档条件约束,通过专门流程安排更新;不是每执行一个 task 就渲染一次 没有页面渲染生命周期;check 阶段不能视为浏览器动画帧回调的对应阶段
继续等待或结束 暂无任务可运行时仍可等待后续活动;页面和 Worker 各有生命周期规则 活动资源、未完成请求等参与存活判断;没有需要继续运行的工作时,进程可以自然退出

其中,next tick 与微任务的顺序仍需结合执行上下文判断,不能把普通回调边界的规则直接套用于 ESM 顶层求值。

浏览器也不是只有一条全局 FIFO 任务队列。更新渲染受到渲染机会与专门调度规则约束,当前 HTML 标准通过 rendering task source(渲染任务源)排入更新渲染的任务;不能把它理解为每执行一个任务、完成微任务检查点后,就必然渲染一次。

本节只划出共同基础与映射边界。《浏览器与 Node.js 事件循环:相同的 JavaScript,不同的调度模型》会把浏览器任务源、微任务检查点与渲染机会,同 Node.js 阶段、回调边界和进程生命周期放在对照实验中详细推演。接下来先回到 Node.js:当模型进入真实服务,怎样用它解释延迟、线程池排队和进程无法退出。

8. 从运行模型到问题排查:事件循环、Worker Pool 与进程存活

只看“请求很慢”无法判断事件循环出了什么问题。一次请求的总耗时可能消耗在 JavaScript 计算、next tick / 微任务饥饿、Worker Pool 排队、磁盘、DNS、数据库或远程 HTTP 服务上。诊断的第一步不是立刻调大线程池,而是先确定等待发生在哪条路径。

下图会使用三个运行时信号:timer drift(timer 回调延迟)表示 timer 达到阈值后,回调仍要等待多久才真正开始执行;event loop delay(事件循环延迟)观察循环本应继续推进却被推迟了多久;ELU(event loop utilization,事件循环利用率)估算循环处于活动状态的比例。它们都是诊断证据,不能单独等同于根因。

事件循环阻塞与 Worker Pool 排队的症状对照

8.1 先按症状建立五类假设

下表再把这些运行时信号与 CPU profile(CPU 性能剖析)、分阶段耗时等证据结合起来。CPU profile 通过采样定位 JavaScript 执行时间主要消耗在哪些函数中。

现象 首要假设 可寻找的证据
所有请求、timer 和健康检查一起延迟 JavaScript 长任务或同步 I/O 阻塞事件循环 event loop delay 上升、ELU 较高、CPU profile 中出现长函数
CPU 不一定持续满载,但 I/O 长期得不到处理 next tick 或微任务持续自我补充 调用栈短却重复密集、采样中大量同一调度函数、限制递归后立即恢复
fsdns.lookup()、部分 crypto/zlib 一起变慢,Socket 仍可响应 Worker Pool 排队或其中的工作变慢 共享线程池任务的排队相关性、受控调整池大小后现象变化
event loop delay 正常,只有某个依赖的请求慢 数据库、DNS、磁盘或远程服务延迟 分阶段耗时、超时记录、下游指标、连接池等待
工作已经结束,但进程一直不退出 仍有被引用的长期资源 活跃资源类型、未关闭的 server、timer、Socket、FSWatcher

这张表表达的是排查起点,不是单个指标与根因的一一对应。CPU profile、应用 trace、系统指标和依赖侧证据应该互相印证。

8.2 用 event loop delay 与 ELU 区分“忙”和“等”

在 Node.js 中,node:perf_hooksmonitorEventLoopDelay() 用于采集 event loop delay,performance.eventLoopUtilization() 用于计算 ELU。下面故意制造一次约 200 毫秒的阻塞,让两个 API 捕捉同一个已知事件:

// loop-health.cjs
const { monitorEventLoopDelay, performance } = require("node:perf_hooks");

const delay = monitorEventLoopDelay({ resolution: 10 });
delay.enable();

const baseline = performance.eventLoopUtilization();

setTimeout(() => {
  const blockUntil = performance.now() + 200;

  while (performance.now() < blockUntil) {
    // 制造已知的事件循环阻塞
  }

  setTimeout(() => {
    delay.disable();

    const utilization = performance.eventLoopUtilization(baseline);

    console.log({
      maxDelayMs: Number(delay.max / 1e6).toFixed(1),
      p99DelayMs: Number(delay.percentile(99) / 1e6).toFixed(1),
      activeMs: utilization.active.toFixed(1),
      idleMs: utilization.idle.toFixed(1),
      utilization: utilization.utilization.toFixed(3),
    });
  }, 30);
}, 30);

一次实测中,maxDelayMs 与表示第 99 百分位的 p99DelayMs 都约为 209.5,ELU 为 0.769。绝对数值会随机器和负载变化;这个短实验的样本很少,p99 只用于展示接口,不能冒充生产统计。

延迟直方图(delay histogram)的时间单位是纳秒,所以示例除以 1e6 转成毫秒;ELU 的 activeidle 使用毫秒。线上观察时,不要只记录平均值;短暂但严重的长尾阻塞通常会更明显地出现在第 95、99 百分位(p95、p99)或最大值中。真正的长期服务还应按时间窗口保存增量 ELU、读取直方图后调用 reset(),并把监控 timer 的生命周期纳入关闭流程。

可以用下面的组合建立初步方向:

event loop delay ELU 常见解释方向
回调内 CPU 计算、同步 API、超大 JSON 处理、正则灾难性回溯等占住 JavaScript 线程
未必持续高 间歇性长任务、next tick / 微任务批次、运行时暂停,需要结合 profile 与时间线
正常 低或中等 如果请求仍慢,更可能在等待外部系统、连接池或 Worker Pool
正常 回调很密集但单次仍足够短;需要结合吞吐量、延迟目标判断是否真有问题

event loop delay 不是请求耗时,ELU 也不是进程 CPU 使用率。工作线程繁忙、数据库执行 SQL 或操作系统等待磁盘,都可能让用户请求变慢,却不一定直接推高主事件循环的 ELU。

8.3 怎样验证 Worker Pool 是否饱和

Worker Pool 的默认容量有限,文件系统、dns.lookup()、部分 cryptozlib 操作会共享它。异步 pbkdf2() 是一种在线程池中执行的密码派生计算;迭代次数越高,单项任务通常占用工作线程越久,因此适合在受控实验中制造池压力。

程序启动后,实验按三步建立对照:

  1. 先完成一次 stat(),记录线程池空闲时的基线。
  2. 提交四项各含 5,000,000 次迭代的 pbkdf2(),有意占用四个工作线程。
  3. 随后安排另一次 stat() 和一个 timer,比较依赖线程池与不依赖线程池的工作怎样响应。

代码中的固定 password、salt 与迭代参数只用于压力实验,不是密码存储建议:

// pool-pressure.cjs
const { pbkdf2 } = require("node:crypto");
const { stat } = require("node:fs");
const { performance } = require("node:perf_hooks");

const startedAt = performance.now();

stat(process.execPath, () => {
  console.log("stat control:", Math.round(performance.now() - startedAt), "ms");

  const crowdedAt = performance.now();

  for (let index = 1; index <= 4; index += 1) {
    pbkdf2("password", "salt", 5_000_000, 32, "sha256", () => {
      console.log(
        `pbkdf2 ${index}:`,
        Math.round(performance.now() - crowdedAt),
        "ms",
      );
    });
  }

  stat(process.execPath, () => {
    console.log(
      "stat under load:",
      Math.round(performance.now() - crowdedAt),
      "ms",
    );
  });

  setTimeout(() => {
    console.log("timer:", Math.round(performance.now() - crowdedAt), "ms");
  }, 20);
});

在启动独立进程前显式固定池大小,避免当前 shell 中已有配置影响实验:

UV_THREADPOOL_SIZE=4 node pool-pressure.cjs

UV_THREADPOOL_SIZE 应在进程启动前设置;等线程池已经初始化后,再在程序中修改 process.env.UV_THREADPOOL_SIZE 并不是可靠的调节方式。

本文的一次实测中,基线 stat 不到 1 毫秒,timer 约 21 毫秒执行,受压时的 stat 等待约 501 毫秒。四个长任务占用了四个工作线程,随后提交的文件任务需要排队;在这次整机 CPU 仍有余量的实验中,JavaScript 线程没有直接执行 PBKDF2 计算,所以 timer 仍能及时获得执行机会。若 CPU 核数较少、容器配额较低或整机已经饱和,工作线程仍可能通过 CPU 调度竞争间接推迟事件循环。

绝对耗时、PBKDF2 完成顺序都取决于 CPU、系统负载和 Node.js 版本。这个实验要比较的是同一环境中的趋势,而不是复制某个固定毫秒数。可以把 UV_THREADPOOL_SIZE 改为 5 做因果对照:多出的工作线程会让 stat 不再等待四个 PBKDF2 中的某一个先结束。这个对照用于验证排队假设,不是生产调优建议。

还要避免三个错误结论:

  1. 调大池后一次实验变快,不等于生产环境越大越好。
  2. Socket 慢通常不能靠调大 UV_THREADPOOL_SIZE 解决,因为普通 Socket I/O 不走这条池路径。
  3. 如果瓶颈是磁盘、CPU 核数或外部服务,更多并发工作可能只会加重竞争。

更可靠的验证方式是:先列出共享池的操作,加入每一段的排队与完成时间,再用可重复的受控负载改变一个变量,最后观察吞吐、长尾延迟、CPU 与系统 I/O 是否一起改善。

8.4 怎样区分事件循环阻塞与外部服务变慢

假设一个 HTTP 接口要查询 PostgreSQL。await client.query() 期间,JavaScript 函数暂停,数据库进程负责执行 SQL,Node.js 主要等待数据库 Socket 的结果。如果数据库锁等待或慢查询持续两秒,请求会很慢,但事件循环仍可能正常处理其他连接。

反过来,如果查询结果返回后,Node.js 同步序列化一个巨大对象两秒,那么这两秒里同一事件循环上的其他请求、timer 和健康检查都会一起受到影响。

因此,应该记录一条请求的分段时间,而不是只有总时间:

等待连接池 → 发送查询 → 数据库首字节 → 接收完整结果 → JavaScript 转换 → 写出响应

如果 event loop delay 正常、其他依赖无关的请求也正常,而“数据库首字节”一段增长,就应继续检查连接池、SQL、锁和数据库资源。若多个无关路径同时出现调度延迟,event loop delay 也上升,则应优先寻找当前进程中的同步长任务。

这一原则同样适用于 DNS、磁盘和远程 HTTP:先标出跨越进程或设备的等待边界,再判断时间消耗在边界的哪一侧。

8.5 进程为什么不退出:检查活跃资源,而不是猜事件循环“卡住”

下面的程序会持续运行:

const timer = setInterval(() => {
  console.log("heartbeat");
}, 1_000);

活动的 interval 默认被引用,会让事件循环保持存活。如果心跳不应该单独阻止进程退出,可以调用:

timer.unref();

unref() 不会取消 timer;只表示如果进程中只剩这个资源,不必为了它继续运行。若 timer 本身必须执行,就不应该用 unref() 掩盖生命周期设计问题。

Node.js 还提供 process.getActiveResourcesInfo(),返回当前让事件循环保持存活的活动资源类型:

console.log(process.getActiveResourcesInfo());

输出可能包含 Timeout、TCP 相关资源、文件系统观察资源等类型。它适合回答“还有哪类资源”,却不会直接指出业务代码中的哪一行创建了资源。下一步仍要结合资源创建日志、诊断报告或最小复现定位所有者。

常见检查顺序是:

  1. server 是否调用了 close(),并真正停止接收连接;
  2. keep-alive Socket、数据库连接池和消息客户端是否完成排空和关闭;
  3. setInterval()、重试 timer、FSWatcher 是否仍然活跃;
  4. 是否还有尚未完成的文件、DNS、加密等 request;
  5. 关闭流程是否仍持有尚未释放的资源;仅有一个 pending Promise 通常不会单独维持事件循环存活。

不要依赖未文档化的 process._getActiveHandles() 作为应用契约,也不要把 process.exit() 当作默认清理方案。强制退出可能截断日志、响应或文件写入;更稳妥的做法是明确关闭资源,让事件循环在没有剩余工作时自然结束。

8.6 一套可重复的诊断流程

把本节收束成一条排查路径:

先定义症状和时间窗口
    ↓
确认是全局调度延迟,还是单一依赖延迟
    ↓
观察 event loop delay、ELU、CPU、系统 I/O 与下游耗时
    ↓
按 API 路径判断:主线程 / Worker Pool / Socket / 外部进程
    ↓
用 CPU profile、trace、分段计时或受控对照验证假设
    ↓
只改变一个根因变量,再复测吞吐与长尾延迟

事件循环模型在这里不只是面试知识。它决定我们应该采集什么证据:主线程阻塞去看 JavaScript 长任务;线程池拥塞去看共享池操作与排队;Socket 等待去看连接和对端;进程不退出去看长期资源生命周期。

本文已经完成事件循环与异步 I/O 范围内的问题排查框架。更完整的生产诊断——例如 heap snapshot、GC trace、诊断报告、async hooks、火焰图、崩溃与内存泄漏——仍需要在后续“内存、延迟与故障诊断”专题中单独展开,避免把所有工具塞进事件循环概念文章。

9. 总结:沿着工作、通知与回调三条线推理

现在可以重新回答开头的问题。JavaScript 调用一个异步 API 后,Node.js 会按操作类型选择相应路径:普通 Socket 主要交由操作系统非阻塞 I/O 机制推进;许多文件系统操作与 dns.lookup() 使用 libuv Worker Pool;dns.resolve*() 通过异步网络查询;timer 则等待时间阈值与事件循环调度机会。

外部工作完成后,操作系统、工作线程或相应原生组件把完成事实交回 libuv 和 Node.js。事件循环在适当阶段处理它,Node.js 再在 JavaScript 线程中调用相应回调。由 Node.js 调度的回调执行完毕、控制权返回运行时边界后,next tick queue 与 V8 微任务队列还会在事件循环继续前进前得到处理。

面对一个新的异步 API,可以依次问五个问题:

  1. 当前调用中有哪些 JavaScript 会同步执行?
  2. 真正耗时的工作由谁推进?
  3. 完成通知通过什么机制返回?
  4. JavaScript 的后续逻辑通过回调、Promise 还是事件监听器交付?
  5. 哪个长期资源或未完成请求让进程继续存活?

最后,把最容易产生误解的说法集中纠正一次:

常见说法 更准确的理解
Node.js 是单线程的 默认用户 JavaScript 主要在一个线程运行;进程、libuv、Worker Pool 和操作系统仍可能使用其他线程
所有异步 I/O 都在线程池中 普通 Socket 主要使用平台 I/O 通知;许多 fsdns.lookup() 和部分 crypto/zlib 才共享 Worker Pool
事件循环负责执行文件读取 工作线程或操作系统推进底层工作;事件循环协调完成事件与 JavaScript 回调
Promise 会让 CPU 工作异步化 Promise 表达未来结果;executor 中的同步计算仍占用当前线程
setTimeout(fn, 0) 会立即执行 delay 是最早调度阈值,回调还要等待当前代码结束和 timer 处理机会
setImmediate() 总比 setTimeout(0) 顶层没有固定顺序;poll 阶段的 I/O 回调中可以推出 immediate 先执行
process.nextTick() 是事件循环的一个阶段 它是 Node.js 的特殊队列,在控制权返回事件循环前处理
回调返回时只处理它自己创建的 next tick 和微任务 调度器处理当前 JavaScript 执行环境中已经入队的任务,不按回调所有权筛选
nextTick 永远先于 Promise CommonJS 顶层通常如此;ESM 顶层处在不同求值上下文,顺序可以相反
EventEmitter 天生异步 emit() 同步、按序调用监听器;触发 emit() 的外部来源可以是异步的
请求慢就是事件循环阻塞 还可能是 Worker Pool 排队、磁盘、DNS、数据库、连接池或远程服务变慢

理解这些边界后,事件循环不再是一张需要死记的圆环图。它是一套可以从 API 一直追到操作系统、再从完成通知追回应用户 JavaScript 的因果模型。

参考资料