什么是TIME_WAIT

TIME_WAIT的产生

TCP的四次挥手过程,如下图:

  • 客户端关闭连接,此时会发送一个TCP首部FIN的标志位会被置为1的报文,也就是FIN报文,之后客户端进入FIN_WAIT_1的状态。
  • 服务端收到该报文后,就像客户端发送 ACK 应答报文,接着服务端进入CLOSED_WAIT状态。
  • 客户端收到服务端的 ACK 应答报文后,就会进入FIN_WAIT_2状态。
  • 等待服务端处理完数据后,也向客户端发送 FIN报文,之后服务端进入LAST_ACK状态。
  • 客户端收到服务端的FIN报文后,回复一个ACK应答报文,之后进入TIME_WAIT状态。
  • 服务端收到ACK应答报文后,进入CLOSE状态,至此服务端已完成连接的关闭。
  • 客户端在经历2MSL之后,进入CLOSE状态,至此客户端也已经完成连接的关闭。

这里可以看到 TIME_WAIT 是主动关闭连接一方,断开连接时最后一个状态。这个状态会持续2MSL(Maximum Segment Lifetime),之后进入CLOSE状态。

MSL指的是TCP协议中任何报文在网络上最大生存时间,任何超过这个时间的数据都会被丢弃。RFC 793规定MSL是2分钟,但实际实现的时候会有所不同,比如Linux默认的是30秒,2MSL也就是60秒。

MSL是由网络层的IP包中的TTL来保证的,TTL是IP头部的一个字段,用于设置一个数据报可经过的路由器数量上限。报文每经过一次路由器的转发,IP头部的TTL字段值就会减1,减到0时报文就会被丢弃。

MSL与TTL的区别:MSL的单位是时间,TTL是经过的路由器跳数,MSL应该大于等于TTL消耗为0的时间,以确保报文被自然消亡。

TTL的值一般是64,Linux将MSL的值设置为30秒,意味着 Linux 认为数据报文经过 64 个路由器的时间不会超过 30 秒,如果超过了,就认为报文已经消失在网络中了。

为什么要TIME_WAIT状态

设置TIME_WAIT主要有两个原因:

  • 防止历史连接中的数据,被后面相同四元组的连接错误接收。
  • 保证 被动关闭的一方 能被正确的关闭。

防止历史连接中的数据,被后面相同四元组的连接错误的接收

如上图所示,服务端在关闭连接之前发送的 SEQ=301 报文,被网络延迟了。接着,服务端以相同的四元组重新打开了新连接,前面被延迟的 SEQ = 301 这时抵达了客户端,而且该数据报文的序列号刚好在客户端接收窗口内,因此客户端会正常接收这个数据报文,但是这个数据报文是上一个连接残留下来的,这样就产生数据错乱等严重的问题。

为了防止历史连接中的数据,被后面相同四元组的连接错误接收,因此TCP设计了TIME_WAIT状态,状态会持续2MSL时长,这个时间足以让两个方向上的数据包都丢弃,使得原来连接的包在网络中都自然消失,再出现的数据包一定是新建连接产生的。

保证被动关闭的一方能被正确的关闭。

如果客户度(主动关闭方)最后一次ACK(第四次挥手)在网络中丢失了,那么按照TCP可靠性原则,服务端(被动关闭方)会重发FIN报文。假如客户端没有TIME_WAIT状态,而是发完最后一次ACK直接进入CLOSE状态,如果该ACK报文丢失了,服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,在收到服务端重传的 FIN 报文后,就会回 RST 报文。

服务端收到这个RST将其解释为一个错误(Connection reset by peer),这对一个可靠的协议来说是一个不优雅的终止方式。

为了防止这种情况出现,客户端必须等待足够长的时间,确保服务端能够收到 ACK,如果服务端没有收到 ACK,那么就会触发 TCP 重传机制,服务端会重新发送一个 FIN,这样一去一来刚好两个 MSL 的时间。

客户端在收到服务端重传的 FIN 报文时,TIME_WAIT 状态的等待时间,会重置回 2MSL。

TIME_WAIT状态产生的问题

过多的TIME_WAIT的危害主要有两种:

  • 第一是占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等;
  • 第二是占用端口资源,端口资源也是有限的,一般可以开启的端口为 32768~61000,也可以通过 net.ipv4.ip_local_port_range参数指定范围。

客户端和服务端的TIME_WAIT过多,造成的影响是不同的。

如果客户端(主动发起关闭连接方)的 TIME_WAIT 状态过多,占满了所有端口资源,那么就无法对「目的 IP+ 目的 PORT」都一样的服务端发起连接了,但是被使用的端口,还是可以继续对另外一个服务端发起连接的。

因此,客户端(发起连接方)都是和「目的 IP+ 目的 PORT 」都一样的服务端建立连接的话,当客户端的 TIME_WAIT 状态连接过多的话,就会受端口资源限制,如果占满了所有端口资源,那么就无法再跟「目的 IP+ 目的 PORT」都一样的服务端建立连接了。

不过,即使是在这种场景下,只要连接的是不同的服务端,端口是可以重复使用的,所以客户端还是可以向其他服务端发起连接的,这是因为内核在定位一个连接的时候,是通过四元组(源IP、源端口、目的IP、目的端口)信息来定位的,并不会因为客户端的端口一样,而导致连接冲突。

如果服务端(主动发起关闭连接方)的 TIME_WAIT 状态过多,并不会导致端口资源受限,因为服务端只监听一个端口,而且由于一个四元组唯一确定一个 TCP 连接,因此理论上服务端可以建立很多连接,但是 TCP 连接过多,会占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等。

解决方案

这里给出优化 TIME-WAIT 的几个方式,都是有利有弊:

  • 打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项
  • 设置 net.ipv4.tcp_max_tw_buckets
  • 程序中使用SO_LINGER ,应用强制使用RST关闭

打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项

Linux 内核参数开启后,则可以复用处于 TIME_WAIT 的 socket 为新的连接所用。有一点需要注意的是,tcp_tw_reuse 功能只能用客户端(连接发起方),因为开启了该功能,在调用 connect() 函数时,内核会随机找一个 time_wait 状态超过 1 秒的连接给新的连接复用。

由于引入了时间戳,我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。

设置 net.ipv4.tcp_max_tw_buckets

这个值默认为 18000,当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将后面的 TIME_WAIT 连接状态重置,这个方法比较暴力。

使用SO_LINGER ,应用强制使用RST关闭

l_linger值为0,那么调用close后,会立该发送一个RST标志给对端,该TCP连接将跳过四次挥手,也就跳过了TIME_WAIT状态,直接关闭。但这为跨越TIME_WAIT状态提供了一个可能,不过是一个非常危险的行为,不值得提倡。

如果服务端要避免过多的 TIME_WAIT 状态的连接,就永远不要主动断开连接,让客户端去断开,由分布在各处的客户端去承受 TIME_WAIT。

参考

https://coolshell.cn/articles/22263.html

https://www.rfc-editor.org/rfc/rfc793

https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux