libuv 事件循环与 Worker Pool

解释 libuv 如何通过事件循环、poll 阶段、handle/request 生命周期和线程池,在一个主线程上调度大量 I/O 与系统任务。

#type / synthesis #status / growing #tech / dev / backend #resource / libuv #resource / nodejs

[!info] related notes

libuv 事件循环与 Worker Pool

范围

这篇笔记讨论的不是“JavaScript 语法上的异步”,而是 Node.js 底层调度真正依赖的 libuv 机制:

  • 事件循环到底在调度什么
  • poll 为什么是核心阶段
  • loop 为什么继续活着、什么时候退出
  • 文件 I/O 为什么常走线程池
  • process.nextTick / Promise 和 libuv 阶段为什么不是一层东西

为什么要放在一起理解

如果只背:

timers -> pending callbacks -> poll -> check

你可以答出阶段名,但很难讲清这些高频追问:

  • 为什么 setImmediate() 在 I/O 回调里经常先于 setTimeout(0)
  • 为什么 Node.js 没有为每个 socket 开一个线程
  • 为什么网络 I/O 和文件 I/O 的路径不一样
  • 为什么 process.nextTick() 可能饿死事件循环
  • 为什么线程池满了以后,fs / crypto / dns.lookup() 会开始排队

这些问题都要回到 libuv 的调度模型统一理解。

先建立三层调度模型

Node.js 里至少要分清三层东西:

第一层:当前 JavaScript 调用栈
  - 当前同步代码
  - 当前 callback 的同步部分

第二层:Node / V8 微任务层
  - process.nextTick()
  - Promise.then() / queueMicrotask()

第三层:libuv event loop 阶段
  - timers
  - pending callbacks
  - idle / prepare
  - poll
  - check
  - close callbacks

也就是说:

  • Promise 微任务不是 poll
  • process.nextTick() 不是 check
  • libuv 阶段和微任务机制是叠在一起工作的,不是同一个队列

libuv 的事件循环本质上在做什么

libuv 的 event loop 不是“while 里不停拿一个 JS callback 出来执行”那么简单。

更准确地说,它是一个 I/O 事件调度器:

注册感兴趣的事件

让 libuv 跟踪 socket / timer / request / process / close

每轮循环决定:
  - 哪些 callback 现在可以跑
  - 要不要阻塞等待 I/O
  - 最多等多久
  - 哪些 handle 正在关闭
  - 哪些后台任务完成了

把对应 callback 交回 Node / JS 执行

一轮 loop iteration 可以怎样理解

从原理上看,一轮循环可以先抽象成:

更新 loop 的当前时间 now

处理到期 timers

判断 loop 是否 alive

处理 pending callbacks

处理 idle / prepare

计算 poll timeout

阻塞等待 I/O,或者不阻塞

处理 I/O 事件

处理 check callbacks

处理 close callbacks

进入下一轮

对 Node.js 用户来说,更好记的上层顺序仍然是:

timers

pending callbacks

idle, prepare

poll

check

close callbacks

但一定要知道:真正决定“等不等、等多久、什么时候跳走”的关键在 poll 前后的调度判断。

loop 为什么会继续跑

事件循环会不会继续,不只看“还有没有 callback”,还要看 loop 是否还活着。

可以先记成三个条件:

  • active 且 ref’d 的 handles
  • active requests
  • closing handles

只要这些东西还存在,loop 通常就不会退出。

这也解释了为什么:

console.log('done');

会立刻结束,但:

setInterval(() => {
  console.log('tick');
}, 1000);

不会立刻退出,因为 interval 让 timer handle 一直保持 alive。

unref() 的意义

像 timer 这样的 handle 可以 unref()

const timer = setInterval(() => {
  console.log('tick');
}, 1000);

timer.unref();

含义不是取消任务,而是:

如果只剩这个 handle,事件循环不必为了它继续活着。

timers:它是阈值,不是精确时钟

setTimeout(fn, 1000) 的含义不是“1000ms 一到立刻执行”,而是:

至少等到这个时间阈值之后,等事件循环轮到它时再执行。

如果主线程在这期间一直被同步代码占着,timer 只能延后。

一个最典型的阻塞例子

setTimeout(() => {
  console.log('timer');
}, 100);

const start = Date.now();
while (Date.now() - start < 1000) {}

console.log('sync done');

这里 timer 不会在 100ms 时打断同步代码,而是等主线程释放后才有机会执行。

setInterval 也不是理想时钟

如果 interval callback 自己跑得很久,下一次间隔也会被拖后。

所以 timer 最好理解成“达到阈值后进入可调度状态”,而不是“闹钟精确触发器”。

pending callbacks:不是所有 I/O 都在 poll 当场执行

pending callbacks 阶段承接的是一些延迟到下一轮的系统级回调。

这意味着:

I/O 完成

一定在当前 poll 阶段立刻回到你的 JS callback

大多数业务代码不需要死记这个阶段的所有细节,但要知道它的存在说明:libuv 不是“有事件就立刻同步回调”,而是会统一安排回调落在哪一轮。

idle / prepare:更多是内部调度钩子

这两个阶段对普通 Node.js 业务代码通常不可见。

可以先把它们理解成:

  • idle:loop 每轮里可运行的内部空闲回调
  • prepare:在即将阻塞等待 I/O 之前执行的内部准备回调

它们常用于运行时内部维护,而不是面向日常业务 API 的阶段。

poll:libuv 事件循环的核心战场

poll 最重要,因为它同时处理两件事:

  1. 这轮最多能阻塞等待 I/O 多久
  2. 哪些 I/O 事件已经来了

为什么它重要

Node.js 没有为每个 socket 启一条线程,而是把 socket 交给操作系统的事件通知机制去监控。

模型更接近:

libuv:
  我关心这些 socket 的 readable / writable 事件

操作系统:
  有事件时我通知你

libuv:
  收到通知后执行相应 callback

而不是:

每个连接一个线程阻塞 read()

poll timeout 是什么

进入 poll 前,libuv 会先算:

我最多能睡多久?

这个 timeout 不能乱给,因为还要顾及:

  • 有没有 timer 快到期
  • 有没有 setImmediate 正等着进入 check
  • 有没有 handle 正在关闭
  • loop 现在是否根本不该阻塞

可以先抽象成:

function computePollTimeout() {
  if (shouldNotBlock()) return 0;
  if (hasNextTimerSoon()) return timeUntilNextTimer;
  return maybeInfinite;
}

为什么 Node.js 没请求时 CPU 很低

因为它不是死循环空转,而是:

  • 没 I/O 时让 OS 帮它休眠
  • 有 I/O 或 timer 条件满足时再被唤醒

这也是 event loop 和“手写 while(true)”的根本差别。

check:setImmediate() 为什么在 I/O 后经常更早

setImmediate() 对应 check 阶段。

当代码处在 I/O callback 里时,当前 loop 往往刚从 poll 侧回来,所以更接近下一步的 check

例如:

import fs from 'node:fs';

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

常见结果是:

immediate
timeout

但如果在主模块顶层直接比较:

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));

顺序不应该背死。

close callbacks:关闭也要纳入统一调度

资源关闭不是“现在立刻同步把所有收尾逻辑跑完”。

像 socket 的 close 回调通常会在 close callbacks 阶段统一执行。

这能避免底层 handle 在任意时刻直接同步销毁,把生命周期问题变得更复杂。

process.nextTick() 为什么危险

process.nextTick() 不是 libuv 阶段,而是 Node.js 自己加的一层高优先级回调机制。

语义可以先记成:

当前 JS 调用结束后
事件循环继续前
立即执行

因此:

setTimeout(() => console.log('timeout'), 0);
process.nextTick(() => console.log('nextTick'));
console.log('sync');

通常是:

sync
nextTick
timeout

为什么会饿死事件循环

如果递归不断塞 nextTick

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

loop();

事件循环可能一直没机会回到 timers、poll、check 等阶段,造成:

  • timer 不执行
  • I/O callback 拿不到机会
  • 服务“没死,但像卡住了一样”

Promise 微任务为什么也会插在 callback 之间

Promise 微任务同样不属于 libuv 某个阶段,但它会在 JavaScript callback 边界被清理。

例如:

setTimeout(() => {
  console.log('timer 1');

  Promise.resolve().then(() => {
    console.log('promise in timer');
  });

  console.log('timer 1 end');
}, 0);

setTimeout(() => {
  console.log('timer 2');
}, 0);

常见输出是:

timer 1
timer 1 end
promise in timer
timer 2

这说明 Node.js 不是“某个阶段里把所有 callback 粗暴跑完才理会微任务”,而是 JS callback 完成后,会先清理高优先级任务,再继续下一批事件循环回调。

为什么网络 I/O 和文件 I/O 的路径不一样

这是理解 libuv 的关键分野。

网络 I/O

网络 I/O 通常可以依赖:

  • 非阻塞 socket
  • OS poller 事件通知

也就是主要走:

socket

OS poller

poll 阶段

JS callback

文件 I/O

很多文件系统调用并不像 socket 那样有统一、稳定、跨平台的事件通知接口,因此 libuv 常把文件系统工作交给线程池。

于是路径更接近:

fs.readFile

libuv worker pool

后台线程执行阻塞文件读取

完成后通知 event loop

JS callback

这也是为什么 Node.js 文档总会把 Event Loop 和 Worker Pool 分开讲。

Worker Pool:它和 worker_threads 不是一个东西

libuv worker pool 是 Node 底层的后台线程池,默认线程数通常较小,主要给:

  • 文件系统 API
  • dns.lookup() 这类调用
  • 部分 crypto
  • zlib

这和 worker-threads 不同:

  • worker pool 不是给你直接写 JS 并行逻辑的主模型
  • worker_threads 才是用户显式创建 JS 线程的方式

三种不同瓶颈要分开看

1. JS callback 里做太久 CPU 计算
   -> 堵 Event Loop

2. 一次性提交很多 fs / crypto / zlib
   -> 堵 Worker Pool

3. 数据库 / 网络本身很慢
   -> 主要是在等外部系统,不一定堵线程

一个完整的运行心智模型

可以把 libuv 的工作总结成:

1. 看 timer 有没有到期
2. 看有没有上一轮延迟的 pending callback
3. 做内部准备
4. 计算要不要阻塞,以及最多睡多久
5. 交给操作系统等待 I/O
6. I/O 到来后执行对应 callback
7. 处理 setImmediate 对应的 check callback
8. 处理 close callback
9. 判断 loop 还活不活
10. 活着就下一轮,不活就退出

Node.js 再在这个基础上叠加:

  • 同步代码先执行
  • process.nextTick() 优先级高于 Promise 微任务
  • Promise 微任务高于下一轮普通阶段任务
  • JavaScript callback 一旦开始,就会同步执行到结束

对比与易混淆点

不要把事件循环理解成“一个普通队列”

真正的核心不是“队列 pop callback”,而是:

  • 底层 I/O 事件监控
  • handle / request 生命周期
  • poll 阻塞等待和唤醒机制

不要把 process.nextTick() 当成某个 libuv 阶段

它属于 Node.js 自己的高优先级调度语义。

不要把 worker pool 理解成 Node.js 的“多线程业务执行模型”

它更像底层替 Node 异步 API 承担阻塞任务的后台池,而不是业务层面的 CPU 并行方案。

一句话总结

libuv 事件循环不是简单的 callback 队列,而是 Node.js 底层的跨平台 I/O 调度核心:

它通过 timers、poll、check、close 等阶段组织回调,通过 OS 事件通知管理大量网络 I/O,通过 handle / request 判断生命周期,通过 worker pool 承接部分阻塞任务;Node.js 再在它之上叠加 process.nextTick()、Promise 微任务和 JavaScript 执行语义。

创建于 2026/5/21 更新于 2026/5/27