Day 5 - worker_threads:分店太贵,能不能在店里加个操作台?
🧾 接上回:分店开起来了,但账单有点贵
Day 4 我们学会了开分店:cluster 起 4 个 worker 进程,把 4 颗 CPU 核都用上,一家店崩了其他三家照常营业。听起来完美。
但月底一算账,发现三件事:
你开了 4 家分店。结果:
- 每家店都要买一整套厨房设备。冰箱、灶台、碗柜,一样都不能少。4 家店 = 4 套设备钱。
- 总店想给分店送一份 200 页的菜谱,只能复印一份寄过去。不能说"你去我办公室柜子里拿",因为那是另一栋楼。
- 开一家新店要装修两周。今天中午客人多,你没法中午开一家、下午关掉。
翻译成技术语言:
| 餐厅现象 | 技术真相 | 数量级 |
|---|---|---|
| 每家店一套设备 | 每个进程有独立的 V8 引擎 + 独立堆内存 | 约 30–50 MB / 进程起步 |
| 菜谱只能复印寄送 | IPC 消息要序列化 → 传输 → 反序列化,是拷贝不是共享 | 传 100 MB 就真的拷 100 MB |
| 开店要装修两周 | fork 一个 Node 进程要重新启动整个运行时 | 约 50–100 ms |
所以问题变成:有没有一种办法,不开新店,而是在同一家店里加一个操作台?共用同一个冰箱,不用复印菜谱,开一个只要几十毫秒。
有。它叫 worker_threads。
📇 概念卡 #1:worker_threads —— 店里的第二个操作台
通用定义(不绑定任何语言):线程是操作系统调度执行的最小单位。同一个进程里的多个线程,共享这个进程的内存空间——同一块地址,谁都能读,谁都能写。
和进程的一句话区别:进程是"隔离资源"的单位,线程是"并行干活"的单位。
进程 = 另开一家店(独立厨房,着火不连坐,沟通靠寄信)。
线程 = 同一家店里加一个操作台(共用冰箱,拿东西直接伸手,但两个人同时抓同一块肉就要打架)。
Node 里的 worker_threads,和你想的不太一样
很多人第一次听到"Node 有多线程了",以为终于可以像 Java 那样几个线程一起跑同一段 JS。不是的。这里有一个必须搞清楚的真相:
每个 worker 线程都有自己独立的 V8 实例(isolate)、自己独立的 event loop、自己独立的 JS 堆。
也就是说:你的 JS 变量并不共享。主线程里 let count = 0,worker 里改不到它。
唯一真正共享的,是一块特殊的原始内存:SharedArrayBuffer。别的都是拷贝。
加的这个操作台,有自己的一套刀具、自己的砧板、自己的备料盒(独立 V8 + 独立 event loop + 独立堆)。
它和主台在同一个房间里,所以:开台快(不用装修一栋楼)、房租便宜(共用水电)。
但两个台子之间递东西,默认还是各做各的、装盘递过去(postMessage 拷贝)。
只有一样东西是真共享的:房间中央那台冰箱(SharedArrayBuffer)。两个人都能直接伸手拿——也正因如此,伸手的时候要讲规矩。
那它到底省了什么?
| 对比项 | child_process / cluster(进程) | worker_threads(线程) |
|---|---|---|
| 启动成本 | 约 50–100 ms(重启整个运行时) | 约 10–40 ms |
| 内存开销 | 约 30–50 MB 起 | 约 5–10 MB 起(共用进程的底座) |
| 传大数据 | 只能拷贝(IPC 序列化) | 可拷贝、可零拷贝转移、可真共享 |
| 崩溃影响 | ✅ 隔离,一个挂了不影响别人 | ⚠️ 同一进程,内存耗尽会一起完蛋 |
| 能跑非 Node 程序吗 | ✅ 能(ffmpeg、python 脚本都行) | ❌ 不能,只能跑 JS/TS |
看最后两行:worker_threads 不是 cluster 的升级版,是它的另一个方向。省了钱和搬运成本,代价是丢了隔离性。这就是今天要建立的判断力。
最小可运行例子
// main.ts —— 主线程
import { Worker } from "node:worker_threads";
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
workerData: { n: 40 }, // 开台时递进去的初始材料(拷贝一份)
});
worker.on("message", (result) => { // 操作台做完了,喊一声
console.log("结果:", result);
worker.terminate(); // 用完关火,不然进程不退出
});
worker.on("error", (err) => console.error("worker 出错:", err));
// worker.ts —— 操作台
import { parentPort, workerData } from "node:worker_threads";
function fib(n: number): number {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
parentPort!.postMessage(fib(workerData.n)); // 做完装盘递回去
Node 24 小福利:worker 文件可以直接写 .ts,不用先编译。Node 会自己剥掉类型。今天的习题就是这么跑的。
🔀 三种传菜方式:拷贝 / 转移 / 共享
这一节是 worker_threads 里最容易被跳过、但面试最爱问的地方。两个线程之间传数据,有三种方式,性能差几百倍。
方式一:拷贝(structured clone)—— 默认行为
worker.postMessage(bigArray); // 默认:完整复印一份
主台把 200 页菜谱复印一份递给操作台。复印要时间、要纸。复印完你们各有一份,你改你的,我改我的,互不影响。
传 200 MB 就真的复制 200 MB。安全,但慢。大多数场景够用,因为你通常只传一个几 KB 的参数。
方式二:转移(transfer)—— 零拷贝,但原件作废
const buf = new ArrayBuffer(200 * 1024 * 1024);
worker.postMessage(buf, [buf]); // 第二个参数:转移清单
// 这行之后,主线程里的 buf.byteLength === 0,它已经不属于你了
不复印了,直接把那本菜谱推过去。快得多(几乎不花时间)。代价是:推过去之后你手上就没有了。
技术上叫"所有权转移"。内存还在原地没动,只是换了个主人。只有 ArrayBuffer 这类原始内存才能转移,普通对象不行。
方式三:共享(SharedArrayBuffer)—— 真正的同一块内存
const shared = new SharedArrayBuffer(1024);
const view = new Int32Array(shared);
worker.postMessage(shared); // 不需要转移清单,双方都能用
// 主线程和 worker 都在操作同一块内存
Atomics.add(view, 0, 1); // 安全地加 1
Atomics.load(view, 0); // 安全地读
房间中央那台冰箱。不用递,两个人随时伸手拿。
但麻烦来了:你正拿肉,我也伸手抓同一块;或者你数到"还剩 3 块"的同时我拿走了 1 块,你算出来的数就是错的。
Atomics 就是"伸手前先喊一声"的规矩——保证一次读或写不会被另一个人插进来打断。
view[0] = view[0] + 1 看起来是一行,实际是三步:读 → 加 → 写回。两个线程同时做,可能都读到 5,都算出 6,都写回 6。加了两次,结果只加了 1。
这类 bug 的可怕之处:它不是每次都发生。你本地跑一万次都对,上线高峰期错一次。
这是所有共享内存并发模型的共同代价。Java、C#、Go 一模一样。而 Node 的单线程模型之所以让人省心,正是因为默认没有这个问题。
| 方式 | 速度 | 安全性 | 什么时候用 |
|---|---|---|---|
| 拷贝(默认) | 慢(和数据量成正比) | ✅ 最安全,各改各的 | 90% 的场景。参数小就别想太多 |
| 转移(transfer) | ⚡ 极快(常数时间) | ✅ 安全(原件作废,不会两人同时改) | 传大块二进制:图片、音视频、文件内容 |
| 共享(SharedArrayBuffer) | ⚡ 极快(不用传) | ⚠️ 要自己用 Atomics 保证正确 | 多个线程要持续读写同一份状态,比如计数器、进度条 |
⚖️ 今天最重要的一节:什么时候该用,什么时候别用
前面都是"怎么用"。这一节是"该不该用"。面试问 worker_threads,九成是在问这个。
先记住一条铁律
worker_threads 解决的是 CPU 问题,不是 I/O 问题。
等数据库、等 HTTP、等文件读取——这些 Day 1 就讲过了,event loop 已经处理得很好,主线程根本没在等。给它们开 worker,不但没用,还更慢。
决策表:照着查就行
| 你的场景 | 该用什么 | 为什么 |
|---|---|---|
| 等数据库 / 调外部 API / 读文件 | 什么都不用,async/await 就够 | I/O 本来就不占主线程,开 worker 是白花钱 |
| CPU 计算 < 10 ms | 直接算 | 开 worker 要 10–40 ms,比算本身还贵 |
| CPU 计算 100 ms 以上,偶尔发生 | worker_threads | 典型场景:生成 PDF、压缩图片、解析大 JSON、加密 |
| CPU 计算,高频持续 | worker pool(线程池) | 提前开好几个台子重复用,省掉每次的开台成本 |
| 想吃满多核来接更多 HTTP 请求 | cluster(多进程) | 要的是"多个完整的服务副本",不是"一个算力外包" |
| 要跑用户上传的代码 / 可能段错误崩溃 | child_process(进程隔离) | worker 崩了会拖累同进程的所有人,进程崩了不会 |
| 要跑 ffmpeg / python / 任何非 Node 程序 | child_process spawn | worker 只能跑 JS |
- 等外卖送到(I/O)→ 站着等是傻的,但你本来就没站着等 → 什么都不用做
- 切一根葱(小 CPU 任务)→ 顺手就切了,喊人来切反而更慢
- 剁一小时肉馅(大 CPU 任务)→ 开个操作台专门剁,前台照常接客
- 每桌都要剁肉馅(高频 CPU)→ 常设三个操作台轮流用,别每次现搭
- 客人太多接不过来(吞吐不够)→ 这是要开分店(cluster),不是加操作台
- 要试一道可能炸厨房的新菜(不可信代码)→ 去另一栋楼试(child_process)
💣 三个坑:创建成本、一请求一 worker、忘了关火
坑 1:以为 worker 是免费的
开一个 worker 要 10–40 ms,还要占 5–10 MB。如果你的任务只要 5 ms,开 worker 让它慢了 5 倍。
判断方法 先测。任务耗时至少要是"开台成本"的 10 倍以上,才值得开。
坑 2:每来一个请求就开一个 worker(最常见的生产事故)
// ❌ 千万别这么写
app.post("/pdf", async (req, res) => {
const worker = new Worker("./pdf-worker.ts"); // 每个请求开一个
// 100 个请求同时进来 = 100 个线程 = 内存爆炸 + 疯狂上下文切换
});
正确做法:worker pool。启动时开固定几个(通常等于 CPU 核数),排队复用。这和数据库连接池是同一个思想——Week 5 学连接池时你会发现是同一套话术。
// ✅ 提前开好,重复用
const pool = new WorkerPool("./pdf-worker.ts", os.availableParallelism());
app.post("/pdf", async (req, res) => {
const pdf = await pool.run(req.body); // 排队,轮到你就有台子用
res.send(pdf);
});
今天的习题 3 就是手写一个最小 worker pool。
坑 3:忘了 terminate,进程不退出
只要还有活着的 worker,Node 进程就不会退出。脚本跑完卡住不动,九成是这个原因。
worker.on("message", (r) => {
console.log(r);
worker.terminate(); // ← 用完关火
});
长期运行的服务里则相反:worker pool 里的 worker 应该一直活着,别每次关。
🌍 跨语言对照:这套取舍在哪儿都一样
今天学的不是"Node 的 worker_threads API",是"共享内存并发"这个模型的代价与收益。换任何语言,这套判断都成立。
| 通用概念 | Node.js | C# / .NET | Java | Python | Go |
|---|---|---|---|---|---|
| 创建一个并行执行单元 | new Worker() | Task.Run() | new Thread() / ExecutorService | threading.Thread | go func() |
| 线程池(复用,别每次新建) | 自己写 worker pool (没有内置) | 内置 ThreadPool ( Task.Run 默认就用) | ExecutorService | ThreadPoolExecutor | runtime 自动调度, 不用你管 |
| 共享内存的默认程度 | ❌ 默认不共享 (每个 worker 独立堆) | ✅ 默认全共享 | ✅ 默认全共享 | ✅ 共享,但有 GIL | ✅ 共享 |
| 防止竞态的工具 | Atomics | lock / Interlocked | synchronized / Atomic* | threading.Lock | sync.Mutex / channel |
| CPU 密集的推荐解法 | worker_threads | 直接开 Task (线程本来就能跑满多核) | 直接开线程 | ⚠️ 线程没用(GIL) 要用 multiprocessing | 直接 goroutine |
- Node 和 Python 是"异类",但方向相反。Python 有 GIL,所以多线程跑不满多核,CPU 密集必须开多进程。Node 是压根不共享堆内存,所以要靠 postMessage / SharedArrayBuffer 显式交换。C#/Java/Go 才是"默认共享"的多数派。
- 默认共享更快,也更容易出错。Java/C# 程序员天天在想"这个变量要不要加锁";Node 程序员基本不用想,因为默认没得共享。这是 Node 单线程模型最被低估的好处——你少了一整类 bug。
👉 学完记得去 roadmap.md 顶部的对照表,在"运行时与并发模型"那一行补一句你自己的话。
🛠️ 今日习题(约 100 min)
三道题,代码全部写在 src/05-worker-threads/。每道题都要跑出数字,不要只看代码。骨架已经建好,你只需要补关键部分。
习题 1(约 30 min):证明 worker 真的救了主线程
文件:ex1-block-vs-worker.ts + ex1-worker.ts
做什么:用一个每 100 ms 打一次的"心跳"当探针,对比两种做法下心跳有没有断:
- 在主线程直接算
fib(40) - 把
fib(40)丢给 worker 算
要回答:
- Q1:第一种做法,心跳停了多久?这段时间里如果有 HTTP 请求进来会怎样?
- Q2:第二种做法,心跳有没有断?为什么?
- Q3:把
fib(40)改成fib(25)(约几毫秒),再跑一次 worker 版。总耗时变长了还是变短了?解释这个结果。
习题 2(约 35 min):拷贝 vs 转移 vs 共享,量出差距
文件:ex2-transfer-modes.ts + ex2-worker.ts
做什么:造一个 200 MB 的 ArrayBuffer,用三种方式发给 worker,各测耗时:
postMessage(buf)—— 拷贝postMessage(buf, [buf])—— 转移SharedArrayBuffer—— 共享
要回答:
- Q1:三者耗时大概差几倍?
- Q2:转移之后,主线程里那个 buffer 的
byteLength是多少?为什么? - Q3:代码里有一段"两个线程各加 100000 次"的实验,一个用
view[0]++,一个用Atomics.add。先猜结果,再运行。猜错的话,写下你原本以为会发生什么。
习题 3(约 35 min):手写最小 worker pool
文件:ex3-worker-pool.ts + ex3-worker.ts
做什么:骨架里已经写好了任务队列和结果收集。你要补的是最核心的调度逻辑(约 10 行,文件里有 TODO 标记)。然后对比:
- 每个任务新建一个 worker(50 个任务 = 50 次开台)
- 用 4 个 worker 的池子跑完 50 个任务
要回答:
- Q1:两种做法总耗时差多少?省下来的时间是什么时间?
- Q2:池子大小从 4 改成 16(假设你的机器只有 8 核),更快还是更慢?为什么?
- Q3:这个 worker pool 和数据库连接池,在思想上是同一件事吗?用三句话说清相同点。
默写题(约 10 min,写在 notes.md)
- 不看讲义,写出决策表里至少 5 行:什么场景该用 worker_threads,什么场景该用 cluster,什么场景该用 child_process,什么场景什么都不用。
- 用"餐厅"比喻解释
SharedArrayBuffer和Atomics的关系,不超过 5 句话。 - 写一句话回答:为什么说 Node 的单线程模型"让你少了一整类 bug"?
✅ 总结与自测
今天带走的 5 句话
- worker_threads 不是 cluster 的升级版,是另一个方向。它省了内存和搬运成本(线程共享进程底座),代价是丢了进程隔离——一个 worker 把内存吃爆,整个进程一起完蛋。
- Node 的"多线程"不共享 JS 变量。每个 worker 有自己的 V8 isolate、自己的 event loop、自己的堆。唯一真共享的是
SharedArrayBuffer。 - 传数据有三档:拷贝(默认,安全但和数据量成正比)、转移(零拷贝,原件作废)、共享(不用传,但要 Atomics 保证正确)。选错这一档,性能差几百倍。
- 铁律:worker 解决 CPU 问题,不解决 I/O 问题。而且任务耗时要远大于开台成本(10–40 ms),否则开 worker 是负优化。
- 高频任务一定要用 pool,不能一请求一 worker。"提前开好、排队复用"和数据库连接池是同一个思想,Week 5 会再见到它。
自测清单(能打勾才算今天过关)
- 能说出 worker_threads 和 cluster 的三个区别(启动成本、内存、崩溃影响),并说明各自适用场景
- 能解释"每个 worker 有独立 V8 isolate"这句话的实际后果:主线程的变量 worker 改不到
- 能说清拷贝 / 转移 / 共享三种传数据方式的差别,以及各自该用在什么地方
- 能讲清什么是竞态条件(race condition),并用"两人同时开冰箱"解释
Atomics在防什么 - 能背出决策表至少 5 行,面对"这个功能卡住了要不要开 worker"能当场判断
- 能说出"一请求一 worker"为什么是生产事故,以及 worker pool 怎么解决
- 能用跨语言视角说明:为什么 Java/C#/Go 是"默认共享"的多数派,而 Node 和 Python 各自是不同方向的异类