Linux 端口复用:SO_REUSEPORT

前言

Linux 3.9版本也添加了SO_REUSEPORT选项。具体请参考:https://kernelnewbies.org/Linux_3.9#Networking:soreuseport。

SO_REUSEPORT允许多个监听套接字绑定到同一个端口,这在像Web服务器绑定到端口80并使用多个线程的情况下非常有用,每个线程可能有自己的监听套接字。这种方式提供了两种常用模式的替代方案:

1)使用单个监听线程将已完成的连接分派给工作线程。
2)多个线程共享单个监听套接字。

在第一种模式中,当连接的流动率很高时,监听线程可能成为瓶颈。这意味着线程可能无法跟上传入的连接,导致性能下降。

在第二种模式中,多个线程在一个简单的事件循环中对单个监听套接字执行accept()调用,接受连接。在高负载情况下,接受到的连接在各个线程之间分布不均匀。这是因为该方法中使用的唤醒机制不能保证套接字间的公平性,导致某些线程接受的连接数量明显多于其他线程。这种不平衡可能导致某些线程和CPU核心的利用率不高。

SO_REUSEPORT使连接在各个线程之间均匀分布。每个线程可以拥有自己的监听套接字,并且传入的连接会均匀地分配到这些套接字上。这确保了连接在各个线程之间的负载均衡,最大限度地利用CPU核心,并提高整体性能。

该特性允许同一机器上的多个进程同时创建不同的 socket 来 bind 和 listen 在相同的端口上。然后在内核层面实现多个用户进程的负载均衡。

对于Linux端口复用参考第4、5、6章节即可。

一、BSD socket

1.1 简介

BSD套接字实现是所有套接字实现的鼻祖。基本上,所有其他系统在某个时间点都复制了BSD套接字实现(至少是其接口),然后开始根据自己的需求进行演进。当然,BSD套接字实现也在同时演进,因此后来复制它的系统得到了之前复制它的系统所缺乏的功能。理解BSD套接字实现是理解所有其他套接字实现的关键,因此即使您不打算编写适用于BSD系统的代码,也应该了解它。

在不同操作系统之间进行套接字代码的可移植性可能会具有挑战性,因为实现细节、API和功能可能存在差异。操作系统通常会根据特定环境和需求提供附加功能或优化。因此,在开发套接字应用程序时,考虑到可移植性方面的因素并将不同操作系统的变化和具体行为纳入考虑是至关重要的。

在我们深入研究这两个选项之前,有几个基本概念你需要了解。TCP/UDP连接通过一个由五个值组成的元组来进行唯一标识:

{<protocol>, <src addr>, <src port>, <dest addr>, <dest port>}
{<协议>, <源地址>, <源端口>, <目标地址>, <目标端口>}

这些值的唯一组合标识了一个连接。因此,没有两个连接可以有完全相同的五个值,否则系统将无法再区分这些连接。

套接字的协议是在使用socket()函数创建套接字时设置的。源地址和端口通过bind()函数设置。目标地址和端口通过connect()函数设置。由于UDP是一种无连接的协议,可以在不连接的情况下使用UDP套接字。然而,允许将它们连接在一起,在某些情况下对您的代码和整体应用程序设计非常有优势。在无连接模式下,通常会由系统自动绑定未显式绑定的UDP套接字,因为未绑定的UDP套接字无法接收任何(回复)数据。对于未绑定的TCP套接字也是如此,在连接之前会自动绑定。

如果您显式绑定了套接字,可以将其绑定到端口0,表示"任意端口"。由于套接字实际上不能绑定到所有现有的端口,系统将在这种情况下选择一个特定的端口(通常是从预定义的、特定于操作系统的源端口范围中选择)。源地址也存在类似的通配符,可以是"任意地址"(对于IPv4是0.0.0.0,对于IPv6是::)。与端口不同,套接字确实可以绑定到"任意地址",这意味着"所有本地接口的所有源IP地址"。如果套接字稍后连接,系统必须选择一个特定的源IP地址,因为套接字不能同时连接和绑定到任何本地IP地址。根据目标地址和路由表的内容,系统将选择一个合适的源地址,并使用所选的源IP地址替换"任意"绑定。

默认情况下,不能将两个套接字绑定到相同的源地址和源端口组合上。只要源端口不同,源地址实际上是无关紧要的。如果满足ipA != ipB,则可以将socketA绑定到ipA:portA,将socketB绑定到ipB:portB,即使portA == portB也是如此。例如,socketA属于一个FTP服务器程序,并绑定到192.168.0.1:21,socketB属于另一个FTP服务器程序,并绑定到10.0.0.1:21,两个绑定都将成功。请注意,套接字可能本地绑定到"任意地址"。如果套接字绑定到0.0.0.0:21,它同时绑定到所有现有的本地地址,在这种情况下,无论尝试绑定到哪个特定的IP地址,都不能将另一个套接字绑定到端口21,因为0.0.0.0与所有现有的本地IP地址冲突。

到目前为止,所说的内容在所有主要操作系统中基本相同。当地址重用开始发挥作用时,事情开始变得与操作系统相关。我们从BSD开始,因为如上所述,它是所有套接字实现的鼻祖。

1.2 SO_REUSEADDR

如果在绑定套接字之前启用了SO_REUSEADDR选项,除非与另一个套接字绑定到完全相同的源地址和端口组合存在冲突,否则套接字可以成功绑定。现在你可能想知道这与之前有什么不同?关键在于"完全相同"。当搜索冲突时,SO_REUSEADDR主要改变了通配符地址(“任意IP地址”)的处理方式。

如果没有使用SO_REUSEADDR,将套接字A绑定到0.0.0.0:21,然后将套接字B绑定到192.168.0.1:21将失败(返回错误EADDRINUSE),因为0.0.0.0表示"任意本地IP地址",因此该套接字认为所有本地IP地址都已被使用,包括192.168.0.1在内。而使用SO_REUSEADDR,绑定将成功,因为0.0.0.0和192.168.0.1并不完全相同,一个是所有本地地址的通配符,另一个是非常具体的本地地址。请注意,无论以何种顺序绑定套接字A和套接字B,如果没有使用SO_REUSEADDR,它总是会失败;如果使用了SO_REUSEADDR,它总是会成功。

为了给你一个更好的概述,让我们在这里做一个表,列出所有可能的组合:

SO_REUSEADDR       socketA        socketB       Result
---------------------------------------------------------------------
  ON/OFF       192.168.0.1:21   192.168.0.1:21    Error (EADDRINUSE)
  ON/OFF       192.168.0.1:21      10.0.0.1:21    OK
  ON/OFF          10.0.0.1:21   192.168.0.1:21    OK
   OFF             0.0.0.0:21   192.168.1.0:21    Error (EADDRINUSE)
   OFF         192.168.1.0:21       0.0.0.0:21    Error (EADDRINUSE)
   ON              0.0.0.0:21   192.168.1.0:21    OK
   ON          192.168.1.0:21       0.0.0.0:21    OK
  ON/OFF           0.0.0.0:21       0.0.0.0:21    Error (EADDRINUSE)

上表假设socketA已经成功绑定到为socketA给定的地址,然后创建socketB,要么设置SO_REUSEADDR,要么不设置,最后绑定到为套接字B给定的地址。Result是套接字B的绑定操作的结果。如果第一列显示ON/OFF,那么SO_REUSEADDR的值与结果无关。

好的,SO_REUSEADDR对通配符地址有影响,很高兴知道。然而,这并不是它的唯一效果。还有一个众所周知的效果,这也是为什么大多数人首先在服务器程序中使用SO_REUSEADDR的原因。对于此选项的其他重要用途,我们必须深入了解TCP协议的工作原理。

当关闭TCP套接字时,通常会执行一个 3-way handshake (or 4-way handshake – 请参考1.2.1) 的序列;这个序列称为FIN-ACK。问题在于,该序列的最后一个ACK可能已经到达对方,也可能尚未到达。只有在它到达对方时,对方才会认为套接字完全关闭。为了防止重用可能仍被某个远程对等方视为打开的地址+端口组合,系统在发送最后一个ACK后不会立即将套接字视为关闭,而是将套接字放入一个常被称为TIME_WAIT的状态。在该状态下,套接字可能会保持数分钟(取决于系统设置)。在大多数系统上,可以通过启用linger并将linger时间设置为零来绕过该状态,但不能保证总是可行,系统是否始终支持此请求,即使系统支持,这也会导致套接字被重置(RST)关闭,这不总是一个好主意。要了解有关linger时间的更多信息,请参阅1.2.2 章节 SO_LINGER。

在TIME_WAIT状态下,如果没有设置SO_REUSEADDR,系统将认为套接字仍然绑定到源地址和端口,任何尝试将新套接字绑定到相同的地址和端口都将失败,直到套接字真正关闭。因此,在关闭套接字后立即重新绑定源地址的期望通常会失败。在大多数情况下,这将失败。然而,如果对要绑定的套接字设置了SO_REUSEADDR,那么一个处于TIME_WAIT状态且绑定到相同地址和端口的另一个套接字将被简单忽略,毕竟它已经是"半死不活"了,您的套接字可以顺利绑定到完全相同的地址而没有任何问题。在这种情况下,另一个套接字可能具有完全相同的地址和端口并不重要。请注意,将套接字绑定到与处于TIME_WAIT状态的关闭套接字完全相同的地址和端口可能会产生意外的、通常是不希望的副作用,如果另一个套接字仍然在工作中,但这超出了本回答的范围,幸运的是,在实践中,这些副作用相当罕见。

关于SO_REUSEADDR,还有一件重要的事情需要知道。前面提到的一切只有在要绑定的套接字启用了地址重用时才有效。并不需要另一个套接字(已经绑定或处于TIME_WAIT状态的套接字)在绑定时也设置了这个标志。决定绑定成功或失败的代码只检查传递给bind()调用的套接字的SO_REUSEADDR标志,对于其他检查的套接字,甚至不会查看这个标志。

1.2.1 3-way or 4-way handshake

当关闭TCP套接字时,通常会执行一个 3-way handshake(or 4-way handshake) 的序列,如下图所示:
在这里插入图片描述
图片来自于:图解网络

CLOSE-WAIT:正在等待来自服务端的连接终止请求。
LAST-ACK:确定客户端关闭中止,服务端套接字已关闭。等待客户端确认。

从这两个状态来在某种情况下可以合并成一个状态:当收到客户端关闭中止请求后,服务端套接字已关闭,不需要等待。

我们常说 Closing a Connection 是四次挥手过程,在如下场景下其实也可以是三次挥手过程,服务端可以把第二步的 ACK 和 第三步的 FIN 作为一次挥手过程。
虽然四次挥手是终止TCP连接的典型方法,但也可以使用三次挥手来终止连接,通常是这种场景:
这种三次握手的连接终止方式可以在两台主机都同意同时关闭连接的情况下使用,或者在服务端没有更多数据要发送,并且在接收到客户端的数据后希望启动终止过程时使用。
即服务端收到客户端的 close请求后,服务端也没有数据要发送,然后自己也调用close ,发送关闭请求。

虽然四次握手是终止TCP连接的典型方法,但也可以使用三次握手来终止连接:
在这种情况下,一台主机(主机A)通过向另一台主机(主机B)发送FIN(Finish)数据包来启动连接终止过程。主机B不会分别回复FIN和ACK(Acknowledgment)数据包,而是在一个数据包中同时设置FIN和ACK标志,表示对收到的数据的确认,并表明它要关闭连接。最后,主机A通过发送一个ACK数据包来确认收到来自主机B的FIN和ACK数据包。

这种三次握手的连接终止方式可以在两台主机都同意同时关闭连接的情况下使用,或者在主机B没有更多数据要发送,并且在接收到主机A的数据后希望启动终止过程时使用。如下图所示:
在这里插入图片描述
如果服务端收到客户端的关闭请求后,还有数据要发送,那么第二步的 ACK 和 第三步的 FIN 不能合并在一起发送,需要四次挥手过程。

1.2.2 SO_LINGER

当一个TCP套接字断开连接时,系统需要考虑以下三个问题:

(1)可能仍然存在未发送的数据在套接字的发送缓冲区中,如果立即关闭套接字,这些数据将会丢失。

(2)可能仍然有数据在传输过程中,也就是说,数据已经发送到对方,但对方还没有确认正确接收该数据,这些数据可能需要重新发送,否则将会丢失。

(3)关闭TCP套接字是一个 3-way handshake(or 4-way handshake) 的过程,没有对第三个数据包的确认。由于发送方不知道第三个数据包是否已经到达,它必须等待一段时间,观察第二个数据包是否被重新发送。如果是,那么第三个数据包已经丢失,必须重新发送。

简单来点来说就是确保接受方能够正确的关闭套接字,如果发送方没有等待一段时间,那么当第三个数据包ACK丢失,接收方又会重新发送第二个数据包FIN & ACK,发送发就会发送回 RST 报文,接受方就不能正确的关闭套接字。

当使用close()调用关闭套接字时,系统通常不会立即销毁套接字,而是会首先尝试解决上述的三个问题,以防止数据丢失并确保干净的断开连接。所有这些操作都在后台进行(通常在操作系统内核中),因此尽管close()调用立即返回,套接字可能仍然存在一段时间,甚至可能发送剩余的数据。系统有一个特定的最长时间限制,系统将在放弃之前尝试进行干净的断开连接,即使这意味着数据会丢失。请注意,这个时间限制可以在几分钟的范围内!

有一个套接字选项名为SO_LINGER,它控制系统如何关闭套接字。您可以使用该选项打开或关闭延迟关闭,并且如果打开延迟关闭,还可以设置超时时间(即使关闭延迟关闭,也可以设置超时时间,但该超时时间没有任何效果)。

默认情况下,延迟关闭是关闭的,这意味着close()调用立即返回,套接字关闭过程的细节由系统处理,通常按照上述描述进行处理。

如果您打开了延迟关闭并设置了非零的超时时间,那么close()调用将不会立即返回。它只会在解决了问题(1)和(2)(所有数据已发送,没有数据在传输中)或达到超时时间时才会返回。通过close调用的结果可以看出是哪种情况。如果返回成功,表示所有剩余数据都已发送并得到确认;如果返回失败并且errno被设置为EWOULDBLOCK,则表示已到达超时时间,可能会丢失一些数据。

对于非阻塞套接字,close()调用不会阻塞,即使设置了非零的延迟关闭时间。在这种情况下,无法获取close操作的结果,因为您不能在同一个套接字上多次调用close()。即使套接字正在延迟关闭,一旦close()返回,套接字文件描述符应该已失效,使用该描述符再次调用close()应该会失败,errno被设置为EBADF(“坏的文件描述符”)。

然而,即使将延迟关闭时间设置得非常短,比如一秒钟,如果套接字不会延迟超过一秒钟,它仍然会在延迟关闭后保持一段时间以处理上述的第三个问题。为了确保干净的断开连接,实现必须确保对方也已经断开了该连接,否则剩余的数据仍可能到达已经关闭的连接。因此,套接字将进入大多数系统称为TIME_WAIT的状态,并在该状态下保持一段特定的时间,无论延迟关闭是否开启,无论设置了什么延迟时间。

除了一种特殊情况:如果启用了延迟关闭,但将延迟时间设置为零,那么情况会发生很大改变。在这种情况下,调用close()将立即关闭套接字。这意味着无论套接字是阻塞还是非阻塞,close()都会立即返回。发送缓冲区中的任何数据都会被丢弃。传输中的数据被忽略,可能已经正确到达对方,也可能没有。而且套接字也不会使用正常的TCP关闭握手过程(FIN-ACK)进行关闭,而是立即使用复位(RST)进行关闭。因此,如果对方在复位之后尝试通过套接字发送数据,该操作将以ECONNRESET(“连接被对等方强制关闭”)失败,而正常的关闭操作将导致EPIPE(“套接字不再连接”)。尽管大多数程序将EPIPE视为无害的事件,但如果它们没有预期到ECONNRESET的发生,它们往往会将其视为严重错误。

请注意,这描述的是原始的BSD套接字实现中的套接字行为(原始意味着这可能与现代BSD实现(如FreeBSD,OpenBSD,NetBSD等)的行为不匹配)。尽管BSD套接字API已被几乎所有其他主要操作系统(如Linux,Android,Windows,macOS,iOS等)所复制,但这些系统上的行为有时会有所不同,这也适用于该API的许多其他方面。

例如,在BSD上,如果关闭非阻塞套接字并启用了延迟关闭,并且延迟时间不为零,并且发送缓冲区中有数据,则close()调用将立即返回,但会指示失败,并将错误设置为EWOULDBLOCK(就像在阻塞套接字上超过延迟时间后的情况一样)。Windows的情况也是如此。但在macOS上,情况并非如此,无论发送缓冲区中是否有数据,close()都会立即返回并指示成功。而在Linux的情况下,即使套接字是非阻塞的,close()调用在这种情况下也会阻塞直到延迟时间到期

正如您所看到的,套接字的行为也可能会根据先前调用的shutdown()以及其他特定于系统的因素发生变化,包括设置延迟超时时间,即使完全关闭了延迟设置。

另一个特定于系统的行为是,如果进程在关闭套接字之前意外终止,系统将代表您关闭套接字,并且某些系统在执行此操作时往往会忽略任何延迟设置,并回退到系统的默认行为。在这种情况下,它们无法在套接字关闭时进行"阻塞",但某些系统甚至会忽略超时为零,并在这种情况下执行FIN-ACK操作。

因此,设置延迟超时为零并不能确保套接字永远不会进入TIME_WAIT状态。它取决于套接字是如何关闭的(通过shutdown()、close()),由谁关闭的(您自己的代码还是系统),它是阻塞的还是非阻塞的,以及最终取决于您的代码运行在哪个系统上。唯一可以说的真实陈述是:

如果您手动关闭一个阻塞的套接字(至少在您关闭它的那一刻是阻塞的,之前可能是非阻塞的),并且此套接字启用了延迟关闭并设置了超时为零,那么这是您避免套接字进入TIME_WAIT状态的最佳机会。虽然不能保证它不会进入TIME_WAIT状态,但如果这样做都无法防止它发生,那么除非您有一种方法确保对方同行将为您启动关闭操作,否则您无法采取其他任何措施来防止它发生;因为只有发起关闭操作的一方可能最终处于TIME_WAIT状态。

因此,我个人的专业提示是:如果您设计一个服务器-客户端协议,请设计成通常由客户端首先关闭连接,因为通常服务器套接字最终进入TIME_WAIT状态是非常不可取的,但更不可取的是通过RST关闭连接,因为这可能导致之前发送到客户端的数据丢失。

close 函数:
close 函数,同时 socket 关闭发送方向和读取方向,也就是 socket 不再有发送和接收数据的能力。如下图所示:
在这里插入图片描述
如果客户端是用 close 函数来关闭连接,那么在 TCP 四次挥手过程中,如果收到了服务端发送的数据,由于客户端已经不再具有发送和接收数据的能力,所以客户端的内核会回 RST 报文给服务端,然后内核会释放连接,这时就不会经历完成的 TCP 四次挥手。

shutdown 函数:
shutdown 函数,可以指定 socket 只关闭发送方向而不关闭读取方向,也就是 socket 不再有发送数据的能力,但是还是具有接收数据的能力。如下图所示:
在这里插入图片描述
shutdown 函数因为可以指定只关闭发送方向而不关闭读取方向,所以即使在 TCP 四次挥手过程中,如果收到了服务端发送的数据,客户端也是可以正常读取到该数据的,然后就会经历完整的 TCP 四次挥手。

1.3 SO_REUSEPORT

SO_REUSEPORT是大多数人所期望的SO_REUSEADDR的行为。基本上,SO_REUSEPORT允许你将任意数量的套接字绑定到完全相同的源地址和端口,只要所有之前绑定的套接字在绑定之前也设置了SO_REUSEPORT。如果第一个绑定到某个地址和端口的套接字没有设置SO_REUSEPORT,那么其他套接字无论是否设置了SO_REUSEPORT,都无法绑定到完全相同的地址和端口,直到第一个套接字再次释放绑定。与SO_REUSEADDR的情况不同,处理SO_REUSEPORT的代码不仅会验证当前绑定的套接字是否设置了SO_REUSEPORT,而且还会验证具有冲突地址和端口的套接字在绑定时是否设置了SO_REUSEPORT。

SO_REUSEPORT并不意味着SO_REUSEADDR。这意味着如果一个套接字在绑定时没有设置SO_REUSEPORT,而另一个套接字在绑定到完全相同的地址和端口时设置了SO_REUSEPORT,那么绑定将失败,这是可以预期的,但如果另一个套接字已经处于正在关闭并处于TIME_WAIT状态,绑定也会失败。要能够将套接字绑定到与处于TIME_WAIT状态的另一个套接字相同的地址和端口,需要在该套接字上设置SO_REUSEADDR或在绑定它们之前在两个套接字上都设置了SO_REUSEPORT。当然,也可以在套接字上同时设置SO_REUSEPORT和SO_REUSEADDR。

关于SO_REUSEPORT没有更多要说的了,除了它比SO_REUSEADDR添加得晚一些,所以你在许多其他系统的套接字实现中找不到它,这些系统在此选项添加之前就进行了“分叉”,而且在此选项添加之前,在BSD中没有办法将两个套接字绑定到完全相同的套接字地址上。

二、Connect() Returning EADDRINUSE

当你开始尝试地址重用时,大多数人都知道bind()可能会失败并返回EADDRINUSE错误。然而,你可能会遇到一个奇怪的情况,即connect()也会因为相同的错误而失败。这是怎么回事呢?毕竟,连接(connection)是由远程地址组成的,这是connect()添加到套接字的,怎么会出现已经被使用的情况呢?在以前,将多个套接字连接到完全相同的远程地址从未成为问题,所以这里出了什么问题呢?

正如我在回答开头所说的,一个连接由五个值的元组定义,还记得吗?而且我还说过,这五个值必须是唯一的,否则系统无法再区分两个连接,对吧?好吧,使用地址重用,你可以将两个相同协议的套接字绑定到相同的源地址和端口。这意味着对于这两个套接字来说,其中三个值已经是相同的。如果你现在尝试将这两个套接字都连接到相同的目标地址和端口,你将创建两个连接的套接字,它们的元组完全相同。这是行不通的,至少对于TCP连接来说是这样(UDP连接本身就不是真正的连接)。如果数据到达任意一个连接,系统无法确定数据属于哪个连接。至少目标地址或目标端口对于每个连接都必须不同,这样系统就不会有问题,可以确定传入数据属于哪个连接。

因此,如果你将两个相同协议的套接字绑定到相同的源地址和端口,并尝试将它们都连接到相同的目标地址和端口,connect()实际上会失败,并返回EADDRINUSE错误,表示已经存在一个具有相同五个值元组的连接套接字。

三、Multicast Addresses

大多数人忽视了多播地址的存在,但它们确实存在。尽管单播地址用于一对一通信,但多播地址用于一对多通信。大多数人在了解IPv6时才意识到多播地址的存在,但在IPv4中也存在多播地址,尽管这个特性在公共互联网上从未被广泛使用。

对于多播地址,SO_REUSEADDR的含义发生了变化,它允许多个套接字绑定到完全相同的源多播地址和端口组合。换句话说,对于多播地址,SO_REUSEADDR的行为与单播地址的SO_REUSEPORT完全相同。实际上,代码对待多播地址时对SO_REUSEADDR和SO_REUSEPORT的处理是相同的,这意味着你可以说对于所有多播地址,SO_REUSEADDR意味着SO_REUSEPORT,反之亦然。

四、Linux

4.1 Linux < 3.9

在Linux 3.9之前,只有SO_REUSEADDR选项存在。这个选项的行为通常与BSD相同,但有两个重要的例外:
(1)只要一个监听(服务器)TCP套接字绑定到特定的端口,对于所有针对该端口的套接字,SO_REUSEADDR选项将完全被忽略。只有在没有设置SO_REUSEADDR的情况下,在BSD中也可以绑定第二个套接字到相同的端口。例如,如果设置了SO_REUSEADDR,则无法先绑定到通配地址,然后绑定到更具体的地址,反之亦然,在BSD中这两种情况都是可能的。但是你可以绑定到相同的端口和两个不同的非通配地址,因为这总是被允许的。在这一方面,Linux比BSD更加限制。

(2)第二个例外是对于客户端套接字,只要在绑定之前都设置了这个选项,它的行为就与BSD中的SO_REUSEPORT完全相同。之所以允许这样做,只是因为在各种协议中,能够将多个套接字绑定到完全相同的UDP套接字地址非常重要,在3.9之前没有SO_REUSEPORT,因此根据需要修改了SO_REUSEADDR的行为来填补这个空白。在这一方面,Linux比BSD更加宽松。

4.2 Linux >= 3.9

Linux 3.9版本也添加了SO_REUSEPORT选项。这个选项的行为与BSD中的选项完全相同,只要在绑定之前为所有套接字设置了该选项,就允许将其绑定到完全相同的地址和端口号。

然而,在其他系统上与SO_REUSEPORT相比,仍然存在两个区别:

(1)为了防止"端口劫持(port hijacking)",有一个特殊限制:所有要共享相同地址和端口组合的套接字必须属于具有相同有效用户ID的进程!因此,一个用户无法"窃取"另一个用户的端口。这是一种特殊的机制,部分弥补了缺少SO_EXCLBIND/SO_EXCLUSIVEADDRUSE标志的影响。

(2)此外,内核对于SO_REUSEPORT套接字执行一些"特殊魔法",这在其他操作系统中是找不到的:对于UDP套接字,它尝试均匀分发数据报对于TCP监听套接字,它尝试均匀分配传入的连接请求(通过调用accept()接受的请求)给共享相同地址和端口组合的所有套接字。因此,一个应用程序可以轻松在多个子进程中打开相同的端口,并使用SO_REUSEPORT实现非常廉价的负载均衡。

五、Linux SO_REUSEPORT socket option

在3.9开发周期中合并的一个功能是对SO_REUSEPORT套接字选项的TCP和UDP支持。这个支持是由Tom Herbert通过一系列补丁实现的。这个新的套接字选项允许同一主机上的多个套接字绑定到同一个端口,旨在提高在多核系统上运行的多线程网络服务器应用程序的性能。

在传统的套接字编程中,一次只能有一个套接字绑定到特定的地址和端口。这导致在多线程服务器应用程序中,如果每个线程都需要监听相同的端口,则需要进行复杂的线程同步和负载均衡操作。而SO_REUSEPORT套接字选项的引入则改变了这种情况。

通过设置SO_REUSEPORT选项,多个套接字可以同时绑定到相同的地址和端口,每个套接字都可以独立地接收传入的连接。这样,服务器应用程序的开发者可以简化线程同步和负载均衡的处理逻辑,并充分利用多核系统的并行处理能力,从而提高性能和伸缩性。

SO_REUSEPORT选项的实现是在内核层面完成的,因此应用程序只需在创建套接字时设置该选项即可。然后内核会负责处理传入连接的分发,并确保它们被正确地分配给不同的套接字。

SO_REUSEPORT的基本概念很简单。多个服务器(进程或线程)可以绑定到同一个端口,如果它们各自按如下方式设置选项:

    int sfd = socket(domain, socktype, 0);

    int optval = 1;
    setsockopt(sfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));

    bind(sfd, (struct sockaddr *) &addr, addrlen);

只要第一个服务器在绑定套接字之前设置了这个选项,那么任意数量的其他服务器也可以在设置选项之前绑定到相同的端口。第一个服务器必须指定这个选项的要求,防止端口劫持的可能性,即恶意应用程序绑定到已被现有服务器使用的端口,以捕获(部分)其传入的连接或数据报。

为了防止未经授权的进程劫持已由使用SO_REUSEPORT绑定的端口,后续绑定到该端口的所有服务器必须具有与执行套接字上的第一个绑定使用的有效用户ID相匹配的有效用户ID。

这种要求确保只有具有相同有效用户ID的进程才能绑定到已由使用SO_REUSEPORT选项的服务器绑定的端口。这样可以防止恶意进程劫持服务器的端口并接收服务器的传入连接。

SO_REUSEPORT选项可用于TCP和UDP套接字。在使用TCP套接字时,它允许将多个监听套接字(通常是不同的线程)绑定到同一个端口。每个线程可以通过调用accept()函数独立地接受该端口上的传入连接。

传统方法之一是使用单个监听线程接受所有传入连接,然后将其传递给其他线程进行处理。这种方法的问题在于,在极端情况下,监听线程可能成为瓶颈。在关于SO_REUSEPORT的早期讨论中,Tom提到他处理的应用程序每秒接受了40,000个连接。考虑到这样的数量,了解到Tom在Google工作并不令人意外。

传统方法之二是由在单个端口上运行的多线程服务器使用的方法,即使所有线程(或进程)在一个简单的事件循环中对单个监听套接字执行accept()调用,形式如下:

    while (1) {
        new_fd = accept(...);
        process_connection(new_fd);
    }

这种技术的问题,正如Tom所指出的,是当多个线程在accept()调用中等待时,唤醒不公平,因此在高负载情况下,传入连接可能以非常不平衡的方式分布在各个线程之间。在Google,他们看到最多连接和最少连接之间的差距达到了三倍;这种不平衡可能导致CPU核心的低利用率。相比之下,SO_REUSEPORT实现将连接均匀地分配给在同一端口上的所有被阻塞在accept()中的线程(或进程)。

与TCP一样,SO_REUSEPORT允许将多个UDP套接字绑定到同一端口。这个功能在运行在UDP上的DNS服务器中可能会很有用。使用SO_REUSEPORT,每个线程可以使用自己的套接字上的recv()函数接受到达该端口的数据报。传统方法是所有线程都竞争在一个共享套接字上执行recv()调用。与上述传统TCP场景的第二种情况类似,这可能导致线程之间负载不均衡。相比之下,SO_REUSEPORT将数据报均匀地分配到所有接收线程中。

Tom指出,传统的SO_REUSEADDR套接字选项已经允许将多个UDP套接字绑定到同一个UDP端口,并在该端口上接受数据报。然而,与SO_REUSEPORT相比,SO_REUSEADDR不能防止端口劫持,并且不能将数据报均匀地分配给接收线程。

关于Tom的补丁还有两个值得注意的点。第一个是实现中的一个有用方面。传入的连接和数据报使用基于连接的4元组(即对等IP地址和端口加上本地IP地址和端口)的哈希分发到服务器套接字。这意味着,例如,如果客户端使用同一个套接字将一系列数据报发送到服务器端口,那么这些数据报将全部被定向到同一个接收服务器(只要它继续存在)。这简化了客户端和服务器之间进行有状态会话的任务。

另一个值得注意的点是当前TCP SO_REUSEPORT实现中存在一个缺陷。如果绑定到端口的监听套接字数量发生变化,因为启动了新的服务器或现有服务器终止,那么在三次握手期间可能会丢弃传入的连接。问题在于连接请求在握手期间接收到初始SYN数据包时与特定的监听套接字绑定。如果绑定到端口的服务器数量发生变化,那么SO_REUSEPORT逻辑可能无法将握手的最后一个ACK路由到正确的监听套接字。在这种情况下,客户端连接将被重置,服务器将留下一个孤立的请求结构。对于这个问题的解决方案还在进行中,可能包括实现一个可以在多个监听套接字之间共享的连接请求表。

SO_REUSEPORT选项是非标准的,但在许多其他UNIX系统(尤其是源于BSD的系统)上以类似的形式提供。它似乎为在多核系统上运行的网络应用程序提供了一种有用的替代方法,可以最大限度地提高性能,因此对于某些应用程序开发者来说,这可能是一个受欢迎的补充。

六、代码示例

服务端代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

int main() {
    int sockfd;
    struct sockaddr_in addr;
    int reuse = 1;

    // 创建套接字
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("socket");
        exit(1);
    }

    // 设置SO_REUSEPORT选项
    if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse)) < 0) {
        perror("setsockopt");
        exit(1);
    }

    // 设置地址和端口
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(8080);

    // 绑定套接字到地址和端口
    if (bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("bind");
        exit(1);
    }

    // 监听连接
    if (listen(sockfd, 10) < 0) {
        perror("listen");
        exit(1);
    }

    pid_t pid = getpid();
    printf("Server listening on port 8080\n");

    // 接受连接
    while (1) {
        int newsockfd;
        struct sockaddr_in client_addr;
        socklen_t client_len = sizeof(client_addr);

        // 接受新连接
        newsockfd = accept(sockfd, (struct sockaddr*)&client_addr, &client_len);
        if (newsockfd < 0) {
            perror("accept");
            exit(1);
        }

        // 打印连接信息和进程ID
        printf("New connection accepted from %s:%d, PID: %d\n",
               inet_ntoa(client_addr.sin_addr),
               ntohs(client_addr.sin_port),
               pid);

        // 处理连接
        // 在这里可以执行与客户端通信的代码

        // 关闭连接
        close(newsockfd);
    }

    // 关闭套接字
    close(sockfd);

    return 0;
}

客户端代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define NUM_CONNECTIONS 20

int main() {
    int sockfds[NUM_CONNECTIONS];
    struct sockaddr_in server_addr;
    int i;

    // 创建多个套接字
    for (i = 0; i < NUM_CONNECTIONS; i++) {
        sockfds[i] = socket(AF_INET, SOCK_STREAM, 0);
        if (sockfds[i] < 0) {
            perror("socket");
            exit(1);
        }
    }

    // 设置服务器地址和端口
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 服务器IP地址
    server_addr.sin_port = htons(8080); // 服务器端口

    // 连接到服务器
    for (i = 0; i < NUM_CONNECTIONS; i++) {
        if (connect(sockfds[i], (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
            perror("connect");
            exit(1);
        }
    }

    printf("Connected to server\n");

    // 关闭套接字
    for (i = 0; i < NUM_CONNECTIONS; i++) {
        close(sockfds[i]);
    }

    return 0;
}

开启三个服务端进程:

# ./server
# ./server
# ./server

然后运行上述客户端程序:

# ./client

参考资料

图解网络
https://www.rfc-editor.org/rfc/rfc793
https://en.wikipedia.org/wiki/Transmission_Control_Protocol
https://lwn.net/Articles/542629/
https://stackoverflow.com/questions/14388706/how-do-so-reuseaddr-and-so-reuseport-differ
深入理解Linux端口重用这一特性

相关推荐

  1. 端口复用的SPI控制

    2024-05-01 03:40:02       48 阅读
  2. linux端口转发

    2024-05-01 03:40:02       33 阅读
  3. Linux防火墙开放端口

    2024-05-01 03:40:02       37 阅读

最近更新

  1. TCP协议是安全的吗?

    2024-05-01 03:40:02       18 阅读
  2. 阿里云服务器执行yum,一直下载docker-ce-stable失败

    2024-05-01 03:40:02       19 阅读
  3. 【Python教程】压缩PDF文件大小

    2024-05-01 03:40:02       19 阅读
  4. 通过文章id递归查询所有评论(xml)

    2024-05-01 03:40:02       20 阅读

热门阅读

  1. 深入理解前端开发:从基础到实践

    2024-05-01 03:40:02       11 阅读
  2. SpringEL表达式编译模式SpelCompilerMode详解

    2024-05-01 03:40:02       13 阅读
  3. 洛谷 P1179 [NOIP2010 普及组] 数字统计

    2024-05-01 03:40:02       13 阅读