首先我们需要了解一些基本知识,一个TCP/UDP连接是被一个五元组确定的{源地址,源端口,协议,目的端口,目的地址}。
因此,任何两个连接都不可能拥有相同的五元组,否则系统将无法区别这两个连接。
当使用socket()函数创建套接字的时候,我们就指定了该套接字使用的protocol(协议),bind()函数设置了源地址和源端口号,而目的地址和目的端口号则由connect()函数设定。
尽管允许对UDP进行"连接",但由于UDP是一个无连接协议,UDP套接字仍然可以不经连接就使用。"未连接"的UDP套接字在数据被第一次发送之前并不会绑定,只有在发送的时候被系统自动绑定,因此未绑定的UDP套接字也就无法收到(回复)数据。未绑定的TCP也一样,它将在连接的时候自动绑定。
如果你明确绑定一个socket,把它绑定到端口0是可行的,它意味着"any port"("任意端口")。由于一个套接字无法真正的被绑定到系统上的所有端口,那么在这种情况下系统将不得不选择一个具体的端口号。源地址使用类似的通配符,也就是"0.0.0.0" 。和端口不同的是,一个套接字可以被绑定到任意地址,这里指本地网络接口的所有地址。由于socket无法在连接的时候同时绑定到所有源IP地址,因此当接下来有一个连接过来的时候,系统将不得不挑选一个源IP地址。考虑到目的地址和路由表中的路由信息,系统将会选择一个合适的源地址,并将任意地址替换为一个选定的地址作为源地址。
默认情况下,任意两个socket都无法绑定到相同的源IP地址和源端口(即源地址和源端口号均相同)。只要源端口号不相同,那么源地址实际上没什么关系。将socket A绑定到地址A和端口X (A:X),socket B绑定到地址B和端口Y (B:Y),只要X != Y,那么这种绑定都是可行的。然而当X==Y的时候只要A != B,这种绑定方式也仍然可行。记住:一个socket可能绑定到本地"any address"。例如一个socket绑定为 0.0.0.0:21,那么它同时绑定了所有的本地地址,在这种情况下,不论其它的socket选择什么特定的IP地址,它们都无法绑定到21端口,因为0.0.0.0和所有的本地地址都会冲突。
SO_REUSEADDR
如果在绑定一个socket之前设置了SO_REUSEADDR,除非两个socket绑定的源地址和端口号都一样,那么这两个绑定都是可行的。也许你会疑惑这跟之前的有什么不一样?关键是SO_REUSEADDR改变了在处理源地址冲突时对通配地址的处理方式。
当没有设置SO_REUSEADDR的时候,socket A先绑定到0.0.0.0:21,然后socket B绑定到192.168.0.1:21的时候将会失败(EADDRINUSE错误),因为0.0.0.0意味着"任意本地IP地址”,也就是"所有本地IP地址“,因此包括192.168.0.1在内的所有IP地址都被认为是已经使用了。但是在设置SO_REUSEADDR之后socket B的绑定将会成功,因为0.0.0.0和192.168.0.1事实上不是同一个IP地址,一个是代表所有地址的通配地址,另一个是一个具体的地址。注意上面的表述对于socket A和socket B的绑定顺序是无关的,没有设置SO_REUSEADDR,它们将失败,设置了SO_REUSEADDR,它将成功。
下面给出了一个表格列出了所有的可能组合:
SO_REUSEADDR socket A socket B 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)
现在我们知道SO_REUSEADDR对通配地址有影响,但这不是它唯一影响到的方面。还有一个众所周知的影响同时也是大多数人在服务器程序上使用SO_REUSEADDR的首要原因。为了了解其它SO_REUSEADDR重要的使用方式,我们需要深入了解TCP协议的工作方式。
一个socket有一个发送缓冲区,当调用send()函数成功后,这并不意味着所有数据都真正被发送出去了,它只意味着数据都被送到了发送缓冲区中。对于UDP socket来说,如果不是立刻发送的话,数据通常也会很快的发送出去,但对于TCP socket,在数据加入到缓冲区和真正被发送出去之间的时延会相当长。这就导致当我们close一个TCP socket的时候,可能在发送缓冲区中保存着等待发送的数据(由于send()成功返回,因此你也许认为数据已经被发送了)。如果TCP的实现是立刻关闭socket,那么所有这些数据都会丢失而你的程序根本不可能知道。TCP被称为可靠协议,像这种丢失数据的方式就不那么可靠了。这也是为什么当我们close一个TCP socket的时候,如果它仍然有数据等待发送,那么该socket会进入TIME_WAIT状态(一般都是在服务器端主动关闭socket的情况下会发生)。这种状态将持续到数据被全部发送或者发生超时。
在内核彻底关闭socket之前等待的总时间(不管是否有数据在发送缓冲区中等待发送)叫做Linger Time。Linger Time在大部分系统上都是一个全局性的配置项而且在默认情况下时间相当长(在大部分系统上是两分钟)。当然对于每个socket我们也可以使用socket选项SO_LINGER进行配置,可以将等待时间设置的更长一点儿或更短一点儿甚至禁用它。禁用Linger Time绝对是一个坏主意,虽然优雅的关闭socket是一个稍微复杂的过程并且涉及到来回的发送数据包(以及在数据包丢失后重发它们),并且这个过程还受到Linger Time的限制。如果禁用Linger Time,socket可能丢失的不仅仅是待发送的数据,而且还会粗暴的关闭socket,在绝大部分情况下,都不应该这样使用。而且如果你用SO_LINGER禁用了Linger Time,而你的程序在显式的关闭socket之前就终止的话,BSD仍然会等待,而不管已经禁用了它。这种情况的一个例子就是你的程序调用了exit() 、或者进程被信号杀死。这样的话,不管在什么情况下,你都无法对某一个socket禁用linger了。
问题在于,系统是怎样看待TIME_WAIT状态的?如果SO_REUSEADDR还没有设置,一个处在TIME_WAIT的socket仍然被认为绑定在源地址和端口,任何其它的试图在同样的地址和端口上绑定一个socket行为都会失败直到原来的socket真正的关闭了,这通常需要等待Linger Time的时长。所以不要指望在一个socket关闭后立刻将源地址和端口绑定到新的socket上,在绝大部分情况下,这种行为都会失败。然而,在设置了SO_REUSEADDR之后试图这样绑定(绑定相同的地址和端口)仅仅只会被忽略,而且你可以将相同的地址绑定到不同的socket上。注意当一个socket处于TIME_WAIT状态,而你试图将它绑定到相同的地址和端口,这会导致未预料的结果,因为处于TIME_WAIT状态的socket仍在"工作",幸运的是这种情况极少发生。
对于SO_REUSEADDR你需要知道的最后一点是只有在你想绑定的socket开启了地址重用之后上面的才会生效,不过这并不需要检查之前已经绑定或处于TIME_WAIT的socket在它们绑定的时候是否也设置这个选项。也就是说,绑定的成功与否只会检查当前bind的socket是否开启了这个标志,不会查看其它的socket。
SO_REUSEPORT
SO_REUSEPORT的含义与绝大部分人对SO_REUSEADDR的理解一样。基本上说来,SO_REUSEPORT允许你将多个socket绑定到相同的地址和端口只要它们在绑定之前都设置了SO_REUSEPORT。如果第一个绑定某个地址和端口的socket没有设置SO_REUSEPORT,那么其他的socket无论有没有设置SO_REUSEPORT都无法绑定到该地址和端口直到第一个socket释放了绑定。
SO_REUSEPORT并不表示SO_REUSEADDR。这意味着如果一个socket在绑定时没有设置SO_REUSEPORT,那么同预期的一样,其它的socket对相同地址和端口的绑定会失败,但是如果绑定相同地址和端口的socket正处在TIME_WAIT状态,新的绑定也会失败。当有个socket绑定后处在TIME_WAIT状态(释放时)时,为了使得其它socket绑定相同地址和端口能够成功,需要设置SO_REUSEADDR或者在这两个socket上都设置SO_REUSEPORT。当然,在socket上同时设置SO_REUSEPORT和SO_REUSEADDR也是可行的。
关于SO_REUSEPORT除了它在被添加到系统的时间比SO_REUSEPORT晚就没有其它需要说的了,这也是为什么在有些系统的socket实现上你找不到这个选项,因为这些系统的代码都是在这个选项被添加到BSD之前fork了BSD,这样就不能将两个socket绑定到真正相同的“地址” (address+port)。Connect() Returning EADDRINUSE?
绝大部分人都知道bind()可能失败返回EADDRINUSE,然而当你开始使用地址重用,你可能会碰到奇怪的情况:connect()失败返回同样的错误EADDRINUSE。怎么会出现这种情况了? 一个远端地址毕竟是connect添加到socket上的,怎么会已经被使用了? 将多个socket连接到相同的远端地址从来没有出现过这样的情况,这是为什么了?
正如我在开头说过的,一个连接是被一个五元组定义的。同样我也说了任意两个连接的五元组不能完全一样,因为这样的话内核就没办法区分这两个连接了。然而,在地址重用的情况下,你可以把同协议的两个socket绑定到完全相同的源地址和源端口,这意味着五元组中已经有三个元素相同了(协议,源地址,源端口)。如果你尝试将这些socket连接到同样的目的地址和目的端口,你就创建了两个完全相同的连接。这是不行的,至少对TCP不行(UDP实际上没有真实的连接)。如果数据到达这两个连接中的任何一个,那么系统将无法区分数据到底属于谁。因此当源地址和源端口相同时,目的地址或者目的端口必须不同,否则内核无法进行区分,这种情况下,connect()将在第二个socket尝试连接时返回EADDRINUSE。
Linux 3.9加入了SO_REUSEPORT。这个选项允许多个socket(TCP or UDP)不管是监听socket还是非监听socket只要都在绑定之前都设置了它,那么就可以绑定到完全相同的地址和端口。为了阻止"port 劫持"有一个特别的限制:所有希望共享源地址和端口的socket都必须拥有相同的有效用户id。因此一个用户就不能从另一个用户那里"偷取"端口。另外,内核在处理SO_REUSEPORT socket的时候使用了其它系统上没有用到的"特别魔法":对于UDP socket,内核尝试平均的转发数据报,对于TCP监听socket,内核尝试将新的客户连接请求(由accept返回)平均的交给共享同一地址和端口的socket(监听socket)。这意味着在其他系统上socket收到一个数据报或连接请求或多或少是随机的,但是linux尝试优化分配。例如:一个简单的服务器程序的多个实例可以使用SO_REUSEPORT socket实现一个简单的负载均衡,因为内核已经把复制的分配都做了。
2015年12月15日00:34:22