Node 说自己是非阻塞的,但它只有 4 个线程:我把 libuv 线程池的账算了一遍

有天下午,一个服务突然慢了。

监控上 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 个核。

我把这个池子能测的都测了一遍。下面是账本。

四条发光的窄通道从一个进程盒子里流向 CPU 核心,后面还排着长队

所有数据来自本机 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,加粗列是默认值)

阶梯状的 3D 柱状图,四步一跳

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 正常这条用来排除"主线程被同步代码占死"——那种情况两个指标会一起飙。

诊断探针在服务器机架上扫描,一条发光的脉冲线

# 七、清单

最后是我自己用的几条:

  1. 默认 4 个线程,够不够取决于你的任务类型,不是取决于你的 QPS。几微秒的小文件读写,4 个甚至 1 个就够;几十毫秒的加密、压缩,4 个一定不够。
  2. 先量再调。把上面那个探针放进服务,跑一天,看 fsLatency 的分布,再决定要不要动 UV_THREADPOOL_SIZE。
  3. 调之前先数核。池大小超过核数之后,对 CPU 密集型任务没有任何收益(实测 12 核机器上 12 之后就平了)。
  4. 别设超过 1024,会被静默截断;也别在容器里随手设成几百,虚拟内存是按每线程 8MB 的栈预留下的(1024 线程 = 9544MB VSZ)。
  5. 长活儿挪走。真要算几十毫秒的 CPU 活儿,线程池不是答案,worker_threads 或者单独的服务才是——而且是另一套线程预算。
  6. dns.lookup 是隐藏消费者。高并发打外部域名时,考虑自建 DNS 缓存,或者换用不占线程池的解析路径。

Node 的"单线程"从来不是说它只有一个线程。它说的是:你的 JavaScript 跑在一个线程上,但你的 I/O 跑在一个只有 4 个人的后台小队里。 前者的问题好查,后者的问题会伪装成"网络慢""磁盘慢""框架慢",然后在你最忙的那个下午出现。

四条发光的窄通道从一个进程盒子里流向 CPU 核心,后面还排着长队