Socket 编程中的端口复用:SO_REUSEADDR 和 SO_REUSEPORT
欢迎来到一个充满移植性挑战的世界!在深入分析这两个选项之前,我们首先需要了解 BSD
套接字实现是所有套接字实现的鼻祖。几乎所有其他系统都在某个时间点复制了 BSD
套接字实现,或至少是其接口,然后在此基础上发展演变。当然,BSD
套接字实现本身也在不断演变,因此那些更晚期复制它的系统获得了更早期系统所缺乏的功能。理解 BSD
套接字实现是理解其他所有套接字实现的关键,即使你不打算为 BSD
系统编写代码,也应该了解它。
在深入探讨 SO_REUSEADDR
和 SO_REUSEPORT
之前,你需要了解一些基本知识。一个 TCP/UDP
连接由五个值组成的元组标识:
1 | {<协议>, <源地址>, <源端口>, <目标地址>, <目标端口>} |
任何唯一的这些值组合标识一个连接。因此,没有两个连接可以具有相同的五个值,否则系统将无法区分这些连接。
套接字的基本操作
- 创建套接字:使用
socket()
函数设置套接字的协议。 - 绑定地址和端口:使用
bind()
函数设置源地址和端口。 - 连接目标地址和端口:使用
connect()
函数设置目标地址和端口。
对于UDP
,由于它是无连接协议,可以在不连接的情况下使用。然而,在某些情况下连接它是有益的。在无连接模式下,如果未显式绑定,UDP
套接字通常会在第一次发送数据时由系统自动绑定,因为未绑定的UDP
套接字无法接收任何(回复)数据。对于未绑定的 TCP 套接字也是如此,它在连接之前会自动绑定。
可以将套接字显式绑定到端口 0
,这意味着任意端口。系统将从预定义的源端口范围中选择一个特定端口。同样,源地址也可以是任意地址(IPv4
中是 0.0.0.0
,IPv6
中是 ::
)。一个套接字可以绑定到“任意地址”,意味着它绑定到所有本地接口的所有源 IP
地址。如果套接字随后连接,系统必须选择一个特定的源 IP
地址,并用选择的源 IP
地址替换任意绑定。
默认情况下,没有两个套接字可以绑定到相同的源地址和源端口组合。只要源端口不同,源地址实际上是无关紧要的。将 socketA
绑定到 ipA
和将 socketB
绑定到 ipB
总是可以的,如果 ipA != ipB
即使 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 | 结果 |
---|---|---|---|
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
,最后绑定 socketB
到给定的地址。结果是 socketB
绑定操作的结果。如果第一列显示 ON/OFF
,则 SO_REUSEADDR
的值与结果无关。
SO_REUSEADDR
的另一个重要作用是在 TCP
协议中的应用。通常,当一个 TCP
套接字关闭时,会进行一个 3
步握手(FIN-ACK
)。问题在于,最后一个 ACK
可能到达另一端,也可能没有到达。如果没有到达,另一端仍然认为套接字是打开的。为了防止重用可能仍然被远程对等方视为打开的地址和端口组合,系统在发送最后一个 ACK
后不会立即将套接字视为关闭,而是将其置于称为 TIME_WAIT
的状态。这个状态可能持续几分钟(取决于系统设置)。虽然大多数系统可以通过启用延迟并设置延迟时间为零来绕过这个状态,但这并不总是可行,并且即使系统支持这种请求,也会导致套接字通过复位(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
的一个重要应用是负载平衡。多个进程或线程可以同时绑定到相同的地址和端口,系统将确保传入的连接或数据包均匀地分布在所有套接字上。对于服务器应用程序来说,这可以显著提高并发处理能力和性能。
在 BSD
系统上,启用 SO_REUSEPORT
后,套接字绑定的行为如下:
SO_REUSEPORT | socketA | socketB | 结果 |
---|---|---|---|
ON | 192.168.0.1:21 | 192.168.0.1:21 | 成功 |
ON | 0.0.0.0:21 | 0.0.0.0:21 | 成功 |
OFF | 0.0.0.0:21 | 192.168.0.1:21 | 成功(如上所述) |
OFF | 192.168.0.1:21 | 0.0.0.0:21 | 成功(如上所述) |
OFF | 0.0.0.0:21 | 0.0.0.0:21 | 错误 (EADDRINUSE) |
OFF | 192.168.0.1:21 | 192.168.0.1:21 | 错误 (EADDRINUSE) |
如上所述,SO_REUSEPORT
的行为与 SO_REUSEADDR
不同。必须为所有套接字设置此标志才能共享同一个地址和端口。这个特性在实现高性能、高并发服务器应用程序时尤为重要。
在其他系统上的行为
Linux
在 Linux
系统上,SO_REUSEADDR
和 SO_REUSEPORT
的行为与 BSD
系统基本一致。然而,Linux
在一些细节上有所不同。例如,在 TCP
套接字关闭时的 TIME_WAIT
状态处理上,Linux
可能比 BSD
更灵活。此外,Linux
还支持一些额外的套接字选项和特性,如 TCP_FASTOPEN
和 TCP_DEFER_ACCEPT
,这些选项可以进一步优化性能和可靠性。
Windows
在 Windows
系统上,SO_REUSEADDR
和 SO_REUSEPORT
的行为也与 BSD
和 Linux
基本一致。然而,Windows
套接字 API
在一些实现细节和错误处理上有所不同。例如,Windows
在处理地址重用和 TIME_WAIT
状态时,可能会有一些特定的行为和限制。此外,Windows
套接字 API
还支持一些特有的选项和特性,如 WSAIoctl
和 overlapped I/O
,这些特性在实现高性能网络应用时非常有用。
macOS
在 macOS
系统上,SO_REUSEADDR
和 SO_REUSEPORT
的行为基本与 BSD 系统相同。由于 macOS
基于 BSD
内核,其套接字实现与 BSD
非常相似。然而,macOS
在一些细节和特性上可能会有所不同。例如,macOS
在处理套接字关闭和资源释放时,可能会有一些特定的优化和改进。此外,macOS
还支持一些特有的套接字选项和特性,如 kqueue
和 GCD
,这些特性在实现高性能、高并发网络应用时非常有用。
结论
理解和正确使用 SO_REUSEADDR
和 SO_REUSEPORT
是实现高性能网络应用的关键。虽然这些选项在不同系统上的实现可能有所不同,但它们的基本原理和应用场景是相同的。在编写跨平台的网络应用时,了解这些选项在不同系统上的行为和限制,可以帮助你更好地优化和调试你的程序。希望本文能够帮助你更好地理解和使用这些重要的套接字选项,并在实际应用中取得成功。