AberSheeran
Aber Sheeran

SO_REUSEADDR 和 SO_REUSEPORT

起笔自
所属文集: 程序杂记
共计 8996 个字符
落笔于

版权申明

本文从 StackOverflow 里 How do SO_REUSEADDR and SO_REUSEPORT differ? 的回答翻译而来。

欢迎来到奇妙的可移植性世界......或者说缺乏可移植性的世界。在我们开始详细分析这两个选项并深入研究不同操作系统如何处理它们之前,应该注意到BSD的套接字实现是所有套接字实现之母。基本上所有其他系统都在某个时间点上复制了BSD的套接字实现(或者至少是它的接口),然后开始自行演化。当然,BSD的套接字实现也是在同一时间进化的,因此后来复制它的系统得到了早期复制它的系统所缺乏的功能。理解BSD套接字实现是理解所有其他套接字实现的关键,所以即使你不关心为BSD系统写代码,你也应该读一读它。

在我们看这两个选项之前,有几个基本知识你应该知道。一个TCP/UDP连接是由五个值组成的元组来识别的:

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

这些值的任何独特组合都可以识别一个连接。因此,没有两个连接可以有相同的五个值,否则系统将无法再区分这些连接。

当用socket()函数创建一个套接字时,套接字的协议被设置。源地址和端口是通过bind()函数设置的。目的地址和端口是通过connect()函数设置的。由于UDP是一个无连接协议,UDP套接字可以在不连接它们的情况下使用。但是允许连接它们,在某些情况下对你的代码和一般的应用设计非常有利。在无连接模式下,当数据首次通过UDP套接字发送时,没有明确绑定的UDP套接字通常会被系统自动绑定,因为未绑定的UDP套接字不能接收任何(回复)数据。未绑定的TCP套接字也是如此,它在被连接之前会被自动绑定。

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

默认情况下,没有两个套接字可以被绑定到相同的源地址和源端口的组合。只要源端口不同,源地址实际上是不相关的。如果ipA != ipB成立,即使portA == portB,将socketA绑定到ipA:portAsocketB绑定到ipB:portB也是可能的。例如,socketA属于一个FTP服务器程序,被绑定到192.168.0.1:21socketB属于另一个FTP服务器程序,被绑定到10.0.0.1:21,两种绑定都会成功。但是请记住,一个套接字可以被本地绑定到 "任何地址"。如果一个套接字被绑定到0.0.0.0:21,它同时被绑定到所有现有的本地地址,在这种情况下,没有其他套接字可以被绑定到端口21,不管它试图绑定到哪个特定的IP地址,因为0.0.0.0与所有现有的本地IP地址冲突。

到目前为止,所说的一切对所有主要的操作系统都是一样的。当地址重用开始发挥作用时,事情就开始变得与操作系统有关了。我们从BSD开始,因为正如我上面所说,它是所有套接字实现之母。

BSD

SO_REUSEADDR

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

如果没有SO_REUSEADDR,将socketA绑定到0.0.0.0:21,然后将socketB绑定到192.168.0.1:21将会失败(出现EADDRINUSE错误),因为0.0.0.0意味着 "任何本地IP地址",因此所有本地IP地址都被认为被这个套接字使用,这也包括192.168.0.1。使用SO_REUSEADDR会成功,因为0.0.0.0192.168.0.1不完全相同的地址,一个是所有本地地址的通配符,另一个是一个非常具体的本地地址。请注意,无论socketAsocketB以何种顺序绑定,上面的语句都是正确的;没有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",最后被绑定到 "socketB "的地址。Result是对socketB进行绑定操作的结果。如果第一列说的是ON/OFF,那么SO_REUSEADDR的值与结果无关。

好的,SO_REUSEADDR 对通配符地址有影响,很好理解。但这并不是它的唯一作用。还有一个众所周知的作用,这也是大多数人在服务器程序中使用SO_REUSEADDR的原因。对于这个选项的另一个重要用途,我们必须深入了解一下TCP协议的工作原理。

如果一个TCP套接字被关闭,通常会进行3次握手;这个序列被称为FIN-ACK。这里的问题是,该序列的最后一个ACK可能已经到达对方,也可能没有到达,只有当它到达时,对方也认为该套接字已完全关闭。为了防止重复使用一个地址+端口的组合,而这个组合可能仍然被一些远程对等体认为是开放的,系统在发送最后一个ACK后,不会立即将一个套接字视为死亡,而是将套接字放入一个通常被称为 "TIME_WAIT "的状态。它可以在这个状态下停留几分钟(取决于系统设置)。在大多数系统中,你可以通过启用徘徊和设置01的徘徊时间来绕过这个状态,但不能保证这总是可行的,也不能保证系统总是尊重这个请求,即使系统尊重它,这也会导致套接字被重置关闭(RST),这并不总是一个好主意。要了解更多关于徘徊时间的信息,请看我关于这个话题的回答

问题是,系统如何对待处于TIME_WAIT状态的套接字?如果没有设置SO_REUSEADDR,处于TIME_WAIT状态的套接字将被认为仍然被绑定在源地址和端口上,任何试图将新的套接字绑定在相同的地址和端口上的尝试都将失败,直到套接字真正被关闭。所以不要指望你能在关闭套接字后立即重新绑定它的源地址。在大多数情况下,这将会失败。然而,如果你试图绑定的套接字设置了SO_REUSEADDR,那么在TIME_WAIT状态下绑定到相同地址和端口的另一个套接字就会被忽略,毕竟它已经 "半死 "了,而你的套接字可以毫无问题地绑定到相同的地址。在这种情况下,另一个套接字可能有完全相同的地址和端口,这并没有什么作用。请注意,将一个套接字与一个处于`TIME_WAIT'状态的濒死套接字绑定到完全相同的地址和端口上,可能会产生意想不到的、通常是不想要的副作用,如果另一个套接字仍然在 "工作 "的话,但这已经超出了本答案的范围,幸运的是这些副作用在实践中相当罕见。

关于SO_REUSEADDR,还有一件事你应该知道。只要你想绑定的套接字启用了地址重用功能,上面所写的一切都会有效。另一个套接字,即已经被绑定或处于TIME_WAIT状态的套接字,在被绑定时也设置了这个标志,这是没有必要的。决定绑定成功或失败的代码只检查输入bind()调用的套接字的SO_REUSEADDR标志,对于所有其他被检查的套接字,这个标志甚至不会被查看。

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_REUSEPORTSO_REUSEADDR

关于 "SO_REUSEPORT "没有太多可说的,除了它比 "SO_REUSEADDR "晚加入,这就是为什么你在其他系统的许多套接字实现中找不到它,这些系统在加入这个选项之前 "分叉 "了BSD的代码,而且在这个选项之前,BSD没有办法将两个套接字绑定到完全相同的套接字地址。

Connect() 返回EADDRINUSE?

大多数人都知道bind()可能会以错误EADDRINUSE失败,然而,当你开始玩地址重用时,你可能会遇到奇怪的情况,connect()也会以该错误失败。这怎么可能呢?一个远程地址,毕竟那是connect添加到套接字中的,怎么可能已经被使用了呢?将多个套接字连接到完全相同的远程地址以前从未出现过问题,那么这里出了什么问题?

正如我在回答的最上面所说,一个连接是由五个值组成的元组定义的,记得吗?我还说过,这五个值必须是唯一的,否则系统就不能再区分两个连接了,对吗?那么,通过地址重用,你可以将同一个协议的两个套接字绑定到同一个源地址和端口。这意味着这两个套接字的五个值中已经有三个是相同的。如果你现在试图将这两个套接字也连接到相同的目标地址和端口,你将创建两个连接的套接字,其图元是绝对相同的。这是不可能的,至少对TCP连接来说是不可能的(UDP连接也不是真正的连接)。如果这两个连接中的任何一个的数据到达,系统就无法判断数据属于哪个连接。至少这两个连接的目标地址或目标端口必须是不同的,这样系统就能顺利地识别进入的数据是属于哪个连接的。

因此,如果你将两个相同协议的套接字绑定到相同的源地址和端口,并试图将它们都连接到相同的目的地址和端口,connect()实际上会在你试图连接的第二个套接字上出现错误EADDRINUSE,这意味着一个具有相同的五个值的套接字已经被连接。

多播地址

大多数人忽略了多播地址的存在,但它们确实存在。单播地址用于一对一的通信,而多播地址则用于一对多的通信。大多数人是在了解了IPv6后才知道多播地址的,但多播地址在IPv4中也存在,尽管这一功能从未在公共互联网上广泛使用。

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

FreeBSD/OpenBSD/NetBSD

所有这些都是相当晚的BSD原始代码的分叉,这就是为什么他们三个都提供了与BSD相同的选项,他们的行为也与BSD相同。

macOS (MacOS X)

在其核心部分,macOS只是一个名为"Darwin"的BSD风格的UNIX,基于BSD代码的一个相当晚的分叉(BSD 4.3),后来甚至在Mac OS 10.3版本中与(当时的)FreeBSD 5代码基础重新同步,以便苹果可以获得完全的POSIX兼容性(macOS是POSIX认证)。尽管其核心是一个微内核("Mach"),但内核的其他部分("XNU")基本上只是一个BSD内核,这就是为什么macOS提供了与BSD相同的选项,它们的行为方式也与BSD相同。

iOS / watchOS / tvOS

iOS只是一个macOS的分叉,它的内核稍作修改和修剪,用户空间工具集稍有缩减,默认框架集也略有不同。据我所知,它们的行为都与macOS完全一样。

Linux

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_REUSEPORTSO_REUSEADDR的行为被相应地改变以填补这一空白。在这方面,Linux比BSD的限制要少。

Linux >= 3.9

Linux 3.9也增加了SO_REUSEPORT选项。这个选项的行为和BSD中的选项完全一样,只要所有的套接字在绑定之前都设置了这个选项,就可以绑定到完全相同的地址和端口号。

然而,在其他系统上与SO_REUSEPORT仍有两个不同之处:

  1. 为了防止 "端口劫持",有一个特殊的限制: 所有想共享同一地址和端口组合的套接字必须属于共享同一有效用户ID的进程!所以一个用户不能 "偷 "另一个用户的端口。这是一些特殊的魔法,在一定程度上弥补了SO_EXCLBIND/SO_EXCLUSIVEADDRUSE标记的缺失。

  2. 此外,内核对SO_REUSEPORT套接字执行了一些其他操作系统没有的 "特殊魔法": 对于UDP套接字,它试图均匀地分配数据包,对于TCP监听套接字,它试图在所有共享相同地址和端口组合的套接字中均匀地分配进入的连接请求(那些通过调用accept()接受的请求)。因此,一个应用程序可以很容易地在多个子进程中打开相同的端口,然后使用SO_REUSEPORT来获得一个非常便宜的负载平衡。

安卓系统

尽管整个Android系统与大多数Linux发行版有些不同,但其核心是一个稍加修改的Linux内核,因此适用于Linux的一切也应该适用于Android。

窗口系统

Windows只知道SO_REUSEADDR选项,没有SO_REUSEPORT。在Windows的套接字上设置SO_REUSEADDR的行为就像在BSD的套接字上设置SO_REUSEPORTSO_REUSEADDR一样,但有一个例外:

在Windows 2003之前,带有SO_REUSEADDR的套接字总是可以被绑定到与已经绑定的套接字完全相同的源地址和端口,即使另一个套接字在被绑定时没有设置这个选项。这种行为允许一个应用程序 "窃取 "另一个应用程序的连接端口。不用说,这有很大的安全隐患!

微软意识到这一点,增加了另一个重要的套接字选项:SO_EXCLUSIVEADDRUSE。在一个套接字上设置SO_EXCLUSIVEADDRUSE可以确保如果绑定成功,源地址和端口的组合就被这个套接字所独占,其他套接字不能绑定到它们,即使它设置了SO_REUSEADDR也不行。

这种默认行为首先在Windows 2003中被改变,微软称之为 "增强型套接字安全"(对于一个在所有其他主要操作系统上都是默认的行为来说,这个名字很有趣)。更多细节只需访问这个页面。这里有三个表格: 第一个显示的是经典行为(在使用兼容模式时仍在使用!),第二个显示的是Windows 2003及以上版本的行为,当bind()调用由同一用户进行时,第三个是bind()调用由不同用户进行时。

Solaris

Solaris是SunOS的继承者。SunOS最初是基于BSD的分叉,SunOS 5和后来是基于SVR4的分叉,然而SVR4是BSD、System V和Xenix的合并,所以在某种程度上Solaris也是BSD的分叉,而且是相当早期的分叉。因此,Solaris只知道SO_REUSEADDR',没有SO_REUSEPORT'。SO_REUSEADDR的行为与BSD中的行为基本相同。据我所知,在Solaris中没有办法获得与SO_REUSEPORT相同的行为,这意味着不可能将两个套接字绑定到完全相同的地址和端口。

与Windows类似,Solaris有一个选项可以给套接字一个排他性的绑定。这个选项被命名为SO_EXCLBIND。如果在绑定一个套接字之前设置了这个选项,在另一个套接字上设置SO_REUSEADDR,在两个套接字被测试为地址冲突时没有任何影响。例如,如果socketA被绑定到一个通配符地址,而socketB启用了SO_REUSEADDR,并且被绑定到一个非通配符地址和socketA的相同端口,这个绑定通常会成功,除非socketA启用了SO_EXCLBIND,在这种情况下,不管socketBSO_REUSEADDR标志,它都会失败。

其他系统

如果你的系统没有列在上面,我写了一个小测试程序,你可以用它来了解你的系统如何处理这两个选项。如果你认为我的结果是错误的,请先运行该程序,然后再发表任何评论和可能的错误主张。

构建代码所需要的只是一点POSIX API(用于网络部分)和一个C99编译器(实际上大多数非C99编译器也可以工作,只要它们提供inttypes.hstdbool.h;例如,gcc在提供完整的C99支持之前早就支持这两者)。

该程序运行所需要的是,在你的系统中至少有一个接口(除了本地接口)被分配了一个IP地址,并且设置了一个使用该接口的默认路由。该程序将收集该IP地址并将其作为第二个 "特定地址"。

它测试所有你能想到的可能的组合:

  • TCP和UDP协议
  • 普通套接字、监听(服务器)套接字、多播套接字
  • `SO_REUSEADDR'设置在socket1,socket2,或两个socket上
  • `SO_REUSEPORT'设置在socket1、socket2或两个socket上。
  • 你可以用0.0.0.0(通配符)、127.0.0.1(特定地址)和你的主接口上发现的第二个特定地址(对于组播,在所有测试中都是224.1.2.3)组成的所有地址组合

并将结果打印在一个漂亮的表格中。它也可以在不知道`SO_REUSEPORT'的系统上工作,在这种情况下,这个选项根本没有被测试。

该程序不容易测试的是SO_REUSEADDR如何作用于处于TIME_WAIT状态的套接字,因为强制并保持套接字处于这种状态是非常棘手的。幸运的是,大多数操作系统似乎在这里表现得像BSD,大多数时候程序员可以简单地忽略这个状态的存在。

这是代码 (我不能把它放在这里,答案有大小的限制,代码会使这个回复超过限制)。

如果你觉得本文值得,不妨赏杯茶
用 Python 标准库 sqlite3 处理 JSON 数据
在 1C 1G 的服务器上跑 mastodon