把大计算切开,再搬到工作线程
目标
用两种方法来处理长时间占住事件循环的计算——切成小片并在片与片之间交还轮次,以及整个搬到 Worker 线程里。并用数字测出各自的代价。
为什么重要
“重就交给 Worker”这个建议只说对了一半。Worker 不共享内存,所以无法直接看到当前请求的对象,而且启动一个线程本身也要时间。如果这段时间比任务本身还长,搬过去就是亏的。
切开则相反。它留在同一个线程里,所以能直接看到状态,代价是片与片之间让出所付出的,要计入总耗时。而且单个切片的耗时,就是延迟的底数——因为在运行一个切片期间,没有人能插进来。
把这两个代价测出来之后,“这件事该放在哪里”就不再是喜好,而是一道计算题。最后一步会把这个计算固化为规则。
步骤
- 在
/root/work/worker/split.mjs中编写chunks(total, size)。 - 在同一个文件中编写
hashRange(from, to, seed)和runSliced(total, sliceSize)。 - 测量一次跑完的一边和切片的一边,写入
/root/work/worker/report.json的runs.whole、runs.sliced。 - 用两套结果计算并写入
slicing.costRatio、tailRatio、sliceMs。 - 创建
/root/work/worker/hash-worker.mjs,用runInWorker(total)搬过去。 - 测量 Worker 运行期间,写入
runs.worker,并写入worker.startupMs。 - 用
place(job)把“放在哪里”做成规则。
参考
chunks(total, size)是{from, to}对象的数组。size小于等于 0 时请抛出异常——否则就会成为永远循环下去的迭代。hashRange(from, to, seed = 0)是把h = (h * 31 + i) >>> 0从from开始循环到to之前得到的值。这是依赖顺序的计算,所以必须在片与片之间把前一个值作为 seed 传递,答案才会相同。runSliced(total, sliceSize)返回{result, slices}。runInWorker(total)以 Promise 形式返回与hashRange(0, total)相同的值,spawnCostMs()用一个空任务只测量启动时间。place(job)的规则:cpuMs小于等于 1 则为"loop",比它大且sharedState为真则为"slice",否则在cpuMs大于等于 50 时为"worker",小于 50 则为"slice"。如果没有cpuMs或者不是数字,请抛出异常。- 启动 Worker 的程序不要用
node -e运行。Worker 会继承父进程的运行参数,从而因ERR_INPUT_TYPE_NOT_ALLOWED而崩溃。测量的程序也要放在文件里,像node measure.mjs这样运行。
切分时不在边界上漏掉东西
在 /root/work/worker/split.mjs 中导出 chunks(total, size)。返回 {from, to} 的数组,最后一个切片可以较短。size 小于等于 0 时抛出异常。
to 不包含那个位置。这样才能与下一个切片的 from 正好衔接。
切片数是 Math.ceil(total / size),把它们拼起来,必须没有空隙也没有重叠地覆盖从 0 到 total。
切开,并在片与片之间交还轮次
在同一个文件中导出 hashRange(from, to, seed = 0) 和 runSliced(total, sliceSize)。runSliced 返回 {result, slices},并在每两个切片之间,向事件循环让出一次。
运行 h = (h * 31 + i) >>> 0,但要把前一个切片的结果作为下一个切片的 seed 传下去。不传的话,答案就会不同。
让出就是 await new Promise(r => setImmediate(r)) 这一行。只分而没有这一行,从事件循环的角度看,就和一次跑完一样——评分器会看这期间定时器运转了几次。
同样的答案,不同的分布
对循环 4 千万次的计算,(甲)一次跑完、(乙)切成小片,各自测量,把 node、total 以及 runs.whole、runs.sliced 写入 /root/work/worker/report.json。两套都要放入 samples、p50、p99、max、workMs、result,runs.sliced 中还要放入 slices。
测量用的尺子,可以做成与第 2 个实验的 withLag 相同的形式,在这个文件里也放一个。
result 在两套中必须相同。如果不同,就是片与片之间没有传递 seed——切开不会改变答案。
计算切开的代价
在 slicing 中写入 costRatio(sliced.workMs / whole.workMs,保留到小数点后第三位)、tailRatio(whole.max / sliced.max,第二位)、sliceMs(sliced.workMs / sliced.slices,第三位)。
costRatio 比 1 略大——这是每次让出都要让事件循环转一圈的代价。
sliceMs 很重要。在单个切片运行期间,没有人能插进来,所以定下目标延迟,切片大小就随之而定。如果想在 50ms 内响应,单个切片就不能超过 50ms。
搬到另一个线程
创建 /root/work/worker/hash-worker.mjs(通过 parentPort 把结果送回去),并在 split.mjs 中导出 runInWorker(total)。它以 Promise 形式返回与 hashRange(0, total) 相同的值,并且同时调用两次也必须没问题。
Worker 不共享内存。需要的值用 workerData 传入,结果通过 postMessage 收回。
所以“请在 Worker 里修改当前请求的对象”是不行的。只是复制过去、再复制回来——能搬的事和不能搬的事,就在这里分开。
计算 Worker 的代价
测量 Worker 运行期间,把 samples、p50、p99、max、workMs、result 写入 runs.worker,并导出 spawnCostMs(),把它的值写入 worker.startupMs。
Worker 运行期间,这个线程的延迟几乎停留在 0——因为计算不在这里。取而代之,请看 startupMs。
如果启动开销比任务本身还大,搬过去就是亏的。所以真实的服务会预先启动几个 Worker 并复用——这个数字就是原因。
固化成规则
导出 place(job)。job.cpuMs 小于等于 1 则为 "loop",比它大且 job.sharedState 为真则为 "slice",否则在 cpuMs 大于等于 50 时为 "worker",小于 50 则为 "slice"。如果没有 cpuMs 或者不是数字,则抛出异常。
对没有数字的任务随便给一个答案,是最危险的。“没有测量”和“很轻”是不同的。
50 这个界线来自前面步骤中测得的启动开销。换一台机器会得到另一个值,那样的话,这个常数也要跟着变——规则是依附于测量的。