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

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

该切开还是搬走,各自要付什么代价

在 TT Lab 中继续学习

一句话总结

处理长时间占住事件循环的计算,只有两条路:把它切成小片,在片与片之间把轮次交还出去;或者整个搬到另一个线程去。两条路都不是免费的,而一旦把各自的代价测出来,“放在哪里”就不再是喜好,而是一道计算题。

为什么需要它

找到了阻塞的代码,问题并不会就此结束。那个计算仍然需要,总得在某个地方做。“把它交给 Worker”是常见的建议,但照着做之后又退回来的情况很多。因为漏掉了两点。

一是不共享内存。Worker 线程无法直接看到这个请求的对象。它只是把值复制过去、再把结果复制回来,所以需要操作当前持有的连接或缓存的事,从一开始就搬不走。如果传递大对象,复制本身就成了新的开销。

另一点是启动需要时间。在实验镜像的 Node 22.11.0 上,用一个空任务测量,启动一个 Worker 并收到答案,要花 22ms 到 31ms。如果把一个 5ms 的计算搬过去,就亏了五倍。所以真实的服务会预先启动几个 Worker 并复用——需要这种设计的原因,就是这个数字。

切开则具有相反的性质。它留在同一个线程,所以能直接看到状态,也没有复制。代价是总耗时会略有增加,更重要的是,单个切片的耗时就成了延迟的底数。

工作原理

切开,一半是“分”,另一半是“让”。如果只把循环分成切片,而在片与片之间什么都不做,从事件循环的角度看,就和一次跑完一样。让出只需要一行。

for (const { from, to } of chunks(total, sliceSize)) {
  result = hashRange(from, to, result);          // 앞 조각의 값을 이어받는다
  await new Promise((resolve) => setImmediate(resolve));   // 여기서 차례를 넘긴다
}

setImmediate 是在 check 阶段被调用的,所以在这一行的间隙里,正在等待的定时器和已完成的输入输出回调会得到处理。在 Node 22.11.0 上,把一个循环 4 千万次的计算一次跑完,事件循环会停顿 290ms,切成 40 片后降到 8ms。总耗时增加到 1.03 倍——这是每次让出都要让事件循环转一圈的代价。

这里就引出一个设计旋钮。在单个切片运行期间,没有人能插进来,所以定下目标延迟,切片大小也就随之而定。在上面的实测中,一个切片是 7.9ms,测得的最大延迟是 8ms。如果想在 50ms 内响应,只要让单个切片不超过 50ms 就行。这不是“大致切一切”,而是可以靠计算定下来的。

搬走则是另一种形态。worker_threads 的 Worker 接收一个文件,在新线程里执行该模块,用 workerData 传入值,用 postMessage 收回结果。在计算于那边运行的期间,这边的事件循环完全自由——把同样的 4 千万次计算交给 Worker 之后,这边的最大延迟是 9ms。

const worker = new Worker(new URL("./hash-worker.mjs", import.meta.url),
                          { workerData: { from: 0, to: total } });
worker.once("message", (result) => { /* 복사되어 건너온 값 */ });

有一个陷阱。Worker 会继承父进程的运行参数。所以在用 node --input-type=module -e "..." 运行的程序里,如果启动基于文件的 Worker,就会因 ERR_INPUT_TYPE_NOT_ALLOWED 而崩溃。使用 Worker 的程序,最好放在文件里直接运行。

在现场相遇的样子

判断规则整理出来比想象中简单。一次在 1ms 以内的,就放着不管——测量的代价比修复的收益还大。比这重、但需要看当前请求状态的,搬不走,所以切开。与状态无关的纯计算,而且重到足以承担启动开销的,就搬走。“重到”的界线来自启动开销,所以在启动要花 22ms 的机器上,50ms 前后是合理的,在别的机器上就会是另一个值。规则是依附于测量的,不是要背的常数。

现场常见的失败,是只装作切开了的代码。把循环从 for 改成 map,或者把函数声明为 async,就以为切开了,其实两者都没有把轮次交还给事件循环。即使在 async 函数里,在遇到 await 之前也是同步一直运行到底。确认有没有让出,办法只有一个——数一数计算运行期间,一个短定时器响了几次。

下一项实验要做什么

用三种方法运行同一个计算。一次跑完、切成小片、以及在 Worker 线程里。三套的答案必须相同(切开不会改变答案),而延迟分布要大不相同。

接着计算各自的代价——切开造成的总耗时增加和单个切片的耗时、Worker 的启动开销。最后把这些数字固化成一条叫 place(job) 的规则,让下一个人也能做出同样的判断。