Node 说自己是非阻塞的,但它只有 4 个线程:我把 libuv 线程池的账算了一遍
- 作者:Bougie
- 创建于:2026-10-12
有天下午,一个服务突然慢了。
监控上 CPU 才 30%,事件循环延迟(event loop lag)一切正常,但接口 p99 从 20ms 涨到了 400ms。日志里什么都没有。我看了半小时才反应过来:事件循环没堵,堵的是它旁边那个只有 4 个线程的池子。
Node 的"非阻塞"是有分工的:
- 网络 I/O、定时器 —— 交给内核的 epoll/kqueue,主线程一个循环轮询就行,理论上想并发多少就多少;
- 文件 I/O、DNS 解析、压缩、部分加密 —— 内核没有好用的异步接口,libuv 只能开几个真线程,把活儿丢过去干。
后者的默认线程数是 4。这个值从 2015 年(Node 0.12 引入 libuv 线程池默认值)就没改过,而今天的机器普遍有 8 到 64 个核。
我把这个池子能测的都测了一遍。下面是账本。

所有数据来自本机 Linux x86_64、12 核、Node v24.16.0,脚本不依赖网络,可以照着复现。
# 一、4 个线程,是一道看得见的台阶
测试用的任务是可控的 CPU 活儿:crypto.pbkdf2,20 万次迭代 sha512,单发约 45ms。同时发起 n 个,看总耗时:
pool = 默认(4)
single task: 45.4 ms
n= 1 wall= 45.9ms
n= 2 wall= 47.0ms
n= 4 wall= 53.7ms
n= 5 wall= 93.4ms ← 第 5 个,多等一轮
n= 8 wall= 97.4ms
n=12 wall= 141.9ms
n=16 wall= 191.8ms
这不是"变慢",是分批。1~4 个一批,5~8 个一批,9~12 个一批,13~16 个一批,每批 45ms 左右。总耗时严格等于 ceil(n / 4) × 单发耗时。
改一下池子大小,台阶就跟着变:
| 并发 n | pool=1 | pool=2 | pool=4 | pool=8 | pool=12 | pool=16 |
|---|---|---|---|---|---|---|
| 4 | 182.1 | 92.8 | 53.7 | 47.3 | 47.7 | 47.3 |
| 8 | 368.7 | 186.0 | 97.4 | 80.5 | 78.5 | 81.3 |
| 12 | 552.7 | 278.7 | 141.9 | 103.5 | 103.8 | 91.3 |
| 16 | 738.6 | 371.7 | 191.8 | 150.9 | 147.4 | 133.4 |
(单位 ms,加粗列是默认值)

pool=1 那列最直观:16 个任务串行跑完,738.6ms,正好是 16 × 45ms。如果你在容器里不小心设了 UV_THREADPOOL_SIZE=1,这就是你的服务。
# 二、到底谁在排队:把池占满,然后挨个戳
上面只是"已知是线程池"的情况。真正要命的是不知道哪些 API 会掉进这个池。
我的办法很笨但有效:先发起 4 个长任务(pbkdf2 80 万次,约 182ms)把池占满,紧接着发起一个探针操作,量它的延迟,和空闲时对比:
blocker(pbkdf2 800k) 单发 ≈ 182ms
操作 空闲(中位) 池被 4 个任务占满时 差值
--------------------------------------------------------------
fs.readFile(1KB) 0.07ms 185.80ms +185.73ms
fs.stat 0.02ms 187.50ms +187.48ms
zlib.gzip(1MB) 1.80ms 187.79ms +185.99ms
dns.lookup 0.06ms 189.24ms +189.18ms
dns.resolve4 0.44ms 0.17ms -0.27ms
crypto.randomBytes 0.05ms 188.37ms +188.32ms
net.connect(本机) 0.77ms 0.18ms -0.59ms
setTimeout(0) 1.08ms 1.08ms +0.01ms
结论清清楚楚:
在池里排队的:所有 fs.* 异步操作(包括 fs.promises)、zlib、dns.lookup、crypto 的异步形式(pbkdf2、scrypt、randomBytes)。
不在池里的:dns.resolve4(走 c-ares,自己有线程)、net.connect / 一切网络 I/O(走 epoll)、setTimeout(在事件循环里)。

这张表解释了一个经典的线上故障:dns.lookup 和 dns.resolve4 名字只差一个词,但前者吃线程池、后者不吃。 而 http.get('http://some-host')、fetch() 默认都走 dns.lookup。所以高并发打外部服务时,DNS 解析会和其它文件操作抢那 4 个线程,表现出来就是"网络请求莫名其妙地慢,但网卡很闲"。
# 三、主线程堵和线程池堵,是两种不同的病
这两种堵的表现完全不同,得分开治。我各造了一次:
| 场景 | 50ms 定时器实际触发 | 偏差 |
|---|---|---|
| 空闲 | 50.1ms | +0.1ms |
| 主线程同步跑 200ms CPU | 200.1ms | +150.1ms |
| 线程池跑 4 个 pbkdf2 | 50.1ms | +0.1ms |
| 线程池跑 16 个 pbkdf2 | 50.1ms | +0.1ms |
主线程被占死时,所有东西一起慢:定时器、socket、新进来的请求,无一幸免。而线程池满了,主线程是闲的——定时器照常在 50.1ms 触发,socket 照常收发,只有掉进池里的那几类操作在排队。
所以诊断的第一步是看事件循环延迟:
const { monitorEventLoopDelay } = require('node:perf_hooks')
const h = monitorEventLoopDelay()
h.enable()
setInterval(() => console.log('loop lag p99:', (h.percentile(99) / 1e6).toFixed(1), 'ms'), 5000)
loop lag 正常、但 fs/DNS 慢 —— 线程池饱和。
loop lag 也高 —— 主线程被同步代码占死了,先去查 JSON.parse 大对象、fs.readFileSync、或者某个没加 await 的重循环。
顺手记一笔:fs.readFileSync 是直接在主线程上跑的,它造成的偏差就是上面那 +150.1ms。
# 四、池是懒创建的:改环境变量的时机决定一切
UV_THREADPOOL_SIZE 所有人都知道,但有个坑:文档说它必须在进程启动前设置。我实测了一下,比文档说的宽松——
process.env.UV_THREADPOOL_SIZE = '16' // 在第一次异步 I/O 之前
none median 190.3ms (不设)
before median 130.6ms (第一次 pbkdf2 之前设)
after median 189.8ms (第一次 pbkdf2 之后设)
在第一次异步 I/O 之前改,是生效的。 因为 libuv 的线程池是懒初始化的——进程起来时一个线程都没建,直到第一个任务提交,才去读一次环境变量然后 pthread_create。
这一点我用 /proc/self/status 直接数过:
设定值 = (默认) | 启动后立即: 7 个线程 → 第一次异步 fs 之后: 11 个线程
设定值 = 1 | 启动后立即: 7 个线程 → 第一次异步 fs 之后: 8 个线程
设定值 = 16 | 启动后立即: 7 个线程 → 第一次异步 fs 之后: 23 个线程
设定值 = 64 | 启动后立即: 7 个线程 → 第一次异步 fs 之后: 71 个线程
设定值 = 2000 | 启动后立即: 7 个线程 → 第一次异步 fs 之后: 1031 个线程
进程刚起来是 7 个线程(主线程 + Node/V8 的常驻线程),池子一个都没建。第一次异步 fs 之后才按设定值一次性建齐。
注意最后一行的 2000 → 1031:libuv 的上限是 1024,超了静默截断,不报错、不警告。1031 = 7 + 1024。

线程不是免费的。同一次启动的虚拟内存:
| 池大小 | 线程数 | RSS | 虚拟内存 |
|---|---|---|---|
| 4 | 11 | 43MB | 1380MB |
| 64 | 71 | 44MB | 1860MB |
| 1024 | 1031 | 51MB | 9544MB |
RSS 几乎没变(线程栈是惰性分配的),但虚拟内存直接飙到 9.5GB。在有 ulimit -v 限制或者内存 cgroup 卡得紧的容器里,这能把进程直接干掉。
顺带一提,worker thread 有自己独立的事件循环、也有自己独立的线程池,并继承环境变量:
pool=默认4 | 主进程 n=16: 196.8ms worker n=16: 200.3ms
pool=8 | 主进程 n=16: 146.4ms worker n=16: 157.8ms
所以起 8 个 worker 不等于有 32 个池线程,是 8×4 = 32 个(如果你设了 16,就是 8×16 = 128 个,小心)。
# 五、加大池子有用吗:一条会封顶的曲线,和两个反例
先说有用的那个。我写了个 HTTP 服务,/login 每次请求算一次 pbkdf2(就是登录接口的真实形态),120 个并发全打过去:
pool=4 /login 吞吐 82 req/s 全部跑完 1460ms p50 752ms
pool=8 /login 吞吐 122 req/s 全部跑完 985ms p50 517ms
pool=12 /login 吞吐 129 req/s 全部跑完 928ms p50 527ms
pool=16 /login 吞吐 129 req/s 全部跑完 932ms p50 540ms
pool=32 /login 吞吐 128 req/s 全部跑完 938ms p50 585ms
对照组 /ping(纯内存,不经线程池)在所有池大小下都是 23~31ms 跑完,纹丝不动。
4 → 8 提升了 33%,8 → 12 提升 6%,12 之后完全不动。 因为这台机器只有 12 个核:120 个任务 × 45ms = 5400ms 的 CPU 活儿,除以 12 核就是 450ms 的物理下限,再多线程也只能干等着。

线程池这只对"等待型"任务有效,对"算型"任务没用,因为核数才是上限。
反例一:压缩这类中等开销的操作,收益温和但真实。每个请求 gzip 一次 256KB,200 并发:
pool=4 /gzip 吞吐 5978 req/s p50 27.9ms
pool=16 /gzip 吞吐 6815 req/s p50 16.6ms
pool=64 /gzip 吞吐 8012 req/s p50 16.9ms
反例二:小文件读取,加大池子反而更慢。 4000 次 4KB 文件读取,并发 200:
pool=1 fs.readFile 4KB 4000 次并发 200 → 48ms (83923 ops/s)
pool=4 fs.readFile 4KB 4000 次并发 200 → 66ms (60950 ops/s)
pool=16 fs.readFile 4KB 4000 次并发 200 → 62ms (64604 ops/s)
pool=64 fs.readFile 4KB 4000 次并发 200 → 62ms (64765 ops/s)
页缓存命中时,一次 read() 只要几微秒,而把任务派发到另一个线程、唤醒它、再拿回结果,光同步原语的开销就比干活还贵。四个线程轮流干还更划算。
所以"把 UV_THREADPOOL_SIZE 调大"从来不是万能药,它只在"单个任务耗时远大于派发开销、且总耗时还没到 CPU 上限"这段区间里有效。
# 六、一个五行的探针,能提前发现它
既然线程池满了会表现为 fs.stat 变慢,那就直接量 fs.stat:
const fs = require('node:fs')
const { monitorEventLoopDelay } = require('node:perf_hooks')
const lag = monitorEventLoopDelay()
lag.enable()
setInterval(async () => {
const t = process.hrtime.bigint()
await fs.promises.stat(__filename)
const fsLatency = Number(process.hrtime.bigint() - t) / 1e6
const loopLag = lag.percentile(99) / 1e6
// 空闲时 fsLatency 约 0.02ms;池被占满时是 187.50ms
if (fsLatency > 10 && loopLag < 20) {
console.warn(`[threadpool] fs ${fsLatency.toFixed(1)}ms,loop ${loopLag.toFixed(1)}ms → 线程池饱和`)
}
lag.reset()
}, 5000)
判据就是第二节那张表的差值:fs.stat 空闲 0.02ms,池满 187.50ms,差四个数量级,不可能误判。而 loop lag 正常这条用来排除"主线程被同步代码占死"——那种情况两个指标会一起飙。

# 七、清单
最后是我自己用的几条:
- 默认 4 个线程,够不够取决于你的任务类型,不是取决于你的 QPS。几微秒的小文件读写,4 个甚至 1 个就够;几十毫秒的加密、压缩,4 个一定不够。
- 先量再调。把上面那个探针放进服务,跑一天,看
fsLatency的分布,再决定要不要动UV_THREADPOOL_SIZE。 - 调之前先数核。池大小超过核数之后,对 CPU 密集型任务没有任何收益(实测 12 核机器上 12 之后就平了)。
- 别设超过 1024,会被静默截断;也别在容器里随手设成几百,虚拟内存是按每线程 8MB 的栈预留下的(1024 线程 = 9544MB VSZ)。
- 长活儿挪走。真要算几十毫秒的 CPU 活儿,线程池不是答案,
worker_threads或者单独的服务才是——而且是另一套线程预算。 dns.lookup是隐藏消费者。高并发打外部域名时,考虑自建 DNS 缓存,或者换用不占线程池的解析路径。
Node 的"单线程"从来不是说它只有一个线程。它说的是:你的 JavaScript 跑在一个线程上,但你的 I/O 跑在一个只有 4 个人的后台小队里。 前者的问题好查,后者的问题会伪装成"网络慢""磁盘慢""框架慢",然后在你最忙的那个下午出现。
