文章

如何实现多个进程同时监听同一个端口

如何实现多个进程同时监听同一个端口

为什么会有多个进程监听同一端口的特殊情况

在通常情况下,一个端口号只能被一个进程绑定和监听。这被称为“端口唯一性”原则,是操作系统内核为了确保网络通信的有序性而设计的。

但是为了解决高性能网络服务中的两个关键问题:高并发和零停机时间, 操作系统允许多个进程监听同一个端口

高并发和可伸缩性

在传统的单进程服务器模型中,一个进程监听并处理所有连接。但随着用户量的增长,单进程会很快成为性能瓶颈,尤其是在多核处理器普及的今天。为了充分利用多核 CPU,服务器需要能够同时处理大量的并发连接。

  • 多进程模型(使用 fork):这是最经典的方式。一个主进程负责监听,每当有新连接到来,它就 fork 一个子进程来处理。这样,每个连接都由一个独立的进程来服务,实现了并行处理。由于子进程继承了父进程的监听端口,它们都可以等待新的连接,实现了负载分担。

  • 多进程负载均衡(使用 SO_REUSEPORT):fork 模型虽然有效,但存在“惊群效应”和上下文切换开销。SO_REUSEPORT 选项的出现,让多个独立的进程可以同时监听同一个端口。当新连接到来时,操作系统的内核会像一个智能分发器一样,将连接均匀地分发给不同的进程,消除了惊群效应,并利用内核级别的负载均衡来提升性能。这是现代高性能服务器(如 Nginx)普遍采用的模式。

零停机时间部署和容错

对于关键的在线服务,任何停机时间都是不可接受的。允许多个进程监听同一个端口,使得平滑重启和热更新成为可能。

  • 平滑重启(使用 SO_REUSEADDR):当服务器程序需要更新时,你不能简单地杀死旧进程再启动新进程。因为旧进程退出后,端口会进入 TIME_WAIT 状态,导致新进程无法立即启动。而 SO_REUSEADDR 允许新进程立即绑定这个处于 TIME_WAIT 状态的端口。这样,你可以在旧进程处理完所有连接后优雅地退出,新进程则可以无缝地接管所有新连接,从而实现零停机时间的部署。

  • 高可用性:如果有多个进程同时监听一个端口,即使其中一个进程崩溃了,其他进程仍然可以继续处理请求,确保服务的连续性。这种设计提供了天然的容错能力。

实现多进程监听同一端口的特殊方法

Fork 继承父进程

当一个进程绑定了一个端口号后,如果它使用 fork 系统调用创建子进程,那么子进程会继承父进程的所有资源,包括打开的文件描述符。由于端口绑定是通过文件描述符来表示的(具体来说是 socket 文件描述符),因此子进程会继承这个已经绑定了端口的 socket 文件描述符

bind() 之后 fork 出来的所有子进程,也可以处理传入的连接,不过要通过加锁或互斥来实现连接分配(也就是自行实现负载均衡)。可参考 Nginx处理惊群问题的ACCPET_MUTEX方案 的方法

但需要注意以下几点:

  • 谁来接受连接? 虽然父子进程都监听同一个端口,但只有其中一个能成功接受一个新的连接。操作系统内核负责决定哪个进程(父进程或子进程)来处理新到来的连接请求。这通常是先到先得的,没有固定的顺序。
  • 资源竞争:由于父子进程都共享这个监听 socket,如果它们都试图通过 accept() 来接受连接,就可能出现竞争。
  • 常见应用场景:这种机制在一些多进程并发服务器模型中很常见。例如,一个主进程(父进程)专门负责监听和接受新的连接,一旦接收到一个连接,它就会 fork 一个子进程来处理这个连接,而父进程则继续回去监听,准备接受下一个连接。这种模型可以有效地分散负载,提高服务器的并发处理能力。

也可以在 bind() 之前 fork 子进程,并使用 SO_REUSEPORT,这时候跟不同进程使用 SO_REUSEPORT 没有区别。

SO_REUSEPORT

SO_REUSEPORT 是专门为允许多个进程或线程同时绑定同一个端口而设计的,这是现代高性能网络服务器中非常重要的一个选项

关于多个非父子关系的进程端口复用,是 Linux 早先从 BSD 的 SO_REUSEPORT 参数继承下来的功能。并且现在 Linux 上的 PORTREUSE 机制比 BSD 更加复杂一些。

它的核心作用是:

  • 允许多个不相关的进程绑定同一个端口。
  • 内核负载均衡。 当有新的连接请求到达时,操作系统的内核会智能地将连接请求分发到这些绑定了同一端口的进程上。这通常使用轮询(round-robin)或者哈希(hash)等算法来实现,可以有效地分摊负载,提高并发处理能力。

这种模型在现代 web 服务器、缓存系统和负载均衡器中非常流行,例如 Nginx处理惊群问题的SO_REUSEPORT方案、HAProxy 等。它避免了 fork 模型的开销,并且能够更好地利用多核 CPU 的能力。

使用流程

  • 设你启动了一个进程,绑定 x 端口,并使用了 SO_REUSEPORT 参数。那么所有后续进程,只要同样使用 SO_REUSEPORT 绑定 x 端口,都可以成功启动,内核会为这些进程公平的分配连接。

  • 果你启动的第一个进程没有使用 SO_REUSEPORT 参数绑定 x 端口,那么后续进程启动时即便添加 SO_REUSEPORT 也会失败。

  • Linux 上有一个额外的限制,那就是进程的 EUID (有效用户ID, 表示进程对于文件和资源的访问权限)必须相同。由于父子级关系的进程的 EUID 默认是相同的 (同个用户创建的进程EUID一般情况也相同),所以在子进程中可以正常使用 SO_REUSEPORT。这样做可以享受到内核级的负载均衡,传统的 fork 共享 socket 文件描述符模型不行。

简单来说,只要是同一个用户启动的进程,都使用了 SO_REUSEPORT 参数绑定端口,那么就可以同时监听一个端口号。

SO_REUSEADDR

SO_REUSEADDR 的核心作用是允许新的进程绑定一个处于 TIME_WAIT 状态的端口, 它的设计目的是解决端口被“占用”的问题

  • 端口的 TIME_WAIT 状态:当一个 TCP 连接关闭时,主动关闭方(通常是客户端)的连接会进入 TIME_WAIT 状态。但有时,服务器也会是主动关闭方,或者因为崩溃而导致连接进入此状态。这个状态会持续一段时间(通常为 2 MSL,即最大报文段生存时间),以确保网络中的所有延迟报文都已过期,防止新连接收到旧连接的重复报文。

  • SO_REUSEADDR 的作用:在默认情况下,操作系统会拒绝任何新的绑定请求,只要这个端口处于 TIME_WAIT 状态。而一旦设置了 SO_REUSEADDR,操作系统就会忽略这个 TIME_WAIT 状态的检查,允许新的进程立即绑定并监听该端口。

典型使用场景

设想一下,你的服务器程序(比如一个 Web 服务器)正在运行,监听 80 端口。由于某种原因,你需要关闭它并立即重启(比如升级代码或修复 Bug)。

当你正常关闭服务器时,操作系统不会立即释放 80 端口。相反,这个端口会进入一个 TIME_WAIT 状态,持续一段时间(通常是几分钟)。这是 TCP 协议的正常行为,目的是确保所有网络中的延迟报文都已过期,防止数据混乱。

如果没有设置 SO_REUSEADDR,当你试图立即重启服务器时,bind() 调用会失败,因为操作系统认为 80 端口还在使用中。

通过设置 SO_REUSEADDR,你可以告诉操作系统:“我知道这个端口可能还在 TIME_WAIT 状态,但没关系,请允许我立即绑定它。” 这让服务器能够无缝地快速重启,避免了因端口被占用而造成的停机时间。

在某些操作系统(尤其是 Linux),SO_REUSEADDR 允许同一台主机上,多个进程同时绑定同一个 IP 地址和端口。这听起来有点违反直觉,但确实是它的一个副作用。

如果你先设置了 SO_REUSEADDR 选项,不同的进程确实可以同时绑定同一个端口。但是,只有一个进程能成功监听并接受连接,其他的绑定会静静地失败,或者在尝试 accept() 时失败。这通常不是一个可靠的并发模型。

参考

本文由作者按照 CC BY 4.0 进行授权