如何区分SO_REUSEADDR和SO_REUSEPORT?

848
< p> SO_REUSEADDR 和 SO_REUSEPORT 套接字选项的< /p> man 手册和程序员文档在不同的操作系统中是不同的,而且常常令人困惑。 一些操作系统甚至没有选项 SO_REUSEPORT 。 互联网上充满了有关此主题的相互矛盾的信息,通常您可以找到仅适用于特定操作系统的特定套接字实现的信息,该信息甚至未在文本中明确提到。

那么, SO_REUSEADDR 与 SO_REUSEPORT 有何不同?

没有 SO_REUSEPORT 的系统是否更受限制?

如果在不同的操作系统上使用其中任何一个,预期行为是什么?

2个回答

2120
欢迎来到便携世界...或者说缺乏它。在我们开始详细分析这两个选项并深入了解不同操作系统如何处理它们之前,应该注意BSD套接字实现是所有套接字实现的母体。基本上,所有其他系统在某个时候都复制了BSD套接字实现(或至少是其接口),然后开始在其自身上进行演进。当然,BSD套接字实现也在同时演进,因此后来复制它的系统得到了早期复制它的系统所缺乏的特性。理解BSD套接字实现是理解所有其他套接字实现的关键,因此即使您不打算为BSD系统编写代码,也应该阅读有关它的内容。
在我们查看这两个选项之前,您应该了解一些基础知识。TCP/UDP连接由五个值的元组标识:
{<协议>,<源地址>,<源端口>,<目标地址>,<目标端口>}
这些值的任何唯一组合都可以标识一个连接。因此,没有两个连接可以具有相同的五个值,否则系统将无法再区分这些连接。
创建套接字时,使用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:21socketB 属于另一个 FTP 服务器程序,绑定到 10.0.0.1:21,两者都会成功绑定。但请记住,套接字可能会被本地绑定到“任何地址”。如果套接字绑定到 0.0.0.0:21,则它同时绑定到所有现有的本地地址,在这种情况下,无论尝试绑定哪个特定的 IP 地址,都不能将其他套接字绑定到端口 21,因为 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    错误 (EADDRINUSE)
  ON/OFF       192.168.0.1:21      10.0.0.1:21    正常
  ON/OFF          10.0.0.1:21   192.168.0.1:21    正常
   OFF             0.0.0.0:21   192.168.1.0:21    错误 (EADDRINUSE)
   OFF         192.168.1.0:21       0.0.0.0:21    错误 (EADDRINUSE)
   ON              0.0.0.0:21   192.168.1.0:21    正常
   ON          192.168.1.0:21       0.0.0.0:21    正常
  ON/OFF           0.0.0.0:21       0.0.0.0:21    错误 (EADDRINUSE)
上表假定socketA已经成功绑定到给定的地址,然后创建socketB,无论是否设置SO_REUSEADDR,最后将其绑定到给定的地址。 ResultsocketB绑定操作的结果。如果第一列显示ON/OFF,则SO_REUSEADDR的值与结果无关。
好的,SO_REUSEADDR对通配符地址有影响,这很重要。但这不是它唯一的影响。还有另一个众所周知的影响,这也是大多数人首先在服务器程序中使用SO_REUSEADDR的原因。对于此选项的另一个重要用途,我们必须深入了解TCP协议的工作方式。
如果TCP套接字正在关闭,则通常会执行3次握手;该序列称为FIN-ACK。问题在于,该序列的最后一个ACK可能已经到达了另一侧,也可能没有到达,只有在到达时,另一侧才认为套接字已完全关闭。为了防止重复使用某些远程对等方仍可能视为打开的地址+端口组合,系统不会立即在发送最后一个ACK后将套接字视为死亡,而是将套接字放入常用的状态中,称为TIME_WAIT。它可以在该状态下停留几分钟(取决于系统设置)。在大多数系统上,您可以通过启用滞留并设置零1的滞留时间来避免该状态,但不能保证始终可能这样做,系统始终会遵守此请求,并且即使系统遵守此请求,这也会导致通过重置(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晚添加,这就是为什么你在许多其他系统的套接字实现中找不到它的原因,这些系统在此选项添加之前“forked”了BSD代码,并且在此选项之前,在BSD中没有办法将两个套接字绑定到完全相同的套接字地址。

Connect() 返回 EADDRINUSE 错误?

许多人知道 bind() 可能会出现错误 EADDRINUSE,然而,当你开始尝试地址重用时,你可能会遇到奇怪的情况,即 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),后来甚至与当前的FreeBSD 5代码库重新同步,以便苹果公司获得完全的POSIX兼容性(macOS是POSIX认证的)。尽管其核心有一个微内核("Mach"),但其余的内核("XNU")基本上只是一个BSD内核,这就是为什么macOS提供了与BSD相同的选项,并且它们的行为也与BSD相同。

iOS / watchOS / tvOS

iOS只是macOS的一个分支,具有略微修改和精简的内核、削减的用户空间工具集和稍微不同的默认框架集。watchOS和tvOS是iOS的分支,它们被进一步削减(特别是watchOS)。据我所知,它们的行为与macOS完全相同。


Linux

Linux < 3.9

在Linux 3.9之前,只有一个名为SO_REUSEADDR的选项存在。此选项的行为通常与BSD相同,但有两个重要的例外:

只要一个监听(服务器)TCP套接字绑定到特定端口,那么 SO_REUSEADDR 选项对于所有针对该端口的套接字都将完全被忽略。只有在BSD中可以将第二个套接字绑定到相同的端口,而没有设置 SO_REUSEADDR 。例如,您不能绑定到通配符地址,然后绑定到更具体的地址或另一种方式,如果您设置了 SO_REUSEADDR ,则在BSD中两者都是可能的。您可以做的是将相同的端口绑定到两个不同的非通配符地址,因为这始终是允许的。在这方面,Linux比BSD更加严格。
第二个例外是对于客户端套接字,此选项的行为与BSD中的 SO_REUSEPORT 完全相同,只要两者在绑定之前都设定了此标志。之所以允许这样做,仅仅是因为可以绑定多个套接字到完全相同的UDP套接字地址对于各种协议来说至关重要,而在3.9之前不存在 SO_REUSEPORT ,因此修改了 SO_REUSEADDR 的行为以填补这个空白。在这方面,Linux比BSD更加宽松。

Linux >= 3.9

自 Linux 3.9 版本起,在 Linux 中也加入了选项SO_REUSEPORT。该选项与 BSD 中的选项行为完全相同,只要所有套接字在绑定前设置了此选项,就可以绑定到完全相同的地址和端口号。

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

  1. 为了防止"端口劫持",有一个特殊的限制:所有想要共享相同地址和端口组合的套接字必须属于共享相同有效用户ID的进程! 因此,一个用户无法"窃取"另一个用户的端口。这是一些特殊的魔术,以弥补缺失的SO_EXCLBIND/SO_EXCLUSIVEADDRUSE标志。

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


Android

尽管整个Android系统与大多数Linux发行版略有不同,但其核心仍是稍微修改过的Linux内核,因此适用于Linux的所有内容也应该适用于Android。


Windows

Windows只支持SO_REUSEADDR选项,没有SO_REUSEPORT。在Windows上将SO_REUSEADDR设置到套接字上的行为类似于在BSD上将SO_REUSEADDRSO_REUSEPORT都设置到套接字上,但有一个例外:

在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在Solaris中的行为与BSD中的行为几乎相同。据我所知,在Solaris中没有办法获得与SO_REUSEPORT相同的行为,这意味着不可能将两个套接字绑定到完全相同的地址和端口。
与Windows类似,Solaris有一个选项可以为套接字提供独占绑定。此选项名为 SO_EXCLBIND。如果在绑定套接字之前设置此选项,则在测试两个套接字是否存在地址冲突时,另一个套接字上设置 SO_REUSEADDR 将不起作用。例如,如果 socketA 绑定到通配符地址,socketB 启用了 SO_REUSEADDR 并绑定到非通配符地址和与 socketA 相同的端口,则此绑定通常会成功,除非启用了 socketASO_EXCLBIND ,在这种情况下,它将失败,而不管 socketBSO_REUSEADDR 标志如何。


其他系统

如果您的系统未列出上述选项,请使用我编写的一个小测试程序来查找您的系统如何处理这两个选项。如果您认为我的结果是错误的,请在发表任何评论并可能做出虚假声明之前先运行该程序。

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

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

它测试了您可以想到的所有可能的组合:

  • TCP和UDP协议
  • 普通套接字、监听(服务器)套接字、多播套接字
  • SO_REUSEADDR设置在socket1、socket2或两个套接字上
  • SO_REUSEPORT设置在socket1、socket2或两个套接字上
  • 您可以从0.0.0.0(通配符)、127.0.0.1(特定地址)以及在主要接口中找到的第二个特定地址(对于多播,所有测试中都是224.1.2.3)创建的所有地址组合

并将结果打印在漂亮的表格中。它还适用于不知道SO_REUSEPORT的系统,在这种情况下,该选项根本没有被测试。

程序无法轻松测试SO_REUSEADDR如何处理处于TIME_WAIT状态的套接字,因为强制并保持套接字处于该状态非常棘手。幸运的是,大多数操作系统似乎在这里简单地像BSD一样行事,大多数时候程序员可以简单地忽略该状态的存在。

这里是代码(我不能在这里包含它,答案有大小限制,代码会超出限制)。


11
例如,“源地址”真正应该是“本地地址”,下面三个字段也是如此。使用INADDR_ANY进行绑定不仅绑定当前的本地地址,还绑定所有未来的本地地址。即使你说不可能,listen肯定会创建具有完全相同协议、本地地址和本地端口的套接字。 - Ben Voigt
10
“源地址”和“目的地址”是用于IP寻址的官方术语(我的主要参考对象)。而“本地地址”和“远程地址”没有意义,因为“远程地址”实际上可以是“本地”地址,而与“目的地址”相反的是“源地址”,而不是“本地地址”。我不知道你对INADDR_ANY有什么问题,我从来没有说过它不能绑定到未来的地址。而listen根本不创建任何套接字,这使得你整个句子有点奇怪。 - Mecki
8
当系统添加新地址时,它也是一种“现有的本地地址”,只是刚刚开始存在而已。我并没有说“对于所有当前存在的本地地址”。实际上,我甚至说此套接字确实真正地绑定到了“通配符”,这意味着该套接字绑定到与此通配符匹配的任何内容,现在、明天和未来一百年都是如此。源和目标同样如此,你在这里只是纠缠细节。你有任何真正的技术贡献要提出吗? - Mecki
10
@Mecki: 你真的认为“existing”这个词包括现在不存在但将来会存在的事物吗?源地址和目标地址并不是琐碎的问题。当传入的数据包与套接字匹配时,您说数据包中的目标地址将与套接字的“源”地址匹配?那是错误的,您已经说过“源”和“目的地”是相反的。套接字上的“本地”地址与传入数据包的“目标地址”进行匹配,并放置在输出数据包的“源”地址上。 - Ben Voigt
13
如果你这样说的话“套接字的本地地址是出站数据包的源地址和入站数据包的目标地址”,那就更容易理解了。数据包有源地址和目标地址,主机和主机上的套接字没有。对于数据报套接字,双方都是平等的。对于TCP套接字,由于三次握手,有一个发起者(客户端)和一个响应者(服务器),但这仍然不意味着连接或连接的套接字具有目标,因为流量是双向的。 - Ben Voigt
显示剩余77条评论

27

Mecki的回答绝对完美,但值得补充的是FreeBSD也支持SO_REUSEPORT_LB,它模仿了Linux的SO_REUSEPORT行为,可以平衡负载,请参阅setsockopt(2)


1
不错的发现。我在查阅手册时没有看到过这个。它绝对值得被提及,因为在将Linux软件移植到FreeBSD时可以非常有帮助。 - Mecki

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接