前言
四次挥手
四次挥手的流程在很多地方都可以看到,这里简略介绍一下,其常见流程如下图所示:
其大体流程如下:
- 客户端发其结束请求,发送seq=X,处于FIN_WAIT_1状态
- 服务端收到结束请求,发送应答ACK=X+1,处于CLOSE_WAIT状态
- 客户端收到X的应答后,处于FIN_WAIT_2状态,此时还可以接收来自服务端的数据
- 服务端没有数据要发送,也发送结束请求,seq=Y,处于LAST_ACK状态
- 客户端又收到服务端的结束请求,客户端回应ACK,此时处于TIME_WAIT状态,确保ACK能够到达服务端;服务端收到客户端最终ACK,关闭连接。
- 2MSL时间结束后,无论服务端是否收到最终ACK,客户端完全结束连接
作为一种常见的四次挥手场景,我们可能习以为常了,但需要注意的是,连接的断开并不只有这种情况,还可以是服务端发起主动关闭,或者双方同时发起,但这不是本文关注的重点。我们直接看看四次挥手有哪些需要注意的。
什么是TCP的半关闭
TCP半关闭指的是一端结束发送后还能够接受来自另一端的数据。也就是说,虽然客户端准备断开连接并且发送了FIN报文,客户端还是可以接收来自服务端的数据。不过这种关闭方式不能使用close接口,而需要使用shutdown:
- #include <sys/socket.h>
- int shutdown(int sockfd, int how);
并且how参数值为SHUT_WR,即1,表明shutdown for writing ,仅关闭本端的发送。
为什么要四次挥手
为什么建立一个TCP连接需要三次握手,而终止一个连接需要四次挥手呢?这是因为TCP半关闭造成的。由于一个TCP连接是全双工的,在两个方向上都能传输数据,因此两个方向就需要单独关闭。所以这个流程是这样的:
- 客户端执行主动关闭,发送FIN报文,告诉服务端,我没有数据要发送了,我要关闭连接,当然了,你有啥数据要给我,我随时候着
- 服务端收到后,必须及时告诉客户端我收到了,因此先回复客户端一个ACK。但是服务端可能还有未发送完的数据,因此它可以将自己未完成的数据进行发送,发送完成之后,再发送给客户端FIN报文,表明我也没啥要发送的了,关闭吧
- 客户端收到后,也回复ACK响应,最终关闭连接
因而整个过程需要四次挥手。
为什么要TIME_WAIT状态
TIME_WAIT也称为2MSL等待时间。MSL为报文最大生存时间,它是任何报文在被丢弃前存在于网络内的最长时间。这个时间在不同类型的系统中可能有所不同,但这不是关键。在我个人的机器上,可以借助netstat命令和nc命令通过下面的方式观察到。在终端1监听1234端口:
- $ nc -l 1234
在终端2连接到1234端口:
- $ nc 127.0.0.1 1234
在终端3通过netstat命令观察:
- $ netstat -anpoc|grep :1234
然后在终端1按ctrl+c,终止连接,立刻观察终端3的结果,我们发现:
- tcp 0 0 127.0.0.1:1234 127.0.0.1:33524 TIME_WAIT - timewait (59.76/0/0)
- tcp 0 0 127.0.0.1:1234 127.0.0.1:33524 TIME_WAIT - timewait (58.74/0/0)
- tcp 0 0 127.0.0.1:1234 127.0.0.1:33524 TIME_WAIT - timewait (57.71/0/0)
- tcp 0 0 127.0.0.1:1234 127.0.0.1:33524 TIME_WAIT - timewait (56.69/0/0)
我们可以观察到,服务端当前处于TIME_WAIT,且有一个timewait的定时器,为1分钟。
netstat命令和nc命令的使用可以分别参考《不可不知的网络命令-netstat》和《网络工具中的”瑞士军刀“-nc》。
TIME_WAIT状态的存在主要考虑以下两个方面:
- 实现可靠的四次挥手
- 避免收到老的报文
为什么说TIME_WAIT是为了实现可靠的四次挥手呢?试想一下,如果客户端最后回应的ACK丢了,那么服务端会再次发送FIN报文,此时,客户端必须处于一个等待状态,否则服务端永远无法收到这个ACK,而会收到一个RST,以为出错。而如果客户端此时处于TIME_WAIT状态,即等待2MSL时间,它还可以再次回应服务端ACK。这也就保证了可靠的四次挥手。
当然了,如果在2MSL时间内,服务端还没有收到,那么对不起,客户端已经仁至义尽了,不会再等待了。
这里需要注意,最终执行主动关闭的那一端会处于TIME_WAIT状态。
那么为什么又说是为了避免收到老的重复报文呢?
试想这样的场景:
假设一开始已经有一个连接在1234端口建立,我们关闭这个连接;过一会我们在同样的ip和端口建立连接,但是TCP必须防止在前一次连接中的老的报文在它原先的连接已终止后,还出现在这个新的连接中,因此,TCP将不允许在处于TIME_WAIT状态的ip和端口处建立新的连接。而2MSL时间过后,老的报文早已在网络中消失了,也就避免了这种情况的发生。
这种情况可以很容易通过《网络编程-一个简单的echo程序》的server程序来观察:
- $ ./server #在一个终端启动server,
- $ ./client 127.0.0.1 1234 #在另一个终端启动client
在服务端终端ctrl+c终止服务端,然后再次启动server:
- $ ./server
- bind error: Address already in use
- $ netstat -anop|grep :1234
- tcp 1 0 127.0.0.1:33722 127.0.0.1:1234 CLOSE_WAIT 11691/client off (0.00/0/0)
- tcp 0 0 127.0.0.1:1234 127.0.0.1:33722 FIN_WAIT2 - timewait (57.92/0/0)
终止服务端后,服务端处于TIME_WAIT状态,此时再次启动server,将不能使用原来的ip和端口建立连接,因此出现Address already in use的报错。
但是需要注意:
- 由于客户端通常使用的是临时端口(仔细观察会发现,客户端每次启动使用的端口基本都不一样),因此客户端即便处于TIME_WAIT状态,也不影响它马上再次启动
- 一些实现允许一个新的连接请求仍然处于TIME_WAIT状态的连接,只要新的seq大于该连接的前一个连接的最后序号
- 通过设置选项SO_REUSEADDR,可以让一个进程重新使用仍处于TIME_WAIT状态的socket
半打开的TCP连接
假设一个连接建立之后,突然有一方异常终止连接了,但是另一个不知道,这个时候TCP的连接就是半打开的。如果服务端不加处理,那么最终就会导致服务端有大量的半打开连接。那么服务端如何知道客户端的连接已经异常终止了呢?如果等待服务端发送数据出错时发现,那么这个时候可能已经太晚了。
幸运的是,TCP有保活定时器。即服务端可以通过设置保活选项来了解客户端是否已经终止连接。
通过下面的方式可以看到很多连接有这样的定时器:
- $ netstat -npo|grep keepalive
- tcp 0 0 192.168.0.103:50832 59.111.179.136:443 ESTABLISHED 5882/chrome keepalive (37.33/0/0)
- tcp 0 0 192.168.0.103:50638 154.8.131.191:443 ESTABLISHED 5882/chrome keepalive (0.00/0/0)
- tcp 0 0 192.168.0.103:59330 203.107.41.32:9026 ESTABLISHED 5882/chrome keepalive (0.35/0/0)
- tcp 0 0 127.0.0.1:45632 127.0.0.1:1080 ESTABLISHED 5886/firefox keepalive (335.28/0/0)
- tcp 0 0 192.168.0.103:49940 59.56.78.189:443 ESTABLISHED 5882/chrome keepalive (26.36/0/0)
但可惜的是,这样的定时器时间太长了,并且它不能代表应用程序能够正常工作,能够正常收发数据,因此应用层常常也会实现一个心跳机制。
总结
本文花了大量篇幅介绍了TIME_WAIT状态,这也是面试中常问的问题,重新梳理TCP的四次挥手是很有必要的。