图 1:本文封面,一个端口四个 worker,谁接新连接。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「多个 worker 进程如何监听同一个端口?如何处理惊群?」是 Nginx 原理题里的常驻款,它实际考两件事:进程间怎么共享监听套接字,以及共享之后怎么防止「一个连接惊醒所有人」。
第一步,共享端口:fork 继承监听描述符。
Nginx 启动时是 master 进程先创建并 bind、listen 好监听套接字,然后 fork 出一批 worker 进程——fork 出的子进程继承父进程的文件描述符表,于是每个 worker 手里都握着同一份监听 socket,谁都可以对它 accept。这就是「多个进程监听同一个端口」的全部秘密:不是端口被复制了,是监听套接字被继承了。Node.js 的 cluster 模块用的是同一套机制。
第二步,惊群:一个连接,全体白醒。
共享带来一个副作用:新连接到达时,内核会把所有在监听 socket 上等待的 worker 同时唤醒,但最终只有一个能 accept 成功,其余的白醒一趟、空转回去继续睡。worker 越多,空转的绝对浪费越大,这就是惊群。它不会让请求出错,但会让 CPU 时间悄悄漏掉。
第三步,解法一:accept_mutex,把竞争收敛成排队。
Nginx 的经典解法是 events 块里的 accept_mutex:开启后 worker 轮流接受新连接——抢到进程间互斥锁的 worker 才把监听 fd 加进自己的事件集合,没抢到的这一轮干脆不监听,惊群退化为串行抢锁。官方文档里两个版本事实值得记住:1.11.3 之前默认值是 on,之后改成了 off;在支持 EPOLLEXCLUSIVE 的系统(Linux 4.5+,内核只唤醒一个等待者)或使用 reuseport 时,没有开启 accept_mutex 的必要。
第四步,解法二:reuseport,把分发下沉到内核。
listen 指令的 reuseport 参数(1.9.1 起)让内核为每个 worker 创建独立的监听套接字:每个 worker 有自己的 accept 队列,新连接由内核按哈希直接分派到某一个队列,只唤醒对应的那一个 worker——惊群从机制上消失了,锁也不用了。代价有两个要留意:流量倾斜,哈希分派不保证绝对均匀,某个 worker 可能长期更忙;升级与重载期的连接排空,老 worker 退出前要自己队列里的连接先处理完。
第五步,用代码亲眼看看共享端口与分发。
Node 的 cluster 是同一套机制的最小样本:master 监听,worker 继承描述符。下面这份代码起 4 个 worker,并发打 16 个连接看分发。用 Node.js 22.15.0 直接运行(node cluster.js)即可:
'use strict';
// 多进程共享同一监听端口实测:Node 的 cluster 与 Nginx 的 master/worker 是同一套机制
// master 创建监听套接字,fork 出的 worker 继承同一份监听描述符,都能 accept 同一个端口
import cluster from 'node:cluster';
import http from 'node:http';
if (cluster.isPrimary) {
console.log(`master(pid=${process.pid}):创建监听套接字,准备 fork 4 个 worker`);
const counts = new Map();
for (let i = 0; i < 4; i++) {
const w = cluster.fork();
counts.set(w.process.pid, 0);
w.on('message', (m) => counts.set(m.pid, (counts.get(m.pid) ?? 0) + 1));
}
setTimeout(() => {
// 16 个连接同时打到同一个端口
const reqs = [];
for (let i = 0; i < 16; i++) {
reqs.push(new Promise((resolve) => {
http.get({ host: '127.0.0.1', port: 8901, agent: false }, (res) => {
res.resume();
res.on('end', resolve);
});
}));
}
Promise.all(reqs).then(() => setTimeout(() => {
for (const [pid, c] of counts) console.log(`worker(pid=${pid})处理了 ${c} 个连接`);
for (const id in cluster.workers) cluster.workers[id].kill();
process.exit(0);
}, 200));
}, 300);
} else {
http.createServer((req, res) => {
process.send({ pid: process.pid });
// 故意处理 50 毫秒,让并发连接确实同时挂着
setTimeout(() => res.end('ok'), 50);
}).listen(8901); // worker 不重新 bind:共享 master 的监听描述符
}
实测输出(pid 每次运行会变,分发比例也会有小幅波动):
master(pid=43027):创建监听套接字,准备 fork 4 个 worker
worker(pid=43029)处理了 5 个连接
worker(pid=43030)处理了 4 个连接
worker(pid=43031)处理了 3 个连接
worker(pid=43032)处理了 4 个连接
四个 worker 在同一个 8901 端口上各接到 3~5 个连接——端口只有一份,连接被分派开了,而且分派并不绝对均匀,这正是 reuseport 类方案里「流量倾斜」的微观写照。
图 2:共享机制、惊群现象与两种解法的对照。
可能的追问与易错点。
「两种解法怎么选?」accept_mutex 把竞争搬到用户态,行为可控、不挑内核版本;reuseport 把分发交给内核,没有锁开销,但要接受流量倾斜并处理好升级期排空。「多实例部署还有这个问题吗?」容器时代常见的是多实例各自监听,惊群问题淡化成「实例级负载均衡」问题,但单机多 worker 的模型仍是理解一切多副本服务的起点。易错点三个:以为 worker 各自 bind 了同一端口——SO_REUSEADDR 不允许两个监听 socket 绑同址同埠,共享靠的是 fork 继承或 reuseport 的专门语义;把惊群当成正确性问题,它是性能问题,请求不会丢;开 reuseport 后忘记评估长连接场景的倾斜。回到答题节奏:先讲 fork 继承套接字,再描述惊群现象,然后给出锁与内核分发两种解法及各自的取舍,最后补一句版本事实收尾。你负责的服务是单机多进程还是多实例?连接分发这层是怎么做的?欢迎聊聊。