我们都知道,tcp是个面向连接的、可靠的、基于字节流的传输层通信协议。
tcp是什么那这里面提到的面向连接,意味着需要 建立连接,使用连接,释放连接。
建立连接是指我们熟知的tcp三次握手。
而使用连接,则是通过一发送、一确认的形式,进行数据传输。
还有就是释放连接,也就是我们常见的tcp四次挥手。
tcp四次挥手大家应该比较了解了,但大家见过三次挥手吗?还有两次挥手呢?
都见过?那四次握手呢?
今天这个话题,不想只是猎奇,也不想搞冷知识。
我们从四次挥手开始说起,搞点实用的知识点。
tcp四次挥手简单回顾下tcp四次挥手。
tcp四次挥手正常情况下。只要数据传输完了,不管是客户端还是服务端,都可以主动发起四次挥手,释放连接。
就跟上图画的一样,假设,这次四次挥手是由客户端主动发起的,那它就是主动方。服务器是被动接收客户端的挥手请求的,叫被动方。
客户端和服务器,一开始,都是处于established状态。
第一次挥手:一般情况下,主动方执行close()或 shutdown()方法,会发个fin报文出来,表示我不再发送数据了。
第二次挥手:在收到主动方的fin报文后,被动方立马回应一个ack,意思是我收到你的fin了,也知道你不再发数据了。
上面提到的是主动方不再发送数据了。但如果这时候,被动方还有数据要发,那就继续发。注意,虽然第二次和第三次挥手之间,被动方是能发数据到主动方的,但主动方能不能正常收就不一定了,这个待会说。
第三次挥手:在被动方在感知到第二次挥手之后,会做了一系列的收尾工作,最后也调用一个 close(), 这时候就会发出第三次挥手的 fin-ack。
第四次挥手:主动方回一个ack,意思是收到了。
其中第一次挥手和第三次挥手,都是我们在应用程序中主动触发的(比如调用close()方法),也就是我们平时写代码需要关注的地方。
第二和第四次挥手,都是内核协议栈自动帮我们完成的,我们写代码的时候碰不到这地方,因此也不需要太关心。
另外不管是主动还是被动,每方发出了一个 fin 和一个ack 。也收到了一个 fin 和一个ack 。这一点大家关注下,待会还会提到。
fin一定要程序执行close()或shutdown()才能发出吗?不一定。一般情况下,通过对socket执行 close() 或 shutdown() 方法会发出fin。但实际上,只要应用程序退出,不管是主动退出,还是被动退出(因为一些莫名其妙的原因被kill了), 都会发出 fin。
fin 是指我不再发送数据,因此shutdown() 关闭读不会给对方发fin, 关闭写才会发fin。
如果机器上fin-wait-2状态特别多,是为什么根据上面的四次挥手图,可以看出,fin-wait-2是主动方那边的状态。
处于这个状态的程序,一直在等第三次挥手的fin。而第三次挥手需要由被动方在代码里执行close() 发出。
因此当机器上fin-wait-2状态特别多,那一般来说,另外一台机器上会有大量的 close_wait。需要检查有大量的 close_wait的那台机器,为什么迟迟不愿调用close()关闭连接。
所以,如果机器上fin-wait-2状态特别多,一般是因为对端一直不执行close()方法发出第三次挥手。
fin-wait-2特别多的原因
主动方在close之后收到的数据,会怎么处理之前写的一篇文章《代码执行send成功后,数据就发出去了吗?》中,从源码的角度提到了,一般情况下,程序主动执行close()的时候;
如果当前连接对应的socket的接收缓冲区有数据,会发rst。
如果发送缓冲区有数据,那会等待发送完,再发第一次挥手的fin。
大家知道,tcp是全双工通信,意思是发送数据的同时,还可以接收数据。
close()的含义是,此时要同时关闭发送和接收消息的功能。
也就是说,虽然理论上,第二次和第三次挥手之间,被动方是可以传数据给主动方的。
但如果 主动方的四次挥手是通过 close() 触发的,那主动方是不会去收这个消息的。而且还会回一个 rst。直接结束掉这次连接。
close()触发tcp四次挥手
第二第三次挥手之间,不能传输数据吗?也不是。前面提到close()的含义是,要同时关闭发送和接收消息的功能。
那如果能做到只关闭发送消息,不关闭接收消息的功能,那就能继续收消息了。这种 half-close 的功能,通过调用shutdown() 方法就能做到。
int shutdown(int sock, int howto);
其中 howto 为断开方式。有以下取值:
shut_rd:关闭读。这时应用层不应该再尝试接收数据,内核协议栈中就算接收缓冲区收到数据也会被丢弃。
shut_wr:关闭写。如果发送缓冲区中还有数据没发,会将将数据传递到目标主机。
shut_rdwr:关闭读和写。相当于close()了。
shutdown触发的tcp四次挥手
怎么知道对端socket执行了close还是shutdown不管主动关闭方调用的是close()还是shutdown(),对于被动方来说,收到的就只有一个fin。
被动关闭方就懵了,"我怎么知道对方让不让我继续发数据?"
其实,大可不必纠结,该发就发。
第二次挥手和第三次挥手之间,如果被动关闭方想发数据,那么在代码层面上,就是执行了 send() 方法。
int send( socket s,const char* buf,int len,int flags);
send() 会把数据拷贝到本机的发送缓冲区。如果发送缓冲区没出问题,都能拷贝进去,所以正常情况下,send()一般都会返回成功。

然后被动方内核协议栈会把数据发给主动关闭方。
如果上一次主动关闭方调用的是shutdown(socket_fd, shut_wr)。那此时,主动关闭方不再发送消息,但能接收被动方的消息,一切如常,皆大欢喜。
如果上一次主动关闭方调用的是close()。那主动方在收到被动方的数据后会直接丢弃,然后回一个rst。
针对第二种情况。
被动方内核协议栈收到了rst,会把连接关闭。但内核连接关闭了,应用层也不知道(除非被通知)。
此时被动方应用层接下来的操作,无非就是读或写。
如果是读,则会返回rst的报错,也就是我们常见的connection reset by peer。
如果是写,那么程序会产生sigpipe信号,应用层代码可以捕获并处理信号,如果不处理,则默认情况下进程会终止,异常退出。
总结一下,当被动关闭方 recv() 返回eof时,说明主动方通过 close()或 shutdown(fd, shut_wr) 发起了第一次挥手。
如果此时被动方执行两次 send()。
第一次send(), 一般会成功返回。
第二次send()时。如果主动方是通过 shutdown(fd, shut_wr) 发起的第一次挥手,那此时send()还是会成功。如果主动方通过 close()发起的第一次挥手,那此时会产生sigpipe信号,进程默认会终止,异常退出。不想异常退出的话,记得捕获处理这个信号。
如果被动方一直不发第三次挥手,会怎么样第三次挥手,是由被动方主动触发的,比如调用close()。
如果由于代码错误或者其他一些原因,被动方就是不执行第三次挥手。
这时候,主动方会根据自身第一次挥手的时候用的是 close() 还是 shutdown(fd, shut_wr) ,有不同的行为表现。
如果是 shutdown(fd, shut_wr) ,说明主动方其实只关闭了写,但还可以读,此时会一直处于 fin-wait-2, 死等被动方的第三次挥手。
如果是 close(), 说明主动方读写都关闭了,这时候会处于 fin-wait-2一段时间,这个时间由 net.ipv4.tcp_fin_timeout 控制,一般是 60s,这个值正好跟2msl一样 。超过这段时间之后,状态不会变成 `time-wait`,而是直接变成`closed`。
# cat /proc/sys/net/ipv4/tcp_fin_timeout60
一直不发第三次挥手的情况
tcp三次挥手四次挥手聊完了,那有没有可能出现三次挥手?
是可能的。
我们知道,tcp四次挥手里,第二次和第三次挥手之间,是有可能有数据传输的。第三次挥手的目的是为了告诉主动方,"被动方没有数据要发了"。
所以,在第一次挥手之后,如果被动方没有数据要发给主动方。第二和第三次挥手是有可能合并传输的。这样就出现了三次挥手。
tcp三次挥手
如果有数据要发,就不能是三次挥手了吗上面提到的是没有数据要发的情况,如果第二、第三次挥手之间有数据要发,就不可能变成三次挥手了吗?
并不是。tcp中还有个特性叫延迟确认。可以简单理解为:接收方收到数据以后不需要立刻马上回复ack确认包。
在此基础上,不是每一次发送数据包都能对应收到一个 ack 确认包,因为接收方可以合并确认。
而这个合并确认,放在四次挥手里,可以把第二次挥手、第三次挥手,以及他们之间的数据传输都合并在一起发送。因此也就出现了三次挥手。
tcp三次挥手延迟确认
tcp两次挥手前面在四次挥手中提到,关闭的时候双方都发出了一个fin和收到了一个ack。
正常情况下tcp连接的两端,是不同ip+端口的进程。
但如果tcp连接的两端,ip+端口是一样的情况下,那么在关闭连接的时候,也同样做到了一端发出了一个fin,也收到了一个 ack,只不过正好这两端其实是同一个socket 。
tcp两次挥手而这种两端ip+端口都一样的连接,叫tcp自连接。
是的,你没看错,我也没打错别字。同一个socket确实可以自己连自己,形成一个连接。
一个socket能建立连接?上面提到了,同一个客户端socket,自己对自己发起连接请求。是可以成功建立连接的。这样的连接,叫tcp自连接。
下面我们尝试下复现。
注意我是在以下系统进行的实验。在mac上多半无法复现。
# cat /etc/os-releasename="centos linux"version="7 (core)"id="centos"id_like="rhel fedora"version_id="7"pretty_name="centos linux 7 (core)"
通过nc命令可以很简单的创建一个tcp自连接
# nc -p 6666 127.0.0.1 6666
上面的 -p 可以指定源端口号。也就是指定了一个端口号为6666的客户端去连接 127.0.0.1:6666 。
# netstat -nt | grep 6666tcp 0 0 127.0.0.1:6666 127.0.0.1:6666 established
整个过程中,都没有服务端参与。可以抓个包看下。
image-20210810093309117可以看到,相同的socket,自己连自己的时候,握手是三次的。挥手是两次的。
tcp自连接上面这张图里,左右都是同一个客户端,把它画成两个是为了方便大家理解状态的迁移。
我们可以拿自连接的握手状态对比下正常情况下的tcp三次握手。
正常情况下的tcp三次握手看了自连接的状态图,再看看下面几个问题。
一端发出第一次握手后,如果又收到了第一次握手的syn包,tcp连接状态会怎么变化?第一次握手过后,连接状态就变成了syn_sent状态。如果此时又收到了第一次握手的syn包,那么连接状态就会从syn_sent状态变成syn_rcvd。
// net/ipv4/tcp_input.cstatic int tcp_rcv_synsent_state_process(){ // syn_sent状态下,收到syn包 if (th->syn) { // 状态置为 syn_rcvd tcp_set_state(sk, tcp_syn_recv); }}
一端发出第二次握手后,如果又收到第二次握手的syn+ack包,tcp连接状态会怎么变化?第二握手过后,连接状态就变为syn_rcvd了,此时如果再收到第二次握手的syn+ack包。连接状态会变为established。
// net/ipv4/tcp_input.cint tcp_rcv_state_process(){ // 前面省略很多逻辑,能走到这就认为肯定有ack if (true) { // 判断下这个ack是否合法 int acceptable = tcp_ack(sk, skb, flag_slowpath | flag_update_ts_recent) > 0; switch (sk->sk_state) { case tcp_syn_recv: if (acceptable) { // 状态从 syn_rcvd 转为 established tcp_set_state(sk, tcp_established); } } }}
一端第一次挥手后,又收到第一次挥手的包,tcp连接状态会怎么变化?第一次挥手过后,一端状态就会变成 fin-wait-1。正常情况下,是要等待第二次挥手的ack。但实际上却等来了 一个第一次挥手的 fin包, 这时候连接状态就会变为closing。
// net/static void tcp_fin(struct sock *sk){ switch (sk->sk_state) { case tcp_fin_wait1: tcp_send_ack(sk); // fin-wait-1状态下,收到了fin,转为 closing tcp_set_state(sk, tcp_closing); break; }}
这可以说是隐藏剧情了。
closing 很少见,除了出现在自连接关闭外,一般还会出现在tcp两端同时关闭连接的情况下。
处于closing状态下时,只要再收到一个ack,就能进入 time-wait 状态,然后等个2msl,连接就彻底断开了。这跟正常的四次挥手还是有些差别的。大家可以滑到文章开头的tcp四次挥手再对比下。
代码复现自连接可能大家会产生怀疑,这是不是nc这个软件本身的bug。
那我们可以尝试下用strace看看它内部都做了啥。
# strace nc -p 6666 127.0.0.1 6666// ...socket(af_inet, sock_stream, ipproto_tcp) = 3fcntl(3, f_getfl) = 0x2 (flags o_rdwr)fcntl(3, f_setfl, o_rdwr|o_nonblock) = 0setsockopt(3, sol_socket, so_reuseaddr, [1], 4) = 0bind(3, {sa_family=af_inet, sin_port=htons(6666), sin_addr=inet_addr("0.0.0.0")}, 16) = 0connect(3, {sa_family=af_inet, sin_port=htons(6666), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 einprogress (operation now in progress)// ...
无非就是以创建了一个客户端socket句柄,然后对这个句柄执行 bind, 绑定它的端口号是6666,然后再向 127.0.0.1:6666发起connect方法。
我们可以尝试用c语言去复现一遍。
下面的代码,只用于复现问题。直接跳过也完全不影响阅读。
#include <stdio.h>#include <unistd.h>#include <sys/socket.h>#include <stdlib.h>#include <arpa/inet.h>#include <ctype.h>#include <string.h>#include <strings.h>int main(){ int lfd, cfd; struct sockaddr_in serv_addr, clie_addr; socklen_t clie_addr_len; char buf[bufsiz]; int n = 0, i = 0, ret = 0 ; printf("this is a client \n"); /*step 1: 创建客户端端socket描述符cfd*/ cfd = socket(af_inet, sock_stream, 0); if(cfd == -1) { perror("socket error"); exit(1); } int flag=1,len=sizeof(int); if( setsockopt(cfd, sol_socket, so_reuseaddr, &flag, len) == -1) { perror("setsockopt"); exit(1); } bzero(&clie_addr, sizeof(clie_addr)); clie_addr.sin_family = af_inet; clie_addr.sin_port = htons(6666); inet_pton(af_inet,"127.0.0.1", &clie_addr.sin_addr.s_addr); /*step 2: 客户端使用bind绑定客户端的ip和端口*/ ret = bind(cfd, (struct sockaddr* )&clie_addr, sizeof(clie_addr)); if(ret != 0) { perror("bind error"); exit(2); } /*step 3: connect链接服务器端的ip和端口号*/ bzero(&serv_addr, sizeof(serv_addr)); serv_addr.sin_family = af_inet; serv_addr.sin_port = htons(6666); inet_pton(af_inet,"127.0.0.1", &serv_addr.sin_addr.s_addr); ret = connect(cfd,(struct sockaddr *)&serv_addr, sizeof(serv_addr)); if(ret != 0) { perror("connect error"); exit(3); } /*step 4: 向服务器端写数据*/ while(1) { fgets(buf, sizeof(buf), stdin); write(cfd, buf, strlen(buf)); n = read(cfd, buf, sizeof(buf)); write(stdout_fileno, buf, n);//写到屏幕上 } /*step 5: 关闭socket描述符*/ close(cfd); return 0;}
保存为 client.c 文件,然后执行下面命令,会发现连接成功。
# gcc client.c -o client && ./clientthis is a client
# netstat -nt | grep 6666tcp 0 0 127.0.0.1:6666 127.0.0.1:6666 established
说明,这不是nc的bug。事实上,这也是内核允许的一种情况。
自连接的解决方案自连接一般不太常见,但遇到了也不难解决。
解决方案比较简单,只要能保证客户端和服务端的端口不一致就行。
事实上,我们写代码的时候一般不会去指定客户端的端口,系统会随机给客户端分配某个范围内的端口。而这个范围,可以通过下面的命令进行查询
# cat /proc/sys/net/ipv4/ip_local_port_range32768 60999
也就是只要我们的服务器端口不在32768-60999这个范围内,比如设置为8888。就可以规避掉这个问题。
另外一个解决方案,可以参考golang标准网络库的实现,在连接建立完成之后判断下ip和端口是否一致,如果遇到自连接,则断开重试。
func dialtcp(net string, laddr, raddr *tcpaddr, deadline time.time) (*tcpconn, error) { // 如果是自连接,这里会重试 for i := 0; i < 2 && (laddr == nil || laddr.port == 0) && (selfconnect(fd, err) || spuriousenotavail(err)); i++ { if err == nil { fd.close() } fd, err = internetsocket(net, laddr, raddr, deadline, syscall.sock_stream, 0, "dial", sockaddrtotcp) } // ...}func selfconnect(fd *netfd, err error) bool { // 判断是否端口、ip一致 return l.port == r.port && l.ip.equal(r.ip)}
四次握手前面提到的tcp自连接是一个客户端自己连自己的场景。那不同客户端之间是否可以互联?
答案是可以的,有一种情况叫tcp同时打开。
tcp同时打开大家可以对比下,tcp同时打开在握手时的状态变化,跟tcp自连接是非常的像。
比如syn_sent状态下,又收到了一个syn,其实就相当于自连接里,在发出了第一次握手后,又收到了第一次握手的请求。结果都是变成 syn_rcvd。
在 syn_rcvd 状态下收到了 syn+ack,就相当于自连接里,在发出第二次握手后,又收到第二次握手的请求,结果都是变成 established。他们的源码其实都是同一块逻辑。
复现tcp同时打开分别在两个控制台下,分别执行下面两行命令。
while true; do nc -p 2224 127.0.0.1 2223 -v;donewhile true; do nc -p 2223 127.0.0.1 2224 -v;done
上面两个命令的含义也比较简单,两个客户端互相请求连接对方的端口号,如果失败了则不停重试。
执行后看到的现象是,一开始会疯狂失败,重试。一段时间后,连接建立完成。
# netstat -an | grep 2223proto recv-q send-q local address foreign address state tcp 0 0 127.0.0.1:2224 127.0.0.1:2223 establishedtcp 0 0 127.0.0.1:2223 127.0.0.1:2224 established
期间抓包获得下面的结果。
可以看到,这里面建立连接用了四次交互。因此可以说这是通过四次握手建立的连接。
而且更重要的是,这里面只涉及两个客户端,没有服务端。
看到这里,不知道大家有没有跟我一样,被刷新了一波认知,对socket有了重新的认识。
在以前的观念里,建立连接,必须要有一个客户端和一个服务端,并且服务端还要执行一个listen()和一个accept()。而实际上,这些都不是必须的。
那么下次,面试官问你没有listen(), tcp能建立连接吗?, 我想大家应该知道该怎么回答了。
但问题又来了,只有两个客户端,没有listen() ,为什么能建立tcp连接?
如果大家感兴趣,我们以后有机会再填上这个坑。
总结四次挥手中,不管是程序主动执行close(),还是进程被杀,都有可能发出第一次挥手fin包。如果机器上fin-wait-2状态特别多,一般是因为对端一直不执行close()方法发出第三次挥手。
close()会同时关闭发送和接收消息的功能。shutdown() 能单独关闭发送或接受消息。
第二、第三次挥手,是有可能合在一起的。于是四次挥手就变成三次挥手了。
同一个socket自己连自己,会产生tcp自连接,自连接的挥手是两次挥手。
没有listen,两个客户端之间也能建立连接。这种情况叫tcp同时打开,它由四次握手产生。
最后今天提到的,不管是两次挥手,还是自连接,或是tcp同时打开什么的。
咋一看,可能对日常搬砖没什么用,实际上也确实没什么用。
并且在面试上大概率也不会被问到。
毕竟一般面试官也不在意茴字有几种写法。
这篇文章的目的,主要是想从另外一个角度让大家重新认识下socket。原来tcp是可以自己连自己的,甚至两个客户端之间,不用服务端也能连起来。
这实在是,太出乎意料了。
以上就是活久见!tcp两次挥手,你见过吗?那四次握手呢?的详细内容。