TT Lab
开始
学习 学习路径 课程

慢的不是一个请求,而是全部

数一数线程池有几个位置

在 TT Lab 中继续学习

目标

亲自测量并确认:同一个计算,根据在哪里运行,有时会阻塞事件循环,有时不会,并用数字数出 libuv 线程池有几个槽位。

为什么重要

crypto.pbkdf2 与 crypto.pbkdf2Sync 做同样的计算。但一个被送到线程池,另一个在事件循环上运行。名字末尾的 Sync 这四个字母,决定了是“只有这个请求慢”,还是“全都慢”。

而且,线程池并不是无限的。默认是四个槽位,所以如果送出五个繁重的异步任务,第五个要等到前面某一个结束,才能开始。读文件、DNS 查询和压缩共用同样的四个槽位,所以压缩任务会拖慢读文件的事确实会发生。相反,套接字读取和 dns.resolve 不使用这些槽位,所以完全不受影响。本实验不是要背这张地图,而是要学会通过测量来确认的方法。

步骤

  1. 在 /root/work/blocking/probe.mjs 中用 classify(name) 写出 API 所处的位置。
  2. 在同一个文件中编写 pct(samples, p) 和 withLag(fn, options)。
  3. 测量 crypto.pbkdf2Sync 和 crypto.pbkdf2,写入 /root/work/blocking/report.json 的 runs.pbkdf2Sync、runs.pbkdf2Async。
  4. 解析一个大的 JSON 字符串并测量,写入 runs.jsonParse。
  5. 创建 /root/work/blocking/wave.mjs,一次性送出多个同样的任务,写入 runs.pool4 和 runs.pool8。
  6. 用 UV_THREADPOOL_SIZE=8 运行同一个程序,写入 runs.pool8big。
  7. 对 zlib.gzipSync 和 zlib.gzip 再应用一次同样的方法。

参考

先画出地图

在 /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 生成的缓冲区,压缩器就会真正干活。