SO_REUSEPORT 概述

SO_REUSEPOR 这个 socket 选项可以让你将多个 socket 绑定在同一个监听端口,然后让内核给你自动做负载均衡,将请求平均地让多个线程进行处理。

SO_REUSEPORT 解决了什么问题

SO_REUSEPORT 支持多个进程或者线程绑定到同一端口,提高服务器程序的性能,解决的问题:

  • 允许多个套接字 bind()/listen() 同一个 TCP/UDP 端口
  • 每一个线程拥有自己的服务器套接字
  • 在服务器套接字上没有了锁的竞争
  • 内核层面实现负载均衡
  • 安全层面,监听同一个端口的套接字只能位于同一个用户下面

其核心的实现主要有三点:

  • 扩展 socket option,增加 SO_REUSEPORT 选项,用来设置 reuseport
  • 修改 bind 系统调用实现,以便支持可以绑定到相同的 IP 和端口
  • 修改处理新建连接的实现,查找 listener 的时候,能够支持在监听相同 IP 和端口的多个 sock 之间均衡选择。
    有了 SO_RESUEPORT 后,每个进程可以自己创建 socket、bind、listen、accept 相同的地址和端口,各自是独立平等的
    让多进程监听同一个端口,各个进程中 accept socket fd 不一样,有新连接建立时,内核只会唤醒一个进程来 accept,并且保证唤醒的均衡性。

安全性考虑

  1. 第一个进程必须 enable 了这个选项之后,后续的进程才可以通过 enable 这个选项将 socket 绑定到同一个端口上。
  2. 绑定到同一个端口的进程的 effective user id 必须一致。

上述规定是为了避免 hijacking:恶意用户通过监听相同的端口来获取用户信息。

在没有 SO_REUSEPORT 的年代

在 SO_REUSEPORT 没有出现之前,多线程编程一般有两种获取到来的请求。

  1. 指派一条线程专门进行 accept,获取 socket 后分派给 worker 线程。这种方法使得进行 accept 的线程成为了单点,容易成为性能的瓶颈。
  2. 多个线程同时进行 accept。这种方法的问题是每一个线程 accept 成功的概率不均匀,导致负载不均衡。

SO_REUSEPORT 的负载均衡算法

使用 (remote_ip, remote_port, local_ip, local_port) 来进行哈希,因此可以保证同一个 client 的包可以路由到同一个进程。但是,当一个 listen 的进程加进来或者 terminate 的时候,由于没有实现 一致性哈希,结果可能导致有些请求由于路由到另外一个进程上,client-server 的三次握手过程可能会被重置。

SO_REUSEPORT 与 SO_REUSEADDR 的区别

Socket 的基本背景

在讨论这两个选项的区别时,我们需要知道的是 BSD 实现是所有 socket 实现的起源。基本上其他所有的系统某种程度上都参考了 BSD socket 实现(或者至少是其接口),然后开始了它们自己的独立发展进化。显然,BSD 本身也是随着时间在不断发展变化的。所以较晚参考 BSD 的系统比较早参考 BSD 的系统多一些特性。所以理解 BSD socket 实现是理解其他 socket 实现的基石。下面我们就分析一下 BSD socket 实现。

在这之前,我们首先要明白如何唯一识别 TCP/UDP 连接。TCP/UDP 是由以下五元组唯一地识别的:

{<protocol>, <src addr>, <src port>, <dest addr>, <dest port>}
 

这些数值组成的任何独特的组合可以唯一地确一个连接。

那么,对于任意连接,这五个值都不能完全相同。 否则的话操作系统就无法区别这些连接了。

一个 socket 的协议是在用 socket()初始化的时候就设置好的。

源地址(source address)和源端口(source port)在调用 bind()的时候设置。

目的地址(destination address)和目的端口(destination port)在调用 connect()的时候设置。

其中 UDP 是无连接的,UDP socket 可以在未与目的端口连接的情况下使用。但 UDP 也可以在某些情况下先与目的地址和端口建立连接后使用。在使用无连接 UDP 发送数据的情况下,如果没有显式地调用 bind(),操作系统会在第一次发送数据时自动将 UDP socket 与本机的地址和某个端口绑定(否则的话程序无法接受任何远程主机回复的数据)。同样的,一个没有绑定地址的 TCP socket 也会在建立连接时被自动绑定一个本机地址和端口。

如果我们手动绑定一个端口,我们可以将 socket 绑定至端口 0,绑定至端口 0 的意思是让系统自己决定使用哪个端口(一般是从一组操作系统特定的提前决定的端口数范围中),所以也就是任何端口的意思。同样的,我们也可以使用一个通配符来让系统决定绑定哪个源地址(ipv4 通配符为 0.0.0.0,ipv6 通配符为::)。而与端口不同的是,一个 socket 可以被绑定到主机上所有接口所对应的地址中的任意一个。基于连接在本 socket 的目的地址和路由表中对应的信息,操作系统将会选择合适的地址来绑定这个 socket,并用这个地址来取代之前的通配符 IP 地址。

在默认情况下,任意两个socket不能被绑定在同一个源地址和源端口组合上。比如说我们将socketA绑定在A:X地址,将socketB绑定在B:Y地址,其中A和B是IP地址,X和Y是端口。那么在A==B的情况下X!=Y必须满足,在X==Y的情况下A!=B必须满足。需要注意的是,如果某一个socket被绑定在通配符IP地址下,那么事实上本机所有IP都会被系统认为与其绑定了。例如一个socket绑定了0.0.0.0:21,在这种情况下,任何其他 socket 不论选择哪一个具体的 IP 地址,其都不能再绑定在 21 端口下。因为通配符 IP0.0.0.0 与所有本地 IP 都冲突。

以上所有内容基本上在主要操作系统中都相同。而各个中 SO_REUSEADDR 会有不同的含义。首先我们来讨论 BSD 实现。因为 BSD 试试其他所有 socket 实现方法的源头。

SO_REUSEADDR

如果在一个 socket 绑定到某一地址和端口之前设置了其 SO_REUSEADDR 的属性,那么除非本 socket 与产生了尝试与另一个 socket 绑定到完全相同的源地址和源端口组合的冲突,否则的话这个 socket 就可以成功的绑定这个地址端口对。这听起来似乎和之前一样。但是其中的关键字是完全。SO_REUSEADDR 主要改变了系统对待通配符 IP 地址冲突的方式。

如果不用SO_REUSEADDR的话,如果我们将socketA绑定到0.0.0.0:21,那么任何将本机其他socket绑定到端口21的举动(如绑定到192.168.1.1:21)都会导致EADDRINUSE错误。因为0.0.0.0是一个通配符IP地址,意味着任意一个IP地址,所以任何其他本机上的IP地址都被系统认为已被占用。如果设置了SO_REUSEADDR选项,因为0.0.0.0:21和192.168.1.1:21并不是完全相同的地址端口对(其中一个是通配符 IP 地址,另一个是一个本机的具体 IP 地址),所以这样的绑定是可以成功的。需要注意的是,无论 socketA 和 socketB 初始化的顺序如何,只要设置了 SO_REUSEADDR,绑定都会成功;而只要没有设置 SO_REUSEADDR,绑定都不会成功。

SO_REUSEADDRsocketAsocketBResult
ON / OFF192.168.1.1:21192.168.1.1:21ERROR(EADDRINUSE)
ON / OFF192.168.1.1:2110.0.1.1:21OK
ON / OFF10.0.1.1:21192.168.1.1:21OK
OFF192.168.1.1:210.0.0.0:21ERROR(EADDRINUSE)
OFF0.0.0.0:21192.168.1.1:21ERROR(EADDRINUSE)
ON192.168.1.1:210.0.0.0:21OK
ON0.0.0.0:21192.168.1.1:21OK
ON / OFF0.0.0.0:210.0.0.0:21OK

这个表格假定 socketA 已经成功地绑定了表格中对应的地址,然后 socketB 被初始化了,其 SO_REUSEADDR 设置的情况如表格第一列所示,然后 socketB 试图绑定表格中对应地址。Result 列是其绑定的结果。如果第一列中的值是 ON/OFF,那么 SO_REUSEADDR 设置与否都与结果无关。

上面讨论了 SO_REUSEADDR 对通配符 IP 地址的作用,但其并不只有这一作用。其另一作用也是为什么大家在进行服务器端编程的时候会采用 SO_REUSEADDR 选项的原因。为了理解其另一个作用及其重要应用,我们需要先更深入地讨论一下 TCP 协议的工作原理。

每一个 socket 都有其相应的发送缓冲区(buffer)。当成功调用其 send()方法的时候,实际上我们所要求发送的数据并不一定被立即发送出去,而是被添加到了发送缓冲区中。对于 UDP socket 来说,即使不是马上被发送,这些数据一般也会被很快发送出去。但对于 TCP socket 来说,在将数据添加到发送缓冲区之后,可能需要等待相对较长的时间之后数据才会被真正发送出去。因此,当我们关闭了一个 TCP socket 之后,其发送缓冲区中可能实际上还仍然有等待发送的数据。但此时因为 send()返回了成功,我们的代码认为数据已经实际上被成功发送了。如果 TCP socket 在我们调用 close()之后直接关闭,那么所有这些数据都将会丢失,而我们的代码根本不会知道。但是,TCP 是一个可靠的传输层协议,直接丢弃这些待传输的数据显然是不可取的。实际上,如果在 socket 的发送缓冲区中还有待发送数据的情况下调用了其 close()方法,其将会进入一个所谓的 TIME_WAIT 状态。在这个状态下,socket 将会持续尝试发送缓冲区的数据直到所有数据都被成功发送或者直到超时,超时被触发的情况下 socket 将会被强制关闭。

操作系统的 kernel 在强制关闭一个 socket 之前的最长等待时间被称为延迟时间(Linger Time)。在大部分系统中延迟时间都已经被全局设置好了,并且相对较长(大部分系统将其设置为 2 分钟)。我们也可以在初始化一个 socket 的时候使用 SO_LINGER 选项来特定地设置每一个 socket 的延迟时间。我们甚至可以完全关闭延迟等待。但是需要注意的是,将延迟时间设置为 0(完全关闭延迟等待)并不是一个好的编程实践。因为优雅地关闭 TCP socket 是一个比较复杂的过程,过程中包括与远程主机交换数个数据包(包括在丢包的情况下的丢失重传),而这个数据包交换的过程所需要的时间也包括在延迟时间中。如果我们停用延迟等待,socket 不止会在关闭的时候直接丢弃所有待发送的数据,而且总是会被强制关闭(由于 TCP 是面向连接的协议,不与远端端口交换关闭数据包将会导致远端端口处于长时间的等待状态)。所以通常我们并不推荐在实际编程中这样做。TCP 断开连接的过程超出了本文讨论的范围,如果对此有兴趣,可以参考这个页面。并且实际上,如果我们禁用了延迟等待,而我们的程序没有显式地关闭 socket 就退出了,BSD(可能包括其他系统)会忽略我们的设置进行延迟等待。例如,如果我们的程序调用了 exit()方法,或者其进程被使用某个信号终止了(包括进程因为非法内存访问之类的情况而崩溃)。所以我们无法百分之百保证一个 socket 在所有情况下忽略延迟等待时间而终止。

这里的问题在于操作系统如何对待处于 TIME_WAIT 阶段的 socket。如果 SO_REUSEADDR 选项没有被设置,处于 TIME_WAIT 阶段的 socket 任然被认为是绑定在原来那个地址和端口上的。直到该 socket 被完全关闭之前(结束 TIME_WAIT 阶段),任何其他企图将一个新 socket 绑定该该地址端口对的操作都无法成功。这一等待的过程可能和延迟等待的时间一样长。所以我们并不能马上将一个新的 socket 绑定到一个刚刚被关闭的 socket 对应的地址端口对上。在大多数情况下这种操作都会失败。

然而,如果我们在新的 socket 上设置了 SO_REUSEADDR 选项,如果此时有另一个 socket 绑定在当前的地址端口对且处于 TIME_WAIT 阶段,那么这个已存在的绑定关系将会被忽略。事实上处于 TIME_WAIT 阶段的 socket 已经是半关闭的状态,将一个新的 socket 绑定在这个地址端口对上不会有任何问题。这样的话原来绑定在这个端口上的 socket 一般不会对新的 socket 产生影响。但需要注意的是,在某些时候,将一个新的 socket 绑定在一个处于 TIME_WAIT 阶段但仍在工作的 socket 所对应的地址端口对会产生一些我们并不想要的,无法预料的负面影响。但这个问题超过了本文的讨论范围。而且幸运的是这些负面影响在实践中很少见到。

最后,关于 SO_REUSEADDR,我们还要注意的一件事是,以上所有内容只要我们对新的 socket 设置了 SO_REUSEADDR 就成立。至于原有的已经绑定在当前地址端口对上的,处于或不处于 TIME_WAIT 阶段的 socket 是否设置了 SO_REUSEADDR 并无影响。决定 bind 操作是否成功的代码仅仅会检查新的被传递到 bind()方法的 socket 的 SO_REUSEADDR 选项。其他涉及到的 socket 的 SO_REUSEADDR 选项并不会被检查。

SO_REUSEPORT

许多人将 SO_REUSEADDR 当成了 SO_REUSEPORT。基本上来说,SO_REUSEPORT 允许我们将任意数目的 socket 绑定到完全相同的源地址端口对上,只要所有之前绑定的 socket 都设置了 SO_REUSEPORT 选项。如果第一个绑定在该地址端口对上的 socket 没有设置 SO_REUSEPORT,无论之后的 socket 是否设置 SO_REUSEPORT,其都无法绑定在与这个地址端口完全相同的地址上。除非第一个绑定在这个地址端口对上的 socket 释放了这个绑定关系。与 SO_REUSEADDR 不同的是 ,处理 SO_REUSEPORT 的代码不仅会检查当前尝试绑定的 socket 的 SO_REUSEPORT,而且也会检查之前已绑定了当前尝试绑定的地址端口对的 socket 的 SO_REUSEPORT 选项。

SO_REUSEPORT 并不等于 SO_REUSEADDR。这么说的含义是如果一个已经绑定了地址的 socket 没有设置 SO_REUSEPORT,而另一个新 socket 设置了 SO_REUSEPORT 且尝试绑定到与当前 socket 完全相同的端口地址对,这次绑定尝试将会失败。同时,如果当前 socket 已经处于 TIME_WAIT 阶段,而这个设置了 SO_REUSEPORT 选项的新 socket 尝试绑定到当前地址,这个绑定操作也会失败。为了能够将新的 socket 绑定到一个当前处于 TIME_WAIT 阶段的 socket 对应的地址端口对上,我们要么需要在绑定之前设置这个新 socket 的 SO_REUSEADDR 选项,要么需要在绑定之前给两个 socket 都设置 SO_REUSEPORT 选项。当然,同时给 socket 设置 SO_REUSEADDR 和 SO_REUSEPORT 选项是也是可以的。

SO_REUSEPORT 是在 SO_REUSEADDR 之后被添加到 BSD 系统中的。这也是为什么现在有些系统的 socket 实现里没有 SO_REUSEPORT 选项。因为它们在这个选项被加入 BSD 系统之前参考了 BSD 的 socket 实现。而在这个选项被加入之前,BSD 系统下没有任何办法能够将两个 socket 绑定在完全相同的地址端口对上。

Connect()返回 EADDRINUSE?

有些时候 bind()操作会返回 EADDRINUSE 错误。但奇怪的是,在我们调用 connect()操作时,也有可能得到 EADDRINUSE 错误。这是为什么呢?为何一个我们尝试令当前端口建立连接的远程地址也会被占用呢?难道将多个 socket 连接到同一个远程地址的操作会有什么问题产生吗?

正如本文之前所说,一个连接关系是由一个五元组确定的。对于任意的连接关系而言,这个五元组必须是唯一的。否则的话,系统将无法分辨两个连接。而现在当我们采用了地址复用之后,我们可以将两个采用相同协议的 socket 绑定到同一地址端口对上。这意味着对这两个 socket 而言,五元组里的{, , }已经相同了。在这种情况下,如果我们尝试将它们都连接到同一个远程地址端口上,这两个连接关系的五元组将完全相同。也就是说,产生了两个完全相同的连接。在 TCP 协议中这是不被允许的(UDP 是无连接的)。如果这两个完全相同的连接种的某一个接收到了数据,系统将无法分辨这个数据到底属于哪个连接。所以在这种情况下,至少这两个 socket 所尝试连接的远程主机的地址和端口不能相同。只有如此,系统才能继续区分这两个连接关系。

所以当我们将两个采用相同协议的 socket 绑定到同一个本地地址端口对上后,如果我们还尝试让它们和同一个目的地址端口对建立连接,第二个尝试调用 connect()方法的 socket 将会报 EADDRINUSE 的错误,这说明一个拥有完全相同的五元组的 socket 已经存在了。

Multicast Address

相对于用于一对一通信的 unicast 地址,multicast 地址用于一对多通信。IPv4 和 IPv6 都拥有 multicast 地址。但是 IPv4 中的 multicast 实际上在公共网路上很少被使用。

SO_REUSEADDR 的意义在 multicast 地址的情况下会与之前有所不同。在这种情况下,SO_REUSEADDR 允许我们将多个 socket 绑定至完全相同的源广播地址端口对上。换句话说,对于 multicast 地址而言,SO_REUSEADDR 的作用相当于 unicast 通信中的 SO_REUSEPORT。事实上,在 multicast 情况下,SO_REUSEADDR 和 SO_REUSEPORT 的作用完全相同。

其他 操作系统 的差异性

FreeBSD/OpenBSD/NetBSD

所有这些系统都是参考了较新的原生 BSD 系统代码。所以这三个系统提供与 BSD 完全相同的 socket 选项,这些选项的含义与原生 BSD 完全相同。

MacOS X

MacOS X 的核心代码实现是基于较新版本的原生 BSD 的 BSD 风格的 UNIX,所以 MacOS X 提供与 BSD 完全相同的 socket 选项,并且它们的含义也与 BSD 系统相同。

iOS

iOS 事实上是一个略微改造过的 MacOS X,所以适用于 MacOS X 的也适用于 iOS。

Linux

在 Linux3.9 之前,只有 SO_REUSEADDR 选项存在。这个选项的作用基本上同 BSD 系统下相同。但其仍有两个重要的区别。

第一个区别是如果一个处于监听(服务器)状态下的 TCP socket 已经被绑定到了一个通配符 IP 地址和一个特定端口下,那么不论这两个 socket 有没有设置 SO_REUSEADDR 选项,任何其他 TCP socket 都无法再被绑定到相同的端口下。即使另一个 socket 使用了一个具体 IP 地址(像在 BSD 系统中允许的那样)也不行。而非监听(客户)TCP socket 则无此限制。

第二个区别是对于 UDP socket 来说,SO_REUSEADDR 的作用和 BSD 中 SO_REUSEPORT 完全相同。所以两个 UDP socket 如果都设置了 SO_REUSEADDR 的话,它们就可以被绑定在一组完全相同的地址端口对上。

Linux3.9 加入了 SO_REUSEPORT 选项。只要所有 socket(包括第一个)在绑定地址前设置了这个选项,两个或多个,TCP 或 UDP,监听(服务器)或非监听(客户)socket 就可以被绑定在完全相同的地址端口组合下。同时,为了防止端口劫持(port hijacking),还有一个特别的限制:所有试图绑定在相同的地址端口组合的 socket 必须属于拥有相同用户 ID 的进程。所以一个用户无法从另一个用户那里“偷窃”端口。

除此之外,对于设置了 SO_REUSEPORT 选项的 socket,Linux kernel 还会执行一些别的系统所没有的特别的操作:对于绑定于同一地址端口组合上的 UDP socket,kernel 尝试在它们之间平均分配收到的数据包;对于绑定于同一地址端口组合上的 TCP 监听 socket,kernel 尝试在它们之间平均分配收到的连接请求(调用 accept()方法所得到的请求)。这意味着相比于其他允许地址复用但随机将收到的数据包或者连接请求分配给连接在同一地址端口组合上的 socket 的系统而言,Linux 尝试了进行流量分配上的优化。比如一个简单的服务器进程的几个不同实例可以方便地使用 SO_REUSEPORT 来实现一个简单的负载均衡,而且这个负载均衡有 kernel 负责, 对程序来说完全免费!

Android

Android 的核心部分是略微修改过的 Linux kernel,所以所有适用于 Linux 的操作也适用于 Android。

Windows

Windows 仅有 SO_REUSEADDR 选项。在 Windows 中对一个 socket 设置 SO_REUSEADDR 的效果与在 BSD 下同时对一个 socket 设置 SO_REUSEPORT 和 SO_REUSEADDR 相同。但其区别在于:即使另一个已绑定地址的 socket 并没有设置 SO_REUSEADDR,一个设置了 SO_REUSEADDR 的 socket 总是可以绑定到与另一个已绑定的 socket 完全相同的地址端口组合上。这个行为可以说是有些危险的。因为它允许了一个应用从另一个引用已连接的端口上偷取数据。微软意识到了这个问题,因此添加了另一个 socket 选项:SO_EXCLUSIVEADDRUSE。对一个 socket 设置 SO_EXCLUSIVEADDRUSE 可以确保一旦该 socket 绑定了一个地址端口组合,任何其他 socket,不论设置 SO_REUSEADDR 与否,都无法再绑定当前的地址端口组合。

Solaris

Solaris 是 SunOS 的继任者。SunOS 从某种程度上来说也是一个较早版本的 BSD 的一个支路。因此 Solaris 只提供 SO_REUSEADDR,且其表现和 BSD 系统中基本相同。据我所知,在 Solaris 系统中无法实现与 SO_REUSEPORT 相同的功能。这意味着在 Solaris 中无法将两个 socket 绑定到完全相同的地址端口组合下。

与 Windows 类似的是,Solaris 也为 socket 提供独占绑定的选项——SO_EXCLBIND。如果一个 socket 在绑定地址前设置了这个选项,即使其他 socket 设置了 SO_REUSEADDR 也将无法绑定至相同地址。例如:如果 socketA 绑定在了通配符 IP 地址下,而 socketB 设置了 SO_REUSEADDR 且绑定在一个具体 IP 地址和与 socketA 相同的端口的组合下,这个操作在 socketA 没有设置 SO_EXCLBIND 的情况下会成功,否则会失败。