libuv 事件循环与 Worker Pool
解释 libuv 如何通过事件循环、poll 阶段、handle/request 生命周期和线程池,在一个主线程上调度大量 I/O 与系统任务。
[!info] related notes
- 所属 MOC: Node.js MOC, Node.js 后端面试 MOC, javascript-in-nodejs-moc
- 上位主题: libuv, Node.js 运行时架构
- 相关概念: Node.js 事件循环阶段, process.nextTick、Promise、setImmediate 与 setTimeout 的关系, worker-threads
- 相关资源: nodejs, V8
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 最重要,因为它同时处理两件事:
- 这轮最多能阻塞等待 I/O 多久
- 哪些 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 执行语义。