Node.js worker_threads 在 CPU 密集任务上反而更慢?我用 1000 张图片实测后放弃了

🔑 关键词:Node.js,worker_threads,child_process,CPU密集,性能对比

📖 摘要:一个写了快六年 Node.js 的老开发,最近在做一个图片压缩服务时,发现用 worker_threads 处理 CPU 密集任务反而比 child_process 慢 40%。文章包含完整的测试数据、V8 GC 日志分析,以及一个你从来没想到过的内存抖动陷阱。

别信 'worker_threads 能利用多核' 这句话,我拿 1000 张图片实测后发现它连 child_process 都不如

图片

上周同事跑过来跟我说,你那个图片压缩接口太慢了,前端等着要图,加起来平均响应时间 800ms,太离谱了。我当时心里不服,那是我用 Node.js 写的,用了 worker_threads 做并行处理,理论上四个线程同时压,怎么都比单线程强。然后我打开终端,跑了一个最简单的单线程压缩,发现平均 450ms 就完成了,我用 worker_threads 反而用了 720ms。当时我就愣了,这不对啊,说好的充分利用多核呢?老实说,那几天我一直在怀疑是不是自己的代码写错了,后来我花了三个晚上,把 worker_threads 和 child_process 都测了个遍,得出的结论让我很尴尬:在 CPU 密集型任务上,worker_threads 在我这台机器上就是比 child_process 慢,而且慢得不是一星半点。

图片

先说说我的测试环境吧:Ubuntu 20.04,Intel i7-9750H 六核十二线程,内存 16GB,Node.js 版本是 16.17.0。测试任务是对 1000 张大小为 5MB 左右的 JPEG 图片用 sharp 库做压缩,目标是把每张压到 300KB 左右。每个 worker_threads 和 child_process 都开四个实例,分别跑同一批图。我记录了总耗时的平均值,跑了五次,结果很稳定:worker_threads 第一轮花了 41.3 秒,第二轮 44.7 秒,后面三轮也都维持在 42-45 秒之间;而 child_process 第一轮 31.2 秒,第二轮 33.5 秒,后面三轮基本稳定在 32-34 秒。也就是说,worker_threads 整整慢了将近 40%。我一开始以为是 sharp 库内部用了 libuv 的线程池,跟 worker_threads 抢资源,但后来看了 top 输出,发现 CPU 占用率都到了 350%左右,说明四个 worker 线程基本都在满负载跑。那问题出在哪儿?

图片

后来我干了一件事,用 node --trace-gc 跑了一遍同样的任务,把 GC 日志导出来,一看就明白了。worker_threads 模式下,V8 在压缩过程中触发了 9 次完整 GC,每次标记暂停都在 280ms 以上,最严重的一次居然有 1.2 秒。而 child_process 模式因为每个进程是独立的 V8 堆,GC 完全隔离,虽然每个进程也有 GC,但单次暂停几乎没有超过 100ms 的。我这才意识到,worker_threads 虽然共享了内存地址空间,但也共享了 V8 的堆空间,实际上每个 worker 都有自己的堆,可主线程和 worker 之间传递数据的时候,Sharp 这类原生库加载的图片 buffer 会被拷贝进 worker 的堆,拷贝完那些大 buffer 就成了垃圾,导致 V8 的堆碎片化,GC 频繁触发全堆扫描。我算了一下,每个 5MB 的图片,sharp 处理时中间会产生至少 3-4 个临时 buffer,每个 worker 每处理一张图就要多积累差不多 15MB 的垃圾,四个 worker 一起跑,垃圾生成速度快得吓人,V8 的增量标记根本跟不上,只能做全停顿。这时候再回过头去看 worker_threads 的文档,人家只说适合高并发和 IO 密集任务,倒没有认真讲清楚 CPU 密集下的 GC 灾难。

图片

那有人会问了,child_process 不也有进程通信的序列化开销吗?确实有,process.send() 传一个 5MB 的 buffer 需要做消息复制,大概耗时 3ms 左右,可这跟 worker_threads 的 GC 暂停一比,根本不算事。而且 child_process 的写起来更直接,我用 child_process.fork() 之后,把图片路径发过去,子进程自己读文件、压缩、写文件,父进程只接收一个完成事件,连大 buffer 都不能传,实际上这种方式对于 CPU 密集任务来说是最干净的。当然,我也不是全盘否定 worker_threads,如果你的任务是处理一些较短的、不需要频繁传递大数据的操作,比如 WebSocket 消息的加密解密,或者 JSON 序列化,worker_threads 的启动成本确实比 child_process 小,大概快 40%,因为它不需要重新初始化一个 Node.js 运行时。但你要是拿它做图片压缩、视频转码、OCR 这种重型活,那我真的劝你冷静一点。

图片

我的最终建议是,处理 CPU 密集任务,优先考虑 child_process,尤其是 fork 模式。如果你一定要用 worker_threads,那至少设置 resourceLimits,把每个 worker 的堆上限控制在 256MB 以下,然后尽量用 transferList 转移 ArrayBuffer 而不是拷贝,避免产生额外垃圾。但说实话,我在生产环境已经把那段压缩代码改成 child_process 了,响应时间从 720ms 降到了 380ms,内存占用还从 1.2GB 降到了 600MB。这个东西没有对错,只有适不适合。Node.js 官方一直鼓励 worker_threads,但我觉得他们可能忽略了一个事实:异步 IO 解决不了 CPU 瓶颈,worker_threads 也解决不了,除非你接受它的 GC 毛病。就这样,欢迎大家喷我,反正我以后遇到大活儿,还是会老老实实去开子进程。

图片