点击蓝字关注我哦
以下是本期干货视频 视频后还附有文字版本哦▼《BAT都在问-为什么TCP建立和释放连接需要三次握手和四次挥手》▼
ps:请在WiFi环境下打开,如果有钱任性请随意
面试中,经常被面试官问道,为什么TCP建立与释放连接,要进行三次握手和四次挥手?
要回答这个问题,重点是理解清楚:
到底是怎么建立三次握手的
为什么需要三次握手
三次握手的实现原理
四次挥手的状态
为什么需要四次挥手
半关闭相关概念
1.理清TCP socket编程和三次握手四次挥手的关系
服务器端服务器端和客户端首先创建socket,服务器调用bind,绑定指定端口和IP。绑定端口主要是为了内核收到数据包知道交给哪个进程,绑定IP主要是因为机器可能有多个网卡,需要选择监听哪个网卡。
然后服务器调用listen,将套接字转成被动套接字。
接下来服务器就阻塞在accept等待客户端的请求到来。
客户端
客户端调用完socket后,就需要调用connect向服务器发起连接请求。其中,发起connect,就是主动发起三次握手,三次握手完成。客户端和服务器随后就可以进行read和write数据了。因为TCP是全双工的,所以read和write过程是双向的。
通信完毕后,其中的一方(服务端 or 客户端)调用close,就会给对方发送一个FIN包,表示我不再给你发送数据了。对方读到FIN,read返回0,感知到对端的关闭连接请求,随后自己也调用close,通知对方,我也关闭了。
2. 理清TCP重要报头字段
TCP为了保证可靠的传输,设计了复杂的头部,为了方便后面的讲述,我们简单回顾一下。
源端口和目的端口:主要是决定数据发给哪个应用程序的。
序列号:序号主要是为了解决乱序问题。
确认号:发送出去的包都需要确认,如果没有收到对方的确认包,就重新发送,直到送达。
首部长度:TCP头部的大小。
标志位:
CWR:拥塞窗口减少。
ECE:显示拥塞提醒回应。
URG:紧急指针。
ACK:设置为1时,确认号有效。
PSH:设置为1时,接收方应尽快将这个报文交给应用层。
RST:为1时,释放连接,重连。
SYN:为1时,发起一个连接。
FIN:为1时,关闭一个连接。
窗口大小:主要用于流量控制。
校验和:对TCP头和数据进行校验。
紧急指针:当URG位为1,这个指针有效。
3. 理清TCP全双工是如何实现的
下面,站在内核数据结构角度,看看系统是如何维护socket,保证全双工的。
Linux中一切皆文件,Socket在Linux中也以文件的形式存在。
调用socket函数,内核返回的是一个文件描述符,既然是文件描述符,那就要在代表进程的task_struct结构体中的已打开文件描述符数组中占用一个文件描述符,这个数组中的内容就是一个指针,指向内核中打开的文件列表,然后通过文件列表找到对应的inode。
网络文件的inode不是指向真正的磁盘文件,而是指向了struct sock结构体,我们在理解上,可以将该内核结构看做我们创建的链接,因为链接需要系统维护,所以要占用系统的资源。
struct sock结构体中有一个发送队列和一个接收队列,因为是两个独立的队列,所以TCP就可以进行同时收发数据,进而实现全双工通信。
队列的节点数据是sk_buff,这个结构体缓存的是完整的数据包。
4. 理清三次握手的过程
前面已经说了,调用connect函数要进行三次握手。
调用connect报错
connnect只有在成功或失败时才返回。错误返回可能有以下几种情况:
客户端发出的 SYN 包没有任何响应,则返回 TIMEOUT 错误。常见的原因时IP写错了。
对客户端的SYN响应的是RST,客户端返回CONNECTION REFUSED错误。这种原因一般是端口没有开启。
客户发出的 SYN 包在网络上有中间某个路由器引起了一个"destination unreachable"的ICMP错误,这种情况一般是客户端和服务器之间路由不通。
调用connect成功
connect调用成功,就会在底层自动执行三次握手。下面看看三次握手的具体过程:
客户端内核协议栈向服务器发送SYN 数据包,告诉服务器客户端的当前序列号为i,客户端进入SYN_SENT状态。
服务器内核协议栈对客户端进行应答,应答号是i+1,同时也发送一个SYN包,告诉客户端自己的序列号是j,服务器进入SYN_RCVD状态。
客户端收到服务器的ACK后,就从connect返回,表示从客户端到服务器的单向连接建立成功,客户端进入ESTABLISHED状态。
客户端的应答包到达服务器,这时accept返回,表示从服务器到客户端的单项连接成功,服务器进入ESTABLISHED状态。
SYN中包的序号从几开始?
为了防止在网络中被延迟的数据包在以后又被传送,导致连接的一方对他进行错误解释,客户端和服务器之间的包序号,不能从1开始编号。
假如:客户端连上服务器,发送了1,2,3三个数据包,但发送的3号数据包,在中间的某个路由器给丢失了,也可能迷路了,客户端就重发。但是刚重发完毕,客户端因为未知原因掉线了,于是重新连上服务器。这时,如果客户端因为一些原因,只想给服务器发1号和2号数据包,不想发送3号数据包,但是上次丢失的被重发的3号数据包这时到服务器了,服务器就会认为是下一个数据包,这时就出错了。
所以,每个连接都要有不同的起始同步序号。这个SYN序号是随时间变化而变化的。每4ms加1,序号是一个32位的数字,如果要重复,就得4个多小时,先前的数据包早就过了TTL时间了,也就不会存在上述问题。
在具备了如上4个背景知识之后,我们再来深入研究一下。
5. 理解connect、listen、accept内部都做了啥
在Linux内核中listen会在内核中创建两个队列,一个用于插入未完成三次握手的请求包,一个用于插入已完成三次握手的连接请求,注意,这里的两个队列和上面讲的读写队列是完全不同的,这两个队列是用于连接管理的,上面的两个队列是用来数据全双工通信的。
int listen(int sockid, int backlog) { tcp_manager *tcp = get_tcp_manager(); // 代表TCP的指针 struct socket *s = fdtable->sockfds[sockid]; // 通过文件表获取当前socket的指针 if (tcp->smap[sockid].socktype == TCP_SOCK_STREAM) { // 如果是TCP,就设置成监听套接字 tcp->smap[sockid].socktype = TCP_SOCK_LISTENER; } // 分配监听对象 tcp_listener *listener = (tcp_listener*)calloc(1, sizeof(tcp_listener)); listener->sockid = sockid; listener->backlog = backlog; listener->s = s; listener->synq = CreateStreamQueue(backlog); // 创建未完成三次握手队列 listener->acceptq = CreateStreamQueue(backlog); // 创建已完成三次握手队列 ListenerInsert(tcp->listeners, listener); // 插入到TCP对象的listeners}
accept函数的实现就是阻塞等待,从完成三次握手的队列中取得一个连接,然后创建一个新的socket,将目标和源IP端口信息填入,然后返回:
int accept(int sockid, struct sockaddr *addr, socklen_t *addrlen) { tcp_manager *tcp = get_tcp_manager(); struct socket *s = fdtable->sockfds[sockid]; // 获取监听套接字的socket tcp_listener *listener = s->listener; tcp_stream *accepted = NULL; // 等待从accept队列中拿完成三次握手的连接 while ( accepted == NULL && (accepted = StreamDequeue(listener->acceptq)) ) { pthread_cond_wait(&listener->accept_cond, &listener->accept_lock); } // 建立新的套接字,并将客户端的连接信息保存进来 struct socket *socket = socket_allocate(TCP_SOCK_STREAM); socket->stream = accepted; accepted->s = socket; socket->s_addr.sin_family = AF_INET; socket->s_addr.sin_port = accepted->dport; socket->s_addr.sin_addr.s_addr = accepted->daddr; return accepted->s->id;}
connect在自己内部创建一个connect队列,将当前要发送SYN请求的信息放入该队列,然后发送第一次SYN到服务器时,服务器就将这个连接放入到自身SYN队列,同时服务器给客户端回复ACK和自己的SYN,客户端收到这个ACK,将连接从connect队列中取出,并且connect函数返回,表示自己这一端连接成功。
服务器收到客户端的ACK,将连接从SYN队列中删除,并形成新的链接节点插入到ACCEPT队列中。阻塞在pthread_cond_wait的accept函数由于ACCEPT队列中有数据而被唤醒。取走队头节点,accept返回。
6. 回答问题:为什么建立连接需要三次握手呢?
(1)为了满足在信道不可靠的情况下建立双向连接。我们来假设网络环境不好,经常丢包,客户端发起了一个连接SYN,但是可能丢包了,也可能超时了,总之没有得到服务器的确认。这时客户端就不断的重发,服务器终于收到了SYN包,他就知道有人要连接自己,回应一个SYN和ACK。我们还是假设网络环境还是不好,那这个回应包也有可能丢失或者超时,当然也可能此时客户端已经挂了。服务器的回应经过多次重发,终于到了客户端,客户端就认为连接建立成功,因为客户端认为我的消息有去有回,此路就是通的。服务器也会等待客户端的应答消息,因为对于服务器来说,我的消息有去有回才算通了。所以,三次握手,能够以最小通信次数,确定在两个方向上的全双工信道是联通的。
(2)减少恶意伪造数据包的用户对服务器攻击。大量的攻击数据包占用着服务器的未完成三次握手队列,使得正常需要提供服务的连接进不来。有了第三次握手,如果服务器收不到攻击数据包的ACK,就会尝试重发SYN+ACK报文,如果多次重试,都连接不上,就会关闭连接。这样就能有效降低SYN攻击带来的资源损害。
一般,这个尝试重发次数默认是5次,分别是经历过1、2、4、8、16、32秒的等待后重试,总共耗时63秒,如果觉得这个时间太长或太短,可以调整tcp_synack_retries的值:
[root@localhost ipv4]# cat /proc/sys/net/ipv4/tcp_synack_retries 5
对于服务器端来说,如果SYN队列满了,再来新的请求报文,直接丢弃。当然,我们可以调整更大的SYN队列:
[root@localhost ipv4]# cat /proc/sys/net/ipv4/tcp_max_syn_backlog 128
但是再大的SYN队列都有可能被占满,能不能在SYN队列占满的情况下,不丢弃连接呢?这就需要开启syncookie功能。syncookie的设计思想是:服务器根据当前状态计算出一个值,放在SYN+ACK报文中,当客户端回复ACK时,取出该值验证,如果合法,就认为连接成功,直接放入ACCEPT队列中。开启syncookie的方法是:
[root@localhost ipv4]# cat /proc/sys/net/ipv4/tcp_syncookies1
(3)减少异常情况下的服务器端资源占用情况。在实际建立连接的过程中,因为通信双方都需要维护连接,换句话说,都要占用系统资源。因为服务器和客户端是1:n的,所以,服务器资源就显得尤为宝贵。n次握手中,我们不担心前几次丢失,我们最担心第n次丢失。因为第n次丢失会造成,连接建立发起方认为连接建立好,而连接建立确认方会认为连接没有建立好,这种认知不一致的情况,会造成一定时间的链接建立确认方的资源浪费。能解决吗?很难,因为我们本来就在一个不稳定的网络环境中,任何一个报文丢失都有可能。既然无法避免,那就转移危害。而奇数次握手能做到将危害转移到连接发起方。三次握手就是比较精简的做法。一般主动发起链接的都在客户端,进而能有效保护服务器资源。
7. 理清四次挥手过程
主动关闭的一方调用close,发送一个TCP的FIN包,表示要关闭连接。之后就进入FIN_WAIT_1状态。
接着,接收到FIN包的一端执行被动关闭。这个FIN由TCP协议栈处理,TCP协议栈为FIN包插入一个文件结束符EOF到接收缓冲区之后,应用程序通过read感知到这个FIN包。被动接受的一方就进入到CLOSE_WAIT状态。
被动方read到EOF,调用close,导致TCP发送一个FIN,被动关闭方就进入了LAST_ACK。
主动方收到FIN,发起确认包ACK,主动方进入TIME_WAIT,被动方收到ACK进入CLOSED。主动方在2倍的MSL时间后,也进入了CLOSED状态。
在Linux中MSL的值固定为30秒,所以TIME_WAIT的时间为60秒。为什么要等待2倍的MSL呢?
(1)如果没有这个TIME_WAIT,端口可以复用于新连接了。这时被动方的FIN报文可能再次到达,可能是路由器重复发的,也可能是被动方没有收到ACK重发的。这样正常的新连接就会被重复发送的旧的FIN误关闭。保留了TIME_WAIT就可以应付重发的FIN。
(2)等待2倍MSL,其实是允许ACK丢失一次,如果一个ACK丢失,被动方重发的FIN就会在第二个MSL内到达,TIME_WAIT就可以应付。那为什么不是4MSL或者更多呢?这是因为就算是网络很差,丢包率为1%,那么连续丢两包的可能性也只有万分之一,这个概率太小了。同时,等待2MSL时间,能够保证双向上的历史数据在网络中消散。
两个方向都要FIN和ACK,所以关闭是四次挥手。
8. 回答问题:
为什么断开连接需要四次挥手呢?
这里又有个疑问,为什么建立连接需要三次握手,而断开连接需要四次挥手呢?
原因是:
TCP不允许连接处于半打开状态时就单向传递数据,所以在三次握手建立连接时,服务器会把SYN和ACK放在一起发送给客户端,数据包中的ACK是用来打开客户端的发送通道,SYN是用来打开服务器端的发送通道。所以原本的四次握手就变成了三次。
但是当处于半关闭状态时,TCP允许单向发送数据。当主动关闭方关闭连接,被动方在不调用close的状态下,可以长时间发送数据,此时连接处于半关闭状态。这一特性是TCP的双向通道相互独立导致的,也导致了关闭连接必须进行四次挥手。
由于处于半连接状态接收数据是我们在实际程序开发中经常用的特性,下面用客户回射服务器来测试处于半关闭状态接收数据,我的测试用例是,客户连续发送两个数据立马发送FIN,服务器隔五秒后再回射数据,看看客户端能不能正常收到数据。
client端代码:
int main( void ) { int cfd; char buf[1024]; fd_set allset, rset; struct sockaddr_in addr; cfd = socket(AF_INET, SOCK_STREAM, 0); addr.sin_family = AF_INET; addr.sin_port = htons(8888); inet_aton("127.0.0.1", &addr.sin_addr); if ( connect(cfd, (struct sockaddr*)&addr, sizeof(addr)) == -1 ) perror("connect"),exit(1); FD_ZERO(&allset); FD_SET(cfd, &allset); FD_SET(fileno(stdin), &allset); for ( ; ; ) { rset = allset; int nready = select(cfd+1, &rset, NULL, NULL, NULL); if ( FD_ISSET(fileno(stdin), &rset) ) { memset(buf, 0x00, sizeof(buf)); if ( fgets(buf, 1024, stdin) == NULL ) shutdown(cfd, SHUT_WR); // 关闭写一半,停留在FIN_WAIT2,可以继续读取数据 else if ( write(cfd, buf, strlen(buf)) == -1 ) break; } if ( FD_ISSET(cfd, &rset) ) { memset(buf, 0x00, sizeof(buf)); int r = read(cfd, buf, 1024); if ( r <= 0 ) break; printf("buf=[%s]\n", buf); } }}
server端代码:
int main( void ) { int op = 1; struct sockaddr_in addr; int lfd = socket(AF_INET, SOCK_STREAM, 0); setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &op, sizeof(op)); addr.sin_family = AF_INET; addr.sin_port = htons(8888); addr.sin_addr.s_addr = htonl(INADDR_ANY); bind(lfd, (struct sockaddr*)&addr, sizeof(addr)); listen(lfd, 1024); int nfd = accept(lfd, NULL, NULL); for ( ; ; ) { char buf[1024] = {}; int r = read(nfd, buf, 1024); if ( r == 0 ) break; sleep(5); write(nfd, buf, r); } close(nfd); close(nfd);}
总结
1.通过socket函数在内核建立的收发缓冲区,说明TCP是全双工的。 2.TCP三次握手的流程和内核状态,SYN同步序号的选取策略, 以及内核中的实现思路,探讨了一下优化的方法。 3.四次挥手的流程,TIME_WAIT存在的必要性,为什么需要四次挥手,半连接。 留个思考题: 四次挥手能不能优化,如果能,哪些点可以优化? 作者:李涛 审核:王海斌 编辑:比特李哥点亮"在看",点亮"offer"
原创不易,点个赞吧~