Day 4 - 进程模型与 child_process / cluster:服务员不够用时,餐厅怎么开分店?
🧱 接上回:服务员再快,也只有一个人 —— 单线程的三块天花板
Day 1–3 我们把这个服务员(event loop)调教得很完美了:从不站着等、水管背压都接了。但直到今天,他都有三个物理上限,再努力也突破不了:
- 只有一颗核。你的机器 8 核、16 核,Node 主进程只用一颗。其余 15 颗核在旁边嗑瓜子 —— 好比一个商场只有一家门店,走廊再宽也没用。
- CPU 硬菜卡全场。event loop 治得好"等"(I/O 等待),治不好"算"。来一个图片压缩、密码哈希、大报表计算,服务员亲自下厨 2 秒,全店客人晾 2 秒。
- 一崩全崩。服务员只有一个,他摔倒了(未捕获异常),整家店当场歇业,所有桌的菜全停。
📇 概念卡 #1:进程 vs 线程 —— 分店 vs 服务员
这是操作系统层面的通用概念,不是 Node 特有的。一张表对比清楚:
| 进程(Process)= 分店 | 线程(Thread)= 服务员 | |
|---|---|---|
| 内存 | 独立:每家分店有自己的厨房、自己的账本(独立堆内存),互相看不见 | 共享:同一店里的服务员共用厨房和账本(共享堆内存) |
| 沟通成本 | 贵:只能靠 IPC(对讲机)、管道、socket —— 发消息、序列化 | 便宜:直接读写共享变量 —— 但要加锁,否则抢同一本账会记乱 |
| 创建成本 | 重:几十 MB 内存起步,启动几十~几百 ms | 轻:几 MB,启动快一个数量级 |
| 崩溃影响 | 一家分店着火,别家照开(故障隔离) | 一个服务员打翻油锅,全店可能连坐(共享内存被写坏) |
| Node 里的形态 | child_process / cluster(今天) |
worker_threads(明天 Day 5) |
为什么 Node 的多核方案首选"开分店"而不是"多招服务员"?因为 Node 的看家本领 event loop 是单线程模型,整个生态(v8 的对象、所有 npm 包)都建立在"不用考虑锁"的前提上。开分店完全不破坏这个前提:每个进程还是一个无忧无虑的单线程 event loop,只是从一家店变成了连锁店。用进程隔离换心智简单,是 Node 的核心工程取舍。
📇 概念卡 #2:child_process 四兄弟 —— exec / execFile / spawn / fork
Node 开一个子进程有四个 API,长得像,脾气完全不同。继续用餐厅打比方:
| API | 餐厅比喻 | 输出方式 | 走 shell? | 适合场景 |
|---|---|---|---|---|
exec("cmd 字符串") |
外卖窗口:点完单站窗口等,菜齐了一次端走 | 全部攒进 buffer,回调里一次给 | 走(字符串交给 shell 解析) | 快速跑个小命令、输出就几行(如 git rev-parse HEAD) |
execFile("node", ["-e", ...]) |
同上,但不经过前台收银(shell),直接下单到厨房 | 同上,buffer 一次性 | 不走(参数数组,无注入风险) | 同 exec 但参数含用户输入时,更安全 |
spawn("cmd", ["arg"]) |
传菜传送带:菜出一盘端一盘,流水作业 | stream,来一块给一块(Day 3 的知识直接复用!) | 默认不走 | 输出量大/耗时长的命令:构建、日志处理、调用 ffmpeg |
fork("./worker.js") |
开分店:新店也是本品牌(跑的是 Node),且配了对讲机 | stream + IPC 通道(send / on message) | 不走 | CPU 密集任务外包、多进程服务(cluster 底层就是它) |
exec 的定时炸弹:maxBuffer
import { exec } from "node:child_process";
exec("node 打印6MB输出的脚本", (err, stdout) => {
// err: Error: stdout maxBuffer length exceeded ❌
});
exec 默认只给输出留 1MB 的托盘,超了直接报错杀进程。调大 maxBuffer 不是修复,只是把炸弹引线加长 —— 输出是不可控的外部输入,攒 buffer 的模式天生怕"量大"。
exec 的另一颗炸弹:shell 注入
// 文件名来自用户输入
exec(`convert ${userFile} out.png`); // 用户输入 "a.png; rm -rf ~" → 完蛋
execFile("convert", [userFile, "out.png"]); // 参数不经 shell 解析,只是一串普通字符 ✅
走 shell = 字符串里每个特殊字符都会被收银台(shell)当成指令解释;不走 shell = 参数原样传给程序,只是数据。规矩:参数里可能出现任何外部输入时,禁用字符串拼接的 exec。
📞 fork 与 IPC:分店和总店之间只有一部对讲机
fork 出来的子进程是一个完整的 Node(又一个 V8,自己的 event loop,自己的几十 MB 内存)。它比 spawn 多的只有一样东西:IPC 通道 —— 总店和分店之间的对讲机。
// 总店(主进程)
const child = fork("./ex2-heavy-task.ts", [], { stdio: ["inherit","inherit","inherit","ipc"] });
child.send({ n: 42 }); // 对讲机派单
child.on("message", (msg) => { ... }); // 听回话
// 分店(子进程 ex2-heavy-task.ts)
process.on("message", (msg) => {
const result = fib(msg.n); // 在自己的厨房里做硬菜
process.send?.({ result, pid: process.pid }); // 喊回去
});
三个要点:
- 消息是拷贝,不是共享。send 的对象会被序列化(类似 JSON 的结构化克隆),分店收到的是复印件。两家店之间不存在"共享变量" —— 这正是进程隔离的意义,想乱都乱不了。
- CPU 硬菜外包后,总店照常营业。习题 2 里你会亲眼看到:主进程自己算 fib(42) 时心跳停跳 1.5 秒;fork 出去算,心跳一下不停。
- 崩溃不连坐,但也没人兜底。分店炸了,主进程活着;主进程炸了,分店变孤儿。谁负责收尸、重启?—— 这就是下一节 cluster 要解决的问题。
🏬 cluster:同一个门牌号开 N 家分店
把"开分店 + 看店 + 收尸重启"这套管理动作标准化,就是 cluster 模块。它专为一种场景设计:同一个 HTTP 服务,起 N 份,吃满 N 颗核。
import cluster from "node:cluster";
import http from "node:http";
if (cluster.isPrimary) {
for (let i = 0; i < 4; i++) cluster.fork(); // 店长开 4 家分店
cluster.on("exit", () => cluster.fork()); // 倒一家,原地重开一家
} else {
http.createServer((req, res) => res.end(String(process.pid)))
.listen(3456); // 4 个进程 listen 同一端口,不报 EADDRINUSE!
}
角色分工
| 角色 | 餐厅对应 | 干什么 | 不干什么 |
|---|---|---|---|
| primary(主进程) | 店长 | fork 分店、接收新连接、round-robin 分给分店、分店死了重开 | 不接客 —— 不处理业务请求 |
| worker(子进程) | 分店 | 跑真正的业务代码,各自是独立的 event loop | 不管别的分店死活 |
同一个门牌号(端口)的魔法
4 个进程 listen(3456) 为什么不打架?因为真正持有监听 socket 的是店长(默认 round-robin 模式)。worker 里的 listen 只是向店长登记"我也要接单";新连接进来时由店长收下,再用 IPC 把连接句柄递给下一家分店。所以对外只有一个门牌号,对内是连锁店。
为什么店长不接客?
店长进程是整个连锁店的单点:它越闲,越不可能崩。让它处理业务 = 让最不能死的人干最容易死的活。稳定性来自"让调度者无所事事"。
🌍 跨语言对照(多进程思想 everywhere)
今天学的不是"Node 的 cluster API",是"单机吃满多核 + 故障隔离"这个通用命题。各语言的答案不同,但问题相同:
| 命题 | Node.js(教具) | C# / .NET | Java | Go |
|---|---|---|---|---|
| 单机吃满多核 | 多进程:cluster 起 N 份(N = 核数) |
多线程:线程池天然多核,一个 Kestrel 实例吃满 | 多线程:Tomcat/Netty 线程池,一个 JVM 实例吃满 | goroutine:runtime 自动调度到所有核 |
| CPU 密集外包 | fork 子进程 / worker_threads(Day 5) |
Task.Run 丢线程池 |
ForkJoinPool / 线程池 | 直接 go func() |
| 进程内共享内存 | 不共享(刻意设计,worker_threads 才可共享 ArrayBuffer) | 共享 + lock/Monitor | 共享 + synchronized/JUC | 共享但文化上劝你用 channel 传消息 |
| 崩一个请求 | 可能带走整个进程 → 靠多进程隔离兜底 | 异常被请求管道兜住,进程通常不死 | 同左 | panic 不 recover 会带走整个进程 |
| 水平扩副本(多机/多实例) | 所有语言殊途同归:无状态服务 + 负载均衡 + N 个副本(PM2 cluster / systemd / k8s replicas)。进程内状态必须外迁(Redis/DB)。 | |||
📌 面试翻译练习:面试官问 "Node 是单线程,怎么用满多核?" 你可以答:"单线程指的是一个进程的 JS 执行线程。生产上是 cluster/PM2 起 N 个进程绑定同一端口,由 primary 做连接分发;这套'多进程副本'的思路和 k8s 起 N 个 Pod 是同构的,只是一台机器还是多台机器的区别。代价是进程间不共享内存,所以状态必须无状态化外迁 —— 这本来就是水平扩容的要求,不算额外成本。"
🛠️ 今日习题(约 30 min)
习题代码在 src/04-process-model/,已写好可直接运行,带中文注释和预测填空。运行方式:npx tsx src/04-process-model/ex1-spawn-vs-exec.ts
习题 1:exec vs spawn —— 外卖窗口 vs 传送带(动手,约 8 min)
文件:ex1-spawn-vs-exec.ts
同一个打印 6MB 输出的命令,exec 直接触发 maxBuffer 爆炸,spawn 流式平稳接完。先写预测再运行。然后回答思考题:调大 maxBuffer 为什么不是修复?参数含用户输入时为什么 exec 字符串拼接是安全事故?
习题 2:fork + IPC —— 硬菜外包给分店(动手,约 10 min)
文件:ex2-fork-ipc.ts(分店伙计在 ex2-heavy-task.ts)
fib(42) 对照实验:主进程亲自算,心跳停跳 1.5 秒;fork 分店算,心跳一下不停。盯着"💓"的个数看,这就是"总店照常营业"的可视化。回答思考题:两家分店之间为什么没有"共享变量"?子进程崩了/主进程崩了各会怎样?
习题 3:cluster —— 一个门牌号开四家分店(动手,约 7 min)
文件:ex3-cluster-http.ts
脚本自导自演:开 4 家分店 → 打 8 个请求看 PID 轮流接单 → 枪毙一家分店,观察店长原地重开 → 再打 4 个,服务零中断。想一想:为什么请求里要关 keep-alive 才能看到轮流接单?(注释里有答案,理解了才算懂连接分发)
习题 4:概念卡默写(动笔不动键盘,约 5 min)
文件:notes.md(骨架已放好)
合上本文档,用中文回答三题(每题不超过 5 句话,必须用自己的话):
- 用"总店 / 分店 / 服务员"的比喻讲清:进程和线程的区别是什么?为什么 Node 要靠"多开分店"而不是"多招服务员"来利用多核?
- child_process 四兄弟各适合什么场景?为什么"输出可能很大"时必须选 spawn?
- cluster 共享同一个端口的魔法是怎么回事?店长为什么只管看店、不接客?
写完后回到第 2、3、5 节对答案,把说错的地方订正。
✅ 总结与自测
今天带走的 4 句话
- 单线程 event loop 有三块天花板:只用一颗核、CPU 硬菜卡全场、一崩全崩;Node 的解法是开分店(多进程),不是多招服务员。
- 进程是资源隔离的单位,线程是调度执行的单位:分店独立厨房独立账本、着火不连坐,代价是沟通只能靠对讲机(IPC 序列化消息)。
- child_process 四兄弟选择法:输出小 exec、外部输入 execFile、输出大 spawn、跑 Node 要对话 fork;"输出可能很大"一票否决 exec。
- cluster = 标准化连锁管理:店长(primary)持有端口只管分发和重启,分店(worker)接客;分店间不共享内存,状态必须外迁 —— 这和多机扩副本是同一个要求。
自测清单(能打勾才算今天过关)
- 能用"分店 vs 服务员"讲清进程和线程在内存、沟通、崩溃影响上的区别
- 能说出 Node 单线程的三块天花板,以及多进程如何逐一解决
- 能给 exec / execFile / spawn / fork 各举一个正确场景,并解释 maxBuffer 和 shell 注入两颗炸弹
- 能讲清 fork 的 IPC 是"拷贝不是共享",以及它如何让主进程在 CPU 硬菜面前不卡
- 能解释 cluster 同一端口不冲突的原理,和"店长不接客"的稳定性逻辑
- 能把 cluster 翻译成通用语言:无状态副本 + LB(k8s replicas),并说出与线程池模型的取舍