网络编程-再看TCP的四次挥手

前言

在《网络编程-从TCP连接的建立说起》中介绍了TCP的三次握手以及一些常见问题,那么四次挥手又有哪些需要特别关注的问题?哪些又是面试最常问的问题呢?

四次挥手

四次挥手的流程在很多地方都可以看到,这里简略介绍一下,其最常见流程如下图所示:

其大体流程如下:

  • 客户端发其结束请求,发送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:

1
2
#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端口:

1
$ nc -l 1234

在终端2连接到1234端口:

1
$ nc 127.0.0.1 1234

在终端3通过netstat命令观察:

1
$ netstat -anpoc|grep :1234

然后在终端1按ctrl+c,终止连接,立刻观察终端3的结果,我们发现:

1
2
3
4
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程序来观察:

1
2
$ ./server #在一个终端启动server,
$ ./client 127.0.0.1 1234 #在另一个终端启动client

在服务端终端ctrl+c终止服务端,然后再次启动server:

1
2
3
4
5
$ ./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有保活定时器。即服务端可以通过设置保活选项来了解客户端是否已经终止连接。
通过下面的方式可以看到很多连接有这样的定时器:

1
2
3
4
5
6
$ 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)

但可惜的是,这样的定时器时间太长了,并且它不能代表应用程序能够正常工作,能够正常收发数据,因此应用层常常也会实现一个心跳机制。

来源:公众号【编程珠玑】
网站:https://www.yanbinghu.com

总结

本文花了大量篇幅介绍了TIME_WAIT状态,这也是面试中常问的问题,重新梳理TCP的四次挥手是很有必要的。

参考

《Unix网络编程》
《TCP/IP协议详解》

守望 wechat
关注公众号[编程珠玑]获取更多原创技术文章
出入相友,守望相助!