故事前提:一家店不够用了
还记得那个只有一个老板 的小吃店吗?上一篇事件循环博客
他靠事件循环这个工作流程,一个人处理多个客人的单子,已经很强了。但问题是:
- 客人越来越多,老板炒菜再快,也架不住一个 CPU 核心跑满了。
- 隔壁新来的客人探头一看:"排队这么长?"扭头就走了。
- 更可怕的是,万一老板累倒了(进程崩溃),整个店直接关门。
这时老板一拍大腿:"我得开分店!"
一、Cluster 就是开连锁分店
cluster 模块允许你从一个 Node.js 进程,派生出多个子进程(Worker) 。每个 Worker 都是一个独立的小吃店分店,有自己完整的:
- 事件循环(老板工作流程)
- 内存空间(自己的灶台、冰箱、菜谱)
- V8 实例(自己的脑子和经验)
总店经理(Master 进程) 不亲自炒菜,只负责:
- 开分店(fork Worker)
- 监视分店的状态
- 给所有分店共享同一个门牌号(同一个端口)
客人来的时候,总店经理就把客人分派给某个分店去服务。
代码一瞥:开 4 家分店
javascript
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
if (cluster.isMaster) {
// 我是总店经理
console.log(`总店经理启动了,准备开 ${numCPUs} 家分店`);
// 开分店,一家店对应一个 CPU 核心
for (let i = 0; i < numCPUs; i++) {
cluster.fork(); // 开分店!
}
cluster.on('exit', (worker, code, signal) => {
console.log(`分店 ${worker.process.pid} 倒闭了,赶紧再开一家`);
cluster.fork(); // 自动补开一家
});
} else {
// 我是一家分店的老板(Worker 进程)
http.createServer((req, res) => {
res.writeHead(200);
res.end(`分店 ${process.pid} 为您服务\n`);
}).listen(8000); // 所有分店共享 8000 号门牌
}
运行后,4 家分店都开始营业,同一个端口 8000,却不会冲突。为什么?
二、共享端口:总店接待处和"抢铃铛"
这得靠总店经理玩的一个把戏。
- 传统情况:一个门牌号(端口)只能挂在一个店铺门口,其他店再挂就会冲突。
- 在 cluster 里,总店经理替所有分店监听这个端口 ,相当于在总店门口设了个接待处。
- 当有客人来(新 TCP 连接),经理把客人带到走廊,然后按门铃,哪个分店的厨师先探出头,客人就归谁。
这个门铃机制,操作系统内部叫文件描述符传递 或SO_REUSEPORT(根据系统不同,具体实现有"调度器分配"或"竞争唤醒")。本质就是把竞争交给内核或 Node,让所有分店都能公平地抢到客人。
比喻 :分店的一排厨房都设在总店后面,每个厨房有条走廊通到前面,经理把客人领到走廊口,各分店厨师听到脚步声就冲出来抢,谁先接到谁服务。由于每个厨师(Worker)运行在独立的 CPU 核心上,真正的并行处理就实现了。
三、进程间通信:总店和分店的对讲机
分店之间是完全独立的,灶台、账本都不能直接碰别人的。如果总店想通知所有分店"今天特价菜改了",或者分店想汇报"我这来了个大客户",怎么办?
用对讲机(IPC 通道)。
- Master 和每个 Worker 之间都有一个内置的 IPC(Inter-Process Communication)通道,Node.js 封装好了,用
worker.send()和process.on('message')就能沟通。 - 分店之间不能直接通话,必须经过总店转达(或者用 Redis/数据库等外部方式)。
javascript
// 在 master 里
const worker = cluster.fork();
worker.send({ special: '今天佛跳墙半价' });
// 在 worker 里
process.on('message', (msg) => {
console.log(`分店收到总店消息:${msg.special}`);
// 可以回话
process.send({ received: true });
});
四、负载均衡:客人怎么分配到分店?
Node.js 的 cluster 默认有两种调度方式,不过现在(Node 16+)大部分系统默认是轮询(Round-Robin)。
- 轮询(Round-Robin):接待处把客人排成一队,然后按顺序发:"1 号店,接客!""2 号店,接客!"... 循环往复,确保每家店客人数相当。
- 抢占式:前面说的"走廊抢铃铛",哪个分店厨师手快谁就抢到。这种方式可能造成某个厨师特别勤快,累死自己,饿死别人。
默认轮询的好处:均衡,防止一个 CPU 吃撑另一个 CPU 闲着。
五、容灾:分店厨师累倒了怎么办?
cluster 有一个超能力:零停机重启(Zero-downtime restart)和故障自动恢复。
- Master 通过
cluster.on('exit')监听 Worker 的崩溃。如果某个分店厨师累倒了(比如内存泄漏挂了),Master 立刻再fork()一个,新分店补上。 - 如果想平滑重启(比如更新代码),你可以:
- 开一家新分店(新代码)。
- 让旧分店不再接新客人(process.disconnect),把手头的客人服务完。
- 旧分店关门,全部客人无缝切换到新分店。
生意不停,客人无感。
六、什么时候该开分店?
如果你的小吃店是:
- CPU 密集型:比如处理大图片、复杂计算、加密解密,单核跑满会阻塞事件循环,必须开分店来分摊。
- 高并发 HTTP 服务:单进程单线程顶多一个核心,最大并发受限于事件循环和 CPU,开分店能线性提升吞吐量。
- 单纯 I/O 密集型(比如数据库读写、代理转发):一个进程的事件循环往往已经够用,开分店可能没必要,不过为了利用多核和容灾也会开。
总结一句话
Cluster 多进程就是开连锁小吃店:总店经理共享一个门牌号,通过接待处分配客人,分店厨师独立炒菜,用对讲机互通消息,谁累倒了立马再开一家,生意永远不打烊。
相信你会有以下疑问:
1. Cluster 多进程模式:为什么几乎没有死锁?
在 Cluster 模式下,死锁几乎不是问题。还记得吗?每个分店有完全独立的灶台、冰箱和账本(独立内存)。
- 死锁是什么? 两个厨师,一个手里拿着盐,一个手里拿着糖,两人都等着对方先放下手里的东西,好让自己拿另一味调料,结果菜全糊了------这就是死锁。
- 分店模式怎么避免的? 各分店之间根本不共享灶台和调料 。分店 A 的厨师用自己的盐和糖,分店 B 的厨师也用他自己的。他们唯一共享的是总店那个门牌号(端口),而客人分发是由操作系统内核(接待员)完成的,不涉及厨师之间的资源争夺。
唯一需要沟通时,用的是对讲机 (IPC)。对讲机通信是异步的,老板说"通知下去,今天特价菜改了",说完就继续干活,不用等任何分店厨师回复"收到"。这种"发后即忘"(fire-and-forget)的模式,从根本上杜绝了两个进程互相等待对方释放资源的死锁可能。
结论:在 Cluster 多进程模式下,死锁问题几乎可以忽略不计。
2. Worker Threads 多线程模式:死锁被关进了笼子
这才是死锁可能潜入的地方。因为学徒和老板共享了同一块"小白板"(SharedArrayBuffer),两个人如果同时上去写字,就可能冲突。
但 Node.js 给小白板配了个神器:Atomics(原子操作)。这相当于在小白板旁边定了两条铁律:
- 一次只能一个人写字(互斥锁 Mutex)。
- 写完了可以通知对方(条件变量 Condition Variable)。
我们来看死锁是怎么被"防住"的:
一个会被"防住"的死锁代码示例
设想一下,老板和学徒做菜都需要盐 和糖 。我们用 SharedArrayBuffer 和 Atomics 来模拟这个资源争夺,看看死锁是怎么被 Atomics 的规则扼杀在摇篮里的。
javascript
// --- 共享资源:盐和糖(0表示空闲,1表示被占用)---
// 在父线程或子线程中初始化
const sab = new SharedArrayBuffer(8);
const locks = new Int32Array(sab); // locks[0] 是盐, locks[1] 是糖
// --- 工具函数:带超时的原子"拿调料"操作 ---
function takeIngredient(lockIndex, ingredientName, workerName) {
const currentLock = Atomics.compareExchange(locks, lockIndex, 0, 1);
if (currentLock === 0) {
console.log(`${workerName} 拿到了${ingredientName}`);
return true;
}
console.log(`${workerName} 拿不到${ingredientName},等一等`);
return false;
}
function putBackIngredient(lockIndex, ingredientName, workerName) {
Atomics.store(locks, lockIndex, 0);
console.log(`${workerName} 放下了${ingredientName}`);
// 通知所有在等这块锁的线程:"资源空闲了!"
Atomics.notify(locks, lockIndex, 1);
}
function waitForIngredient(lockIndex, ingredientName, workerName) {
console.log(`${workerName} 正在苦等${ingredientName}...`);
const result = Atomics.wait(locks, lockIndex, 1, 2000); // 最多等2秒
if (result === 'timed-out') {
console.log(`${workerName} 等了2秒还没等到${ingredientName},不干了!`);
return false;
}
return true;
}
// --- 老板(父线程)的逻辑:先拿盐,再拿糖 ---
function chefMaster() {
console.log('老板:开始做菜');
// 1. 拿盐
while (!takeIngredient(0, '盐', '老板')) {
if (!waitForIngredient(0, '盐', '老板')) return; // 超时则放弃
}
// 模拟做事
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
// 2. 拿糖
while (!takeIngredient(1, '糖', '老板')) {
if (!waitForIngredient(1, '糖', '老板')) { // 超时则放弃
putBackIngredient(0, '盐', '老板'); // 释放已拿到的资源!
return;
}
}
console.log('老板:菜做好了!');
putBackIngredient(1, '糖', '老板');
putBackIngredient(0, '盐', '老板');
}
// --- 学徒(子线程)的逻辑:先拿糖,再拿盐(故意颠倒顺序)---
function chefWorker() {
console.log('学徒:开始做菜');
// 1. 拿糖
while (!takeIngredient(1, '糖', '学徒')) {
if (!waitForIngredient(1, '糖', '学徒')) return;
}
// 模拟做事
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
// 2. 拿盐
while (!takeIngredient(0, '盐', '学徒')) {
if (!waitForIngredient(0, '盐', '学徒')) {
putBackIngredient(1, '糖', '学徒'); // 释放已拿到的资源!
return;
}
}
console.log('学徒:菜做好了!');
putBackIngredient(0, '盐', '学徒');
putBackIngredient(1, '糖', '学徒');
}
// 运行后,输出会是这样:
// 老板:开始做菜
// 老板 拿到了盐
// 学徒:开始做菜
// 学徒 拿到了糖
// 老板 拿不到糖,等一等 <--- 经典死锁点!互相等待
// 老板 正在苦等糖...
// 学徒 拿不到盐,等一等
// 学徒 正在苦等盐...
// ...2秒后...
// 老板 等了2秒还没等到糖,不干了!
// 老板 放下了盐
// 学徒 等到了盐 <--- 老板一放盐,学徒立刻拿到
// 学徒:菜做好了!
看到了吗?死锁成形了,但被"超时"机制化解了。 这就是 Node.js 笼子上的锁:Atomics.wait 内置超时,你可以规定最多等多久,等不到就主动放弃并释放已占的资源,避免无限死等。
最佳实践:干脆不用小白板
更常见、更安全的做法是:根本就不共享内存,只传消息。
javascript
// 老板.js
worker.postMessage({ task: 'cook', dish: '佛跳墙' });
worker.on('message', (msg) => {
if (msg.status === 'done') {
// 端菜
}
});
// 学徒.js
parentPort.on('message', (task) => {
// 炖汤...
parentPort.postMessage({ status: 'done' });
});
这种模式下,老板和学徒之间只有"指令"和"结果"的传递,不存在共享资源,死锁的土壤被完全铲除。唯一的代价是传输大量数据时需要复制,性能不如共享内存。
总结一下
| 模式 | 死锁风险 | 原因 | 如何避免 |
|---|---|---|---|
| Cluster 多进程 | 几乎没有 | 进程间内存完全隔离,只通过无锁的异步消息通信 | 无需特别处理 |
| Worker Threads (传消息) | 没有 | 消息传递模式,无共享资源 | 默认方式,放心用 |
| Worker Threads (共享内存) | 有,但可控 | 共享 SharedArrayBuffer 可能引发竞争和死锁 |
Atomics + 超时机制,或干脆不用 |
所以,你担心的死锁问题,Node.js 设计者早就想到了。他们既给了你强大的武器(共享内存多线程),又给武器上了保险(Atomics 和消息传递),还告诉你:"大部分时候,用'对讲机'就够了,别去碰那个'小白板'。"
这就是 Node.js 多线程哲学:给你多线程的利刃,但把锁和死锁这些锈迹,尽可能挡在抽象层外。