<audio id="audio" title="10 | TIME_WAIT：隐藏在细节下的魔鬼" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0c/49/0cd463a790c13c3414b50d77ebcfa249.mp3"></audio>

你好，我是盛延敏，这是网络编程实战的第10讲，欢迎回来。

在前面的基础篇里，我们对网络编程涉及到的基础知识进行了梳理，主要内容包括C/S编程模型、TCP协议、UDP协议和本地套接字等内容。在提高篇里，我将结合我的经验，引导你对TCP和UDP进行更深入的理解。

学习完提高篇之后，我希望你对如何提高TCP及UDP程序的健壮性有一个全面清晰的认识，从而为深入理解性能篇打下良好的基础。

在前面的基础篇里，我们了解了TCP四次挥手，在四次挥手的过程中，发起连接断开的一方会有一段时间处于TIME_WAIT的状态，你知道TIME_WAIT是用来做什么的么？在面试和实战中，TIME_WAIT相关的问题始终是绕不过去的一道难题。下面就请跟随我，一起找出隐藏在细节下的魔鬼吧。

## TIME_WAIT发生的场景

让我们先从一例线上故障说起。在一次升级线上应用服务之后，我们发现该服务的可用性变得时好时坏，一段时间可以对外提供服务，一段时间突然又不可以，大家都百思不得其解。运维同学登录到服务所在的主机上，使用netstat命令查看后才发现，主机上有成千上万处于TIME_WAIT状态的连接。

经过层层剖析后，我们发现罪魁祸首就是TIME_WAIT。为什么呢？我们这个应用服务需要通过发起TCP连接对外提供服务。每个连接会占用一个本地端口，当在高并发的情况下，TIME_WAIT状态的连接过多，多到把本机可用的端口耗尽，应用服务对外表现的症状，就是不能正常工作了。当过了一段时间之后，处于TIME_WAIT的连接被系统回收并关闭后，释放出本地端口可供使用，应用服务对外表现为，可以正常工作。这样周而复始，便会出现了一会儿不可以，过一两分钟又可以正常工作的现象。

那么为什么会产生这么多的TIME_WAIT连接呢？

这要从TCP的四次挥手说起。

<img src="https://static001.geekbang.org/resource/image/f3/e1/f34823ce42a49e4eadaf642a75d14de1.png" alt=""><br>
TCP连接终止时，主机1先发送FIN报文，主机2进入CLOSE_WAIT状态，并发送一个ACK应答，同时，主机2通过read调用获得EOF，并将此结果通知应用程序进行主动关闭操作，发送FIN报文。主机1在接收到FIN报文后发送ACK应答，此时主机1进入TIME_WAIT状态。

主机1在TIME_WAIT停留持续时间是固定的，是最长分节生命期MSL（maximum segment lifetime）的两倍，一般称之为2MSL。和大多数BSD派生的系统一样，Linux系统里有一个硬编码的字段，名称为`TCP_TIMEWAIT_LEN`，其值为60秒。也就是说，**Linux系统停留在TIME_WAIT的时间为固定的60秒。**

```
#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-        WAIT state, about 60 seconds	*/

```

过了这个时间之后，主机1就进入CLOSED状态。为什么是这个时间呢？你可以先想一想，稍后我会给出解答。

你一定要记住一点，**只有发起连接终止的一方会进入TIME_WAIT状态**。这一点面试的时候经常会被问到。

## TIME_WAIT的作用

你可能会问，为什么不直接进入CLOSED状态，而要停留在TIME_WAIT这个状态？

这要从两个方面来说。

首先，这样做是为了确保最后的ACK能让被动关闭方接收，从而帮助其正常关闭。

TCP在设计的时候，做了充分的容错性设计，比如，TCP假设报文会出错，需要重传。在这里，如果图中主机1的ACK报文没有传输成功，那么主机2就会重新发送FIN报文。

如果主机1没有维护TIME_WAIT状态，而直接进入CLOSED状态，它就失去了当前状态的上下文，只能回复一个RST操作，从而导致被动关闭方出现错误。

现在主机1知道自己处于TIME_WAIT的状态，就可以在接收到FIN报文之后，重新发出一个ACK报文，使得主机2可以进入正常的CLOSED状态。

第二个理由和连接“化身”和报文迷走有关系，为了让旧连接的重复分节在网络中自然消失。

我们知道，在网络中，经常会发生报文经过一段时间才能到达目的地的情况，产生的原因是多种多样的，如路由器重启，链路突然出现故障等。如果迷走报文到达时，发现TCP连接四元组（源IP，源端口，目的IP，目的端口）所代表的连接不复存在，那么很简单，这个报文自然丢弃。

我们考虑这样一个场景，在原连接中断后，又重新创建了一个原连接的“化身”，说是化身其实是因为这个连接和原先的连接四元组完全相同，如果迷失报文经过一段时间也到达，那么这个报文会被误认为是连接“化身”的一个TCP分节，这样就会对TCP通信产生影响。

<img src="https://static001.geekbang.org/resource/image/94/5f/945c60ae06d282dcc22ad3b868f1175f.png" alt=""><br>
所以，TCP就设计出了这么一个机制，经过2MSL这个时间，足以让两个方向上的分组都被丢弃，使得原来连接的分组在网络中都自然消失，再出现的分组一定都是新化身所产生的。

划重点，2MSL的时间是**从主机1接收到FIN后发送ACK开始计时的**；如果在TIME_WAIT时间内，因为主机1的ACK没有传输到主机2，主机1又接收到了主机2重发的FIN报文，那么2MSL时间将重新计时。道理很简单，因为2MSL的时间，目的是为了让旧连接的所有报文都能自然消亡，现在主机1重新发送了ACK报文，自然需要重新计时，以便防止这个ACK报文对新可能的连接化身造成干扰。

## TIME_WAIT的危害

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

第一是内存资源占用，这个目前看来不是太严重，基本可以忽略。

第二是对端口资源的占用，一个TCP连接至少消耗一个本地端口。要知道，端口资源也是有限的，一般可以开启的端口为32768～61000 ，也可以通过`net.ipv4.ip_local_port_range`指定，如果TIME_WAIT状态过多，会导致无法创建新连接。这个也是我们在一开始讲到的那个例子。

## 如何优化TIME_WAIT？

在高并发的情况下，如果我们想对TIME_WAIT做一些优化，来解决我们一开始提到的例子，该如何办呢？

### net.ipv4.tcp_max_tw_buckets

一个暴力的方法是通过sysctl命令，将系统值调小。这个值默认为18000，当系统中处于TIME_WAIT的连接一旦超过这个值时，系统就会将所有的TIME_WAIT连接状态重置，并且只打印出警告信息。这个方法过于暴力，而且治标不治本，带来的问题远比解决的问题多，不推荐使用。

### 调低TCP_TIMEWAIT_LEN，重新编译系统

这个方法是一个不错的方法，缺点是需要“一点”内核方面的知识，能够重新编译内核。我想这个不是大多数人能接受的方式。

### SO_LINGER的设置

英文单词“linger”的意思为停留，我们可以通过设置套接字选项，来设置调用close或者shutdown关闭连接时的行为。

```
int setsockopt(int sockfd, int level, int optname, const void *optval,
　　　　　　　　socklen_t optlen);

```

```
struct linger {
　int　 l_onoff;　　　　/* 0=off, nonzero=on */
　int　 l_linger;　　　　/* linger time, POSIX specifies units as seconds */
}

```

设置linger参数有几种可能：

- 如果`l_onoff`为0，那么关闭本选项。`l_linger`的值被忽略，这对应了默认行为，close或shutdown立即返回。如果在套接字发送缓冲区中有数据残留，系统会将试着把这些数据发送出去。
- 如果`l_onoff`为非0， 且`l_linger`值也为0，那么调用close后，会立该发送一个RST标志给对端，该TCP连接将跳过四次挥手，也就跳过了TIME_WAIT状态，直接关闭。这种关闭的方式称为“强行关闭”。 在这种情况下，排队数据不会被发送，被动关闭方也不知道对端已经彻底断开。只有当被动关闭方正阻塞在`recv()`调用上时，接受到RST时，会立刻得到一个“connet reset by peer”的异常。

```
struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
setsockopt(s,SOL_SOCKET,SO_LINGER, &amp;so_linger,sizeof(so_linger));

```

- 如果`l_onoff`为非0， 且`l_linger`的值也非0，那么调用close后，调用close的线程就将阻塞，直到数据被发送出去，或者设置的`l_linger`计时时间到。

第二种可能为跨越TIME_WAIT状态提供了一个可能，不过是一个非常危险的行为，不值得提倡。

### net.ipv4.tcp_tw_reuse：更安全的设置

那么Linux有没有提供更安全的选择呢？

当然有。这就是`net.ipv4.tcp_tw_reuse`选项。

Linux系统对于`net.ipv4.tcp_tw_reuse`的解释如下:

```
Allow to reuse TIME-WAIT sockets for new connections when it is safe from protocol viewpoint. Default value is 0.It should not be changed without advice/request of technical experts.

```

这段话的大意是从协议角度理解如果是安全可控的，可以复用处于TIME_WAIT的套接字为新的连接所用。

那么什么是协议角度理解的安全可控呢？主要有两点：

1. 只适用于连接发起方（C/S模型中的客户端）；
1. 对应的TIME_WAIT状态的连接创建时间超过1秒才可以被复用。

使用这个选项，还有一个前提，需要打开对TCP时间戳的支持，即`net.ipv4.tcp_timestamps=1`（默认即为1）。

要知道，TCP协议也在与时俱进，RFC 1323中实现了TCP拓展规范，以便保证TCP的高可用，并引入了新的TCP选项，两个4字节的时间戳字段，用于记录TCP发送方的当前时间戳和从对端接收到的最新时间戳。由于引入了时间戳，我们在前面提到的2MSL问题就不复存在了，因为重复的数据包会因为时间戳过期被自然丢弃。

## 总结

在今天的内容里，我讲了TCP的四次挥手，重点对TIME_WAIT的产生、作用以及优化进行了讲解，你需要记住以下三点：

- TIME_WAIT的引入是为了让TCP报文得以自然消失，同时为了让被动关闭方能够正常关闭；
- 不要试图使用`SO_LINGER`设置套接字选项，跳过TIME_WAIT；
- 现代Linux系统引入了更安全可控的方案，可以帮助我们尽可能地复用TIME_WAIT状态的连接。

## 思考题

最后按照惯例，我留两道思考题，供你消化今天的内容。

1. 最大分组MSL是TCP分组在网络中存活的最长时间，你知道这个最长时间是如何达成的？换句话说，是怎么样的机制，可以保证在MSL达到之后，报文就自然消亡了呢？
1. RFC 1323引入了TCP时间戳，那么这需要在发送方和接收方之间定义一个统一的时钟吗？

欢迎你在评论区写下你的思考，如果通过这篇文章你理解了TIME_WAIT，欢迎你把这篇文章分享给你的朋友或者同事，一起交流学习一下。
