欢迎来到便携世界...或者说缺乏它。在我们开始详细分析这两个选项并深入了解不同操作系统如何处理它们之前,应该注意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:21
,
socketB
属于另一个 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.0
和
192.168.0.1
不是完全相同的地址,一个是用于所有本地地址的通配符,另一个是非常特定的本地地址。请注意,无论
socketA
和
socketB
以哪种顺序绑定,上述语句都是正确的;如果没有使用
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
,最后将其绑定到给定的地址。
Result
是
socketB
绑定操作的结果。如果第一列显示
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_REUSEPORT
和
SO_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_REUSEADDR
和
SO_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
仍然有两个不同之处:
为了防止"端口劫持",有一个特殊的限制:所有想要共享相同地址和端口组合的套接字必须属于共享相同有效用户ID的进程! 因此,一个用户无法"窃取"另一个用户的端口。这是一些特殊的魔术,以弥补缺失的SO_EXCLBIND
/SO_EXCLUSIVEADDRUSE
标志。
此外,内核对SO_REUSEPORT
套接字执行一些在其他操作系统中找不到的"特殊魔法":对于UDP套接字,它尝试均匀分布数据报;对于TCP监听套接字,它尝试均匀分配所有共享相同地址和端口组合的套接字接收的连接请求(通过调用accept()
接受的那些)。因此,应用程序可以轻松地在多个子进程中打开相同的端口,然后使用SO_REUSEPORT
来获得非常廉价的负载均衡。
Android
尽管整个Android系统与大多数Linux发行版略有不同,但其核心仍是稍微修改过的Linux内核,因此适用于Linux的所有内容也应该适用于Android。
Windows
Windows只支持SO_REUSEADDR
选项,没有SO_REUSEPORT
。在Windows上将SO_REUSEADDR
设置到套接字上的行为类似于在BSD上将SO_REUSEADDR
和SO_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
相同的端口,则此绑定通常会成功,除非启用了
socketA
的
SO_EXCLBIND
,在这种情况下,它将失败,而不管
socketB
的
SO_REUSEADDR
标志如何。
其他系统
如果您的系统未列出上述选项,请使用我编写的一个小测试程序来查找您的系统如何处理这两个选项。如果您认为我的结果是错误的,请在发表任何评论并可能做出虚假声明之前先运行该程序。
代码构建所需的仅是一些POSIX API(用于网络部分)和C99编译器(实际上,大多数非C99编译器也可以工作,只要它们提供 inttypes.h
和 stdbool.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一样行事,大多数时候程序员可以简单地忽略该状态的存在。
这里是代码(我不能在这里包含它,答案有大小限制,代码会超出限制)。
INADDR_ANY
进行绑定不仅绑定当前的本地地址,还绑定所有未来的本地地址。即使你说不可能,listen
肯定会创建具有完全相同协议、本地地址和本地端口的套接字。 - Ben VoigtINADDR_ANY
有什么问题,我从来没有说过它不能绑定到未来的地址。而listen
根本不创建任何套接字,这使得你整个句子有点奇怪。 - Mecki