0%

引言

SO_REUSEADDR 与 SO_REUSEPORT 这两个 socket 选项有时总是让人分不清,在查看相关资料时正好看到一篇不错的文章,所以干脆直接搬过来吧。

基础

socket 的鼻祖是 BSD,因为那时很多系统都直接复制了 BSD 的实现,并在之后逐渐发展,只不过复制的同时也将对应的 socket 缺陷复制了下来。所以理解 BSD 实现也是理解其他系统实现的关键。

在我们讲解这两参数之前,我们需要补充一点基础知识:连接五元组:{<protocol>, <src addr>, <src port>, <dest addr>, <dest port>} 该五个值的组合唯一标识一个连接。也就是说不会存在两个连接,它们五个属性完全一样。

socket 所使用的协议是通过 socket() 函数创建 socket 时设置的,源地址、源端口是在调用 bind() 函数时设置,目的地址、目的端口则是 connect() 时设置。由于 UDP 是无连接的协议,所以你可以在不创建连接的情况下使用它(当然它也同样支持连接模式)。在无连接模式下,UDP socket 在首次发送数据时,会被系统自动绑定,否则它是无法接收任何数据的。同样的道理,未绑定的 TCP 在连接之前,会被系统自动绑定。

可以使用 bind() 函数显示绑定地址及端口。当端口指定为 0 时,意味着让系统随机选择一个可用的端口进行绑定。类似,我们也可以指定通配符地址,如:IPv4 的 0.0.0.0 或者 IPv6 的 ::。与端口不同的是,它会将本地所有接口都作为源地址。根据目的地址以及路由表信息,系统会选择一个适当的接口地址作为该 socket 的源地址。

默认情况下,相同地址以及端口的两个 socket 是不能绑定成功的。只要源端口不同,那么它就是源地址无关的了。如果两个连接的地址不同,即使它们的端口相同,同样可以绑定成功;但是记住,如果一个端口比如:21,绑定到了 0.0.0.0,那么它也就意味着绑定到了本地所有接口的 21 端口上。其他的服务想绑定 21 端口,那是不可能的了。

到目前为止,大部分操作系统都类似,但是随着地址重用技术的使用,功能也开始特定于系统。不过,我们仍然以 BSD 开始介绍,谁让它是 socket 实现的起源呢。

BSD

SO_REUSEADDR

如果在绑定的时候指定了 SO_REUSEADDR 选项,除非有其他的 socket 恰恰绑定到了相同的源地址以及源端口,否则都会绑定成功。那么,跟之前有什么不同呢?假如在没有指定 SO_REUSEADDR 选项时,我们把 socketA 绑定到了 0.0.0.0:21,随后绑定 socketB 到 192.168.0.1:21 就会失败(错误码 EADDRINUSE),0.0.0.0 意味这本地接口的所有地址,这也包括了同为本地接口的 192.168.0.1。如果指定了 SO_REUSEADDR 选项,那么本次绑定就会成功,因为 0.0.0.0192.168.0.1 并不是完全相同的地址。我们通过以下表格来说明可能出现的任意组合:

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.0.1:21 Error (EADDRINUSE)
OFF 192.168.0.1:21 0.0.0.0:21 Error (EADDRINUSE)
ON 0.0.0.0:21 192.168.0.1:21 OK
ON 192.168.0.1:21 0.0.0.0:21 OK
ON/OFF 0.0.0.0:21 0.0.0.0:21 Error (EADDRINUSE)

SO_REUSEADDR 除了会影响通配符地址,它还有另外一个作用,不过在讲解之前,我们需要先了解一下 TCP 协议的工作原理。

当成功调用 send() 函数发送数据时,数据其实并没有真正的发送出去,而是放到了发送缓冲区中。对于 UDP 套接字而言,即使数据不是立即发送出去,它通常也会很快的发送出去。但是对于 TCP 而言却不是这样,从数据放到发送缓冲区到真实发送出去可能会有一个很大的延迟。这就会有一个问题,当你关闭 socket 时,发送缓冲区中可能仍有数据未发送,但是程序认为它发送成功了,毕竟 send() 函数已经成功返回了。如果 TCP 实现是,当你立即关闭一个请求时,它的数据也随之丢失,在你看来,它就不是一个可靠的协议。这就是为什么当你关闭套接字时,会有一个 TIME_OUT 状态的原因。

无论是否还有数据在传输,内核在关闭 socket 时所等待的时间被称为 Linger Time。Linger Time 在大多系统上都是全局配置的,并且它的默认值很长(大多系统默认为 2 分钟)。当然,你可以通过 SO_LINGER 选项修改对应的 socket 选项,以调整这个时间。关闭 Linger Time 并不推荐,因为 TCP 对于 socket 的关闭是一个复杂的过程,涉及到了包的发送、回复、丢失包的重传,而以上的过程受 Linger Time 的限制。如果禁止 Linger Time,socket 并不只是丢失数据,它也会以强制关闭取代常规的 socket 关闭方式。另外,即使你使用 SO_LINGER 选项禁用 Linger Time,当你的程序没有显示的关闭 socket 而退出时,BSD(或许包括其他系统)仍然会保留这个 Linger Time 从而忽略你的配置,比如你的程序调用 exit() 或者收到相关信号而退出。所以你无法确保所有的场景都会按照你的预期进行。

那么系统是如何处理处于 TIME_OUT 状态下的套接字呢?如果 SO_REUSEADDR 选项没有设置,那么该套接字对应的源地址以及源端口就仍然处于绑定状态,任何其他的新 socket 想要绑定到相同的地址、端口的都会失败,除非先前的 socket 已经完全关闭,这可能会花费 Linger Time 的时间。所以不要期待你可以在关闭 socket 时,迅速重新绑定。但是,如果为你将要绑定的 socket 设置 SO_REUSEADDR 选项,那么对于相同地址、端口处于 TIME_OUT 的 socket 就会被忽略,你的 socket 就能立即绑定成功了。

最后还有一点需要我们知道,就是对于 SO_REUSEADDR 选项只有在调用 bind() 函数时才是必须的,因为只有此时才会检查是否绑定成功,其他场景都不需要。

SO_REUSEPORT

SO_REUSEPORT 才是大家所期待的 SO_REUSEADDR,因为 SO_REUSEPORT 允许任意数量的套接字绑定到相同的地址、接口上。但是如果第一个套接字绑定时没有指定该选项,那么之后的绑定到相同信息的套接字都不会成功,无论它是否指定 SO_REUSEPORT,除非先前的套接字被释放。与 SO_REUSEADDR 不同的是,程序不仅检查当前的套接字是否有 SO_REUSEPORT 选项,也会检查即将绑定具有相同地址、端口的套接字的 SO_REUSEPORT 选项是否被设置。

另外,如果一个未设置 SO_REUSEPORT 选项的套接字绑定到一个具有 SO_REUSEPORT 同时处于 TIME_OUT 状态的套接字时也会失败。所以为了绑定成功,两者需要在绑定时同时指定 SO_REUSEPORT 选项。当然,在绑定时同时指定 SO_REUSEPORT 以及 SO_REUSEADDR 也是可以的。

由于大部分系统是在该选项加入到 BSD 之前 “forked” 的,所以你很少在其他系统中找到该选项的实现。而在这之前想要把完全相同信息的套接字绑定成功是不可能的。

Connect() 返回 EADDRINUSE 错误?

很多人在 bind() 时遇到 EADDRINUSE 错误,但是当你使用地址重用,调用 connect() 时也可能会遇到该错误。这就让人费解了,远程地址毕竟是添加到 socket 中用来连接的,怎么会被使用呢?

还记得文章开头时介绍的,一个连接由五元组唯一标识。当两个完全一样的 socket 出现时,系统是无法区分两者的。说到这里,我想你已经明白了。当启用地址重用时,你可以绑定相同地址、端口、协议的 socket。此时五元组中的三个元素已经相同了,而你在连接到相同的远程地址、端口。那么两个 socket 的五元组就完全相同了,这显然是不允许的(至少对于 TCP 来说是这样,而 UDP 并不是一个真实的连接)。

所以,如果你绑定了两个相同源地址、端口的 socket ,当你其中一个 socket 调用 connect() 发生 EADDRINUSE 错误时,这就意味着已有一个相同的 socket 连接已经建立了。

多播地址

SO_REUSEADDR 对于多播场景下,行为有所不同,因为它允许多个套接字绑定到相同的多播地址以及端口。换句话说,它的行为就跟 SO_REUSEPORT 对于单播地址一样。事实上,程序对于 SO_REUSEADDR 以及 SO_REUSEPORT 在多播场景下都是一样的。

FreeBSD/OpenBSD/NetBSD

以上系统都来源于较晚的 BSD 分支,也就造成它们与 BSD 具有相同的选项,相同的行为。

macOS (MacOS X) / iOS / watchOS / tvOS

以上系统表现行为与 BSD 一致,不在做过多介绍。

Linux

Linux < 3.9

在 3.9 之前,Linux 只有 SO_REUSEADDR 选项,其行为与 BSD 大致相同,除了以下重要的两点:

  • 只要一个端口被一个监听的 TCP 套接字绑定上,预绑定到该端口的其他套接字中的 SO_REUSEADDR 选项就会被忽略,也就是绑定失败。只有 BSD 中,在未指定 SO_REUSEADDR 时,绑定第二个套接字到同一个端口才成为可能。比如说:你绑定了一个通配符地址,你就不能再绑定相同端口的套接字,反之也是一样。但是,如果在 BSD 平台,当你指定了 SO_REUSEADDR,就能绑定成功。所以,在 3.9 下你只能绑定非通配符的不同地址到相同端口上。这一点,Linux 比 BSD 要更严格。
  • 第二点不同体现在客户端 socket 上,该参数的行为跟 BSD 中的 SO_REUSEPORT 完全一样,前提是,在绑定之前,它们都设置了 SO_REUSEADDR。之所以这样是因为,在 3.9 之前并没有 SO_REUSEPORT,而将不同协议的多个套接字绑定到完全一样的 UDP 套接字地址的功能又很重要,所以修改了 SO_REUSEADDR 的行为以弥补这个空白。这点上,Linux 又比 BSD 宽松。

Linux >= 3.9

3.9 之后的版本增加了 SO_REUSEPORT 选项,与 BSD 中的完全一致。只要套接字在绑定之前指定了该参数,那么地址与端口完全一样的套接字就能绑定成功。但是仍有两点与其他系统不同:

  • 为了防止端口挟持,这里有一个特殊的限制:只有相同用户 ID 下的进程才能共享彼此相同的地址、端口的组合。一个用户想“窃取”另外一个用户的端口是不可能的。这其实也是对缺少 SO_EXCLBINDSO_EXCLUSIVEADDRUSE 选项的一种补偿。
  • 另外,该内核也为具有 SO_REUSEPORT 的套接字实现了一些其他系统不曾有的功能:对于 UDP 套接字,它可以尝试均发数据报;对于 TCP 的监听套接字,它可以将传入的请求平均分配给那些具有相同地址、端口的套接字。这样一来,程序就可以在多个子进程中利用 SO_REUSEPORT 开启多个相同的套接字以实现“免费“的负载均衡。

Android

虽然整个 Android 系统与 Linux 发行版本有稍许不同,但是其核心也只是 Linux 的轻量修改版本,所以它的行为应该跟 Linux 一致。

Windows

Windows 只有 SO_REUSEADDR 而不存在 SO_REUSEPORT。具有 SO_REUSEADDR 的套接字与 BSD 中具有 SO_REUSEPORT 以及 SO_REUSEPORT 两者的行为一致,只不过有一点例外:

Windows 2003 之前,当套接字设置了 SO_REUSEADDR 之后,它总是可以绑定到与已绑定套接字完全一样的地址、端口上,即使那个套接字并没有设置 SO_REUSEADDR。这也就造成一个应用可以“窃取”另一个应用已连接的端口。显然易见,这里存在一个重大的安全隐患。

该行为的第一次更新是在 Windows 2003 中,并被微软成为”增强的套接字安全“,通过 just visit this page 获取详细信息。文中包括三张表:第一张展示了经典行为(作为兼容模式使用);第二张展示了在 Windows 2003 以及之后的版本中,同一用户调用 bind() 的行为;第三张表展示不同用户调用 bind() 的行为。

Solaris

Solaris 只有 SO_REUSEADDR 而没有 SO_REUSEPORT,也就是说无法做到将两个完全相同地址、端口的套接字绑定到一起。至于 SO_REUSEADDR 行为则跟 BSD 中的基本一致,毕竟 Solaris 是 SunOS 的继任者,而 SunOS 又起源于 BSD。

与 Windows 类似的是,Solaris 有一个 SO_EXCLBIND 选项用于 socket 独占绑定。如果在 socket 绑定之前设置了该选项,那么即使另外一个 socket 设置了 SO_REUSEADDR,当发生地址冲突的时候,该 socket 选项也不起任何作用。例如:socketA 绑定了通配符地址,socketB 开启了 SO_REUSEADDR 就能成功绑定到与 socketA 相同端口但是非通配地址上。但是如果 socketA 开启了 SO_EXCLBIN 之后,socketB 就别再想绑定成功了。


参考