AI → Full-Stack
365 天全栈成长 · PHASE 1 · WEEK 1

Day 4 - 进程模型与 child_process / cluster:服务员不够用时,餐厅怎么开分店?

今日时长:3 小时(学习 50 min + 动手 100 min + 整理 30 min)· 主题:多进程(Multi-process)——一个服务员再快也只有一颗 CPU 核,想吃饱多核红利就得开分店 · 练习语言:Node.js(它只是教具,"进程隔离 + 水平扩副本"这套思想才是主角)

🧱 接上回:服务员再快,也只有一个人 —— 单线程的三块天花板

Day 1–3 我们把这个服务员(event loop)调教得很完美了:从不站着等、水管背压都接了。但直到今天,他都有三个物理上限,再努力也突破不了:

  1. 只有一颗核。你的机器 8 核、16 核,Node 主进程只用一颗。其余 15 颗核在旁边嗑瓜子 —— 好比一个商场只有一家门店,走廊再宽也没用。
  2. CPU 硬菜卡全场。event loop 治得好"等"(I/O 等待),治不好"算"。来一个图片压缩、密码哈希、大报表计算,服务员亲自下厨 2 秒,全店客人晾 2 秒。
  3. 一崩全崩。服务员只有一个,他摔倒了(未捕获异常),整家店当场歇业,所有桌的菜全停。
💡 生活翻译: 生意火了,一个服务员接待能力到顶了怎么办?思路一:给店里多招服务员(线程,共享厨房、共享账本);思路二:开分店(进程)——独立的店面、独立的厨房、独立的账本,一家着火不烧别家。今天学的全是思路二:Node 世界里怎么开分店、怎么管分店。
📇 先记住一句话: 进程是资源隔离的单位,线程是调度执行的单位。今天所有 API 都是在回答三个问题:怎么开分店(child_process)?分店之间怎么说话(IPC)?怎么让 N 家分店共用一个门牌号(cluster)?

📇 概念卡 #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 的核心工程取舍。

⚠️ 别记歪了: "开分店"不是 Node 独家发明。Java 的 Tomcat 单实例吃满多核靠的是多线程;但 Java 应用部署时照样水平扩多个 JVM 实例 —— 因为隔离和扩容是两个维度的问题,所有语言最后都要回答"单机怎么用满多核"和"多机怎么扩副本"这两题。第 6 节有完整对照。

📇 概念卡 #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。

📇 选择题秒答法: 输出小 + 信任输入 → exec;输出小 + 外部输入 → execFile;输出大/流式 → spawn;跑的是 Node 且要对话 → fork。"输出可能很大"这一条一票否决 exec/execFile。

📞 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 }); // 喊回去
});

三个要点:

  1. 消息是拷贝,不是共享。send 的对象会被序列化(类似 JSON 的结构化克隆),分店收到的是复印件。两家店之间不存在"共享变量" —— 这正是进程隔离的意义,想乱都乱不了。
  2. CPU 硬菜外包后,总店照常营业。习题 2 里你会亲眼看到:主进程自己算 fib(42) 时心跳停跳 1.5 秒;fork 出去算,心跳一下不停。
  3. 崩溃不连坐,但也没人兜底。分店炸了,主进程活着;主进程炸了,分店变孤儿。谁负责收尸、重启?—— 这就是下一节 cluster 要解决的问题。
💡 生活翻译: 总店接待客人(event loop 接请求),来了道硬菜(图片压缩)就打电话让分店做,自己接着招呼下一桌;分店做好打电话回来,总店端上桌。总店服务员从头到尾没进过厨房。

🏬 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 把连接句柄递给下一家分店。所以对外只有一个门牌号,对内是连锁店。

为什么店长不接客?

店长进程是整个连锁店的单点:它越闲,越不可能崩。让它处理业务 = 让最不能死的人干最容易死的活。稳定性来自"让调度者无所事事"。

⚠️ cluster 的两条边界,面试常考:分店之间不共享内存 —— 4 个 worker 各有 4 份缓存、4 份内存态。session、计数器、限流这类状态放内存里会直接出错,得外迁到 Redis。② cluster 只解决单机多核,不解决多机;生产上"进程管理"这活通常交给更专业的 PM2 / systemd / k8s,cluster 重在帮你理解原理。

🌍 跨语言对照(多进程思想 everywhere)

今天学的不是"Node 的 cluster API",是"单机吃满多核 + 故障隔离"这个通用命题。各语言的答案不同,但问题相同:

命题Node.js(教具)C# / .NETJavaGo
单机吃满多核 多进程: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 答"多核"用多进程(隔离换简单),.NET/Java 用多线程(共享换效率,用锁管复杂度),Go 用轻量协程(runtime 调度,channel 代替锁)。三者没有高下,是对"共享 vs 隔离"这对矛盾的不同取舍。而到了"多机扩副本"这一层,大家都长一个样:无状态 + 外迁状态 + LB。

📌 面试翻译练习:面试官问 "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 句话,必须用自己的话):

  1. 用"总店 / 分店 / 服务员"的比喻讲清:进程和线程的区别是什么?为什么 Node 要靠"多开分店"而不是"多招服务员"来利用多核?
  2. child_process 四兄弟各适合什么场景?为什么"输出可能很大"时必须选 spawn?
  3. cluster 共享同一个端口的魔法是怎么回事?店长为什么只管看店、不接客?

写完后回到第 2、3、5 节对答案,把说错的地方订正。

✅ 总结与自测

今天带走的 4 句话

  1. 单线程 event loop 有三块天花板:只用一颗核、CPU 硬菜卡全场、一崩全崩;Node 的解法是开分店(多进程),不是多招服务员。
  2. 进程是资源隔离的单位,线程是调度执行的单位:分店独立厨房独立账本、着火不连坐,代价是沟通只能靠对讲机(IPC 序列化消息)。
  3. child_process 四兄弟选择法:输出小 exec、外部输入 execFile、输出大 spawn、跑 Node 要对话 fork;"输出可能很大"一票否决 exec。
  4. cluster = 标准化连锁管理:店长(primary)持有端口只管分发和重启,分店(worker)接客;分店间不共享内存,状态必须外迁 —— 这和多机扩副本是同一个要求。

自测清单(能打勾才算今天过关)

  • 能用"分店 vs 服务员"讲清进程和线程在内存、沟通、崩溃影响上的区别
  • 能说出 Node 单线程的三块天花板,以及多进程如何逐一解决
  • 能给 exec / execFile / spawn / fork 各举一个正确场景,并解释 maxBuffer 和 shell 注入两颗炸弹
  • 能讲清 fork 的 IPC 是"拷贝不是共享",以及它如何让主进程在 CPU 硬菜面前不卡
  • 能解释 cluster 同一端口不冲突的原理,和"店长不接客"的稳定性逻辑
  • 能把 cluster 翻译成通用语言:无状态副本 + LB(k8s replicas),并说出与线程池模型的取舍