套接字选项SO_REUSEADDR和SO_REUSEPORT的手册页和程序员文档对于不同的操作系统是不同的,并且常常非常混乱。有些操作系统甚至没有SO_REUSEPORT选项。WEB上充斥着关于这个主题的矛盾信息,通常你可以找到仅适用于特定操作系统的一个套接字实现的信息,而文本中甚至没有明确提到这些信息。

那么So_REUSEADDR和So_REUSEPORT到底有什么不同?

没有SO_ REUSEPORT的系统更受限制吗?

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


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

在我们研究这两个选项之前,您应该了解一些基本知识。TCP/UDP连接由五个值的元组标识:

{<protocol>、<src addr>、>src port>、<dest addr>和<dest port>}

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

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

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

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

到目前为止,所有主要操作系统所说的一切都差不多。当地址重用发挥作用时,事情开始变得特定于操作系统。我们从BSD开始,因为如上所述,它是所有套接字实现的母体。

BSD

SO-reusaddr

如果在绑定套接字之前在套接字上启用了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_REUSEADD,它总是成功。

为了让您更好地了解情况,我们在这里制作一个表格,列出所有可能的组合:

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)

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

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

如果TCP套接字正在关闭,通常会执行三向握手;该序列被称为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_REUSEORT允许您将任意数量的套接字绑定到完全相同的源地址和端口,只要所有先前绑定的套接字在绑定之前也设置了SO_REUSEPORT。如果绑定到地址和端口的第一个套接字没有设置SO_REUSEPORT,则在第一个套接字再次释放其绑定之前,任何其他套接字都不能绑定到完全相同的地址和端口,无论该其他套接字是否设置了SO_REUSEORT。与SO_REUSEADDR的情况不同,处理SO_REUSEPORT的代码不仅会验证当前绑定的套接字是否设置了SO_REUSEORT,而且还会验证地址和端口冲突的套接字在绑定时是否设置了SO_REUSEPORT。

SO_REUSEPORT并不意味着SO_REUSEADDR。这意味着,如果一个套接字在绑定时没有设置SO_REUSEPORT,而另一个套接字绑定到完全相同的地址和端口时设置了SO_REUSEORT,则绑定失败,这是意料之中的,但如果另一个已经死亡并且处于TIME_WAIT状态,则绑定也会失败。为了能够将一个套接字绑定到与TIME_WAIT状态下的另一个套接字相同的地址和端口,需要在该套接字上设置SO_REUSEADDR,或者在绑定它们之前必须在两个套接字上设置了SO_REUSEPORT。当然,可以在套接字上同时设置SO_REUSEPORT和SO_REUSEADDR。

关于SO_REUSEPORT,除了它是在SO_REUSEADDR之后添加的之外,没有什么好说的了,这就是为什么在其他系统的许多套接字实现中都找不到它的原因,这些系统在添加此选项之前“分叉”了BSD代码,而且在添加此项之前,无法将两个套接字绑定到BSD中完全相同的套接字地址。

Connect()返回EADDRINUSE?

大多数人都知道bind()可能会因错误EADDRINUSE而失败,然而,当您开始玩弄地址重用时,可能会遇到connect()也会因错误而失败的奇怪情况。这怎么可能?在连接添加到套接字的所有内容之后,远程地址怎么可能已经在使用?将多个套接字连接到完全相同的远程地址以前从来都不是问题,所以这里出了什么问题?

正如我在回复顶部所说,连接是由五个值组成的元组定义的,记得吗?我还说,这五个值必须是唯一的,否则系统无法再区分两个连接,对吗?通过地址重用,您可以将同一协议的两个套接字绑定到同一源地址和端口。这意味着这两个套接字的五个值中的三个已经相同。如果现在尝试将这两个套接字也连接到相同的目标地址和端口,则将创建两个连接的套接字,其元组完全相同。这无法工作,至少对于TCP连接来说是不行的(UDP连接无论如何都不是真正的连接)。如果数据来自两个连接中的任何一个,则系统无法判断数据属于哪个连接。至少两个连接的目标地址或目标端口必须不同,这样系统就没有问题识别传入数据属于哪一个连接。

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

多播地址

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

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

FreeBSD/OpenBSD/NetBSD

所有这些都是原始BSD代码的晚期分支,这就是为什么它们三个都提供了与BSD相同的选项,并且它们的行为方式也与BSD一样。

macOS(macOS X)

在其核心,macOS只是一个名为“达尔文”的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分叉,它有一个稍微修改和精简的内核,稍微精简了一些用户空间工具集和一个稍微不同的默认框架集。watchOS和tvOS是iOS的分支,被进一步剥离(尤其是watchOS)。据我所知,它们的行为与macOS完全相同。

Linux系统

Linux<3.9

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

只要侦听(服务器)TCP套接字绑定到一个特定端口,所有以该端口为目标的套接字都会完全忽略SO_REUSEADDR选项。只有在BSD中也可以不设置SO_REUSEADD的情况下,才能将第二个套接字绑定到同一端口。例如,您不能绑定到通配符地址,然后绑定到更具体的地址,或者反过来,如果您设置了SO_REUSEADDR,这两者在BSD中都是可能的。您可以做的是绑定到同一个端口和两个不同的非通配符地址,这是始终允许的。在这方面,Linux比BSD更具限制性。第二个例外是,对于客户端套接字,该选项的行为与BSD中的SO_REUSEPORT完全相同,只要两者在绑定之前都设置了该标志。允许这样做的原因很简单,因为对于各种协议来说,能够将多个套接字绑定到完全相同的UDP套接字地址是很重要的,因为3.9之前没有SO_REUSEPORT,所以SO_REUSEADDR的行为也相应地改变,以填补这一空白。在这方面,Linux比BSD限制更少。

Linux>=3.9

Linux3.9也为Linux添加了选项SO_REUSEPORT。该选项的行为与BSD中的选项完全相同,并且允许绑定到完全相同的地址和端口号,只要所有套接字在绑定之前都设置了该选项。

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

为了防止“端口劫持”,有一个特殊的限制:所有想要共享相同地址和端口组合的套接字必须属于共享相同有效用户ID的进程!因此,一个用户不能“窃取”另一个用户的端口。这是一种特殊的魔力,可以在某种程度上弥补缺少的SO_EXCLBIND/SO_EXCLUSIVEADDRUSE标志。此外,内核对SO_REUSEPORT套接字执行了一些其他操作系统中没有的“特殊魔力”:对于UDP套接字,它试图均匀地分发数据报,对于TCP侦听套接字,它尝试在共享相同地址和端口组合的所有套接字上均匀地分发传入的连接请求(通过调用accept()接受的连接请求)。因此,应用程序可以很容易地在多个子进程中打开同一端口,然后使用SO_REUSEPORT来获得非常便宜的负载平衡。

安卓

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

窗户

Windows只知道SO_REUSEADDR选项,没有SO_REUSEPORT。在Windows中的套接字上设置SO_REUSEADDR的行为类似于在BSD中的套接字中设置SO_REUSEPORT和SO_REUSEADD,只有一个例外:

在Windows 2003之前,具有SO_REUSEADDR的套接字可以始终绑定到与已绑定套接字完全相同的源地址和端口,即使其他套接字在绑定时未设置此选项。此行为允许应用程序“窃取”另一个应用程序的连接端口。不用说,这对安全有重大影响!

微软意识到了这一点,并添加了另一个重要的套接字选项:SO_EXCLUSIVEADDRUSE。在套接字上设置SO_EXCLUSIVEADDUSE可以确保如果绑定成功,则源地址和端口的组合由该套接字独占,并且没有其他套接字可以绑定到它们,即使设置了SO_REUSEADDR。

这种默认行为在Windows 2003中首次更改,微软称之为“增强套接字安全”(这是所有其他主要操作系统默认行为的有趣名称)。有关更多详细信息,请访问此页面。有三个表:第一个表显示了经典的行为(在使用兼容模式时仍在使用!),第二个表显示Windows 2003及更高版本的行为(当bind()调用由同一用户执行时),第三个表显示不同用户执行bind(()调用时)。

Solaris

Solaris是SunOS的继任者。SunOS最初基于BSD、SunOS5的分支,后来基于SVR4的分支,然而SVR4是BSD、SystemV和Xenix的合并,因此在某种程度上Solaris也是BSD分支,而且是一个相当早的分支。因此,Solaris只知道SO_REUSEADDR,不存在SO_REUSEPORT。SO_REUSEADDR的行为与BSD中的行为几乎相同。据我所知,在Solaris中无法获得与SO_REUSEPORT相同的行为,这意味着不可能将两个套接字绑定到完全相同的地址和端口。

与Windows类似,Solaris可以为套接字提供独占绑定。此选项名为SO_EXCLBIND。如果在绑定套接字之前在套接字上设置了此选项,那么如果测试两个套接字的地址冲突,则在另一个套接字上设置SO_REUSEADDR将无效。例如,如果套接字a绑定到通配符地址,而套接字B启用了SO_REUSEADDR,并且绑定到非通配符地址和与套接字a相同的端口,则该绑定通常会成功,除非套接字a启用了SO_EXCLBIND,在这种情况下,无论套接字B的SO_REUSEADD标志如何,该绑定都会失败。

其他系统

如果您的系统没有在上面列出,我编写了一个小测试程序,您可以使用它来了解您的系统如何处理这两个选项。此外,如果你认为我的结果是错误的,请在发布任何评论和可能做出虚假声明之前先运行该程序。

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

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

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

TCP和UDP协议普通套接字、侦听(服务器)套接字、多播套接字SO_REUSEADDR设置在套接字1、套接字2或两个套接字上SO_REUSEPORT设置在套接字1、套接字2或两个套接字上您可以使用0.0.0.0(通配符)、127.0.0.1(特定地址)和在主接口上找到的第二个特定地址(对于多播,在所有测试中仅为224.1.2.3)组成所有地址组合

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

程序无法轻易测试SO_REUSEADDR如何在TIME_WAIT状态下作用于套接字,因为强制并保持套接字处于该状态非常困难。幸运的是,大多数操作系统在这里看起来都像BSD,大多数时候程序员都可以忽略这种状态的存在。

这是代码(我不能将其包含在这里,答案有大小限制,代码会将此回复推过限制)。

Mecki的回答绝对完美,但值得补充的是,FreeBSD还支持SO_REUSEPORT_LB,它模仿了Linux的SO_REUSEORT行为——它平衡了负载;参见setsockopt(2)