AI → Full-Stack

🌐 This lesson is available in Chinese only for now. English and German translations are planned for Week 46.

365 天全栈成长 · PHASE 1 · WEEK 1

Day 5 - worker_threads:分店太贵,能不能在店里加个操作台?

今日时长:3 小时(学习 50 min + 动手 100 min + 整理 30 min)· 主题:线程(Thread)与共享内存——同一家店里加人手,比开分店便宜,但共用一个冰箱就要讲规矩 · 练习语言:Node.js(它只是教具,"什么时候该并行、代价是什么"才是主角)

🧾 接上回:分店开起来了,但账单有点贵

Day 4 我们学会了开分店:cluster 起 4 个 worker 进程,把 4 颗 CPU 核都用上,一家店崩了其他三家照常营业。听起来完美。

但月底一算账,发现三件事:

🍜 餐厅版

你开了 4 家分店。结果:

  1. 每家店都要买一整套厨房设备。冰箱、灶台、碗柜,一样都不能少。4 家店 = 4 套设备钱。
  2. 总店想给分店送一份 200 页的菜谱,只能复印一份寄过去。不能说"你去我办公室柜子里拿",因为那是另一栋楼。
  3. 开一家新店要装修两周。今天中午客人多,你没法中午开一家、下午关掉。

翻译成技术语言:

餐厅现象技术真相数量级
每家店一套设备每个进程有独立的 V8 引擎 + 独立堆内存约 30–50 MB / 进程起步
菜谱只能复印寄送IPC 消息要序列化 → 传输 → 反序列化,是拷贝不是共享传 100 MB 就真的拷 100 MB
开店要装修两周fork 一个 Node 进程要重新启动整个运行时约 50–100 ms

所以问题变成:有没有一种办法,不开新店,而是在同一家店里加一个操作台?共用同一个冰箱,不用复印菜谱,开一个只要几十毫秒。

有。它叫 worker_threads

📇 概念卡 #1:worker_threads —— 店里的第二个操作台

概念卡:线程(Thread)

通用定义(不绑定任何语言):线程是操作系统调度执行的最小单位。同一个进程里的多个线程,共享这个进程的内存空间——同一块地址,谁都能读,谁都能写。

和进程的一句话区别:进程是"隔离资源"的单位,线程是"并行干活"的单位。
进程 = 另开一家店(独立厨房,着火不连坐,沟通靠寄信)。
线程 = 同一家店里加一个操作台(共用冰箱,拿东西直接伸手,但两个人同时抓同一块肉就要打架)。

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 就是"伸手前先喊一声"的规矩——保证一次读或写不会被另一个人插进来打断。

⚠️ 这就是"竞态条件"(race condition)

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 spawnworker 只能跑 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.jsC# / .NETJavaPythonGo
创建一个并行执行单元new Worker()Task.Run()new Thread() / ExecutorServicethreading.Threadgo func()
线程池(复用,别每次新建)自己写 worker pool
(没有内置)
内置 ThreadPool
(Task.Run 默认就用)
ExecutorServiceThreadPoolExecutorruntime 自动调度,
不用你管
共享内存的默认程度❌ 默认不共享
(每个 worker 独立堆)
✅ 默认全共享✅ 默认全共享✅ 共享,但有 GIL✅ 共享
防止竞态的工具Atomicslock / Interlockedsynchronized / Atomic*threading.Locksync.Mutex / channel
CPU 密集的推荐解法worker_threads直接开 Task
(线程本来就能跑满多核)
直接开线程⚠️ 线程没用(GIL)
要用 multiprocessing
直接 goroutine
两个值得记住的对照结论
  1. Node 和 Python 是"异类",但方向相反。Python 有 GIL,所以多线程跑不满多核,CPU 密集必须开多进程。Node 是压根不共享堆内存,所以要靠 postMessage / SharedArrayBuffer 显式交换。C#/Java/Go 才是"默认共享"的多数派。
  2. 默认共享更快,也更容易出错。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 打一次的"心跳"当探针,对比两种做法下心跳有没有断:

  1. 在主线程直接算 fib(40)
  2. 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,各测耗时:

  1. postMessage(buf) —— 拷贝
  2. postMessage(buf, [buf]) —— 转移
  3. 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 标记)。然后对比:

  1. 每个任务新建一个 worker(50 个任务 = 50 次开台)
  2. 用 4 个 worker 的池子跑完 50 个任务

要回答:

  • Q1:两种做法总耗时差多少?省下来的时间是什么时间?
  • Q2:池子大小从 4 改成 16(假设你的机器只有 8 核),更快还是更慢?为什么?
  • Q3:这个 worker pool 和数据库连接池,在思想上是同一件事吗?用三句话说清相同点。

默写题(约 10 min,写在 notes.md)

  1. 不看讲义,写出决策表里至少 5 行:什么场景该用 worker_threads,什么场景该用 cluster,什么场景该用 child_process,什么场景什么都不用。
  2. 用"餐厅"比喻解释 SharedArrayBufferAtomics 的关系,不超过 5 句话。
  3. 写一句话回答:为什么说 Node 的单线程模型"让你少了一整类 bug"?

✅ 总结与自测

今天带走的 5 句话

  1. worker_threads 不是 cluster 的升级版,是另一个方向。它省了内存和搬运成本(线程共享进程底座),代价是丢了进程隔离——一个 worker 把内存吃爆,整个进程一起完蛋。
  2. Node 的"多线程"不共享 JS 变量。每个 worker 有自己的 V8 isolate、自己的 event loop、自己的堆。唯一真共享的是 SharedArrayBuffer
  3. 传数据有三档:拷贝(默认,安全但和数据量成正比)、转移(零拷贝,原件作废)、共享(不用传,但要 Atomics 保证正确)。选错这一档,性能差几百倍。
  4. 铁律:worker 解决 CPU 问题,不解决 I/O 问题。而且任务耗时要远大于开台成本(10–40 ms),否则开 worker 是负优化。
  5. 高频任务一定要用 pool,不能一请求一 worker。"提前开好、排队复用"和数据库连接池是同一个思想,Week 5 会再见到它。

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

  • 能说出 worker_threads 和 cluster 的三个区别(启动成本、内存、崩溃影响),并说明各自适用场景
  • 能解释"每个 worker 有独立 V8 isolate"这句话的实际后果:主线程的变量 worker 改不到
  • 能说清拷贝 / 转移 / 共享三种传数据方式的差别,以及各自该用在什么地方
  • 能讲清什么是竞态条件(race condition),并用"两人同时开冰箱"解释 Atomics 在防什么
  • 能背出决策表至少 5 行,面对"这个功能卡住了要不要开 worker"能当场判断
  • 能说出"一请求一 worker"为什么是生产事故,以及 worker pool 怎么解决
  • 能用跨语言视角说明:为什么 Java/C#/Go 是"默认共享"的多数派,而 Node 和 Python 各自是不同方向的异类