socket 端口复用 SO_REUSEPORT 与 SO_REUSEADDR

背景

在学习 SO_REUSEADDR 地址复用的时候,看到有人提到了 SO_REUSEPORT 。于是也了解了一下。

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_REUSEADDR socketA socketB Result
ON / OFF 192.168.1.1:21 192.168.1.1:21 ERROR(EADDRINUSE)
ON / OFF 192.168.1.1:21 10.0.1.1:21 OK
ON / OFF 10.0.1.1:21 192.168.1.1:21 OK
OFF 192.168.1.1:21 0.0.0.0:21 ERROR(EADDRINUSE)
OFF 0.0.0.0:21 192.168.1.1:21 ERROR(EADDRINUSE)
ON 192.168.1.1:21 0.0.0.0:21 OK
ON 0.0.0.0:21 192.168.1.1:21 OK
ON / OFF 0.0.0.0:21 0.0.0.0:21 OK

这个表格假定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的情况下会成功,否则会失败。

posted @ 2020-03-23 17:05  schips  阅读(6532)  评论(1编辑  收藏  举报