深蓝黑背景封面:右侧大标题「一个端口,一群 worker 抢接连接」,副标题「惊群问题与 accept_mutex、reuseport 两种解法」;左侧原创示意图——顶端一个写有 80 的端口方块向下分出四条线,连到四个 worker 进程方块,其中一条连接箭头旁画着四个同时惊醒的感叹号,表示惊群;左上标注「面试经典 · 系统设计」,右上标注「Day 06 · 10-24」 图 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 类方案里「流量倾斜」的微观写照。

机制加对照图:上方左侧画出共享机制——master 进程先创建监听套接字,fork 出的多个 worker 继承同一份监听描述符,于是都能 accept 同一个端口;上方右侧画出惊群现象——一个新连接到来时所有等待中的 worker 被同时唤醒,但只有一个能抢到,其余白醒一趟,空转消耗随 worker 数放大。下方两种解法对照:accept_mutex 用一把进程间互斥锁,抢到锁的 worker 才把监听 fd 加进自己的事件集合,没抢到的这轮干脆不监听,惊群退化为串行抢锁;reuseport 让内核为每个 worker 建独立监听队列,由内核按哈希把新连接直接分派到某个队列,只唤醒对应那一个 worker。底部提示:锁方案把竞争搬到用户态、可跨内核版本;reuseport 把分发下沉到内核,但要留意流量倾斜与升级期的连接排空 图 2:共享机制、惊群现象与两种解法的对照。

可能的追问与易错点。

「两种解法怎么选?」accept_mutex 把竞争搬到用户态,行为可控、不挑内核版本;reuseport 把分发交给内核,没有锁开销,但要接受流量倾斜并处理好升级期排空。「多实例部署还有这个问题吗?」容器时代常见的是多实例各自监听,惊群问题淡化成「实例级负载均衡」问题,但单机多 worker 的模型仍是理解一切多副本服务的起点。易错点三个:以为 worker 各自 bind 了同一端口——SO_REUSEADDR 不允许两个监听 socket 绑同址同埠,共享靠的是 fork 继承或 reuseport 的专门语义;把惊群当成正确性问题,它是性能问题,请求不会丢;开 reuseport 后忘记评估长连接场景的倾斜。回到答题节奏:先讲 fork 继承套接字,再描述惊群现象,然后给出锁与内核分发两种解法及各自的取舍,最后补一句版本事实收尾。你负责的服务是单机多进程还是多实例?连接分发这层是怎么做的?欢迎聊聊。