套接字选项 SO_REUSEADDR
和 SO_REUSEPORT
的 man pages
和程序员文档对于不同的操作系统是不同的,并且通常非常混乱。有些操作系统甚至没有选项 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 套接字无法接收任何(回复)数据。未绑定的 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
,它同时绑定到所有现有的本地地址,在这种情况下,没有其他套接字可以绑定到端口 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.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 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
。它可以处于该状态数分钟(取决于系统的设置)。在大多数系统上,您可以通过启用延迟并将延迟时间设置为 zero1 来绕过该状态,但不能保证这总是可能的,系统将始终支持此请求,即使系统支持它,这也会导致通过重置 (RST
) 关闭套接字,这并不总是一个好主意。要了解有关逗留时间的更多信息,请查看 my answer about this topic。
问题是,系统如何处理处于状态 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
允许您将任意数量的套接字绑定到完全相同相同的源地址和端口,只要 all 先前绑定的套接字之前也设置了 SO_REUSEPORT
他们被束缚了。如果绑定到地址和端口的第一个套接字没有设置 SO_REUSEPORT
,则任何其他套接字都不能绑定到完全相同的地址和端口,无论这个另一个套接字是否设置了 SO_REUSEPORT
,直到第一个socket 再次释放它的绑定。与 SO_REUESADDR
不同,代码处理 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 代码,并且在此选项之前没有办法将两个套接字绑定到 BSD 中完全相同的套接字地址。
Connect() 返回 EADDRINUSE?
大多数人都知道 bind()
可能会因错误 EADDRINUSE
而失败,但是,当您开始尝试地址重用时,您可能会遇到 connect()
也因该错误而失败的奇怪情况。怎么会这样?一个远程地址,毕竟是连接添加到套接字的,怎么可能已经在使用呢?将多个套接字连接到完全相同的远程地址以前从来都不是问题,那么这里出了什么问题呢?
正如我在回复的开头所说,连接是由五个值的元组定义的,还记得吗?而且我还说过,这五个值必须是唯一的,否则系统无法再区分两个连接,对吧?好吧,通过地址重用,您可以将相同协议的两个套接字绑定到相同的源地址和端口。这意味着这五个值中的三个对于这两个套接字已经相同。如果您现在尝试将这两个套接字也连接到相同的目标地址和端口,您将创建两个连接的套接字,它们的元组完全相同。这行不通,至少对于 TCP 连接不起作用(UDP 连接无论如何都不是真正的连接)。如果数据到达两个连接中的任何一个,系统就无法判断数据属于哪个连接。至少每个连接的目标地址或目标端口必须不同,这样系统就可以毫无问题地识别传入数据属于哪个连接。
因此,如果您将相同协议的两个套接字绑定到相同的源地址和端口,并尝试将它们都连接到相同的目标地址和端口,connect()
实际上会失败,并出现错误 EADDRINUSE
用于您尝试的第二个套接字connect,这意味着已经连接了具有相同的五个值的元组的套接字。
多播地址
大多数人忽略了多播地址存在的事实,但它们确实存在。单播地址用于一对一通信,而多播地址用于一对多通信。大多数人在了解 IPv6 时就知道了多播地址,但多播地址也存在于 IPv4 中,尽管此功能从未在公共 Internet 上广泛使用。
对于多播地址,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 重新同步Mac OS 10.3 版本的 5 代码库,以便 Apple 可以获得完全的 POSIX 合规性(macOS 已通过 POSIX 认证)。尽管在其核心(“Mach”)中有一个微内核(“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 系统与大多数 Linux 发行版有些不同,但其核心是稍微修改过的 Linux 内核,因此适用于 Linux 的所有内容也应该适用于 Android。
视窗
Windows 只知道 SO_REUSEADDR
选项,没有 SO_REUSEPORT
。在 Windows 中的套接字上设置 SO_REUSEADDR
的行为类似于在 BSD 中的套接字上设置 SO_REUSEPORT
和 SO_REUSEADDR
,但有一个例外:
在 Windows 2003 之前,带有 SO_REUSEADDR
的套接字始终可以与已绑定的套接字绑定到完全相同的源地址和端口,即使另一个套接字在绑定时没有设置此选项。这种行为允许应用程序“窃取”另一个应用程序的连接端口。不用说,这具有重大的安全隐患!
Microsoft 意识到这一点并添加了另一个重要的套接字选项:SO_EXCLUSIVEADDRUSE
。在套接字上设置 SO_EXCLUSIVEADDRUSE
可确保如果绑定成功,则源地址和端口的组合由该套接字独占拥有,并且没有其他套接字可以绑定到它们,甚至如果它有 { 3}设置。
此默认行为首先在 Windows 2003 中进行了更改,Microsoft 将其称为“增强的套接字安全性”(所有其他主要操作系统默认行为的有趣名称)。更多详情just visit this page。共有三个表:第一个显示经典行为(在使用兼容模式时仍在使用!),第二个显示当 bind()
调用由同一用户进行时 Windows 2003 及更高版本的行为,第三个当 bind()
呼叫由不同的用户进行时。
索拉里斯
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
具有 { 1} 启用,在这种情况下,无论 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,大多数时候程序员可以简单地忽略该状态的存在。
Here's the code(我不能在此处包含它,答案有大小限制,并且代码会将这个回复推到超出限制的范围内)。
Mecki 的回答绝对完美,但值得补充的是,FreeBSD 还支持 SO_REUSEPORT_LB
,它模仿了 Linux 的 SO_REUSEPORT
行为——它平衡了负载;见setsockopt(2)
INADDR_ANY
绑定不会绑定现有的本地地址,但也会绑定所有未来的本地地址。listen
肯定会创建具有完全相同的协议、本地地址和本地端口的套接字,即使您说这是不可能的。INADDR_ANY
有什么问题,我从未说过它不会绑定到未来的地址。而且listen
根本不创建任何套接字,这使您的整个句子有点奇怪。