TCP 四次挥手
4.22 TCP 四次挥手,可以变成三次吗?
在用 wireshark 工具抓包的时候,我们也会常看到 TCP 挥手过程是三次,而不是四次,如下图:

先来回答为什么 RFC 文档里定义 TCP 挥手过程是要四次?
再来回答什么 情况下,什么 情况会出现三次挥手?
TCP 四次挥手
TCP 四次挥手的过程如下:

具体过程:
客⼾端主动调用关闭连接的函数,于是就会发送 FIN 报文,这个 FIN 报文代表客⼾端不会再发
送数据了,进入 FIN_WAIT_1 状态;服务端收到了 FIN 报文,然后⻢上回复一个 ACK 确认报文,此时服务端进入 CLOSE_WAIT 状
态。在收到 FIN 报文的时候,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区
中,服务端应用程序可以通过 read 调用来感知这个 FIN 包,这个 EOF 会被放在已排队等候的
其他已接收的数据之后,所以必须要得继续 read 接收缓冲区已接收的数据;
接着,当服务端在 read 数据的时候,最后自然就会读到 EOF ,接着 read() 就会返回 0,这时服务端应用程序如果有 数据要发送的话,就发完数据后才调用关闭连接的函数,如果服 务端应用
程序没有数据要发送的话,可以直接调用关闭连接的函数,这时服务端就会发一个 FIN 包,这
个 FIN 报文代表服务端不会再发送数据了,之后处于 LAST_ACK 状态;
客⼾端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客⼾端将进入 TIME_WAIT
状态;
服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态;
客⼾端经过 2MSL 时间之后,也进入 CLOSE 状态;
你可以看到,每个方向都需要一个 FIN 和一个 ACK ,因此通常被称为四次挥手。
为什么 TCP 挥手需要四次呢?
服务器收到客⼾端的 FIN 报文时,内核会⻢上回一个 ACK 应答报文,但是服务端应用程序可能还
有数据要发送,所以并不能⻢上发送 FIN 报文,而是将发送 FIN 报文的控制权交给服务端应用程
序:
如果服 务端应用程序有数据要发送的话,就发完数据后,才调用关闭连接的函数;
如果服 务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数,
从上 面过程可知,** 是否要发送第三次挥手的控制权不在内核,而是在被动关闭方(上图的服务
端)的应用程序,因为应用程序可能还有数据要发送,由应用程序决定什么 时候调用关闭连接的
函数,当调用了关闭连接的函数,内核就会发送 FIN 报文了,** 所以服务端的 ACK 和 FIN 一般都
会分开发送。
FIN 报文一定得调用关闭连接的函数,才会发送吗?
不一定。
如果进程退出了,不管是不是正常退出,还是异常退出(如进程崩溃),内核都会发送 FIN 报文,
与对方完成四次挥手。
粗暴关闭 vs 优雅关闭
前面介绍 TCP 四次挥手的时候,并没有详细介绍关闭连接的函数,其实关闭的连接的函数有两种
函数:
close 函数,同时 socket 关闭发送方向和 读取方向,也就是 socket 不再有发送和接收数 据的能
力。如果有 多进程/多线程共享同一个 socket ,如果有 一个进程调用了 close 关闭只是让 socket
引用计数 -1 ,并不会导致 socket 不可用,同时也不 会发出 FIN 报文,其他进程还是可以正常读
写该 socket ,直到引用计数变为 0,才会发出 FIN 报文。
shutdown 函数,可以指定 socket 只关闭发送方向而不关闭读取方向,也就是 socket 不再有发
送数据的能力,但是还是具有接收数 据的能力。如果有 多进程/多线程共享同一个 socket ,
shutdown 则不管引用计数,直接使得该 socket 不可用,然后发出 FIN 报文,如果有 别的进程
企图使用该 socket ,将会受到影响。
如果客⼾端是用 close 函数来关闭连接,那么在 TCP 四次挥手过程中,如果收到了服务端发送的
数据,由于客⼾端已经不再具有发送和接收数 据的能力,所以客⼾端的内核会回 RST 报文给服务 端,然后内核会释放连接,这时就不会经历完成的 TCP 四次挥手,所以我们常说,调用 close 是
粗暴的关闭。

当服务端收到 RST 后,内核就会释放连接,当服务端应用程序再次发起读操作或者写操作时,就
能感知到连接已经被释放了:
如果是读操作,则会返回 RST 的报错,也就是我们常⻅的Connection reset by peer 。
如果是写操作,那么程序会产生 SIGPIPE 信号,应用层代码可以捕获并处理信号,如果不处
理,则默认情况下进程会终止,异常退出。
相对的,shutdown 函数因为可以指定只关闭发送方向而不关闭读取方向,所以即使在 TCP 四次挥
手过程中,如果收到了服务端发送的数据,客⼾端也是可以正常读取到该数据的,然后就会经历
完整的 TCP 四次挥手,所以我们常说,调用 shutdown 是优雅的关闭。

但是注意,shutdown 函数也可以指定「只关闭读取方向,而不关闭发送方向」,但是这时候内核
是不会发送 FIN 报文的,因为发送 FIN 报文是意味着我方将不再发送任何 数据,而 shutdown 如
果指定「不关闭发送方向」,就意味着 socket 还有发送数据的能力,所以内核就不会发送 FIN 。
什么 情况会出现三次挥手?
当被动关闭方(上图的服务端)在 TCP 挥手过程中,「没有数据要发送」并且「开启了 TCP 延迟
确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三 次挥手。

然后因为 TCP 延迟确认机制是默认开启的,所以导致我们抓包时,看⻅三次挥手的次数比四次挥
手还多。
什么 是 TCP 延迟确认机制?
当发送没有携带数据的 ACK ,它的网络效率也是很低的,因为它也有 40 个字节的 IP 头 和 TCP
头,但却没有携带数据报文。 为了 解决 ACK 传输效率低问题,所以就衍生出了 TCP 延迟确认。
TCP 延迟确认的策略:
当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方
当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送
如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK

延迟等待的时间是在 Linux 内核中定义的,如下图:

关键就需要 HZ 这个数值大小,HZ 是跟系统的时钟频率有关,每个操作系统都不一样,在我的
Linux 系统中 HZ 大小是 1000 ,如下图:

知道了 HZ 的大小,那么就可以算出:
最大延迟确认时间是 200 ms (1000/5 )
最短延迟确认时间是 40 ms (1000/25 )
怎么关闭 TCP 延迟确认机制?
如果要关闭 TCP 延迟确认机制,可以在 Socket 设置里启用 TCP_QUICKACK 。
// 1 表示开启 TCP_QUICKACK,即关闭 TCP 延迟确认机制
int value = 1;
setsockopt(socketfd, IPPROTO_TCP, TCP_QUICKACK, (char*)& value, sizeof(int));实验验 证
实验一
接下来,来给大家做个实验,验证这个结论:
当被动关闭方(上图的服务端)在 TCP 挥手过程中,「没有数据要发送」并且「开启了 TCP
延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三 次挥手。
服务端的代码如下,做的事情很简单,就读取数据,然后当 read 返回 0 的时候,就⻢上调用
close 关闭连接。因为 TCP 延迟确认机制是默认开启的,所以不需要特殊设置。
\#include <stdlib.h>
\#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <netdb.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <netinet/tcp.h>#define MAXLINE 1024
int main (int argc , char *argv [])
{
// 1. 创建一个监听 socket
int listenfd = socket (AF_INET , SOCK_STREAM , 0);
if (listenfd < 0)
{
fprintf (stderr , "socket error : %s\n" , strerror (errno ));
return -1;
}
// 2. 初始化服务器地址和端口
struct sockaddr_in server_addr ;
bzero (&server_addr , sizeof (struct sockaddr_in ));
server_addr .sin_family = AF_INET ;
server_addr .sin_addr .s_addr = htonl (INADDR_ANY );
server_addr .sin_port = htons (8888 );
// 3. 绑定地址 +端口
if (bind (listenfd , (struct sockaddr *)(&server_addr ), sizeof (struct sockaddr )) < 0)
{
fprintf (stderr ,"bind error:%s\n" , strerror (errno ));
return -1;
}
printf ("begin listen....\n" );
// 4. 开始监听
if (listen (listenfd , 128 ))
{
fprintf (stderr , "listen error:%s\n\a" , strerror (errno ));
exit (1);
}
// 5. 获取已连接的 socket
int clientfd = accept(listenfd, (struct sockaddr *)&client_addr, &client_addrlen);
if(clientfd < 0) {
fprintf(stderr, "accept error:%s\n\a", strerror(errno));
exit(1);
}
printf("accept success\n");
char message[MAXLINE] = {0};
while(1) {
//6. 读取客户端发送的数据
int n = read(clientfd, message, MAXLINE);
if(n < 0) { // 读取错误
fprintf(stderr, "read error:%s\n\a", strerror(errno));
break;
} else if(n == 0) { // 返回 0 ,代表读到 FIN 报文
fprintf(stderr, "client closed \n");
close(clientfd); // 没有数据要发送,立马关闭连接
break;
}
message[n] = 0;
printf("received %d bytes: %s\n", n, message);
}
close(listenfd);
return 0;
}客⼾端代码如下,做的事情也很简单,与服务端连接成功后,就发送数据给服务端,然后睡眠 一
秒后,就调用 close 关闭连接,所以客⼾端是主动关闭方:
\#include <stdlib.h>
\#include <stdio.h>
\#include <errno.h>
\#include <string.h>
\#include <netdb.h>
\#include <sys/types.h>
\#include <netinet/in.h>
\#include <sys/socket.h>
int main(int argc, char *argv[])
// 1. 创建一个监听 socket
int connectfd = socket(AF_INET, SOCK_STREAM, 0);
if(connectfd < 0)
{
fprintf(stderr, "socket error : %s\n", strerror(errno));
return -1;
}
// 2. 初始化服务器地址和端口
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(struct sockaddr_in));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
server_addr.sin_port = htons(8888);
// 3. 连接服务器
if(connect(connectfd, (struct sockaddr *)(&server_addr), sizeof(server_addr)) < 0)
{
fprintf(stderr,"connect error:%s\n", strerror(errno));
return -1;
}
printf("connect success\n");
char sendline[64] = "hello, i am xiaolin";
//4. 发送数据
int ret = send(connectfd, sendline, strlen(sendline), 0);
if(ret != strlen(sendline)) {
fprintf(stderr,"send data error:%s\n", strerror(errno));
return -1;
}
printf("already send %d bytes\n", ret);
sleep(1);
//5. 关闭连接
close(connectfd);
return 0;
}编译服务端和客⼾端的代码:

先启用服务端:

然后用 tcpdump 工具开始抓包,命令如下:
tcpdump -i lo tcp and port 8888 -s0 -w /home/tcp_close.pcap
然后启 用客⼾端,可以看到,与服务端连接成功后,发完数据就退出了。

此时,服务端的输出:

接下来,我们来看看 抓包的结果。

可以看到,TCP 挥手次数是 3 次。
所以,下面这个结论是没问题的。
结论:当被动关闭方(上图的服务端)在 TCP 挥手过程中,「没有数据要发送」并且「开启
了 TCP 延迟确认机制(默认会开启)」,那么第二和第三次挥手就会合并传输,这样就出现
了三 次挥手。
实验二
我们再做一次实验,来看看 关闭 TCP 延迟确认机制,会出现四次挥手吗?
客⼾端代码保持不变,服务端代码需要增加一点东西。
在上面服务端代码中,增加了打开了 TCP_QUICKACK (快速应答)机制的代码,如下:

编译好服务端代码后,就开始运行服务端和客⼾端的代码,同时用 tcpdump 进行抓包。

抓包的结果如下,可以看到是四次挥手。
所以,当被动关闭方(上图的服务端)在 TCP 挥手过程中,「没有数据要发送」,同时「关闭了
TCP 延迟确认机制」,那么就会是四次挥手。
设置 TCP_QUICKACK 的代码,为什么 要放在 read 返回 0 之后?
我也是多次实验才发现,在 bind 之前设置 TCP_QUICKACK 是不生效的,只有在 read 返回 0 的时候,设置 TCP_QUICKACK 才会出现四次挥手。
网上查了下 资料说,设置 TCP_QUICKACK 并不是永久的,所以每次 读取数据的时候,如果想要立
刻回 ACK ,那就得在每次 读取数据之后,重新设置 TCP_QUICKACK 。而我这里的实验,目的是为了 当收到客⼾端的 FIN 报文(第一次挥手)后,立⻢回 ACK 报文。所
以就在 read 返回 0 的时候,设置 TCP_QUICKACK 。当然,实际应用中,没人会在这个位置设置TCP_QUICKACK ,因为操作系统都通过 TCP 延迟确认机制帮我们把四次挥手优化成了三 次挥手
了。
总结
当被动关闭方在 TCP 挥手过程中,如果「没有数据要发送」,同时「没有开启 TCP_QUICKACK (默
认情况就是没有开启,没有开启 TCP_QUICKACK ,等于就是在使用 TCP 延迟确认机制)」,那么第二和第三次挥手就会合并传输,这样就出现了三 次挥手。
所以,出现三次挥手现象,是因为 TCP 延迟确认机制导致的。
