面试官:请解释一下TCP建立连接的两次握手

面试者:......(不是三次吗?)

面试官:请解释一下TCP断开连接的三次挥手

面试者:......(不是四次吗?)

注意:以下都是在Linux环境测试,内核*5.10.16.3-microsoft-standard-WSL2*。

1. tcpdump命令的使用

在解释TCP的建立连接过程和断开连接过程之前,介绍一下网络监测利器tcpdump;但是这里不展开对tcpdump的使用,主要用最简单的参数来获取对我们下文解释有需要的数据。

$ sudo tcpdump -i lo port 8090 # tcpdump需要root权限 # -S 完整显示seq # -i 选择需要监听的interface,这里我们用lo(环回网口),本地测试 # 整个命令的作用就是监听环回网口上8090端口的网络数据 # 以下是我们获取到的一条数据 11:16:21.261142 IP localhost.49566 > localhost.8099: Flags [S], seq 81901745, win 65495, options [mss 65495,sackOK,TS val 200755255 ecr 0,nop,wscale 7], length 0

我们以此来解释这条数据:

11:16:21.261142,表示这条数据收到的时间戳,默认是精确到微秒。

IP,表示这是一个IPv4的包。

localhost.49566 > localhost.8099:源端地址和端口 > 目的端地址和端口。

Flags [S],这是一个sync包,其它的标志有S (SYN), F (FIN), P (PUSH), R (RST), U (URG), W(ECN CWR), E (ECN-Echo) or '.' (ACK), or `none' 没有标志设置。

seq 81901745,发送端的序号是81901745。

win 65495:发送端的滑动窗口大小是65495。

options [mss 65495,sackOK,TS val 200755255 ecr 0,nop,wscale 7]:一些TCP选项。

length 0:有效载荷为0。

2. 简单的服务端和客户端程序

程序为了说明链路建立和断开的过程,为了简单起见,没有复杂的网络变成过程。为了篇幅,有些代码写在了一行。

/** * server.cpp */ #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <stdlib.h> #include <arpa/inet.h> #include <strings.h> #include <string.h> #include <iostream> #include <fcntl.h> #include <unistd.h> int main(int argc, char* argv[]) { int sock = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in serv_addr; bzero(&serv_addr, sizeof serv_addr); serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(8099); serv_addr.sin_addr.s_addr = inet_addr("0.0.0.0"); int reuseaddr = 1; setsockopt(sock, SOCK_STREAM, SO_REUSEADDR, &reuseaddr, sizeof reuseaddr); // 为了端口复用,不影响tcp过程 int ret = bind(sock, (struct sockaddr *)&serv_addr, sizeof serv_addr); if (ret == -1) { std::cerr << "bind error.\n"; exit(-1); } ret = listen(sock, 1024); // backlog : 全连接队列大小 if (ret == -1) { std::cerr << "listen error.\n"; exit(-1); } struct sockaddr_in peer_addr; int len = sizeof peer_addr; char buffer[1024]; int acc_socket = accept(sock, (struct sockaddr *)&peer_addr, (socklen_t *)&len); if (acc_socket == -1) { std::cerr << "accept error.\n"; exit(-1); } std::cout << "accepted: " << inet_ntoa(peer_addr.sin_addr) << ", port: " << ntohs(peer_addr.sin_port) << std::endl; while (true) { memset(buffer, 0, 1024); ret = recv(acc_socket, buffer, 1024, 0); if (ret == -1) { std::cerr << "recv error.\n"; close(acc_socket); exit(-1); } else if (ret == 0) { close(acc_socket); std::cout << "end of file.\n"; exit(0); } std::cout << buffer << std::endl; } return 0; }

/** * client.cpp */ #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <stdlib.h> #include <arpa/inet.h> #include <strings.h> #include <string.h> #include <iostream> int main(int argc, char* argv[]) { int sock = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in serv_addr; bzero(&serv_addr, sizeof serv_addr); serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(8099); serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); int ret = connect(sock, (struct sockaddr *)&serv_addr, sizeof serv_addr); if (ret == -1) { std::cerr << "connect error.\n"; exit(-1); } char buffer[1024]; while (true) { memset(buffer, 0, 1024); std::cin >> buffer; ret = send(sock, buffer, strlen(buffer), 0); if (ret == -1) { std::cerr << "send error.\n"; exit(-1); } else if (ret == 0) { std::cerr << "peer closed.\n"; exit(-1); } } return 0; }

编译server和client

$ g server.cpp -o server $ g client.cpp -o client

生成可执行文件server和client。

3. tcp数据传输过程监测

首先我们运行tcpdump

$ sudo tcpdump -i lo port 8099 tcpdump: verbose output suppressed, use -v or -vv for full protocol decode listening on lo, link-type EN10MB (Ethernet), capture size 262144 bytes

然后再开一个命令行窗口运行server

$ ./server

我们这时候可以观察tcpdump命令没有任何输出,然后运行client

$ ./client

这时候tcpdump有如下输出,读者在自己的主机上运行的结果有些参数是不一样的,但是整个过程一样

14:27:08.099236 IP localhost.49624 > localhost.8099: Flags [S], seq 1562745839, win 65495, options [mss 65495,sackOK,TS val 212202094 ecr 0,nop,wscale 7], length 0 14:27:08.099256 IP localhost.8099 > localhost.49624: Flags [S.], seq 1375681665, ack 1562745840, win 65483, options [mss 65495,sackOK,TS val 212202094 ecr 212202094,nop,wscale 7], length 0 14:27:08.099265 IP localhost.49624 > localhost.8099: Flags [.], ack 1, win 512, options [nop,nop,TS val 212202094 ecr 212202094], length 0

以上输出表示 客户端从localhost的49624端口发送了一个sync报文到服务端localhost的8099端口,报文序号是1562745839; 服务端发送了一个sync的ack到客户端,报文序号是1375681665,应答序号是1562745840(表示服务端下一个可接收的序号是1562745840); 客户端发送了一个ack,表示客户端收到了服务端的应答,可以接收服务端的下个序号是1;

注意上面的最后一条ack,序号1,这是tcpdump简化了序号,为了方便阅读,如果我们需要显示完整的需要,只需要在tcpdump的命令行里加上参数-S即sudo tcpdump -S -i lo port 8099,那么最后一次客户端发送到服务端的ack就应该是这个样子

14:27:08.099265 IP localhost.49624 > localhost.8099: Flags [.], ack 11375681666, win 512, options [nop,nop,TS val 212202094 ecr 212202094], length 0

到目前我们看到的还是TCP建立连接的三次握手过程,那我们的两次握手过程呢?

4. 两次握手主角TCP Fast Open

这就要请出另外一个主角,TCP Fast Open,这是谷歌的一个团队提出的,他们觉得TCP的三次握手太耗时了,就提出了这么一个方案,减少一次ack的时间,现在RFC 7413中有解释。

以下图片展示了三次握手和两次握手的流程对比:

tcp的基础知识(你好TCP重新认识TCP)(1)

要开启TCP Fast Open,Linux内核版本至少需要3.7。

使用命令行:

$ sysctl net.ipv4.tcp_fastopen # 查看当前的tcp_fastopen开启状态 net.ipv4.tcp_fastopen = 1 # 当前系统默认为1 # 0 关闭fast open # 1 作为客户端时开启 # 2 作为服务端时开启 # 3 客户端和服务端都开启 $ sudo sysctl -w net.ipv4.tcp_fastopen=3 # 客户端和服务端都开启 net.ipv4.tcp_fastopen = 3

然后我们修改我们的server.cpp和client.cpp:

/** * server.cpp */ // ...... int reuseaddr = 1; setsockopt(sock, SOCK_STREAM, SO_REUSEADDR, &reuseaddr, sizeof reuseaddr); // 增加以下代码, 注意要在listen之前设置 int qlen = 5; //fast open 队列 setsockopt(sock, IPPROTO_TCP, TCP_FASTOPEN, &qlen, sizeof(qlen)); // ......

/** * client.cpp */ // 在Linux内核版本4.11前,用sendto MSG_FASTOPEN标志, 不需要再调用connect /* 注掉 int ret = connect(sock, (struct sockaddr *)&serv_addr, sizeof serv_addr); if (ret == -1) { std::cerr << "connect error.\n"; exit(-1); } */ // 发送报文改成 char buffer[1024]; memset(buffer, 0, 1024); std::cin >> buffer; int ret = sendto(sock, buffer, strlen(buffer), MSG_FASTOPEN, (struct sockaddr *)&serv_addr, sizeof(serv_addr)); // 在Linux内核版本4.11之后,系统提供了TCP_FASTOPEN_CONNECT选项 int enable = 1; // connect前如下设置 int ret = setsockopt(sock, IPPROTO_TCP, TCP_FASTOPEN_CONNECT, &enable, sizeof(enable)); // 跟平常一样调用 ret = connect(socket, saddr, saddr_len);

编译之后启动服务端和客户端,并且客户端发送hello给服务端。

我们看看这样修改之后tcpdump的输出结果:

15:39:47.509201 IP localhost.49664 > localhost.8099: Flags [S], seq 1993298214, win 65495, options [mss 65495,sackOK,TS val 216561503 ecr 0,nop,wscale 7,tfo cookiereq,nop,nop], length 0 15:39:47.509215 IP localhost.8099 > localhost.49664: Flags [S.], seq 1034872048, ack 1993298215, win 65483, options [mss 65495,sackOK,TS val 216561504 ecr 216561503,nop,wscale 7,tfo cookie 13bcbb0891552445,nop,nop], length 0 15:39:47.509228 IP localhost.49664 > localhost.8099: Flags [P.], seq 1993298215:1993298220, ack 1034872049, win 512, options [nop,nop,TS val 216561504 ecr 216561504], length 5 15:39:47.509255 IP localhost.8099 > localhost.49664: Flags [.], ack 1993298220, win 512, options [nop,nop,TS val 216561504 ecr 216561504], length 0

我们可以看到, 客户端从localhost的49664端口发送了一个sync报文到服务端localhost的8099端口,报文序号是1993298214,并求情一个cookie; 服务端发送了一个sync的ack到客户端,报文序号是1034872048,应答序号是1993298215(表示服务端下一个可接收的序号是1993298215); 客户端发送了一个包,有效载荷长度5。 服务端发送ack给客户端。

我们退出服务端和客户端,再重新启动服务端和客户端,并且客户端向服务端发送hello,继续看tcpdump的输出:

16:52:34.575420 IP localhost.49702 > localhost.8099: Flags [S], seq 3941954492:3941954497, win 65495, options [mss 65495,sackOK,TS val 220928570 ecr 0,nop,wscale 7,tfo cookie 13bcbb0891552445,nop,nop], length 5 16:52:34.575456 IP localhost.8099 > localhost.49702: Flags [S.], seq 1440641212, ack 3941954498, win 65483, options [mss 65495,sackOK,TS val 220928570 ecr 220928570,nop,wscale 7], length 0 16:52:34.575467 IP localhost.49702 > localhost.8099: Flags [.], ack 1, win 512, options [nop,nop,TS val 220928570 ecr 220928570], length 0

我们可以看到,这次客户端向服务端发送SYN时同时带了数据包和cookie,不用再做三次握手,节省了很多时间。

写到这里,基本上TCP两次握手的问题已经差不多了。我们来看看另一个问题,TCP断开连接时的三次挥手.

5. 三次挥手

细心的读者在做实验的时候应该已经发现了,我们从客户端用ctrl c退出程序断开连接的时候tcpdump会得到以下结果:

20:42:28.422854 IP localhost.44612 > localhost.8099: Flags [F.], seq 6, ack 1, win 512, options [nop,nop,TS val 3952421244 ecr 3952416809], length 0 20:42:28.422915 IP localhost.8099 > localhost.44612: Flags [F.], seq 1, ack 7, win 512, options [nop,nop,TS val 3952421244 ecr 3952421244], length 0 20:42:28.422934 IP localhost.44612 > localhost.8099: Flags [.], ack 2, win 512, options [nop,nop,TS val 3952421244 ecr 3952421244], length 0

这是tcp的延迟ack造成了我们看到的挥手报文只有三次,收到报文报文后不立即应答ack,当我们在程序里面close socket的时候会发送FIN,这时候,FIN和ACK会作为一个包一起发送出去,只需要这个包的FIN和ACK标志位都设置值就行了。

所以三次挥手只是第二步和第三步的报文合并了,主动断开方的tcp状态从FIN_WAIT1直接跳过FIN_WAIT2变成TIME_WAIT,被动断开方状态迁移过程不变,如下图:

tcp的基础知识(你好TCP重新认识TCP)(2)

我们继续做实验来验证,我们修改服务端的代码,在关闭socket之前sleep一段时间

while (true) { memset(buffer, 0, 1024); ret = recv(acc_socket, buffer, 1024, 0); if (ret == -1) { std::cerr << "recv error.\n"; close(acc_socket); exit(-1); } else if (ret == 0) { std::this_thread::sleep_for(std::chrono::seconds(3)); // 增加这行,关闭socket之前先休眠3秒 close(acc_socket); std::cout << "end of file.\n"; exit(0); } std::cout << buffer << std::endl; }

tcpdump会得到类似以下的结果:

19:51:10.123413 IP localhost.45046 > localhost.8099: Flags [F.], seq 6, ack 1, win 512, options [nop,nop,TS val 4005024437 ecr 4005020558], length 0 19:51:10.173607 IP localhost.8099 > localhost.45046: Flags [.], ack 7, win 512, options [nop,nop,TS val 4005024487 ecr 4005024437], length 0 19:51:13.123675 IP localhost.8099 > localhost.45046: Flags [F.], seq 1, ack 7, win 512, options [nop,nop,TS val 4005027437 ecr 4005024437], length 0 19:51:13.123694 IP localhost.45046 > localhost.8099: Flags [.], ack 2, win 512, options [nop,nop,TS val 4005027437 ecr 4005027437], length 0

我们看到这是四次挥手的过程,而且第一个FIN收到之后50ms左右才发出ACK,这就是延迟ACK等待的时间,不同的机器测出来数值不同,同一台机器多次测试结果也不一定相同。

我们把延迟ACK关闭了来看看结果是什么样的,首先修改服务端的代码:

int quickack = 1; while (true) { memset(buffer, 0, 1024); ret = recv(acc_socket, buffer, 1024, 0); // 关闭延迟ack setsockopt(acc_socket, IPPROTO_TCP, TCP_QUICKACK, &quickack, sizeof(quickack)); if (ret == -1) { std::cerr << "recv error.\n"; close(acc_socket); exit(-1); } else if (ret == 0) { close(acc_socket); std::cout << "end of file.\n"; exit(0); } std::cout << buffer << std::endl; }

我们看看tcpdump的结果:

20:18:06.959692 IP localhost.45128 > localhost.8099: Flags [F.], seq 1, ack 1, win 512, options [nop,nop,TS val 4006641273 ecr 4006640344], length 0 20:18:06.959760 IP localhost.8099 > localhost.45128: Flags [.], ack 2, win 512, options [nop,nop,TS val 4006641273 ecr 4006641273], length 0 20:18:06.959789 IP localhost.8099 > localhost.45128: Flags [F.], seq 1, ack 2, win 512, options [nop,nop,TS val 4006641273 ecr 4006641273], length 0 20:18:06.959818 IP localhost.45128 > localhost.8099: Flags [.], ack 2, win 512, options [nop,nop,TS val 4006641273 ecr 4006641273], length 0

被动关闭端收到FIN后立马发送了ACK,我们再close的时候就只发送了FIN。

到此我们的TCP断开连接三次挥手过程也讲完了。

6. 总结

TCP经过多年的发展,已经和最开始的实现有些改进,增加不少的奇技淫巧,感兴趣的同学可以直接看源码。不过现在的源码是越来越复杂了,而且各个操作的实现有些细微的差异,考验各位的功力了,附上一张经典图片。

TCP状态变更图

tcp的基础知识(你好TCP重新认识TCP)(3)

这是一张经典图片,可以结合tcpdump工具,具体实验一下。

文章有不足之处还请指正。

,