关于我们

质量为本、客户为根、勇于拼搏、务实创新

< 返回新闻公共列表

多个进程怎么共享同一个监听端口:SO_REUSEPORT 解决了什么,又留下了什么问题

发布时间:2026-10-21

先把"多进程监听同一个端口"这件事拆开

十几个 worker 进程全部拉起来了,CPU 使用率停在三成上下,吞吐却怎么压都上不去。这种现场在高并发接入的机器上并不少见,一路排查到内核态才会发现:卡住的不是业务代码,也不是网卡,而是十几个进程在反复争抢同一样东西——那个监听端口上刚建立好的连接。加机器、加核数、加 worker 数量,都绕不开这个点。

要说清楚这件事,得先把"多进程监听同一个端口"这句含糊的话拆开。一个服务端 socket 的生命周期大致分三段:先 bind,把一个 socket 绑定到某个 IP 与端口;再 listen,让它进入监听状态,内核开始为它维护未完成连接与已完成连接的两条队列;最后 accept,从已完成队列里取出一个连接,返回一个新的、代表这条连接的 socket。监听 socket 与已连接 socket 是两个不同的文件描述符:前者只负责收连接,后者才承载真正的数据收发。

端口冲突的判断发生在 bind 阶段,判断依据不是"端口"这一个数字,而是地址与端口的组合,以及这条组合是否已经被别的 socket 以监听的方式占住。已经建立的连接不会阻止新的监听 socket 再绑同一个本地端口,因为一条 TCP 连接是由四元组(源地址、源端口、目的地址、目的端口)唯一确定的,成千上万条已连接 socket 共享同一个本地端口是常态。

于是"多个进程共享同一个监听端口"其实有两种完全不同的做法。第一种是:只有一个监听 socket,多个进程都围着它转,各自去 accept,问题变成"谁来 accept、谁抢到算谁的"。第二种是:每个进程都有自己的监听 socket,这些 socket 绑定同一个 IP 与端口,问题变成"内核把新来的连接交给哪一个 socket"。前一种是老办法,后一种才是 SO_REUSEPORT 干的事。两者的分水岭在于:分发这件事,究竟发生在应用里,还是发生在内核里。

把这个分水岭记住,后面所有的收益与代价都能从它推出来。凡是应用自己分发的方案,策略可控但要付分发成本;凡是内核分发的方案,成本被压进内核路径,代价是你不再能插手。

老办法一:主进程 accept 再分发,卡在哪

最早也最直观的做法是让主进程独占监听 socket。主进程负责 bind、listen、accept,每 accept 出一个已连接 socket,就把它交给某个 worker 去处理业务。worker 只做数据收发与业务逻辑,不碰 accept。

把已连接 socket 交到另一个进程手里,工程上有两条路。一条是让 worker 在 fork 的时候就继承主进程的文件描述符表——但这条路只对 fork 之前就已经存在的描述符有效,主进程 accept 出来的描述符是在 fork 之后才产生的,继承不到。另一条是在主进程与 worker 之间建立 UNIX domain socket 通道,通过传递文件描述符的方式把已连接 socket 一个个送过去,也就是借助辅助数据把描述符"搬"到另一个进程的文件描述符表里。

这个方案的好处非常明确。分发策略完全由应用自己写:可以按 worker 当前连接数挑最闲的,可以按业务标签路由,可以在 accept 之后、分发之前做统一的准入判断与限流。连接的到来顺序是确定的,不会出现某个进程饿死的情况,可观测性也最好——主进程手上有全局的连接计数。

代价同样明确。首先,整个服务的新连接入口被串行化到一个执行流上了:主进程一次只能 accept 一个,分发一个,再回来 accept 下一个。连接建立越密集,这个串行点越先成为天花板。其次,主进程从"管理者"变成了"必经之路",它一旦因为某次分发阻塞、因为某个 worker 通道写满而卡住,或者自身被调度延迟,所有 worker 就同时拿不到新连接——CPU 空着,吞吐不动,这正是开头那个现场的成因之一。

还有一个常被低估的成本:文件描述符传递本身要维护一套进程间通道,要处理传递失败、描述符泄漏、worker 崩溃时未取走的连接等问题。通道两端的错误处理往往写得比业务代码还复杂。最后是可用性——主进程挂掉,即使十几个 worker 全都健康,服务也立刻失去接入能力,因为已经没有人在 accept 了。所以这套结构在高连接建立速率的场景里会越来越吃力,人们自然会想到:干脆让所有 worker 自己去 accept。

老办法二:多进程抢 accept,惊群与锁争抢

第二种老办法是让所有 worker 共享同一个监听 socket,各自在自己的执行流里调用 accept。主进程退化为纯粹的管理者,负责拉起与回收 worker,不再参与数据路径。这在结构上比第一种简单得多,也确实把 accept 这件事从单点变成了多点。

但它立刻撞上两个问题。第一个是历史上著名的"惊群":当一个新连接到达时,内核需要唤醒在这个监听 socket 上等待的进程。早期实现会把所有等待者一并唤醒,而最终只有一个能成功取走连接,其余的被唤醒后发现队列已空,只能再睡回去。这一次白醒不只是浪费一点 CPU,它还会带来额外的上下文切换与调度抖动,等待者越多,浪费越明显。内核在唤醒路径上做过改进,被同时唤醒的等待者数量已大幅减少,因此今天再谈惊群,重点已经不在"唤醒几个",而在第二个问题。

第二个问题是锁争抢,也是这类方案真正的天花板所在。无论有多少个进程在 accept,它们争的是同一个监听 socket 上那条唯一的已完成连接队列。取出一个连接意味着修改这条共享队列,修改就必须加锁。也就是说,accept 这个动作在高并发下依然是串行的:同一时刻只有一个进程能进入临界区把连接取走,其他进程要么在自旋,要么被阻塞后再次被调度。进程数越多,参与争抢的线程越多,锁本身与随之而来的缓存行 bouncing 就越贵,于是你会看到很典型的一幕——CPU 只用了三成,但吞吐纹丝不动,因为大部分算力消耗在争抢、等待与切换上,而不是在处理连接。

这个方案还有一个隐性代价:唤醒哪个进程不由你决定。连接分布因此不可控,可能集中在少数几个进程上,且没有任何机制把你手上的连接挪走。另外,当连接到达速率超过应用取走速率时,已完成连接队列会积压甚至溢出,那时的表现是丢连接而不是变慢——队列溢出与 backlog 参数如何取值是另一个话题,不在本文范围。

SO_REUSEPORT 把分发这件事交给了内核

SO_REUSEPORT 换了个思路:不再让多个进程抢同一个监听 socket,而是允许多个 socket 绑定同一个 IP 与端口,前提是每个参与绑定的 socket 都设置了这个选项。设置必须在 bind 之前完成,事后补是无效的。这样,每个进程都拥有自己独立的监听 socket,也就拥有自己独立的已完成连接队列。

分发的位置随之改变。新连接在内核里完成握手之后,内核会在这一组绑定到同一地址的监听 socket 之间做一次选择,把这条连接放进被选中的那个 socket 的队列里。进程各自 accept 自己那个 socket,彼此之间没有共享队列,也就不再需要争抢同一把锁。原来那条串行的临界区被拆成了多条互不干扰的路径,这正是它能在多核机器上把 accept 能力近似线性地摊开的原因。

值得注意的是,锁争抢并没有凭空消失,它只是被挪走了:内核仍然要维护这一组 socket 的集合,仍然要在新连接到达时做一次选择。但这个选择发生在内核的连接建立路径上,是一次查找与计算,不涉及跨进程的阻塞与上下文切换,代价远低于让十几个进程在同一个用户态临界区上排队。

这里必须和另一个常被混淆的选项划清界限。SO_REUSEADDR 主要解决的是 TIME_WAIT 状态下的地址重用问题——服务重启时,旧连接留下的 TIME_WAIT 状态会让 bind 失败,设置该选项可以让绑定继续成功。它并不提供分发能力,也不允许多个进程同时监听并分担连接。SO_REUSEPORT 解决的才是多个 socket 同时监听同一端口并分发连接这件事。两者目的不同,工程上经常同时设置,但把它们当成一回事是常见的误解。

收益讲完了,剩下的全是代价。SO_REUSEPORT 把分发交给内核的同时,也把"谁来处理这条连接"的决定权交了出去,而内核做这个决定时,用的是一套与应用无关的哈希规则。

内核按什么分发:哈希、连接归属与它"粘"在哪个进程上

内核通常按连接的四元组做哈希,用哈希结果在这一组监听 socket 里选中一个,把连接放进去。这个规则带来一个非常重要的性质:同一条连接固定落到同一个 socket,同一客户端的后续连接也倾向于落到同一个监听 socket。只要参与分发的 socket 集合不变,哈希结果就是稳定的。

稳定性是有价值的。进程本地往往维护着各种与连接相关的状态:连接池、会话缓存、TLS 会话复用信息、业务侧的本地缓存、用户态的连接表项。如果同一个客户端的连接总是落到同一个进程,这些本地结构的命中率就会更高,跨进程同步的需求更少。对做本地缓存亲和的服务来说,这反而是 SO_REUSEPORT 比"主进程随机分发"更讨喜的一面——随机分发会让同一客户端的多次请求散落到不同进程,每一份本地缓存都要重复一份。

但这个性质的另一面是:分布是否均匀,取决于客户端的分布,而不是取决于进程的忙闲。哈希的输入是客户端的地址与端口,内核不会去看哪个进程现在连接少、CPU 空闲。如果客户端天然不均——比如少数几个大客户贡献了大部分连接,或者大量客户端经由同一个 NAT 出口、同一个企业网关、同一个移动核心网出口汇聚过来——那么这些连接的源地址高度集中,哈希结果也会高度集中,连接会成批地落到同一个进程上。

长连接业务会把这个问题放大。短连接场景下,连接不断建立与释放,哈希结果虽然不均但一直在重洗,进程间的偏差会被时间平均掉一部分;长连接场景下,连接一旦建立就长期驻留,最初落错了地方,就会错很久。于是"某个进程特别忙、其他进程很闲"的状态会稳定地持续下去。

还有一层:哈希空间是会变的。监听地址变化、参与分发的 socket 数量变化,都会改变哈希到 socket 的映射关系。这意味着进程数的调整并不仅仅影响新增连接的分配,还会影响已有的客户端后续连接会落在哪里——这一点在滚动重启时会变得很具体。

代价一:连接一旦归属,负载不均无法自愈

最需要提前接受的一条事实是:已经建立的连接不会因为负载不均被内核迁移到别的进程。内核的分发只发生在新连接建立的那一次,之后这条连接就"粘"在它被分配到的那个进程上,直到它自己关闭。内核不会在某个进程连接数过高时把它手上的连接分一些给邻居,也没有这样的接口。

换句话说,这套机制没有自愈能力。进程 A 被三个大客户压满,进程 B、C、D 空闲着,内核依然会把来自这三个大客户的新连接继续交给 A,因为哈希规则没变。这种情况在客户端分布高度集中时不是偶发,而是结构性结果。监控上会看到一个非常刺眼的形态:整机 CPU 使用率不高,但单个进程的 CPU 接近打满,它的连接数也远高于同批其他进程。

纠正的办法只能落在应用层。常见有三条。其一是连接迁移,即在业务层主动让一部分连接断开并重连,重连后的连接会重新参与哈希,有机会落到别的进程——代价是要客户端能接受重连,且重连瞬间会有抖动。其二是限流,对单个客户端或单个源地址的连接数设上限,避免少数来源吃满一个进程,这需要在接入侧就能识别来源。其三是分层,在业务进程之前放一层专门的接入层,由接入层承担连接归属问题,把请求再按自己的策略往内部分发——代价是多一跳,以及接入层本身要足够轻。

工程上这意味着一件事:连接归属要当成需要长期监控的量,而不是一次性配置就不管了。观测维度要从整机下沉到进程:每个进程当前持有多少连接、CPU 与软中断分布是否均衡、连接数最多的那几个来源是谁。只有按进程看,才能发现哈希不均正在发生;只看整机的平均值,一切都是正常的。

代价二:优雅退出与滚动重启时连接会怎么走

滚动重启是这套方案最容易出事的地方,因为"关闭监听 socket"与"关闭已建立的连接"是两件独立的事,而很多人默认它们是一件事。

旧进程开始退出时,它通常会先关闭自己的监听 socket。这个 socket 一旦关闭,就从内核的那一组分发集合里消失了,之后到达的新连接不会再被分给它,而是分发到仍然在监听的新进程上——这一半是自动的,不需要额外操作。问题在另一半:仍然留在旧进程上的那些已建立连接,不会因为监听 socket 关闭而自动转移,它们会随进程退出而进入关闭流程。内核不会把它们交给新进程,应用层也没有现成的接管机制。

因此真正的处置流程必须包含三步。第一步是摘流:先把即将重启的实例从上游的负载均衡或健康检查里摘掉,确保新连接在关闭监听 socket 之前就已经不再进来,否则摘流与关监听之间那一小段窗口仍会有连接落到即将退出的进程上。第二步是优雅退出:给在途请求留出处理完成的时间,设一个明确的退出超时,超时之后才强制结束。第三步才是进程退出与连接关闭,客户端在连接断开后按自己的重连逻辑重新建立连接,重连的请求会落到新进程。

这里有个容易被忽略的细节:新旧进程并存期间,两组监听 socket 同时在分发集合里,哈希空间与稳态时不同;旧进程退出后,哈希空间再次变化。这两次变化会让部分客户端的后续连接换到另一个进程上,此前积累的本地缓存亲和随之失效——重启完成后短暂的缓存命中率下降、延迟抬高,是这个机制的正常结果,不是异常。重启批次越大、参与进程数变化越剧烈,这个抖动越明显,所以滚动重启通常要分批、要留观察间隔,而不是一批全部换掉。

还有一处要提前想清楚:强制结束与优雅退出的差别,在客户端看来是完全不同的。优雅退出时连接是正常关闭,客户端能感知并重连;强制 kill 时连接被直接重置,客户端看到的是请求失败。退出超时该设多久,取决于在途请求最长可能跑多久,这个数字只能从业务自身的最长处理时间来定,没有通用答案。

代价三:与多队列网卡、NUMA 的对齐问题

现代网卡普遍是多队列的,网卡会按自己的哈希规则把不同的流分到不同的硬件队列,每个队列的中断再亲和到某个 CPU 核上。这套分发发生在比 SO_REUSEPORT 更靠下的位置,而且它是另一套独立的哈希,不会与 SO_REUSEPORT 的分发自动对齐。

由此产生的错配是这样的:内核按四元组哈希把一条连接分给了进程 A,进程 A 跑在 NUMA 节点 0 的某个核上;而这条连接的包由网卡按自己的哈希送进了队列 N,队列 N 的中断亲和在 NUMA 节点 1 的核上。结果是——收包、协议处理在节点 1,应用处理在节点 0,数据在两个节点之间来回搬。跨节点访问内存的代价会直接吃掉你在 accept 路径上省下来的那点收益。

一台机器上有两颗物理 CPU、每个物理 CPU 带自己的本地内存时,这个问题尤其明显。进程跑在哪个节点、网卡队列的中断打在哪个核、网卡插在哪条 PCIe 通道上属于哪个节点,这三者需要一起考虑。SO_REUSEPORT 只解决了"新连接分给谁",它不知道进程在哪颗 CPU 上,也不会帮你把进程挪到网卡所在的节点。

所以正确的顺序是:先规划网卡队列与中断亲和、再规划进程的核绑定与内存分配策略,最后才是让 SO_REUSEPORT 把连接分到这些已经摆好位置的进程上。反过来的顺序——先把进程撒在各处,再指望 SO_REUSEPORT 解决性能——往往是无效的。需要注意的是,在 NUMA 架构的机器上,是否要跨节点分配进程、是否要把进程固定在与网卡同节点的核上,取决于机型、网卡型号与队列数,不同配置的可选做法差别很大,没有一套放之四海皆准的绑法。

验证对齐与否,要靠按节点的观测:看进程实际在哪些核上运行、网卡队列的中断落在哪些核上、跨节点的内存访问计数是否异常偏高。这些数字如果长期不对劲,说明省下的锁争抢已经被跨节点访问抵消了。

什么时候该用,什么时候用了也没用

先说值得用的情形。判断标准只有一条:连接建立的速率是否已经高到让 accept 路径成为瓶颈。典型特征是短连接密集、握手频繁、进程数已经跟核数对齐、但 CPU 还没用满而吞吐上不去,并且用按进程的观测能看到多个进程在 accept 相关路径上排队。这类场景里,把共享队列拆成多个独立队列的收益最直接。

再说用了也没用的情形。如果瓶颈根本不在接入路径,而在数据库、下游服务、磁盘 IO 或者业务自身的串行段,那么再怎么优化 accept 都不会改变端到端的结果——这属于优化错了位置。如果连接数本身很少、且都是长连接,accept 每秒发生的次数有限,争抢也就无从谈起,改造的收益接近于零。如果服务本来就是单进程模型,或者业务逻辑依赖进程间强同步,引入多监听 socket 只会增加复杂度。

还有几种情况属于"用了反而更麻烦"。第一种是需要在 accept 这个点上做统一策略的:比如所有新连接都要过一遍白名单、都要在接入瞬间打上统一标签、都要按全局配额做准入。单一 accept 点上做这些很自然,分散到多个监听 socket 之后就变成每个进程各做一份,配额与状态需要跨进程同步。第二种是依赖连接顺序或依赖连接与进程固定映射的业务,哈希分发会打破这种假设。第三种是客户端极度集中且全是长连接的场景,哈希不均会被长期锁定,而没有自愈机制兜底。

第四种是需要精确的连接级调度与配额的服务:SO_REUSEPORT 的分发只按哈希,不看进程忙闲,也无法保证每个进程的负载在可控范围内。你需要的如果是"按实际负载分配",那么它提供的并不是你要的那种分配。

监听方案 分发发生的位置 主要瓶颈 适用场景
主进程 accept 后分发给 worker 应用态,主进程单点决策 accept 与分发被串行化在一个执行流上,主进程阻塞即全局停摆 连接建立速率不高,但需要严格的分发策略、统一准入与顺序保证
多进程对同一监听 socket 争抢 accept 内核唤醒等待者,应用态取走连接 已完成连接队列唯一,取连接需加锁,锁争抢与上下文切换吃掉算力 进程数不多、连接建立压力有限,希望结构简单、主进程不参与数据路径
SO_REUSEPORT 多监听 socket 内核按四元组哈希在多个监听 socket 间分发 哈希不均无法自愈,连接归属固定,滚动重启时归属会重洗 短连接密集、accept 已成为瓶颈、多核机器且进程已与核数和 NUMA 节点对齐
接入层转发,后端多进程各管各的连接 接入层按自己的策略二次分发 多一跳带来的转发开销,接入层自身需要足够轻且可水平扩展 客户端分布高度集中、需要按负载分配或需要连接迁移与统一配额

一万网络深耕 IDC 行业 19 年(成立于 2007 年),在华南、华东、华北及中国香港、海外多地整合自营节点与 BGP 多线资源;这类接入型业务的机型、网卡队列与 NUMA 拓扑,请按自有连接规模向服务商确认。高并发接入场景的机型、网卡与核绑定方案以官网实时信息或咨询确认为准。

关于 SO_REUSEPORT,几个常被问错的问题

问:SO_REUSEPORT 和 SO_REUSEADDR 到底差在哪?差在解决的问题不一样。SO_REUSEADDR 主要解决 TIME_WAIT 状态下的地址重用,让服务重启时不会因为旧连接的 TIME_WAIT 状态而 bind 失败;SO_REUSEPORT 解决的是多个 socket 同时绑定同一 IP 与端口、并由内核在它们之间分发新连接。前者不提供分发能力,后者不解决 TIME_WAIT 冲突。工程上两者经常同时设置,但功能不能互相替代。

问:是不是只要主进程设置了这个选项就够了?不够。要求是参与绑定的每一个 socket 都要设置,而且要在 bind 之前设置。只设置其中一个,其余的 socket 在 bind 时依然会因为地址已被占用而失败,表现就是第二个进程起不来。这是改造时最常见的低级错误。

问:它能让负载自动变均衡吗?不能。分发是按四元组哈希做的,看的是连接来自哪里,不看进程当前忙不忙。客户端分布不均时,进程间的负载就会不均,而且已建立的连接不会被迁移,忙的进程会一直忙下去。要均衡,得在应用层做限流、连接迁移或者加一层接入层。

问:已经建立的连接能不能被挪到别的进程?内核层面没有这个能力。分发只在新连接建立时发生一次,之后连接就固定属于那个进程。想让连接换地方,只能在应用层让它断开重连,重连时重新参与哈希——代价是客户端要能接受重连,且会有瞬时抖动。

问:启用了它之后,惊群是不是就彻底没了?惊群问题在内核演进中已经大幅缓解,这一点与是否启用 SO_REUSEPORT 无关。SO_REUSEPORT 真正解决的是另一个东西:多个进程争抢同一个已完成连接队列时的锁争抢。启用之后每个进程有自己的队列,这条争抢路径才被拆掉。把两者混为一谈,会导致对收益来源的误判。

问:滚动重启时旧进程上的连接会自动转移吗?不会。旧进程关闭监听 socket 之后,新连接会分发到新进程,但旧进程上已建立的连接会随进程退出而进入关闭流程。要少丢连接,必须自己配合摘流与优雅退出:先从上游摘掉,再给在途请求留出处理时间,最后才退出。

问:是不是所有内核版本都支持?支持情况取决于具体的内核版本与发行版,改造前要在目标内核上确认该选项可用、分发行为符合预期,不要假设所有环境一致。同一份配置在不同内核上的行为差异,往往要到上线之后才暴露。

上线前该怎么验证这套监听方案

第一件事是确认前提成立,而不是先改配置。先证明瓶颈确实在 accept 路径上:按进程观察在 accept 相关调用上的等待与排队情况,看多个进程是否真的在同一条路径上互相阻塞。如果观测结果显示时间主要花在业务处理、下游等待或磁盘 IO 上,那么这一步改造的收益有限,应该先去解决真正的瓶颈。判断顺序反了,后面所有的验证都是白做。

第二件事是确认机制真的生效了。要验证内核是否支持该选项、每个 socket 是否都在 bind 之前设置成功、启动时是否有进程因为地址冲突而起不来。生效之后,用系统自带的 socket 查看工具观察同一个端口下是否出现了多个监听条目,而不是只有一个;再观察新建连接是否确实被分到了不同的进程——按进程统计连接数,如果全部集中在一个进程上,说明分发并没有按预期工作。

第三件事是做开关对比,但要注意观测口径。对比启用前后的指标应该包括:按进程的连接数分布是否更均衡、accept 相关路径上的等待是否下降、上下文切换与软中断分布是否改善、整机吞吐与 CPU 使用率的关系是否变化。观测要落在按进程的维度上,只看整机平均值会掩盖哈希不均。这里强调的是观察与核对,不预设任何结论,因为不同业务的连接形态差异很大。

第四件事是演练滚动重启,而且要在业务低峰之外也演练一次。完整走一遍:从上游摘流、等待在途请求处理、关闭监听 socket、进程退出、观察新连接是否全部落到新进程、观察客户端重连是否成功、观察重启完成后本地缓存命中率是否有短暂下降。同时要验证退出超时这个参数是否合理:设得太短会强制中断在途请求,设得太长会让发布流程拖沓。这个数字只能依据业务自身的最长处理时间来定。

第五件事是核对待机对齐情况。确认进程实际运行在哪些核上、网卡队列的中断落在哪些核上、跨节点的内存访问是否异常偏高。如果进程分布与网卡队列、NUMA 节点错配,接入路径省下的开销会被跨节点访问抵消,此时应该先调整绑定与中断亲和,再谈其他优化。

第六件事是灰度。先在单台机器上开启,观察足够长时间,确认连接分布、进程负载、重启行为都符合预期之后再逐步扩大范围。因为连接归属缺乏自愈能力,问题往往不是立刻出现的,而是在客户端分布发生变化、某个大客户流量上来之后才暴露——只有在灰度阶段留足观察窗口,这类延迟暴露的问题才不至于在全面铺开之后集中爆发。


上一篇:一百台机器同时装机为什么卡在第二步:PXE 这段链路里,瓶颈不在镜像大小

下一篇:OAuth 2.0、OIDC 和 SAML 到底分工在哪:按客户端形态选,而不是按"哪个更安全"