Node.js 如何利用多核 CPU?
简化版
Node 主线程执行 JS,单个进程通常只能利用一个 CPU 核心。要利用多核,可以用 cluster 多进程处理请求,或用 worker_threads 处理 CPU 密集任务。生产中也常用 PM2、容器副本或进程管理器扩展实例。
详细版
方案区别:
- cluster:多个 Node 进程共享端口,适合扩展 HTTP 服务吞吐。
- child_process:启动子进程执行独立任务。
- worker_threads:同进程多线程,适合 CPU 密集计算。
- 多实例部署:通过 PM2、Docker、K8s 横向扩展。
Node 擅长 I/O 密集任务,但 CPU 密集计算会阻塞事件循环,需要拆到 worker 或独立服务。
完整版教学
一、Node 单进程的问题
Node 的 JS 执行线程一次只能跑一个调用栈。即使机器有 8 核,一个 Node 进程默认也不会自动把 JS 计算分摊到 8 个核心。
所以高并发服务通常不只启动一个进程。
二、cluster 的用途
cluster 可以 fork 多个 worker 进程,由主进程分发连接。每个 worker 都是独立 Node 进程,有自己的事件循环和内存空间。
它适合 HTTP 服务横向扩展,但进程之间状态不共享。会话、缓存、限流计数等不能简单放内存里,需要 Redis、数据库或外部存储。
三、worker_threads 的用途
worker_threads 是线程级能力,适合 CPU 密集计算,如图片处理、加密计算、大 JSON 解析、复杂报表生成。
它和主线程可以通过消息通信,也可以使用 SharedArrayBuffer 做共享内存,但复杂度更高。生产中通常复用固定大小的 worker 池,因为每个任务都新建线程会重复支付启动和内存成本。
四、面试追问与工程落地
常见追问是“cluster 和 worker_threads 怎么选”。对外服务扩吞吐,选多进程/多实例;单次请求里有重 CPU 计算,选 worker_threads 或任务队列。不要用 cluster 解决单请求 CPU 阻塞问题。
工程中还要考虑进程守护、优雅重启、日志聚合、健康检查。多进程不是只 fork 出来就完事,异常退出和流量切换都要设计。
五、进程、线程与 I/O 线程池不是一回事
单个 Node isolate 的 JavaScript 调用栈由一个线程执行,但 Node 并非整个进程只有一个线程:libuv 线程池可承接部分文件、加密、压缩和 DNS 工作。worker_threads 创建新的 JS isolate 并行执行 JavaScript;cluster worker 则是独立进程,拥有独立堆和事件循环。
| 方案 | 内存隔离 | 通信 | 适合场景 |
|---|---|---|---|
| cluster/多进程 | 独立堆,故障隔离强 | IPC/外部存储 | HTTP 服务多核吞吐 |
| worker_threads | 同进程不同 isolate,可共享内存 | MessagePort/SharedArrayBuffer | CPU 密集 JavaScript |
| child_process | 独立进程,可运行其他程序 | stdio/IPC | 外部命令、隔离任务 |
| 多容器副本 | 进程与运行环境隔离 | 网络/共享服务 | 跨机器水平扩展 |
假设 8 核机器上单进程稳定处理 1000 QPS,启动 8 个进程不代表必然达到 8000 QPS;数据库、网络、负载不均和串行部分都会限制扩展。若 20% 工作无法并行,理想 8 核加速比按 Amdahl 思路也只有 1 / (0.2 + 0.8/8) ≈ 3.33。
六、Worker 池、数据传输与优雅重启
每个请求临时创建 Worker 通常不划算,线程启动和数据克隆可能超过小任务本身,应使用固定大小 Worker 池并设置队列上限。大 ArrayBuffer 可用 transfer list 转移所有权减少复制;SharedArrayBuffer 能共享内存,但需要 Atomics 和严格同步,复杂度显著上升。
cluster 能让多个 worker 共享服务端口,但应用内存并不共享;登录会话、幂等状态和限流计数要放外部系统。现代部署也常直接运行多个独立 Node 实例交给容器编排和负载均衡,不必为了多核强制使用 cluster API。
优雅重启要先摘除健康检查、停止接收新连接,再等待存量请求和队列完成,并设置例如 30 秒硬超时后退出。滚动期间至少保留健康实例,不能一次杀掉全部 worker。
SIGTERM → readiness=false → server.close() → 等待存量请求
→ 关闭数据库/队列连接 → 正常退出(超时则强制)
多核选型先问“我要扩请求副本,还是并行一段 CPU 计算”;前者偏进程/容器,后者偏 Worker 池。
选型与容量校验
落地时可按下面顺序判断:
- 先 profile,确认瓶颈确实是 JavaScript CPU,而不是数据库或网络等待。
- 单个请求内可并行的重计算,优先评估固定大小的 worker 池。
- 需要利用多核承载更多独立 HTTP 请求时,使用多进程或多实例。
- 状态放到 Redis、数据库等外部系统,避免依赖某个进程的本地内存。
- 对 worker 队列设置长度、超时和拒绝策略,防止计算任务无限堆积。
- 对进程实施摘流量、等待存量请求、超时退出和自动拉起。
例如 8 核机器不应机械创建数百个 CPU worker;可从接近核心数的池开始压测,并为事件循环、系统线程和其他进程保留余量。最终数量由吞吐、P99、CPU 饱和度和内存共同决定,而不是由核心数公式单独决定。
七、常见误区与追问
- 误区:Node 是单线程,所以文件和网络 I/O 都在主线程执行。 JS 调用栈单线程,I/O 可由内核或 libuv 线程池完成。
- 误区:cluster worker 之间可以直接共享普通对象。 它们是独立进程,普通堆内存完全隔离。
- 误区:每个 CPU 任务都新建一个 Worker 最简单高效。 启动与克隆有成本,高频任务应使用池化和有界队列。
- 追问:worker_threads 为什么不适合普通异步 I/O? Node 内置异步 I/O 已高效,额外线程和消息通信通常只增加成本。
- 追问:cluster 能解决单请求 500 ms CPU 阻塞吗? 只能让其他进程继续服务,该请求仍占一个 worker;应拆到 Worker 池或独立计算服务。
- 追问:SharedArrayBuffer 是否总比 postMessage 快? 不一定,它省复制但引入同步和竞争,必须按数据量实测。
- 追问:多进程后内存缓存有什么问题? 每个进程各一份,命中和失效不一致,容量也按进程倍增。
八、加强记忆
Node 利用多核有两条线:服务吞吐靠多进程,CPU 计算靠 worker。I/O 并发是 Node 强项,CPU 长任务要移出主事件循环。两种方案都要限制队列、外置共享状态并设计故障恢复,最终并发数由压测而不是核心数口诀决定。