深入探索 TCP TIME-WAIT

  • 2020 年 3 月 16 日
  • 筆記

 1​ TIME-WAIT 状态

主动关闭连接的一方,在四次挥手最后一次发送 ACK 后,进入 TIME_WAIT 状态。在这个状态里,主动关闭连接一方等待 2MSL(Maximum Segment Life,报文段最大生存时间,在RFC793 中定义为 2 min,而在 Linux 中定义为 30s),若这段时间内未收到被动关闭一方重发的 FIN,则由 TIME_WAIT 状态转到 CLOSED 状态。

祭上状态机图:

 

 在这里为了讨论方便,假设主动关闭连接的一方均为本地客户端,被动关闭连接的一方均为服务端,以客户端与服务端 TCP 状态的变化来讨论。

2​ 存在的目的

为什么 TCP 需要设置 TIME-WAIT 状态等待 2MSL 才能转到 CLOSED 状态关闭连接呢?

​2.1​ 避免在新连接上收到旧连接的数据

避免在同一四元组(源地址、源端口、目的地址、目的端口)上的新连接收到旧连接的数据。
如下图所示,服务端第一次发送的序号为 3 的数据包因延时未送达客户端,服务端重发第二次序号为 3 的数据包后客户端接收到并主动断开连接。

在很短时间内,客户端重新向服务端发起连接,这时服务端发送序号 1、序号 2 的数据给客户端,但同时客户端也收到了在网络上延时到达的服务端第一次发送的序号为 3 的数据包。

 

 RFC793 中描述了 ISN 每 4 微秒会自增 1,达到  2^32 后又从 0 开始。这样周始往复,一个 ISN 的周期大约是 4.55 个小时。所以虽然 TCP 每次建立连接时的 SYN 序列号都不会相同,但若如果在接收窗口很大的情况下,快速重新建立的连接使用的序列号可能会有一部分与旧连接使用过的序列号重合,因此新连接误接收旧连接相同序列号的数据包是有机率发生的。

客户端通过 TIME-WAIT 等待 2MLS 的时间可以避免这个问题:1)收到的延迟数据包被丢弃;2)2MLS 的时间会让 ISN 与旧连接使用过的序列号重合范围减小甚至不重合。TIME-WAIT 为新连接准备了时间缓冲带,旧连接的数据包与新连接的数据包因此有足够的界限。

2.2​ 确保服务端正确关闭连接

服务端如果没有收到四次挥手中的最后一个 ACK,将会一直处于 LAST-ACK 状态,并一直重传 FIN 报文,有三种可能的情况发生:

  • 放弃重传 FIN,并移除该连接;

  • 收到 ACK,状态转为 CLOSED,并正常关闭连接;

  • 收到 RST,并移除该连接。

客户端通过 TIME-WAIT 等待 2MLS 时间确保服务端正确关闭连接。如若在 TIME-WAIT 收到服务端重传的 FIN,说明最后发送的 ACK 在网络中丢失了,需要重发 ACK 以确保服务端能收到 ACK 并正确关闭连接。这也是为什么 TIME-WAIT 等待时间是 2MLS 的原因,如果服务端重传 FIN,客户端必定在 2MLS 期间内收到:即使服务端收到 ACK 再重传 FIN, 这个过程也只需要 2MLS 时间。 

3​ 引发的问题

TIME-WAIT 状态虽好,但是当大量的连接处于 TIME-WAIT 状态而未被及时关闭,它会导致以下问题:

​3.1​ 占用连接资源

TIME-WAIT 状态在 Linux 下会持续 60s,在这 60s 内,不能建立相同相同四元组(源地址、源端口、目的地址、目的端口)的新连接。

​3.2​ 占用内存空间

在内核中,一个 TIME-WAIT 状态的 socket 与三个结构体相关,而这些数据结构在内存中都占用一定的空间:

struct tcp_timewait_sock

每当收到一个新的报文时,会在名为  “TCP established” 的哈希表中查找这个连接。该哈希表中的每个桶不仅包含 TIME-WAIT 状态的连接链表,还包含其它正常状态的连接链表。其中,TIME-WAIT 状态的链表元素数据结构是 tcp_timewait_sock(168 bytes),而其它正常状态的链表元素结构是 struct tcp_sock。

struct tcp_timewait_sock {        struct inet_timewait_sock tw_sk;        u32    tw_rcv_nxt;        u32    tw_snd_nxt;        u32    tw_rcv_wnd;        u32    tw_ts_offset;        u32    tw_ts_recent;        long   tw_ts_recent_stamp;    };    struct inet_timewait_sock {        struct sock_common  __tw_common;            int                     tw_timeout;        volatile unsigned char  tw_substate;        unsigned char           tw_rcv_wscale;        __be16 tw_sport;        unsigned int tw_ipv6only     : 1,                     tw_transparent  : 1,                     tw_pad          : 6,                     tw_tos          : 8,                     tw_ipv6_offset  : 16;        unsigned long            tw_ttd;        struct inet_bind_bucket *tw_tb;        struct hlist_node        tw_death_node;    };

struct hlist_node

struct inet_timewait_sock 的数据成员 tw_death_node,用来跟踪 TIME-WAIT 状态的连接的存活时间,存活时间越长排在链表越靠后的位置。

struct inet_bind_socket

绑定端口的哈希表,保存本地被绑定的端口及相关联的参数,用于:1)判断是否可以在给定的端口上绑定;2)寻找未被绑定的可用的端口。

哈希表的每个元素数据结构为 inet_bind_socket(48 bytes)。

3.3​ 占用 CPU 资源

在 CPU 使用上,查找一个可用的本地端口的代价可能有一丢丢大。这项工作由函数 inet_csk_get_port() 完成:锁住并迭代本地的所有端口,直到找到一个未使用的端口。

​4​ 解决方案

​4.1​ 增加四元组可选范围

具体来说:

  1. 客户端设置 net.ipv4.ip_local_port_range 来扩充客户端端口范围;

  2. 客户端使用更多的 IP 地址,例如,在负载均衡器上配置更多的 IP;

  3. 服务端监听更多的端口,如 81,82,83 等;

  4. 服务端监听更多的 IP 地址。

​4.2​ SO_LINGER 选项

默认情况下,应用程序调用 close() 关闭 socket 后会立即返回,TCP 模块会把发送缓存中的残余的数据继续发送完,最终转到 TIME-WAIT 状态。

SO_LINGER 是 socket 的一个选项,当 socket 被  close 时,该选项控制 socket 是否延迟关闭,以及如何处理发送缓存中的残余数据。

通过调用 setsockopt 来设置 socket 选项:

#include <sys/socket.h>    int setsockopt( int sockfd, int level, int option_name, const void* option_value, socklen_t option_len );

sockfd 参数指定被操作的目标 socket,level 参数指定要操作哪个协议的选项,比如 IPv4、IPv6、TCP 等,option_name 参数则指定选项的名字。option_value 和 option_len 参数分别是被操作选项的值和长度。详情见 setsockopt

设置 SO_LINGER 选项的值时,我们需要给 setsockopt 传递一个 linger 类型的结构体,其定义如下:

#include <sys/socket.h>    struct linger    {        int l_onoff; /* 开启:非 0,关闭:0 */        int l_linger;  /* 延迟时间 */    };

根据 linger 结构体中两个成员变量的不同值,close() 会产生如下三种行为之一:

  • l_onff 为 0:SO_LINGER 关闭,close 用默认行为来关闭 socket;

  • l_onff 非 0:SO_LINGER 开启,

    • l_linger == 0:客户端应用程序调用 close() 后立即返回,发送缓存中的残余数据被丢弃,同时发送一个 RST 给服务端来异常终止当前连接;

    • l_linger > 0:

      • socket 是阻塞的:客户端应用程序调用 close() 后等待 l_linger 的时间,直到发送完所有缓存中的残余数据并得到远端的确认。如果这段时间内还没有发送完残余数据,close() 返回 -1 并设置 errno 为 EWOULDBLOCK;

      • socket 是非阻塞的:客户端应用程序调用 close() 后立即返回,根据 close() 的返回值与 error 来判断残余数据是否已经发送完毕。

因此,开启 SO_LINGER 并将 l_linger 设置为 0 时,服务端会收到 RST 并关闭连接。相当于跳过 TIME_WAIT 状态直接关闭服务端的连接。但是,SO_LINGER 并没有解决新连接收到旧连接数据包的问题。

​4.3​ SO_REUSEADDR 选项/net.ipv4.tcp_tw_reuse

开启 SO_REUSEADDR 选项或者配置 net.ipv4.tcp_tw_reuse 为 1 后,Linux 将可以复用处于 TIME-WAIT 状态的连接。前面我们说到,TIME-WAIT 状态存在的目的一是为了避免在新连接上收到旧连接的数据,二是为了确保被动关闭方正确关闭连接,那么我们开启 SO_REUSEADDR 复用连接不是一切回到原点了吗?这一切都是 TCP 时间戳选项的功劳。

RFC 1323 描述了一套如何在大宽带高速网络下提升性能的 TCP 扩展,在这其中,新定义一个新的 TCP 时间戳选项:

 

Kind 

1 字节,固定为 8。 

Length

1 字节,固定为 10。

Timestamp Value (TSval)

4 字节, TCP 发送此选项时的当前时间戳。

TImestamp Echo Reply (TSecr)

4 字节,仅在 ACK 中有效,把收到的 TSval 回填到 TSecr 中发回给远端。当此报文不是 ACK,即 TSercr 无效时,TSecr 的值必须是 0 。

 

我们来看时间戳如何接手 TIME-WAIT 的问题:

1)避免在新连接上收到旧连接的数据

旧连接的数据会因为时间戳过于老旧而被丢弃;

2)确保服务端正确关闭连接

一旦客户端用新的连接替代了 TIME-WAIT 状态的连接,客户端发出 SYN 报文后服务端重传 FIN 报文(因为时间戳的关系,服务端识别出是新的连接,客户端的 SYN 报文被忽略)。因为客户端当前处于 SYN-SENT 状态,所以会回复 RST,这使得服务端能正确脱离 LAST-ACK 状态并关闭连接。这之后,SYN 初始化报文会重发,重新进入新连接的建立流程:

 

 

 

​4.4​ net.ipv4.tcp_tw_recycle

net.ipv4.tcp_tw_recycle 配置为 1 会开启系统对 TIME-WAIT 状态的 socket 的快速回收。

net.ipv4.tcp_tw_recycle 同样利用 TCP 的时间戳选项来优化 TIME-WAIT:Linux 每收到一个远端(IP)的数据包,都记录它的时间戳。当处于 TIME-WAIT 状态的 socket 收到的同一远端的数据包时间戳小于记录值,Linux 直接丢弃该数据包并回收 socket。

但是,net.ipv4.tcp_tw_recycle 并不被推荐(Linux 从 4.12 内核版本开始移除了 tcp_tw_recycle 配置),它可能会导致很多难以排查的古怪问题。特别是服务器或者客户端在 NAT 网络中,多个服务器或客户端共用 NAT 设备的时间戳,数据包可能会被丢弃。

​4.5​ net.ipv4.tcp_max_tw_buckets

表示系统同时保持 TIME-WAIT 套接字的最大数量,如果超过这个数字,TIME-WAIT 套接字将立刻被清除并打印警告信息。默认值为180000。

​5​ 参考资料

  1. TCP/IP详解 卷1:协议

  2. Linux 高性能服务器编程

  3. TCP 的那些事儿(上)

  4. Coping with the TCP TIME-WAIT state on busy Linux servers …

  5. 对 Linux TCP 的若干终点和误会

  6. 被抛弃的tcp_recycle