数一数线程池有几个位置
目标
亲自测量并确认:同一个计算,根据在哪里运行,有时会阻塞事件循环,有时不会,并用数字数出 libuv 线程池有几个槽位。
为什么重要
crypto.pbkdf2 与 crypto.pbkdf2Sync 做同样的计算。但一个被送到线程池,另一个在事件循环上运行。名字末尾的 Sync 这四个字母,决定了是“只有这个请求慢”,还是“全都慢”。
而且,线程池并不是无限的。默认是四个槽位,所以如果送出五个繁重的异步任务,第五个要等到前面某一个结束,才能开始。读文件、DNS 查询和压缩共用同样的四个槽位,所以压缩任务会拖慢读文件的事确实会发生。相反,套接字读取和 dns.resolve 不使用这些槽位,所以完全不受影响。本实验不是要背这张地图,而是要学会通过测量来确认的方法。
步骤
- 在
/root/work/blocking/probe.mjs中用classify(name)写出 API 所处的位置。 - 在同一个文件中编写
pct(samples, p)和withLag(fn, options)。 - 测量
crypto.pbkdf2Sync和crypto.pbkdf2,写入/root/work/blocking/report.json的runs.pbkdf2Sync、runs.pbkdf2Async。 - 解析一个大的 JSON 字符串并测量,写入
runs.jsonParse。 - 创建
/root/work/blocking/wave.mjs,一次性送出多个同样的任务,写入runs.pool4和runs.pool8。 - 用
UV_THREADPOOL_SIZE=8运行同一个程序,写入runs.pool8big。 - 对
zlib.gzipSync和zlib.gzip再应用一次同样的方法。
参考
classify返回"loop"(在事件循环上运行)、"threadpool"(libuv 线程池)、"kernel"(事件循环只是等着,直到内核通知)三者之一,对表中没有的名字则返回"unknown"。withLag(fn, {durationMs, intervalMs, startAfterMs})先开始测量,稍后调用fn()(如果返回 Promise 就等待它),把这段时间放进workMs,返回{intervalMs, durationMs, samples, workMs}。wave.mjs通过process.argv[2]接收个数,输出{"n": .., "threadpoolSize": .., "finishMs": [..]}一行。finishMs是把结束时刻(ms)按升序排序的结果。- 常见错误:在程序内部设置
process.env.UV_THREADPOOL_SIZE = "8"。线程池在那之前就已经创建好了,所以什么都不会发生。
先画出地图
在 /root/work/blocking/probe.mjs 中导出 classify(name)。fs.readFileSync、crypto.pbkdf2Sync、zlib.gzipSync、JSON.parse、JSON.stringify 是 "loop",fs.readFile、crypto.pbkdf2、crypto.randomBytes、zlib.gzip、dns.lookup 是 "threadpool",dns.resolve4、dns.reverse、dns.resolveMx 是 "kernel",其余是 "unknown"。
这份清单不是背下来的,而是官方文档里写着的——命令行文档的 UV_THREADPOOL_SIZE 一节,以及 DNS 文档的“实现注意事项”一节。
这张表的关键是 dns.lookup 与 dns.resolve4 的区别。前者在线程池里调用操作系统的 getaddrinfo,后者直接通过网络查询,不使用线程池。
一边让它干活一边测量的尺子
在同一个文件中导出 pct(samples, p)(与第 1 个实验相同的规则)和 withLag(fn, {durationMs, intervalMs, startAfterMs})。返回 {intervalMs, durationMs, samples, workMs}。
要先开始测量,在 startAfterMs 之后调用 fn()。如果 fn() 返回 Promise,就等它结束,并把所花的时间放进 workMs。
任务结束之后,也必须继续测量到 durationMs 满为止。因为被阻塞的那一段,会在阻塞解除之后的样本中以大值的形式出现。
同样的计算,不同的位置
用 crypto.pbkdf2Sync 和 crypto.pbkdf2(异步)各做一次同样的计算并测量,分别写入 /root/work/blocking/report.json 的 runs.pbkdf2Sync 和 runs.pbkdf2Async。各自放入 samples、p50、p99、max、workMs,最上层也要写入 node。
要让它一次花 150ms 以上,差别才看得出来。请提高迭代次数(例如用 sha512 迭代 300000 次)。
两种情况下的 workMs 会差不多——因为工作量相同。变化的是,在此期间事件循环能做什么。
没有地方可以挪的事
生成一个超过 4MB 的 JSON 字符串,用 JSON.parse 解析并测量,连同 bytes 一起写入 runs.jsonParse。
JSON.parse 没有异步版本。即使用 fs.readFile 异步读取文件,解析也是在事件循环上发生的。
所以接收大 JSON 的端点,请求体大小限制就是延迟预算。在这里测得的数字,就是确定那个限制的依据。
数一数有几个槽位
创建 /root/work/blocking/wave.mjs。一次性送出 process.argv[2] 个 crypto.pbkdf2(异步),把结束时刻(ms)按升序排序,输出 {n, threadpoolSize, finishMs} 一行。threadpoolSize 在没有 process.env.UV_THREADPOOL_SIZE 时是 4。把用 4 个和 8 个运行的结果写入 runs.pool4、runs.pool8,并把 finishMs[7] / finishMs[3] 写入 runs.pool8.waveRatio。
每个任务要花 150ms 以上,才能看出槽位的分界。
四个几乎在同一时刻结束,而八个会分成两批。后面四个是在前面四个把槽位腾出来之前,连开始都没有开始。
增加槽位就能解决吗
给出 UV_THREADPOOL_SIZE=8,用 8 个运行同一个 wave.mjs,写入 runs.pool8big。连同 threadpoolSize、finishMs、waveRatio,还要把 pool8.finishMs[7] / pool8big.finishMs[7] 写入 speedup。
环境变量必须在程序启动时给出——UV_THREADPOOL_SIZE=8 node wave.mjs 8。
波浪会消失。但请看全部结束的时刻。这台 Pod 的 CPU 只有 2 核,所以增加的只是能同时开始的任务数,而不是做计算的人手。
把同样的方法用到新的 API 上
生成一个超过 4MB 的缓冲区,用 zlib.gzipSync 和 zlib.gzip(异步)各压缩一次并测量,连同 bytes 一起写入 runs.gzipSync、runs.gzipAsync。
顺序是:先在第 1 步的表里确认 zlib 所处的位置,再通过测量来确认这张表是否正确。遇到第一次见到的库时,做的正是这件事。
容易压缩的数据很快就结束,看不出差别。如果使用 crypto.randomBytes 生成的缓冲区,压缩器就会真正干活。