传输层协议——TCP协议

目录

TCP协议所处的位置

TCP协议的格式

如何理解报头?(包含4位首部长度字段和选项字段的知识点)

如何理解TCP协议的可靠性?(包含确认应答策略、按序到达策略、捎带应答策略、32位序号字段和32位确认序号字段的知识点)

16位窗口大小字段(包含流量控制策略的知识点)

6位标志位字段(包含16位紧急指针字段、3次握手的知识点)

4次挥手

当我们通过netstat指令发现服务端具有大量CLOSE_WAIT状态的连接时,原因是什么呢?

关于TIME_WAIT状态

为什么TIME_WAIT状态会导致bind失败?

TIME_WAIT状态导致bind失败会引发什么问题呢?又该如何解决呢? 

超时重传策略

滑动窗口(包含快重传策略的知识点)

拥塞控制策略(包含拥塞窗口、慢启动策略的知识点)

延迟应答策略

对TCP协议的总结

TCP协议的粘包问题

TCP异常情况


TCP协议所处的位置

a4926a9996a34b32b837189f2cc0d1b0.png

如上图就很好地说明了TCP协议所处的位置。额外说下两个知识点:

1、在socket套接字编程时用到的各种socket套接字接口都是位于应用层和传输层之间的一层系统调用接口,这些接口是系统提供的,我们可以通过这些接口搭建上层应用。我们经常说HTTP是基于TCP的,实际就是因为HTTP报文是通过调用【和TCP协议强相关的socket套接字接口】实现在网络中传输的。

2、socket套接字层往下的传输层实际就是由操作系统管理的,因此TCP是属于内核当中的,是操作系统本身协议栈自带的,其代码不是由上层用户编写的,TCP的所有功能都是由操作系统完成,因此网络也是操作系统的一部分。

TCP协议的格式

ee9c3cac01b74f75b7d30b27797ba8a6.png

TCP报文的报头当中各个字段的含义如下: 

  • 16位源端口号:表示当前这个报文是哪个进程发的。以前在编写TCP或者UDP服务端时,我们会显示bind绑定ip和port,port端口号就是在传输层起作用、ip就是在网络层起作用。
  • 16位目的端口号:表示当前这个报文要被发送到指定ip对应的主机上的哪个进程。以前在编写TCP或者UDP服务端时,我们会显示bind绑定ip和port,port端口号就是在传输层起作用、ip就是在网络层起作用。
  • 剩余的内容会在下文中慢慢揭晓。                  

如何理解报头?(包含4位首部长度字段和选项字段的知识点)

 (结合下图思考)操作系统是C语言写的,而TCP协议又是属于内核协议栈的,因此TCP协议也一定是用C语言编写的,TCP报头实际就是一个如下的位段类型。

76bb4b2bc49449adb1c1161cae5fb275.png

然后注意,虽然struct tcp_header的大小是固定的20字节,但这并不表示TCP协议的报头就是固定的20字节,TCP协议的报头长度是变化的,这是因为在TCP协议中有一个选项字段(可以在上上图中看到),选项字段的长度是变化的,该字段没有被包含进struct tcp_header中(就是因为变长的选项字段没有被包含进struct tcp_header中,所以struct tcp_header的大小才能是固定的),但该选项字段的确是属于TCP协议的报头部分的。既然选项字段的长度是变化的,那谁知道选项字段到底多长呢?答案:TCP协议的报头由两个部分组成,第一是struct tcp_header部分,第二是选项字段,我们通过TCP协议的报头中的4位首部长度字段就能知道TCP协议的报头整体有多长(如何知道会在下一段中说明),知道报头整体有多长后,用整体长度减去struct tcp_header所占的20字节,剩余的就是选项字段的长度大小了。

有人可能会说【4位首部长度字段是4个二进制比特位,要么为0,要么为1,就算是全1,那4个二进制比特位能表示出的十进制数字也只不过是15,现在光struct tcp_header都有固定的20字节,更何况还有选项字段,你凭什么说通过TCP协议的报头中的4位首部长度字段就能知道TCP协议的报头整体有多长呢?】,这里笔者想说,的确4个二进制比特位最大表示的十进制数字是15,但这里15这个数字的单位并不是1字节,而是4字节,所以虽然4位首部长度字段的值的区间是左闭右闭的【0,15】,但所表示的TCP协议的报头的整体长度的区间是左闭右闭的【0,15*4】,即【0,60】,又因为即使选项字段的长度是0字节,TCP协议的报头中还有固定20字节的struct tcp_header,所以表示TCP协议报头的整体长度的4位首部长度字段的值不可能小于5,所以准确来说4位首部长度字段的值的区间是左闭右闭的【5,15】,其所表示的TCP协议的报头的整体长度的区间是左闭右闭的【5*4,15*4】,即【20,60】。

TCP协议如何进行数据封装?

(结合下图思考)当应用层将数据交给传输层后,在传输层就会创建一个TCP报头类型的变量,然后填充报头当中的各个字段,此时就得到了一个TCP报头。此时操作系统再在内核当中开辟一块空间,将TCP报头和有效载荷拼凑到一起,此时就形成了TCP报文。 

从代码层面上如何体现上一段的内容呢?答案:在<<数据链路层——以太网协议、ARP协议>>一文中讲解过代码层面上的以太网帧的封装过程,那个过程和这里的过程基本是完全一致的,请移步去看。

8086837d355048328f18dfb333b4baea.png

TCP协议如何进行数据分用?

(结合下图思考)当传输层从下层获取到一个报文后,就会先读取该报文的报头,先从报头中提取出目的端口号,然后从报头中提取出4位首部长度字段(这么做的原因是TCP协议的报头和UDP协议的报头不一样,UDP协议的报头长度大小是固定的,但TCP协议的报头长度大小不是固定的,而是变长的,所以想要把TCP协议的报头和有效载荷分离,就得通过4位首部长度字段,这部分内容在上文中已经进行过详细的说明,如果忘了请在上文中回顾),然后通过4位首部长度字段把TCP协议的报头和有效载荷分离,然后就可以把有效载荷全向上交付给目的端口号对应的应用层进程了。

720a492e0cf44f95a5ded22ff1afa562.png

说一下,从上图中也就告诉了我们TCP协议如何将报头与有效载荷进行分离和TCP协议如何决定将有效载荷交付给上层的哪一个进程。

如何理解TCP协议的可靠性?(包含确认应答策略、按序到达策略、捎带应答策略、32位序号字段和32位确认序号字段的知识点)

我们常说UDP协议不具备可靠性,即无法保证数据一定能从一端到达另一端,比如如果因为网络故障等原因导致UDP协议的报文在网络中丢了,导致无法到达对方主机,那么UDP协议层不会给应用层返回任何错误信息,也不会做任何事情,报文丢了那就丢了吧,反正UDP协议不管了;而我们常说TCP协议是具有可靠性的,那么如何理解这个可靠性呢?答案会在下文中慢慢浮现出来。

b27a0574207346bfa90cb3ca71e802f3.png

(结合上图思考)为什么网络中会存在不可靠?答案:现代的计算机大部分都是基于冯诺依曼体系结构的。虽然这里的输入设备、输出设备、内存、CPU都在一台机器上,但这几个硬件设备是彼此独立的。如果它们之间要进行数据交互,就必须要想办法进行通信,因此这几个设备实际是用“线”连接起来的,其中连接内存和外设之间的“线”叫做IO总线,而连接内存和CPU之间的“线”叫做系统总线。由于这几个硬件设备都是在一台机器上的,因此这里传输数据的“线”是很短的,传输数据时出现错误的概率也非常低。

但如果要进行通信的两个设备相隔千里,那么连接两个设备的“线”就会变得非常长(当然,这个“线”并不一定是物理意义上能看得见摸得着的线,比如打电话时的数据传输就是通过光电信号;同时这里的两个设备也不是直连,数据在这两个设备之间传输时会有中间设备作为媒介的),传输数据时出现错误的概率也会大大增高,此时要保证传输到对端的数据无误,就必须引入可靠性。

总之,网络中存在不可靠的根本原因就是,两个需要进行通信的设备之间距离实在是太远了,因为距离太远,所以数据在长距离传输过程中就可能会出现各种各样的问题,TCP协议就是在此背景下诞生的一种保证可靠性的协议。(额外说一下,实际单独的一台计算机可以看作成一个小型的网络,计算机上的各种硬件设备之间实际也是在进行数据通信,并且它们在通信时也必须遵守各自的通信协议,只不过它们之间的通信协议更多是描述一些数据的含义)

那么TCP协议是如何保证这个可靠性的呢?举个例子,在实际生活中,有一名同学A和一名同学B,如果A和B只相隔3米,那么双方互相向对方输出信息时,信息基本不可能丢失(即A向B说话时,B基本不可能听不见;B向A说话时,A也基本不可能听不见),但如果双方相隔50米,此时双方互相向对方输出信息时,因为双方距离太远了,所以信息就很有可能因为风太大或者其他原因而丢失了(即A向B说话时,B很有可能因为风太大或者其他原因导致没有听见A说的话),于是A和B就商量出了一个办法(结合下图思考),比如A向B说完一句“你在干嘛”后,B首先得向A回复一句“收到”,这样当A收到这句“收到”后,A就能100%确定刚刚自己发出去的“你在干嘛”被B收到了(如果因为A发给B的“你在干嘛”在网络中丢包了,导致B压根没有收到A发的“你在干嘛”,导致B没有给A回复“收到”,进而导致A没有收到“收到”,那么A就会认为自己发的“你在干嘛”没有被B收到,然后A就会重传“你在干嘛”;或者是因为B收到了A发的“你在干嘛”并且也给A回复了“收到”,但“收到”在网络中丢包了导致A没有收到“收到”,那么A就会认为自己发的“你在干嘛”没有被B收到,然后A就会重传“你在干嘛”),然后B再向A回复一句“我没干嘛”,同理当A收到B回复的“我没干嘛”后,A在回复B前,也要先对B说一句“收到”,这样当B收到这句“收到”后,B就能100%确定刚刚自己发出去的“我没干嘛”被A收到了(如果因为B发给A的“我没干嘛”在网络中丢包了,导致A压根没有收到B发的“我没干嘛”,导致A没有给B回复“收到”,进而导致B没有收到“收到”,那么B就会认为自己发的“我没干嘛”没有被A收到,然后B就会重传“我没干嘛”;或者是因为A收到了B发的“我没干嘛”并且也给B回复了“收到”,但“收到”在网络中丢包了导致B没有收到“收到”,那么B就会认为自己发的“我没干嘛”没有被A收到,然后B就会重传“我没干嘛”)

8f5f642b68164fcb8f6cfe11968b732c.png

上一段这样的方法就叫做确认应答机制,TCP协议实际上就是通过这个确认应答机制保证TCP协议的可靠性的,A发信息给B后,只要A能收到一个B的匹配的应答,A就能确定自己发出去的信息被B收到了。可以发现这样的方法是存在一个问题的,那就是最后发送信息的一方最后发出的一条信息是没法保证被对方收到的,这是因为我是作为最后发送信息的一方、我发出的信息就是通信中的最后一条信息,对方是不能回应任何信息的,否则对方发送的信息才是通信中的最后一条信息,既然对方不能回应任何信息,那我作为发送方就是无法知道我发送的信息是否被对方收到了。所以最后给出上文中提出的问题【如何理解这个可靠性】的答案:在网络中是不存在100%的可靠的,即使是TCP协议,也只能保证大部分情况下可靠,即除了最后一条信息没法保证可靠,其他信息都是能保证可靠的。


问题:说一下,在实际中双方互相传输TCP报文时并不一定会像上图那样表现成一问一答的模式,而可能是下图左半部分这样,客户端一次性给服务端发送一批TCP请求,服务端收到后,也一次性给客户端回复一批TCP响应(这样做的原因在下文讲解滑动窗口的部分),那么问题来了,客户端如何知道哪个响应对应哪个请求呢?毕竟客户端只有知道了哪个响应对应哪个请求,客户端才能知道自己发出去的哪一个请求一定被服务端收到了。

答案:如下图右半部分,每个TCP报文的报头中都有一个32位序号字段和32位确认序号字段,客户端给服务端发起一批TCP请求时,每个请求中都是会填充这个32位序号字段的,比如下图右半部分将每个TCP请求的32位序号字段分别填充成了1000、2000、3000。当服务端收到这一批TCP请求后,给客户端回复一批TCP响应时,也是会给每个响应中的32位确认序号字段进行填充的,填充的规则为,如果某响应是【32位序号字段为 x 的TCP请求】的响应,那么该响应的32位确认序号字段就要被填充成 x+1 ,所以下图右半部分将每个TCP响应的32位确认序号字段分别填充成了1001、2001、3001(注意下图右半部分中服务端发出TCP响应的顺序和客户端发出TCP请求的顺序是不一样的,这是正常的,TCP协议本就无法保证两者的顺序一致,这是因为TCP请求的报文在网络中进行路由转发时,并不是每一个报文选择的路由路径都是一样的,因此TCP请求的报文被发送的顺序和被接收的顺序可能是不同的,而服务端先收到哪个TCP请求,就先构建并发送哪个匹配该请求的TCP响应,所以下图右半部分中服务端发出TCP响应的顺序才是“乱序”的。注意乱序问题是需要解决的,如果不解决就会引发很多错误,比如试想一下客户端先收到的TCP响应的响应正文是“吃的啥”,然后再收到的TCP响应的响应正文是“吃了吗”,客户端收到的这一批信息就是乱序的,我作为使用客户端的用户当然就懵逼了。乱序除了会引发这种错误,还会引发很多其他的错误,乱序本身也是表示传输信息不可靠的一种情况,所以乱序问题也是需要解决的,那如何解决呢?很简单,因为TCP请求报文或者TCP响应报文中都是携带32位序号的,所以通信双方中的任意一方收到乱序的报文后,都是可以通过报文的32位序号将乱序的报文按照从小到大的方式重新进行排序的,排完序后再把这些报文中的正文数据放到自己本地的接收缓冲区里,后序应用层调用read或者recv函数从接收缓冲区中读取到的数据就是有序的了,这种策略被称为按序到达策略),这样一来客户端收到某条TCP响应后,发现该响应的32位确认序号是2001,客户端就知道了这是匹配【32位序号字段为2000的TCP请求】的TCP响应,通过这样的方式,客户端连续收到一批TCP响应后,就知道了哪个响应是对应哪个请求的了,客户端也就能知道自己发出去的哪一个请求一定被服务端收到了。

095ec77f2204476a979cb607736e6e0e.png


然后要说明的是,不要认为服务端发送的TCP响应中的32位确认序号字段的作用仅仅是让客户端知道自己发出去的哪一个(注意数量是一个)请求一定被服务端收到了。而要知道服务端发送的TCP响应中的32位确认序号表示确认序号对应的数字之前的所有的TCP请求报文全部都被服务端收到了(注意数量是全部),并告诉客户端下次发送TCP请求时,要把该TCP请求中的32位序号填充成这个TCP响应中的32位确认序号的值。举个例子,如果客户端只收到了32位确认序号为1001的TCP响应,那么客户端只能确定自己发的32位序号为1000的TCP请求被服务端收到了,无法确认32位序号为2000和3000的TCP请求是否被服务端收到了;但如果客户端只收到了服务端发来的32位确认序号为3001的TCP响应,32位确认序号为2001和1001的TCP响应在网络中丢包了,此时因为服务端发送的TCP响应中的32位确认序号表示确认序号对应的数字之前的所有的TCP请求报文全部都被服务端收到了,所以即使32位确认序号为2001和1001的TCP响应在网络中丢包了,客户端也是能确定除了自己发的32位序号为3000的TCP请求一定被服务端收到了外,还有自己发的32位序号为1000和2000的TCP请求也一定被服务端收到了。也正是因为TCP响应报文的报头中的32位确认序号字段所表示的含义是确认序号对应的数字之前的所有的TCP请求报文全部都被服务端收到了,所以双方在基于TCP协议进行通信时是允许部分TCP响应在网络中丢包、甚至有时候压根不发TCP响应的(不发的原因是想等数字累积大了后一次性发送,这样更方便)。

再举个例子,因为服务端发送的TCP响应中的32位确认序号表示确认序号对应的数字之前的所有的TCP请求报文全部都被服务端收到了,所以假如客户端发送了32位序号为1000、2000和3000的TCP请求,但32位序号为2000的TCP请求报文丢了,那么服务端也只会给客户端发送32位确认序号为1001的TCP响应,然后客户端收到这个TCP响应后下次就会重发1001到2000这段数据了,等到服务端收到重发的32位序号为2000的TCP报文后,因为服务端在之前就已经收到过32位序号为3000的TCP报文了,所以此时回复给客户端的TCP响应的32位确认序号就是3001了。


(结合下图思考)进一步认识32位序号和32位确认序号:我们理解TCP通信里的发送缓冲区和接收缓冲区时,我们可以把它们当作一个char类型的数组,比如char sendbuffer[num]和char recvbuffer[num],不必关心num具体是多少。

此时有人可能会疑惑,为什么缓冲区数组的类型是char呢?我想通过send放到发送缓冲区的数据如果不是C语言字符串类型,而是一个结构化类型的数据或者是一个int类型的数据,你char类型的数组如何接收呢?答案:在<<对协议的基本认识>>一文中说过,因为不同主机上大小端的差异和内存对齐规则的差异以及为了尽可能的节省网络资源(即让发送的数据变小),我们在应用层中是不能直接调用send或者write发送结构化类型或者int、double类型的数据的,将这些数据发送到发送缓冲区前,都是需要先在应用层编码将这些数据转化成C语言字符串类型,然后再发送的,否则应用层的代码就编写得有BUG。综上也就是说,不管原来要send发送到发送缓冲区中的数据是什么类型,最后都会先转化成C语言字符串类型,于是发送缓冲区就可以是char类型的数组了,同时接收缓冲区又只用于接收发送缓冲区中的数据,发送缓冲区中的数据都是C语言字符串,所以接收缓冲区当然也可以是char类型的数组了。

既然发送缓冲区和接收缓冲区都是char类型的数组,因为数组具有下标,于是主机A调用send将应用层中的数据比如是 “我adjk是la1234” 发送到发送缓冲区里后,这个数据中的每个字节天然就具有了序号,比如说每个下标都是一个序号,同时因为是char数组,每个下标都对应一个字节,于是这个数据中的每个字节天然就具有了序号(所以以后我们要清楚,32位序号是给数据的每个字节都编了号)。后序OS根据TCP协议将主机A的发送缓冲区中的数据发送到主机B的接收缓冲区里时,如果发送出去的数据的范围是下标0到下标100(或者说序号是0到100),那么该TCP报文的32位序号字段上填充的值就是100,后序收到的主机B发来的TCP响应报文里的32位确认序号字段上填充的值就是101,以告诉主机A下次发数据就从数组的下标为101的位置上开始发;如果发送出去的数据的范围是下标101到下标200(或者说序号是101到200),那么该TCP报文的32位序号字段上填充的值就是200,后序收到的主机B发来的TCP响应报文里的32位确认序号字段上填充的值就是201,以告诉主机A下次发数据就从数组的下标为201的位置上开始发。同时因为主机A的发送缓冲区中的数据被OS根据TCP协议发出时是通过这样的形式,于是主机B的OS根据TCP协议将这一批数据接收到自己的接收缓冲区时就能根据这一批数据的32位序号(或者说根据这一批数据的下标号),把这一批数据放到接收缓冲区这个char数组对应的相同下标号上,通过这样就保证了这一批数据在主机A的发送缓冲区中位于什么位置时,在主机B的接收缓冲区中也能位于相同的位置,或者换句话说就保证了数据的有序性,此后主机B的应用层在调用read或者recv从接收缓冲区中拿数据时,拿到的数据就都是有序的了。


问题:为什么TCP请求报文和TCP响应报文中都要同时有32位序号字段和32位确认序号字段呢?只有一个32位序号字段不行吗?比如当发出的TCP请求或者TCP响应只表示本机对对端的应答时,就在32位序号字段中填充表示32位确认序号的值,当发出的TCP请求或者TCP响应只表示本机发给对端的正文内容时,就在32位序号字段中填充表示32位序号的值。

答案:(结合下图思考)答案是不行, 以服务端发给客户端的TCP响应举例,在实际通信中,并不会严格像上文叙述的那样,即不会一定(只是可能)让一个TCP响应的作用仅仅是让客户端知道自己发出去的哪一个TCP请求一定被服务端收到了,毕竟上文那样的方式(即服务端先单独构建并发送一个表示应答的TCP响应发给客户端让客户端知道自己发的某个请求被服务端收到了,然后再单独构建并发送一个表示服务端回复给客户端的正文内容的TCP响应)太麻烦了,而是可能(不是一定,只是可能,并且这个可能性是比较大的,因为实际通信中双方都会给对方发送带有有效载荷的报文的可能性是比较大的)让一个TCP响应在表示应答的同时,也表示服务端想回复给客户端的正文内容,这种策略就叫做捎带应答策略。这就像有个人问你吃饭了没,你如果回答“吃了”,那么这句话不光表示对对方的应答,还表示你回复给对方的正文内容。既然一个TCP响应要表示对对方的应答,那么当然一个TCP响应报文的报头中要有32位确认序号字段;同时既然一个TCP响应还要表示本机回复给对端的正文内容,为了保证本机一定能知道发出的这个内容(或者说TCP响应)是否被对端给收到,那么当然一个TCP响应报文的报头中要有32位序号字段。这就是为什么TCP响应报文中要同时有32位序号字段和32位确认序号字段。TCP请求中同时有32位序号字段和32位确认序号字段的原因也是同理,毕竟有时候客户端发送的TCP请求也要既表示对服务端发送的TCP响应的应答,又表示客户端回复给服务端的正文内容。

16位窗口大小字段(包含流量控制策略的知识点)

走到这里,我们就讲解完了报头中的16位源/目的端口号字段、32位序号字段、32位确认序号字段和4位首部长度字段,接下来咱们说一说16位窗口大小字段是干嘛的。

(tips:在<<套接字socket编程的基础知识点>>一文中讲解面向字节流这种TCP协议下的通信模式时,我们对上图中的内容(即发送缓冲区和接收缓冲区,以及基于TCP协议的socket编程的常见函数如write等)做过详细的说明,如果读者对这些内容感到陌生,建议先到该篇文章中阅读这些内容)

当主机A上的客户端和主机B上的服务端通信时,主机A的OS会根据TCP协议将发送缓冲区中的内容发送到网络进而发送到主机B的接收缓冲区里,在这个过程中,除了有可能发生【发送的内容在网络中丢失】的情况和上文中讲过的【发送的内容被接收到后是乱序的】的情况,还有可能发生一种情况,那就是主机A的客户端给主机B的服务端发送数据的速度太快,服务端调用recv或者read从接收缓冲区中拿数据的频率又太低(即服务端接收数据的速度太慢),那么就会导致服务端的接收缓冲区被数据充满,如果此时还不通过某种策略对客户端的发送缓冲区进行控制,还能让客户端所在的主机的OS把自己发送缓冲区中的数据发给服务端的接收缓冲区(说一下,实际中当接收端的接收缓冲区已满的情况下,因为TCP协议中有接下来会进行讲解的流量控制策略,所以发送端所在的主机的OS根据TCP协议是不会也不能再把发送端的发送缓冲区中的数据发送到网络进而发给接收端的接收缓冲区的,这里举例时我们说服务端的接收缓冲区被数据充满时还能让客户端所在的主机的OS把自己发送缓冲区中的数据发给服务端的接收缓冲区只是一种假设,实际中因为有流量控制策略,所以是不能再发送数据的,这里我们举例只是为了说明如果没有流量控制策略会有什么后果,而后果就是这种假设能够成立,即服务端的接收缓冲区被数据充满时还能让客户端所在的主机的OS把自己发送缓冲区中的数据发给服务端的接收缓冲区,这时就会因为服务端的接收缓冲区已满,导致这些数据无法被服务端的接收缓冲区接收,于是导致这些数据就在网络中被丢弃了,这就非常坑爹了。(然后注意不要混淆一点,接收端的接收缓冲区满了并不会导致发送端在调用send函数时陷入阻塞,只有发送端本地的发送缓冲区满了,发送端调用send函数才会陷入阻塞,所以【当接收端的接收缓冲区已满的情况下,发送端所在的主机的OS根据TCP协议是不会也不能再把发送端的发送缓冲区中的数据发送到网络进而发给接收端的接收缓冲区的】并不代表发送端不能调用send函数把自己应用层的数据放到自己的发送缓冲区中,只要发送端的发送缓冲区没有满,那么发送端就能调用send函数把自己应用层的数据放到自己的发送缓冲区中)

有人可能会说【怎么就坑爹了?TCP协议不是具有可靠性的吗,这些数据丢了让客户端重传不就行了?】,这里笔者想说,的确,如果最初设计者想这么设计TCP协议,那从技术角度上来说是一定能实现的,并且这样设计TCP协议还会使得设计变得更容易,但问题来了,对于这些被丢弃的数据,它们有什么错?这些数据从客户端到服务端经过这么多中间设备,遭受到了这么多白眼,千里迢迢地跑到服务端,却因为你服务端的接收缓冲区满了,因为你服务端的过失,导致我数据被你服务端无情丢弃,我作为数据有什么错?更重要的是,整个互联网中绝大多数主机在跨网络通信时都会使用TCP协议,如果大家都像这样无脑发送数据导致接收端无法再接收数据,进而导致数据在网络中丢包,哪怕网络中只有10%到20%的数据会因为这种原因丢包,那也是对网络资源以及其他资源极大的浪费,你比如说这么多中间设备在转发数据包时需不需要CPU资源?需不需要内存资源?需不需要费电?都是需要的,所以既然我客户端发送给你服务端的报文数据是没有问题的,你服务端就不应该丢弃这个报文,虽然从技术层面上是能将TCP协议设计成像【让服务端先丢弃报文,后序让客户端重传报文】这样的模式,但从技术上能实现并不代表实现出来后在效率上是合理的,可以明显发现这样的资源利用效率是很低的,很浪费各类资源,那什么样才是合理的呢?

如果接收端的接收缓冲区已经无法接收数据了,那就不要再让发送端的发送缓冲区发送数据了,这才是比较合理的。如何做到当接收端的接收缓冲区无法接收数据时让发送端的发送缓冲区不再发送数据呢?可以这样做到:对于客户端和服务端双方来说,不管是谁给谁发信息,每当一方给另一方发信息时,都把自己的接收能力告诉对方,即把自己接收缓冲区还有多少剩余空间告诉对方(注意必须是把接收缓冲区中剩余的空间告诉对方,而不是把接收缓冲区中的总空间告诉对方,因为总空间大并不一定代表剩余空间大,可能我总空间不如你大,但是我剩余的空间比你大,那我的接收能力当然比你更强,所以接收缓冲区的总空间大小并不能决定某一方的接收能力,决定某一方接收能力大小的是该方接收缓冲区中剩余空间的大小),比如客户端给服务端发送TCP请求时,就把自己接收缓冲区中剩余空间的大小信息也夹带在TCP请求中,比如客户端告诉服务端自己还有4KB的剩余空间,告诉服务端心中要有数,你服务端的发送缓冲区最多再给我的接收缓冲区发4KB的数据,然后你服务端就不要给我发了,其中客户端把自己接收缓冲区中剩余空间的大小信息夹带在TCP请求中就是通过把该大小信息填充到【TCP请求的报头中的16位窗口大小字段】上实现的。

所以问题【如何做到当接收端的接收缓冲区无法接收数据时让发送端的发送缓冲区不再发送数据呢?】的答案也就出来了,TCP协议规定通信的双方都要根据对方的接收能力(即对方的接收缓冲区中剩余空间的大小)来决定自己的发送缓冲区发送数据时的数据大小,比如客户端给服务端发送TCP请求时,就把自己接收缓冲区中剩余空间的大小信息填充到TCP请求的报头中的16位窗口大小字段上,以告诉服务端自己的接收能力,这样一来服务端在给客户端发送TCP响应时就知道自己发送的TCP响应的大小最多不该超过多少字节了;服务端给客户端发送TCP响应时,也把自己接收缓冲区中剩余空间的大小信息填充到TCP响应的报头中的16位窗口大小字段上,以告诉客户端自己的接收能力,这样一来客户端在给服务端发送TCP请求时就知道自己发送的TCP请求的大小最多不该超过多少字节了。这种控制通信双方发送数据的大小的策略就叫做流量控制。


(如果不理解当前这一段以及下几段的内容,是需要先阅读完下文的6位标志位字段和3次握手的知识点后再回头看的)问题:主机A的OS根据TCP协议第一次把自己发送缓冲区中的数据发给主机B的接收缓冲区时,按理来说此时主机B还从未给主机A发过数据,主机A也就不知道主机B的接收缓冲区中剩余空间的大小,那么主机A的OS根据TCP协议第一次把自己发送缓冲区中的数据发给主机B的接收缓冲区时,它怎么知道自己最多可以发多大字节的数据呢?会不会出现主机B的接收缓冲区只有4KB的大小,但主机A的OS因为不知道只有4KB,于是一次性把自己发送缓冲区中6KB的数据发给主机B导致部分数据被丢弃呢?

答案:想要让双方都知道对方接收缓冲区中剩余空间的大小,那么双方至少要交换一次报文,注意交换报文并不等于【A要给B发送一次带有正文数据的报文,B要给A发送一次带有正文数据的报文】,早在A第一次给B发送数据前,A和B双方在建立连接进行3次握手时,双方完成第2次握手后就已经交换过报文了,比如A给B发送SYN时,这个SYN标志位字段为1的报文里,16位窗口大小字段就填充的是A的接收缓冲区中剩余空间的大小,再比如B给A发送SYN+ACK时,这个SYN和ACK标志位字段为1的报文里,16位窗口大小字段就填充的是B的接收缓冲区中剩余空间的大小,此时双方就都知道了对方的接收缓冲区的大小了,等到双方3次握手结束建立好连接后,双方就可以发送带有正文数据的报文了,此时双方的OS就知道自己把发送缓冲区中的数据发给对方时这个数据最多不应该超过多少了。

在下文讲解3次握手和RST标志位时我们说过,如果双方的3次握手没有完成时(即双方的连接没有建立成功时),就有一方给另一方发送带有正文数据(或者说有效载荷)的报文,那么接收到该报文的一方就会给发送方发送RST标志位为1的TCP报文,目的是告诉发送方先重新和我进行整个3次握手以完成建立连接,再给我发送带有正文数据(或者说有效载荷)的报文,也就是说,一方想给另一方发送带有正文数据的报文前需要双方先进行3次握手,否则就会发送失败,理解了上一段的内容后我们就知道了发送带有正文数据的报文前需要先进行3次握手的原因之一(注意只是原因之一,而不是全部原因)就是:不先进行3次握手可能会导致发送方把自己发送缓冲区中的数据发给接收方的接收缓冲区时一次发太多了,接收方的接收缓冲区接收不下,导致部分数据只能被丢弃,但数据在网络中转发是需要网络、内存等资源的,我数据是正常的数据,千里迢迢历经千辛万苦才跑到你这里,你却将我丢弃,这就会导致各类资源的利用效率很低。

注意,上一段内容就证明了只要是在3次握手的过程中,不管是客户端发给服务端的TCP请求,还是服务端发给客户端的TCP响应,都一定是只有报头部分,而没有响应正文部分或者请求正文部分的。


问题:根据流量控制策略,发送端收到接收端发来的TCP报文后,发现其中的16位窗口大小字段上填充的值是0,于是发送端就知道了接收端的接收缓冲区已经满了,发送端就不会再把自己发送缓冲区中的数据发送给接收端的接收缓冲区了,而是进行等待,等到接收端的接收缓冲区不满时(比如接收缓冲区被上层应用层调用read或者recv取走了一部分数据),然后再发送。问题来了,发送端在等待期间,如何知道什么时候接收端的接收缓冲区不为满呢?

答案:首先要知道的是,即使接收端的接收缓冲区满了,发送端也是能给接收端发送不带有正文数据的TCP报文的,同时接收端也能接收成功。然后进入正题,TCP协议中会有两种策略,如上图所示,第一种叫做窗口探测,就是当接收端的接收缓冲区满了导致发送端无法将自己发送缓冲区中的数据发给接收端时,发送端会定期给接收端发送一个不带有正文数据的TCP报文(或者换句话说就是发送一个TCP报头),因为任何一端接收到一个报文后,根据TCP协议的规定都是必须进行应答的,所以接收端也会给发送端发送一个表示应答的TCP报文,同时因为根据TCP协议的规定,不管是什么报文,每个报文被发送前都是需要填充报头中的各个字段的,就包括16位窗口大小字段,于是当发送端收到这个表示应答的TCP报文后,就知道了接收端的接收缓冲区是什么情况了。可以看到,因为发送端会定期询问,所以当接收端的接收缓冲区不满时,发送端很快就能知道,然后就能继续发送数据了。

第二种叫做窗口更新通知,说白了就是当接收端的接收缓冲区不满时,接收端就会主动给发送端发送一个不带有正文数据的、16位窗口字段不为0的TCP报文(或者换句话说就是发送一个TCP报头),以告诉发送端可以将发送缓冲区中的数据发给接收端的接收缓冲区了。

流量控制策略在实际工作模式中会一起使用以上两种策略。


以上就是【流量控制策略】和【TCP报文的报头中的16位窗口大小字段】的全部内容了。

6位标志位字段(包含16位紧急指针字段、3次握手的知识点)

ee9c3cac01b74f75b7d30b27797ba8a6.png

如上图,保留字段右边的URG、ACK等等6个英文就是6位标志位字段。(说一下,每个标志位本质就是一个#define的宏替换)

问题:为什么一个TCP报文的报头需要这么多个标志位字段呢?

答案:TCP报文分为TCP请求和TCP响应,以TCP请求举例,不要认为客户端发给服务端的TCP请求全是同一种类型,我们平时客户端给服务端发送信息时,代表这些信息的TCP请求被称为常规报文。除了常规报文外,某些TCP请求可能表示的是建立连接的报文、也可能表示的是断开连接的报文、也可能是用于确认应答的报文,也有可能是其他类型的报文。总之,服务端收到的TCP请求可能是各种类型的报文,所以服务端就需要通过TCP请求的报头中的6位标志位字段确定该TCP请求是什么类型的报文,然后服务端才能根据TCP请求的报文是什么类型的从而做出与之匹配的动作。TCP响应也是同理,所以也不要认为服务端发给客户端的TCP响应全是同一种类型。


6位标记位中的每个标记位含义如下:

  • SYN: 如果SYN标志位为1,不为0,则表示该TCP报文是一个用于建立连接的报文,收到该报文的一方就会根据一些建立连接的策略或者机制决定是否和发起该TCP报文的一方建立连接,如果决定是,则就根据策略自动建立连接。
  • FIN:是finish的前3个字母,如果FIN标志位为1,不为0,则表示该TCP报文是一个用于断开连接的报文,所以换言之如果TCP报文是一个用于正常通信的常规报文或者是用于建立连接的报文,则该报文的FIN标志位一定是0。说一下,调用close函数就会发送FIN标志位为1的TCP请求或者响应报文。
  • ACK:在上文中我们介绍过一种确认应答策略,所以如果某个TCP报文具有应答属性(即该TCP报文既表示对对端的应答,又表示回复给对端的正文内容;或者换句话说一个报文给对端给予应答时捎带了正文数据就叫做该TCP报文具有应答属性),或者该TCP报文就是一个纯粹的应答(即该TCP报文只有报头,没有请求正文或者响应正文),则该报文的ACK标志位就一定是1,而不为0。网络中双方通信时互相传输的大多数报文都是具有应答属性的,所以网络中大多数报文的ACK标志位都是设置成1了的。哪些报文的ACK标志位没有被设置成1呢?比如说客户端和服务端通信时,客户端向服务端发送的第一条TCP报文肯定是表示建立连接请求的报文,所以该报文的ACK标志位肯定就是0了。
  • PSH:在上文中我们介绍过一种流量控制策略,根据这部分内容的叙述我们可以理解,客户端和服务端双方在通信时,每次交换的报文中都会带有16位窗口大小字段以告诉对方自己的接收缓冲区中还有多少剩余空间,如果某次客户端收到服务端的TCP响应报文时,根据TCP响应报文的16位窗口大小字段发现服务端的接收缓冲区已经满了,没有剩余空间了,那么此时客户端所在的主机的OS根据TCP协议中的流量控制策略就不会再把客户端的发送缓冲区中的数据发到网络进而发给服务端的接收缓冲区了(注意这并不代表客户端不能调用send函数把自己应用层的数据放到自己的发送缓冲区中,只要客户端的发送缓冲区没有满,那么客户端就能调用send函数把自己应用层的数据放到自己的发送缓冲区中),而是让客户端进行等待,等服务端的接收缓冲区中有剩余的空间(或者说是等服务端的应用层调用read/recv把服务端的接收缓冲区中的数据取走一部分或者全部取走),可是客户端等啊等啊等了很长的时间后,服务端的接收缓冲区依然没有就绪,然后客户端就着急了,我总不能一直等吧,此时客户端就会给服务端发一个报头中的PSH标志位为1的TCP请求(该TCP请求没有请求正文部分,只有报头部分),PSH的全称是PUSH,表示督促对方尽快将接收缓冲区中的数据向上交付。等到服务端收到这个TCP请求后,发现PSH标志位为1,服务端就知道客户端已经很着急了,我需要赶快把接收缓冲区中的数据向上交付了。至于怎么赶快交付,现在还说不清楚,这个需要在以后讲解多路转接时才能进行说明。
  • URG:在上文中我们介绍过一种按序到达策略,根据这部分内容的叙述我们可以理解,客户端和服务端双方在通信时,可能一方会给另一方一次批量化地发很多条TCP报文,因为这些TCP报文在进行路由转发时的路线可能不一样,所以会导致发送端发送报文的顺序和接收端接收报文的顺序没有任何关系,为了保证接收端接收报文的顺序和发送端发送报文的顺序一致(因为如果顺序不一致,收到的报文是乱序的,那就会引发一系列错误,至于是什么错误已经在上文中进行过讲解了,这里不再赘述),接收端是需要将接收到的乱序的报文根据32位序号进行排序的,排完序后再把这些数据放到接收端的接收缓冲区里,后序应用层调用read或者recv函数从接收缓冲区中读取到的数据就是有序的了。但在有一种场景下,比如发送端先发送了几个常规的报文,但突然因为某种紧急情况,导致发送端要给接收端发送一个比较紧急的报文,此时因为TCP协议中有按序到达策略,所以即使这个报文非常紧急,这个报文被接收端接收到后也会被OS排序在较靠后的位置,于是紧急报文中的正文内容(或者说有效载荷)就会被放在接收端的接收缓冲区中较靠后的位置上,这样一来该紧急报文中的正文内容被向上交付的时间点当然也会较晚(被向上交付的原因是上层应用层调用了read或者recv函数),这就不合理了,所以为了在这种情况下能让紧急报文可以“插队”,而不让紧急报文也遵守按序到达的策略,于是TCP报文的报头中就诞生了URG标志位,URG全称urge,表示紧急的意思。URG标志位字段需要配合报头中的16位紧急指针字段进行使用,说一下,紧急报文中的正文数据(或者说有效载荷)并不全是紧急数据,16位紧急指针字段上的值是一个偏移量(单位是字节),而不是一个地址,只有从紧急报文的正文数据的开头开始,往后偏移【16位紧急指针字段上存储的值】个字节后的1个字节才是紧急数据。当某一端接收到【报头中的URG标志位为1的TCP报文】后,发现URG标志位为1,于是这一端就知道了报文中的16位紧急指针字段是有效的,然后这一端就根据偏移量找到这1个字节的数据,然后把该数据插队放到自己的接收缓冲区的头部,这样这1个字节的紧急数据就能够尽快的交付给上层了。
  • 剩下的RST标志位在目前阶段还不容易说清楚,需要先讲完3次握手这个知识点,等到讲解完毕后,回头看RST标志位就非常容易了,所以接下来先讲解3次握手。

3次握手

客户端和服务端在基于TCP协议进行正式通信前,双方要先建立连接(比如客户端要调用connect,服务端要调用listen和accept),然后才能正式通信,这个建立连接的过程就叫做3次握手。

问题:如何理解上一段中所说的双方建立的这个连接呢?

答案:因为将来可能有大量的客户端(不同主机上的客户端)都要连接唯一的服务端,所以服务端进程所在的主机一定会存在大量的连接,服务端所在的主机的OS要不要管理这些连接呢?肯定是要的,管理的方式也是先描述,再组织。所谓的连接本质其实就是内核中的一种数据结构类型的对象,服务端建立连接成功的时候,就是在内存中创建对应的连接对象,然后再把多个连接对象组织成链表或者其他数据结构,后序服务端管理这么多个连接就是通过对该数据结构的增删查改。

综上我们还可以得到一个结论:既然连接本质就是一种数据结构类型的对象,不管是客户端还是服务端,建立连接成功的时候就是在内存中创建对应的连接对象,那么可以发现客户端或者服务端维护连接一定是需要内存资源和CPU资源的(任何指令都是CPU执行的,包括创建连接对象和后序进行维护的指令,所以需要CPU资源)。


3次握手的具体流程如下图。要注意的是客户端需要完成3次握手,服务端也需要完成3次握手,只有双方都完成了3次握手后,双方的连接才能建立连接成功,所以当只有一方比如只有客户端完成了3次握手但服务端没有完成,那么连接是无法建立成功的。

说一下,只要是在3次握手的过程中,双方发送的TCP请求报文和TCP响应报文一定只有报头部分,而没有响应或者请求正文部分,详细原因在上文讲解的16位窗口大小字段的结尾部分。

  • 首先客户端会给服务端发送一个报头中的SYN标志位为1的TCP请求,其中SYN标志位为1表示我客户端想和你服务端建立连接,发送SYN是客户端的第一次握手
  • 当服务端收到客户端发的SYN后(收到SYN是服务端的第一次握手),服务端就会给客户端发送一个报头中的SYN标志位和ACK标志位都为1的TCP响应,其中SYN标志位为1表示我服务端愿意和你客户端建立连接,ACK标志位为1表示本次TCP响应是对之前客户端发来的TCP请求的应答,发送SYN+ACK是服务端的第二次握手
  • 最后当客户端收到服务端发的SYN+ACK后(收到SYN+ACK是客户端的第二次握手),客户端就会再给服务端发送一个报头中的ACK标志位为1的TCP请求,其中ACK标志位为1表示本次TCP请求是对之前服务端发来的TCP响应的应答,发送ACK是客户端的第三次握手最后当服务端收到这个ACK后,服务端也就完成了自己的第三次握手
  • 以上就是双方3次握手的全部流程了,说一下,不要认为3次握手是一定能握手成功的,它是有可能失败的。比如说客户端第一次发送SYN时,服务端就关机,3次握手当然会失败了,当然除了这种情况,肯定也是有其他情况导致3次握手失败的。不过也不必担心,双方3次握手建立连接失败时,会重新进行整个3次握手。

客户端和服务端在3次握手建立连接时的状态变化如下图。这里的状态是什么意思呢?和进程的状态(比如阻塞,运行态)一样,这些状态本质都是一个宏、是一个整数,客户端和服务端所在的主机都会各自在内核中维护自己的连接状态信息(注意虽然此时客户端和服务端的连接并未建立,双方主机中也都没有各自创建出连接对象这种内核数据结构,但这不代表客户端和服务端所在的主机不会记录自己的连接状态信息,是会维护这些信息的)。对下图的流程进行一下说明:

  • 最开始时客户端和服务端都处于CLOSED状态。
  • 服务端为了能够接收客户端发来的连接请求,就需要调用listen函数将自己的状态由CLOSED状态变为LISTEN状态。
  • 服务端将自己变成LISTEN状态后,此时客户端就可以和服务端进行三次握手了,当客户端发起第一次握手,即发起SYN后,状态就会变为SYN_SENT状态。
  • 处于LISTEN状态的服务端收到客户端的连接请求SYN后,就向客户端发起第二次握手,即发送SYN+ACK,当发送完毕后,服务器的状态就会变成SYN_RCVD。
  • 当客户端收到服务器发来的第二次握手后,就会向服务器发送最后一次握手,即发送ACK,发送完毕后,客户端的连接就已经建立成功了(无所谓服务端是否受到了客户端发的ACK,只要客户端发送了ACK,客户端的连接就建立成功了),状态就变为了ESTABLISHED。
  • 等到服务端收到客户端发来的最后一次握手ACK后,服务端的连接也就建立成功了,此时服务端的状态也变成ESTABLISHED。


问题:为什么客户端和服务端都要各自进行3次握手才能保证双方建立连接成功呢?1次握手行不行呢?2次和4次行不行呢?

答案:先从建立连接的安全角度上进行说明,再从验证通信是否是全双工的角度上进行说明。

从安全角度上进行的说明如下:

  1. (结合上图思考)如果是2次握手就能让双方建立连接成功,客户端发送SYN后,服务端收到该SYN就完成了自己的第一次握手,服务端发送SYN+ACK后,即使此时客户端没有收到这个SYN+ACK,服务端也完成了自己的第二次握手,2次握手后服务端也就单方面建立连接成功了,即服务端的连接对象就创建成功了,服务端就需要维护这个连接对象。注意此时通过黑客的编码控制,黑客编写的客户端是可以无视服务端发来的这个SYN+ACK的,这就意味着我作为黑客只需要一台主机就可以0成本地把服务器搞崩溃,比如我不断地向服务端发送SYN请求,然后服务端就需要不断地创建并维护新的连接对象,在上文中说过创建并维护新的连接对象是需要内存资源的,于是服务端的内存就会一点一点被吃掉,然后服务器就挂掉了。可以发现,因为我客户端每次都无视了SYN+ACK,于是我客户端就不会建立连接成功,于是客户端所在的主机就不需要创建并维护连接对象,所以是一点内存资源都不需要占用的,所以黑客编写的客户端就可以0成本地把服务器搞崩溃了。这样的攻击方式就叫做SYN洪水攻击。
  2. (结合上图思考)如果是1次握手就能让双方建立连接成功,显然这是不安全的,因为SYN可以伪造,而你服务器又不验证(因为一次握手无法交互进行验证),别人发一个SYN给你服务器,你服务器就建立连接成功并创建连接对象,这样别人通过一定的手段给你服务器发海量的SYN,你服务器一瞬间就崩溃了,所以一次握手就让双方建立连接成功是很不安全的,很容易受到攻击,所以一次握手不行。
  3. (结合上图思考)如果是3次握手就能让双方建立连接成功,对比【2次握手就能让双方建立连接成功】的情况,可以发现要想让服务端能建立连接成功、想让服务端创建连接对象,服务端就必须接收到客户端发来的ACK(因为这样服务端才完成了第三次握手),而当客户端发送完这个ACK后,根据上图可以发现此时客户端也完成了自己的3次握手,客户端也建立连接成功了,也需要在内核中创建连接对象。所以此时黑客就无法再仅仅通过一台主机把服务器搞崩溃了,因为你黑客想让我服务端创建连接对象,那你黑客所在的主机也得创建连接对象,所以换句话说如果你黑客想攻击我服务器,那你的主机就需要和我服务器承受同样的代价,而一般来说服务器的配置都是高于个人的主机的,所以想通过SYN洪水的方式攻击服务器,仅仅靠黑客的一台主机是不行的。同时如果是3次握手才能让双方建立连接成功,建立连接异常时还能把风险转移到客户端上,比如说客户端发起第三次握手,即发起最后的ACK时,如果ACK在网络中丢失了,那么也只有客户端的连接建立成功了,只有客户端会在内核中创建连接对象,而服务端因为没有受到ACK,是不会建立连接成功并创建连接对象的,这就叫做把风险转移到客户端上。
  4. (结合上图思考)如果是4次握手就能让双方建立连接成功,那么会出现和【2次握手就能让双方建立连接成功】一样的问题,都会让黑客只需要一台主机就可以0成本地把服务器搞崩溃,因为服务端进行第四次握手时,假设第四次握手发送的报文类型是ACK,那么服务端发完ACK后就会立刻成功建立连接并创建连接对象从而占用一些内存,但客户端却可以无视服务端发来的这个ACK,导致客户端没有进行第四次握手从而没有建立连接成功,从而不需要创建连接对象,所以也是一点内存资源都不需要占用的,所以黑客就又可以通过自己编写的客户端不断给服务端发送SYN从而0成本地把服务器搞崩溃了。

说一下,规定双方都要各自进行3次握手才能建立连接成功的主要目的并不是为了防止恶意攻击,3次握手并不能防止比较强力的恶意攻击,只是提高了一点门槛,让一个小白用户无法通过小白软件成功地攻击我服务器。如果想要破解掉3次握手,黑客可以通过这样的方式:搞一个链接出来并放到各个视频的评论区里,点击该链接后会自动下载并运行黑客编写的木马程序,当有大量用户都因为好奇点击该链接后,这些用户的电脑就会被感染并在后台运行木马程序,黑客就可以通过木马程序操控这些用户的电脑,木马的逻辑为不断获取系统时间,到了12点整就访问指定服务器,如果木马感染了10万台主机,控制10万台主机访问指定服务器,那么即使这些主机在访问服务器时都是正常进行3次握手,也会让服务器一瞬间挂掉了,这同样是SYN洪水。所以想要让双方建立的连接是安全的,光靠一个3次握手是无法搞定的,需要专门的策略(比如设置黑名单,accept建立连接成功后发现该连接的ip是在黑名单里,就立刻断开连接等)来保证安全。规定双方都要各自进行3次握手才能建立连接成功的主要目的是验证通信是否是全双工的。

再从验证通信是否是全双工的角度上进行的说明如下:

  1. (结合上图思考)如果是3次握手就能让双方建立连接成功,对于客户端来说,客户端进行第一次握手后,即给服务端发送SYN后,如果客户端再收到了服务端发来的SYN+ACK,因为收到了SYN+ACK,那么客户端就知道了自己能收数据,同时因为收到的是SYN+ACK,其中ACK是具有应答含义的,那么客户端就知道了自己曾经发的SYN被服务端收到了,那么客户端也就知道了自己能发数据;对于服务端来说,因为最初服务端收到了客户端发送的SYN,那么服务端就知道了自己能收数据,服务端给客户端发送SYN+ACK后,因为服务端收到了客户端发来的ACK,其中ACK是具有应答含义的,那么服务端就知道了自己曾经发的SYN+ACK被客户端收到了,那么服务端也就知道了自己能发数据。综上可以发现,客户端和服务端都知道了自己既能发数据也能收数据,也就验证了通信是全双工的、信道是通畅的。
  2. (结合上图思考)如果是2次握手就能让双方建立连接成功,对于客户端来说,客户端进行第一次握手后,即给服务端发送SYN后,如果客户端再收到了服务端发来的SYN+ACK,因为收到了SYN+ACK,那么客户端就知道了自己能收数据,同时因为收到的是SYN+ACK,其中ACK是具有应答含义的,那么客户端就知道了自己曾经发的SYN被服务端收到了,那么客户端也就知道了自己能发数据;对于服务端来说,因为最初服务端收到了客户端发送的SYN,那么服务端就知道了自己能收数据,服务端给客户端发送SYN+ACK后,因为只能进行两次握手,所以服务端不会收到客户端发来的ACK,那么服务端就不知道自己曾经发的SYN+ACK是否被客户端收到了,那么服务端也就不知道自己能不能发数据。综上可以发现,客户端是知道自己既能发数据也能收数据,但服务端只能知道自己能收数据,并不知道自己能不能发数据,所以验证不了通信是全双工的。
  3. (结合上图思考)如果是1次握手就能让双方建立连接成功,对于客户端来说,因为不会收到服务端的SYN+ACK,所以客户端既无法知道自己能不能发数据,也不知道自己能不能收数据;对于服务端来说,因为服务端收到了客户端发来的SYN,所以服务端是知道自己可以收数据的,但因为服务端不会给客户端发SYN+ACK,所以服务端也就更不会收到客户端发来的ACK,所以服务端是无法知道自己能不能发数据的。综上可以发现,客户端是既无法知道自己能不能发数据,也不知道自己能不能收数据的;服务端只能知道自己能收数据,但并不知道自己能不能发数据,所以验证不了通信是全双工的。

根据上文【从安全角度上进行的说明】我们可以发现,如果握手次数是奇数次(1次除外),那么黑客进行攻击时,双方都需要承担同样的成本;而如果握手次数是偶数次,那么当黑客进行攻击时,只有服务端需要承担成本。所以这就告诉我们一个道理,握手的次数是偶数次一定不行的,都不安全,一定只能是奇数次。那有人就会说【5、7、9....也是奇数,双方建立连接时可不可以是5、7、9....次握手呢?】这里笔者想说,你要清楚握手的目的,握手的主要目的是为了确定通信是否是全双工的,握手的次数选择奇数次只是为了让服务器变得不那么好攻击(即变得不能随便找一个小白用户通过小白软件就能攻击我服务器),既然3次握手就已经能验证通信是否是全双工的了,为什么还要选择5、7、9...次呢?即使选择了它们,唯一的意义也只有浪费各类资源罢了(比如转发数据包是需要内存资源,网络资源的),所以3次握手就是最优解了,不要想一些乱七八糟的。这就是为什么客户端和服务端都要各自进行3次握手才能保证双方建立连接成功。


以上就是关于3次握手的全部内容了,讲解完3次握手后,咱们就能够理解最后剩余的一个标志位了,那就是如下图1中的RST标志位。如下图2是3次握手的流程图,在上文中我们说过对于客户端,只要客户端给服务端发送了ACK,那么即使服务端还没有收到这个ACK,客户端的第三次握手也已经进行完毕了、客户端的连接也已经建立成功了。如果此时客户端发给服务端的ACK真在网络中丢包了,那么服务端因为没有收到ACK,于是服务端就没有完成第三次握手,于是服务端就无法建立连接成功,但站在客户端的视角,我客户端是建立连接成功了的,那我就可以给你服务端发带有请求正文的常规TCP请求报文,此时当服务端收到该TCP请求后就会很疑惑,我的连接都还没有建立好,你就给我发常规的TCP请求报文,这不符合3次握手的规矩啊?然后很快服务端就聪明地想到了,一定是客户端给我发了一个ACK,但这个ACK在网络中丢失了导致我没有收到,于是服务端就会先关闭掉自己的这个没有建立完全的连接,然后给客户端发送一个报头中的RST标志位为1的TCP响应报文,表示告诉你客户端,你客户端需要先关闭这个和我服务端建立完毕了的旧的连接,然后重新和我服务端进行3次握手以完成建立连接(RST表示reset,重置连接的意思)。

所以综上所述,在服务端的连接还没有建立成功、但客户端的连接建立成功了并且客户端向服务端发送常规的带有请求正文的TCP请求时,服务端就会给客户端发送RST标志位为1的TCP响应表示让客户端重新和我服务端进行3次握手;或者在服务端的连接建立成功、客户端的连接也建立成功并且双方已经进行过一段时间的通信时,因为服务器拔网线或者拔电源导致服务端的连接对象被销毁、即导致服务端的连接断开了,这时因为客户端的连接依然是正常的(为什么是正常的会在下文标题为“TCP异常情况”的部分中说明),于是客户端依然给服务端发送常规的带有请求正文的TCP请求,如果此时服务端很快的恢复了网络或者重启了,当服务端收到TCP请求后,就也会给客户端发送RST标志位为1的TCP响应表示让客户端重新和我服务端进行3次握手。

  • 图1如下。ee9c3cac01b74f75b7d30b27797ba8a6.png
  • 图2如下。 

4次挥手

在上文讲解3次握手时我们说过连接的本质是一种内核数据结构类型的对象,所以双方维护连接都是需要内存成本的,所以双方在基于TCP协议进行通信完毕后就需要断开连接,断开连接的过程就叫做四次挥手。

4次挥手的具体流程如下图。

要注意的是客户端需要完成4次挥手,服务端也需要完成4次挥手,只有双方都完成了4次挥手后,双方的连接才能都断开成功,双方在内核中的连接对象才能都被销毁,所以当只有一方比如只有客户端完成了4次挥手但服务端没有完成,那么连接是无法断开成功的。

  • 首先客户端会给服务端发送一个报头中的FIN标志位为1的TCP请求(除了报头部分,该TCP请求还可能有请求正文部分,当然也可能没有请求正文部分),其中FIN标志位为1表示我客户端想和你服务端断开连接,发送FIN是客户端的第一次挥手
  • 当服务端收到客户端发的FIN后(收到FIN是服务端的第一次挥手),服务端就会给客户端发送一个报头中的ACK标志位为1的TCP响应(除了报头部分,该TCP响应还可能有响应正文部分,当然也可能没有响应正文部分),其中ACK标志位为1表示本次TCP响应是对之前客户端发来的TCP请求的应答,发送ACK是服务端的第二次挥手
  • 当客户端收到服务端发的ACK后,就完成了客户端的第二次挥手
  • 然后服务端会给客户端发送一个报头中的FIN标志位为1的TCP响应,其中FIN标志位为1表示我服务端想和你客户端断开连接,发送FIN是服务端的第三次挥手
  • 当客户端收到服务端发的FIN后(收到FIN是客户端的第三次挥手),客户端就会再给服务端发送一个报头中的ACK标志位为1的TCP请求(除了报头部分,该TCP请求还可能有请求正文部分,当然也可能没有请求正文部分),其中ACK标志位为1表示本次TCP请求是对之前服务端发来的TCP响应的应答,发送ACK是客户端的第四次挥手最后当服务端收到这个ACK后,服务端也就完成了自己的第四次挥手
  • 以上就是双方4次挥手的全部流程了,说一下,不要认为4次挥手是一定能挥手成功的,它是有可能失败的。比如客户端最后一次挥手时,即给服务端发送ACK时,如果这个ACK丢了,那么只有客户端的连接断开成功了,但服务端的连接没有断开成功,此时双方的4次挥手就失败了。不过也不必担心,即使挥手失败,TCP协议也是有相关策略补救的,比如服务端发现客户端长时间不来访问我,那客户端就会自动断开自己的连接,此时双方的连接就都断开了。

客户端和服务端在4次挥手断开连接时的状态变化如下图。

这里的状态是什么意思呢?和进程的状态(比如阻塞,运行态)一样,这些状态本质都是一个宏、是一个整数,客户端和服务端所在的主机都会各自在内核中维护自己的连接状态信息(即使连接对象正在被销毁或者已经被销毁了,在内核中也是会有其他字段存储连接状态信息的)。对下图的流程进行一下说明:

  • 最初在挥手前客户端和服务端都处于连接建立后的ESTABLISHED状态。
  • 客户端为了与服务端断开连接主动向服务器发起连接断开请求(即FIN标志位为1的TCP请求),发送后客户端的状态立刻变为FIN_WAIT_1。
  • 服务端收到客户端发来的连接断开请求FIN后,会给客户端发送ACK进行响应,发送后服务端的状态立刻变为CLOSE_WAIT。
  • 然后服务端会向客户端发起断开连接的请求FIN,发送后服务端的状态立刻变为LASE_ACK。
  • 客户端收到服务端发来的FIN后,会给服务端发送最后一个响应报文ACK,发送后客户端立刻进入TIME_WAIT状态,然后会等待一个2MSL的时间(Maximum Segment Lifetime,报文最大生存时间)才进入CLOSED状态,变成CLOSED状态时,客户端会彻底关闭连接,销毁自己的连接对象。(关于TIME_WAIT状态,下文中会详细介绍)
  • 当服务器收到客户端发来的最后一个响应报文ACK后,服务端会彻底关闭连接,销毁自己的连接对象,变为CLOSED状态。

为什么是四次挥手?

  • 由于TCP是全双工的,建立连接的时候需要建立双方的连接,断开连接时也同样如此。断开连接时,不仅客户端要调用close函数以销毁从客户端到服务端方向的通信信道(即从客户端的发送缓冲区到服务端的接收缓冲区的信道),服务端也要调用close函数销毁从服务端到客户端的通信信道(即从服务端的发送缓冲区到客户端的接收缓冲区的信道),其中每个close就对应两次挥手(即调用一次close就会自动完成两次挥手,因为调用close会发送FIN,而收到FIN的一方会自动响应ACK,于是调用一次close就完成了两次挥手),每两次挥手对应的就是关闭一个方向的通信信道,因此断开连接时需要进行四次挥手。
  • 需要注意的是,对于服务端来说,服务端的四次挥手当中的第二次和第三次挥手不能合并在一起,因为第三次握手是服务端想要与客户端断开连接时发给客户端的FIN,而当服务端收到客户端的FIN并发送ACK后,服务端不一定会马上发起第三次挥手FIN,即不一定会马上调用close函数(在上文中讲FIN标志位时说过FIN是通过调用close函数发送的)销毁从服务端的发送缓冲区到客户端的接收缓冲区的信道,因为服务端的发送缓冲区中可能还有残留的数据要被OS根据TCP协议发送给客户端的接收缓冲区,只有当OS把服务端的发送缓冲区中的这些数据发送完后,才会让服务端向客户端发起第三次挥手FIN。当然这些操作都是OS根据TCP协议进行的,我们用户无法干涉。

当我们通过netstat指令发现服务端具有大量CLOSE_WAIT状态的连接时,原因是什么呢?

(结合上图思考)根据上文讲解4次挥手时的状态变化的过程,我们不难发现服务端有大量CLOSE_WAIT状态的连接一定是因为服务端发送ACK后没有继续发送FIN,而在上文中讲解6位标志位字段中的FIN标志位时我们说过调用close函数就会发送FIN标志位为1的TCP请求或者响应报文,所以服务端具有大量CLOSE_WAIT状态的连接的原因就是服务端的代码编写得有BUG,服务端在给若干个客户端提供服务完毕后没有调用close函数关闭自己创建的若干个服务套接字文件对应的文件描述符service_sock,而只有若干个客户端调用了close,导致双方的后两次挥手无法完成。(额外说一下,这样就会出现一个致命问题,随着有越来越多的客户端连接服务端,服务端就会创建越来越多的服务套接字文件和客户端通信,服务完毕后就会有越来越多的服务套接字文件对应的文件描述符service_sock没有被close关闭,我们以前只知道这会导致可用的文件描述符越来越少,导致文件描述符泄漏的问题;如今我们就还知道了如果服务端不调用close,那服务端的连接就不会断开成功,而是一直处于CLOSE_WAIT的状态,这就会导致服务端所在的主机中存在越来越多的连接对象无法被销毁,进而导致服务端所在的主机中可用的内存资源越来越少,导致服务器卡死)

咱们可以通过编码证明一下【服务端具有大量CLOSE_WAIT状态的连接】的原因是否如同上文所说。如下图1的代码就是一个简单的TCP服务端的代码,将下图1的代码编译好后就直接运行它。然后如下图2,在多个shell中通过telnet命令连接这个服务端进程,连接完毕后什么也不用干,直接quit退出即可,然后再新开一个shell并使用netstat命令查看当前主机中的网络连接情况,此时就会发现服务端进程tcp_server具有多个状态为CLOSE_WAIT的连接(有多少个telnet就会有多少个状态为CLOSE_WAIT的连接),这就证明了上一段所叙述的内容是正确的。最后说明一下常见的疑点:

  • 说明一下,下图1中tcp_server.cc的代码没有创建子进程或者新线程的逻辑,是只有一个执行流的,所以当有多个客户端来连服务端tcp_server时,服务端无法并发地为多个客户端提供服务,只能串行化提供服务,即服务完客户端A后才能再服务客户端B,注意虽然是串行化,但并不影响我们的实验,因为服务端的提供的服务就只是把连接服务端的客户端所bind绑定的ip和port打印出来,然后服务就结束了,所以服务端给每一个客户端提供的服务都会在一瞬间结束,但注意服务结束并不代表双方的连接会断开,比如因为服务端没有调用close函数,客户端也没有调用close函数,所以服务端与每一个客户端建立的连接都是不会断开的。
  • 说明一下tcp_server的状态为什么会是CLOSE_WAIT:作为客户端的telnet进程quit后,telnet进程就退出了,一个进程在退出时,不管是正常退出还是异常退出,该进程的生命都已经结束了,而我们说过一个进程中的文件描述符的生命周期是随进程的,即使该进程中没有close的代码,OS也会在进程的生命结束时自动调用close关闭该进程的文件描述符表中的所有文件描述符,所以telnet在quit时,就会率先给服务端tcp_server发起4次挥手中的第一次挥手,但根据上一段的叙述和上图(4次挥手时状态变化的流程图)我们能够理解此时会因为服务端tcp_server中没有调用close,导致后两次挥手无法完成,于是tcp_server的状态就变成了CLOSE_WAIT。
  • 有人可能会疑惑,下图1中tcp_server.cc的代码并没有创建子进程或者新线程的逻辑,从头到尾只有一个执行流,现在根据上一段的说明我已经能够理解为什么下图2中的tcp_server的状态是CLOSE_WAIT,但我不能理解的是为什么下图2中会出现多个状态为CLOSE_WAIT的tcp_server进程呢?这些tcp_server进程是子进程或者新线程吗?答案:并不是,可以发现在下图2的PID/Program name这一栏中,红框处的tcp_server的PID都是25533,这就证明了4个tcp_server实际上是同一个进程。实际上,netstat命令是用于查看当前主机中的网络连接情况的,里面的4个tcp_server并不表示这是4个子进程或者4个新线程,而表示和tcp_server这1个进程有关的连接有4个,所以不要搞混淆了。
  • 扩展认识一下netstat指令:输入netstat指令后,PID/Program name一栏的值是当前主机上的一个进程A的PID和进程名,表示当前这一行连接是属于进程A的;Local Address表示进程A绑定的ip和端口号; Foreign Address表示连接进程A的【其他主机上的进程绑定的ip和端口号】; State表示当前这一行属于进程A的连接的状态;

图1如下。

图2如下。  

上面的图1的代码如下所示。

#include<iostream>
using namespace std;
#include <unistd.h>//提供close函数
//以下四个文件被称为网络四件套,包含后绝大多数网络接口就能使用了
#include<sys/types.h>//系统库,提供socket编程需要的相关的接口
#include<sys/socket.h>//系统库,提供socket编程需要的相关的接口
#include<netinet/in.h>//提供sockaddr_in结构体
#include<arpa/inet.h>//提供sockaddr_in结构体

//usage:[./tcp_server port]
int main(int argc, char* argv[])
{
    int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in local;
    local.sin_family = AF_INET;
    local.sin_port = htons(atoi(argv[1]));
    local.sin_addr.s_addr = inet_addr("0.0.0.0");
    if (bind(listen_sock, (sockaddr*)&local, sizeof(local)) < 0)
    {
        exit(100);
    }
    listen(listen_sock, 15);
    sockaddr_in peer;
    socklen_t len = sizeof(peer);
    while(1)
    {
        int service_sock = accept(listen_sock, (sockaddr*)&peer, &len);
        if(service_sock > 0)
        {
            string ip = inet_ntoa(peer.sin_addr);
            int port = ntohs(peer.sin_port);
            cout<<ip<<":"<<port<<endl;
        }
    }
    return 0;
}

关于TIME_WAIT状态

(结合上图思考)根据上文讲解的4次挥手时的状态变化过程,我们能够理解如果双方都调用close函数,那么主动先调用close函数的一方A在收到另一方B的FIN后,再给B发送ACK后,主动调用close函数的一方A就会变成TIME_WAIT状态,咱们通过编码来验证一下这一点。

我们的验证思路是:让服务端tcp_server和客户端telnet建立连接完毕后,让服务端主动先调用close函数和客户端断开连接,此时当客户端telnet收到服务端发来的FIN后,会自动调用close函数,双方就自动完成了4次挥手,同时因为服务端是主动close的一方,所以netstat时能看见服务端的连接状态是TIME_WAIT。

根据上面的思路,我们直接先把上面讲解CLOSE_WAIT状态时的tcp_server.cc的代码拿过来,然后增加一行close函数的代码即可,如下图1所示。然后编译并运行服务端,然后如下图2所示通过telnet连接服务端,此时因为服务端和telnet建立连接完毕后会立刻主动调用close以让双方完成4次挥手,所以最后调用netstat就能观察到服务端的TIME_WAIT状态了(因为运行服务端tcp_server时绑定的是8080号端口,所以netstat中local address一栏里的端口号为8080的这一行连接就是属于服务端tcp_sever的,可以看到这一行属于tcp_server的连接就是TIME_WAIT状态)

  • 图1如下。
  • 图2如下。 

为什么TIME_WAIT状态会导致bind失败?

各位在测试自己编写的【基于TCP或者UDP协议的网络服务器】时一定遇到过一种情况,比如说在8080号端口上启动服务端进程后,如果已经有客户端和服务端成功地建立了连接,如果此时ctrl c把服务端进程杀死,那么立刻重新在8080号端口上启动服务端就会失败,只能在其他端口号上比如8081上启动服务端,或者等待几分钟后才能重新在8080号端口上启动服务端。(再次强调一下,这种情况能发生的前提一定是已经有客户端和服务端成功地建立了连接,然后才ctrl c把服务端杀死。如果没有客户端与服务端建立连接,那么服务端启动并用ctrl c把它杀死后,是可以立刻在8080号端口上重启服务端的)

咱们通过编码来复现一下这种情况。复现的流程为:直接把上面讲解CLOSE_WAIT状态时的tcp_server.cc的代码拿过来,如下图1所示。然后如下图2所示,编译并在8080号端口上运行它后,通过telnet命令连接服务端,然后ctrl c杀死服务端进程,此时立刻在8080号端口上重启服务端进程就会失败了。

如下图2所示,今天我们就要知道这种重启失败的情况发生的原因就是因为此时服务端进程的状态是TIME_WAIT。在上文中说过,不管是正常退出还是异常退出,只要进程退出了,OS就会把该进程的文件描述符表中的所有文件描述符close掉,所以服务端被ctrl c杀死后,就会主动先调用close,客户端telnet收到FIN并发送ACK后,根据telnet的编码逻辑也会自动调用close,于是双方就都完成了4次挥手。如下图2所示,此时通过netstat命令就能看见主动先调用close的服务端进入了TIME_WAIT状态,在这种状态下的进程在运行时(结合下图1思考),调用bind函数就会调用失败(下文中会说明为什么会失败),于是服务端进程就因为调用bind失败从而进入if分支调用了exit(100),于是服务端进程就退出了,所以服务端进程重启就失败了。(说一下,调用bind函数失败时,系统并不会自动结束当前进程,而是会继续向下执行代码,所以想让进程在调用bind函数失败时退出,从而达到一种服务端重启失败的效果,就需要程序员编码控制服务端退出,否则服务端立刻在重复的端口上重启是一直能成功的,只不过此时因为服务端bind绑定失败,无法用于接受连接或进行任何其他网络操作,于是导致客户端无法连接服务端以及进行通信)

如何证明上面的这些内容呢?如下图3,通过echo $?查看上一个在系统中运行的进程的退出码,可以发现退出码是100,这说明的确服务端进程是因为exit(100)而退出的,而调用exit的原因又是因为bind函数调用失败,所以也就证明了服务端无法重启的原因的确是因为调用bind函数失败。那如何证明服务端调用bind函数失败的原因是服务端处于TIME_WAIT状态呢?如下图3,之前服务端处于TIME_WAIT状态时,重启一直失败(本质就是调用bind函数失败),而当服务端经过一段时间从TIME_WAIT状态变成CLOSED状态彻底断开连接进而无法在netstat命令中看见服务端后,重启就能成功了(本质就是调用bind函数成功了),这就说明了服务端调用bind函数失败就是因为服务端处于TIME_WAIT状态。

问题:为什么进程处于TIME_WAIT状态时会导致调用bind函数失败呢?

答案:虽然双方的4次挥手已经完成,但是主动close断开连接的一方要维持一段时间的TIME_ WAIT状态,在该状态下,地址信息ip、port依旧是被占用的,所以这一方再想bind绑定已经被占用的ip和port当然就会失败了。

  • 图1如下。
  • 图2如下。
  • 图3如下。

TIME_WAIT状态导致bind失败会引发什么问题呢?又该如何解决呢? 

举个例子,假设在双11或者过年时,京东或者淘宝服务器被10w个客户端连接时没有出现问题,即服务器的内存资源是足够维护并创建10w个连接对象的,但再有1个连接过来时,服务器撑不住了,于是OS就自动将服务端进程杀掉了。此时最直观的做法是立即重启服务端进程以提供服务,但问题在于无法立即重启服务端进程,因为此时就会如同上文中所说,服务端进程异常退出时OS会自动把服务端进程的文件描述符表中的所有文件描述符全都close关闭掉,这里面就包括10w个给客户端提供服务的服务套接字文件对应的文件描述符,每个客户端收到服务端发来的FIN并给服务端发送ACK以完成前两次挥手后,都还会自动调用close给服务端发送FIN以完成后两次挥手,服务端收到FIN并给每个客户端发送ACK后,服务端和每个客户端就全部完成了4次挥手,并且因为服务端是主动close断开连接的一方,所以服务端就进入了TIME_WAIT状态,所以服务器上会存在10w个TIME_WAIT状态的连接(存在大量的TIME_WAIT连接和存在大量的CLOSE_WAIT连接不一样,前者是不会占用内存资源的,因为此时服务端和所有的客户端全部都完成了4次挥手,服务端即将进入CLOSED状态,进入CLOSED状态后连接就彻底断开了,连接对象也会被销毁,所以服务器上的10w个连接对象都即将被销毁,不会再占用内存资源)。在上文中我们证明了当服务端进程处于TIME_WAIT状态时,在【和之前相同的端口号】上重启服务端进程是会失败的(本质是服务端进程调用bind函数失败,然后根据编码逻辑进入if分支调用exit函数导致服务端进程退出),必须在另一个端口号上重启服务端进程(本质就是给服务端进程换一个端口号进行bind绑定)或者是等待几分钟后再在【和之前相同的端口号】上重启服务端进程,才能重启服务端进程成功,这就会导致很严重的问题,如下。

如果选择在另一个端口号上重启服务端进程,因为客户端不知道你换了端口号,所以客户端调用connect函数试图连接服务端时依然会使用旧的端口号,那么就会导致双方的连接无法建立,所以这种选择肯定是不行的;如果选择等待几分钟再在相同的端口号上重启服务端,这也是不行的,因为过节的时候,在淘宝和京东这样的服务器上每秒的交易量都是很大的,可能1秒就是几百上千万元的交易量,你动不动就要等待两分钟,你想想会造成多大的经济损失。

所以可以发现,服务端进程是需要有一种在进程退出后立刻能在相同的端口号上重启的能力的,为此,系统就给我们提供了一个接口,如下图所示。这个接口用于让我们在通过socket函数创建监听套接字listen_sock后,给这个listen_sock设置属性,比如说参数sockfd、level、optname表示的意思就是给指定的文件描述符(对应sockfd参数)在套接字层(对应level参数)上设置地址复用的功能(对应optname参数),把地址复用的功能打开。这个时候即使服务端进程和上面的情况一样挂掉了并且变成了TIME_WAIT状态,但因为TIME_WAIT状态不会再被使用了(即服务端会直接绕过TIME_WAIT状态的判断,比如说调用bind函数时,该函数内部肯定会进行if判断的,比如if查看需要绑定的端口号是否已经被占用了,比如if查看正在进行绑定的当前进程是否处于TIME_WAIT状态,调用setsockopt函数就是不让OS进行if判断,直接绕过TIME_WAIT状态的判断),所以这时服务端进程重启时bind绑定和之前相同的端口号是能bind成功的,于是服务端进程就能立刻在相同的端口号上重启了。

咱们通过编码来证明上一段的内容。如下图1所示,直接先把上面讲解CLOSE_WAIT状态时的tcp_server.cc的代码拿过来,然后增加红框处的代码即可。然后如下图2所示,编译并在8080号端口上运行它后,通过telnet命令连接服务端,然后ctrl c杀死服务端进程,然后netstat观察服务端进程tcp_server,可以发现它的状态是TIME_WAIT,但因为此时服务端进程是调用了setsockopt函数打开了地址复用的功能的,所以这个TIME_WAIT状态已经是不会再起作用的了,此时立刻在和之前相同的8080号端口上重启服务端进程是能重启成功的。

所以从今天开始,我们以后编写任何TCP或者UDP网络服务器时,在服务端进程中调用socket函数创建套接字后,都是需要增加下图1红框中的两行代码的,以给该套接字打开地址复用的功能,这样才能保证服务端进程挂掉后能立刻在相同的端口号上重启。

  • 图1如下。
  • 图2如下。 

超时重传策略

如上图所示,当主机A给主机B发送序号从1到1000的数据(即TCP报文)时,如果该数据在网络中丢包了,则主机B就收不到该数据,主机B也感知不到主机A给自己发过信息,于是主机B就不会给主机A发送表示应答的TCP报文,此时主机A因为收不到应答,主机A也不知道自己发送的数据是否在网络中丢包了,只能继续等待主机B的应答,但此时我们知道主机B是永远不会给A应答的,如果不引入某种策略,而让A一直等待应答,这显然是不合理的,于是就引入了超时重传策略,给主机A设置一个时间间隔,如果主机A发送数据后在这一段时间内没有收到应答,那么主机A就认为自己发送的数据丢失了,主机A就会重传这个数据。

(结合上图思考)当主机A收不到应答时,情况只有两种,要么是主机A发的序号是从1到1000的数据真的丢了;要么是没有丢,主机B收到了,但主机B发送的应答在网络中丢了。至于是哪种,主机A无法知道,也不需要知道,因为对于主机A来说这没有意义,但问题在于如果是第二种情况,每次主机A发送数据后,主机B回复的应答都在网络中丢了,那么主机A就会不断地超时重传,那么此时主机B就收到了很多份相同的数据,那么主机B能不能完成去重呢?

答案是能,因为每个数据或者说每个报文中都是有32位序号字段的,如果是相同的报文,那么当然序号也是相同的,主机B收到若干报文后,发现序号相同,那么主机B就会进行去重。


问题:那么一个时间间隔多长时就判断为超时呢?

答案:(结合上图思考)如果时间太短,那会导致主机A发送报文后,主机B发的应答还在路上时,主机A就认为自己发的报文丢了,触发重传策略,那么主机A就会频繁的重传相同的报文,主机B就会频繁的收到重复的报文并去重,主机A发送报文和主机B接收报文并去重都是需要网络资源和内存资源的,所以频繁进行这些操作实际上就会导致浪费各类资源,导致资源利用效率变低;

(结合上图思考)如果时间太长,那又会导致当【主机A发送的报文丢了从而主机A收不到应答】时,或者当【主机A发送的报文没丢,但主机B发的应答丢了从而主机A收不到应答】时,主机A等待的时间过长,进而导致主机A重传的时间点较晚,导致双方通信的效率变低。

综上可以发现这个时间间隔既不能太长,也不能太短,那可不可以将这个时间间隔设置成一个固定的时间呢?答案也是不行的,理由如下:

  • (结合上图思考)比如因为当网络不好的时候,双方收发报文的速度都会相对较慢,如果这个固定的时间间隔设置得比较短,那么就会出现和上面一样的情况,即可能主机A给主机B发送报文后,主机B回复给主机A的应答没有丢,而是正在路上慢悠悠的行进从而导致超时,于是就触发了主机A的重传机制,这样一来主机A就会频繁的重传相同的报文,主机B就会频繁的收到重复的报文并去重,进而导致浪费网络资源。
  • (结合上图思考)再比如因为网络较好的时候,双方收发报文的速度都较快,如果这个固定的时间间隔设置得比较长,那么就会出现和上面一样的情况,即可能会导致当【主机A发送的报文丢了从而主机A收不到应答】时,或者当【主机A发送的报文没丢,但主机B发的应答丢了从而主机A收不到应答】时,主机A等待判定是否超时的时间过长,进而导致主机A重传的时间点较晚,导致双方通信的效率变低。

所以综上可以发现因为网络可能出现抖动(即时好时坏),所以是不能选择将这个时间间隔设置成一个固定的值的,TCP协议为了保证无论在什么环境下都能进行比较高性能的通信,它会动态计算这个最大超时时间:

  1. Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍。
  2. 如果等待 1*500ms(即(2^0)*500ms)重发一次后仍然得不到应答,则会等待 2*500ms 后(即(2^1)*500ms后)再进行一次重传。
  3. 如果仍然得不到应答,则会等待 4*500ms 后(即(2^2)*500ms后)再进行一次重传。依次类推,以指数形式(即2^x)递增。当这个最大超时时间变大到一定的程度后,发送方主机的OS根据TCP协议就会认为网络或者对端主机出现了异常,然后就会强制关闭自己的这条连接,即强制销毁连接对象。
以上就是问题【那么一个时间间隔多长时就判断为超时呢?】的答案了。从这里我们还能得到一个启示:当发送端给接收端发信息时,如果接收端因为拔网线导致网络异常或者接收端拔电源导致主机异常,进而一直不给予发送端应答,那么发送端重传几次失败后, 发送方主机的OS根据TCP协议就会认为网络或者对端主机出现了异常,然后就会强制关闭自己的这条连接,即强制销毁连接对象。

滑动窗口(包含快重传策略的知识点)

通过设置滑动窗口可以提高网络发送效率,如何体现这一点呢?会在下文中慢慢体现出来。

在讲解确认应答策略时我们说过,实际上根据确认应答策略,双方互相传输TCP报文时并不一定会像下图1那样表现成一问一答的模式,而可能像是下图2这样,客户端一次性给服务端发送一批TCP请求,服务端收到后,也一次性给客户端回复一批TCP响应。这么做的原因也很好理解,假如主机A发送数据的总量都是50条,像下图1这样的串行化的方式,即【主机A得到一条应答后再发下一条数据】,那么对比下图2的方式,即【主机A不得到应答就发下一条数据、一次性发送50条数据后再慢慢等待所有的应答】,下图1的方式无疑是效率更低的,这就像50个人都要跑完一来一回的路程,假设每个人的速度一样,那么让50个人一个一个地跑完所花的总时间肯定是50个人同时跑完所花的总时间的50倍的,而之所以能像下图2这样主机A一次性发送50条数据,正是因为滑动窗口以及围绕滑动窗口建立的一些机制的存在。

说明一下上面所说的主机A给主机B一次性发送50个数据的详细流程(为了说明清楚,过程中会穿插一些其他知识点):如下图3所示,OS根据TCP协议把主机A的发送缓冲区中的数据发给主机B的接收缓冲区时,在主机A的发送缓冲区中是分成3个部分的(当然这3个部分都是可以不存在的,比如当发送缓冲区为空时,这3个部分就都不存在),分别为【已经发送并且已经收到ACK的数据】、【可以发送并且暂时不需要收到ACK的数据】、【还不可以发送的数据】。因为位于第一个部分中的数据已经收到了应答,所以主机A的应用层下一次调用send或者write把应用层中的数据放到发送缓冲区时,第一个部分中的数据就可以被新来的数据覆盖了,而第二个部分中的数据因为还没有收到应答(因为如果收到了应答,那就代表数据一定被发送了,那么这部分数据就应该被归为第一部分,而不是第二部分)所以主机A的应用层下一次调用send或者write把应用层中的数据放到发送缓冲区时,第二个部分中的数据就不可以被新来的数据覆盖,这也是为什么主机A的发送缓冲区发数据给主机B的接收缓冲区时,如果数据丢了可以进行上文刚讲解的超时重传。其中表示第二个部分的窗口,即表示【可以发送并且暂时不需要收到ACK的数据】这个部分的窗口就被称为滑动窗口(因为当一次性发送一批数据后,等到这些数据都收到应答后,表示第一个部分的窗口的范围就会变大,所以表示第二个部分的窗口就会向右滑动,所以第二个窗口就被称为滑动窗口了),所以以后我们就要知道,所谓的滑动窗口就是指发送缓冲区中的一个范围,滑动窗口是位于发送缓冲区里的,上文所说的50个数据就是滑动窗口里的全部数据,主机A给主机B一次性发送50个数据就是主机A的OS根据TCP协议把滑动窗口中的所有数据分成50个报文一次性全发给了主机B的接收缓冲区(为什么要把数据分成50个报文再发送、而不是直接将数据以一个报文发送呢?这个问题需要在讲解IP协议的文章中标题为发送端在网络层中进行分片的危害的部分进行说明)。第三个部分里的数据还不可以被发送的原因也很简单,在上文中我们是讲过流量控制策略的,因为此时主机B的接收缓冲区能够接收的数据量是有上限的,而根据上文叙述可知滑动窗口本质就表示主机A的发送缓冲区中的可以被一次性发送给主机B的接收缓冲区的全部数据,所以滑动窗口也是有上限的,所以如果主机A的发送缓冲区中还有剩余的第三部分的数据,当然这些数据是不可以被发送的了。

  • 图1如下。
  •  图2如下。
  •  图3如下。

以上只是对滑动窗口的基础认识,接下来咱们更加深入地说明滑动窗口这个模型。

在上文讲解32位序号和32位确认序号时我们说过,理解TCP通信里的发送缓冲区和接收缓冲区时,我们可以把它们当作一个char类型的数组,比如char sendbuffer[num]和char recvbuffer[num],不必关心num具体是多少,如下图左边所示。现在我们还要知道,滑动窗口本质就是通过两个下标win_start和win_end标定出来的(win表示windows,翻译为窗口),主机A的发送缓冲区中的win_begin=主机A收到的主机B发来的报文中的32位确认序号,主机A的发送缓冲区中的win_end=win_start+主机B的接收能力(即主机B的接收缓冲区中剩余空间的大小,也可以说是主机A收到的主机B发来的报文中的16位窗口大小)所谓窗口向右滑动就是在win_start不变的情况下win_end变大,或者win_start和win_end同时在变大。

问题:滑动窗口必须向右移动吗?

答案:不一定,举个例子,假设此时主机B的接收缓冲区还剩1024字节的空间,主机A的发送缓冲区发送400字节的数据给主机B的接收缓冲区后(这个报文中是携带32位序号的),如果主机B的接收缓冲区收到了,则会给主机A发送携带ACK应答的TCP报文(该报文中是携带32位确认序号的),等主机A收到ACK后,根据32位确认序号就知道自己曾经发的400字节被收到了,于是主机A的滑动窗口中的win_start下标就会从当前位置向右偏移400个位置,需要注意的是如果直到主机B收完这400字节的数据时,主机B的上层应用层都没有调用recv或者read把接收缓冲区中的数据拿走,那么主机B的接收缓冲区就还剩下624字节的剩余空间,那么之前所说的主机B发给主机A的携带ACK应答的TCP报文中,它的16位窗口字段就是624字节,此时主机A收到这个报文后,win_end就不会向右偏移(如果不理解为什么不会向右偏移,建议画张流程图,如下图所示)。

问题:滑动窗口可以为0吗?

答案:可以,上文说过滑动窗口本质就是通过两个下标win_start和win_end标定出来的(win表示windows,翻译为窗口),主机A的发送缓冲区中的win_begin=主机A收到的主机B发来的报文中的32位确认序号,主机A的发送缓冲区中的win_end=win_start+主机B的接收能力(即主机B的接收缓冲区中剩余空间的大小,也可以说是主机A收到的主机B发来的报文中的16位窗口大小),那么只要当win_start=win_end,滑动窗口就为0了,因为win_end=win_start+主机B的接收能力,所以只要满足主机B的接收能力为0的条件,就能达成win_start=win_end,也就是说,只要主机A的发送缓冲区发送数据时发着发着把主机B的接收缓冲区给发满了,那么主机A的滑动窗口就会变成0(拿上一个问题中的例子继续举例,在上一个例子中只发了400字节,如果这里再发624字节的数据,并且直到主机B收完这624字节的数据时,主机B的上层应用层都没有调用recv或者read把接收缓冲区中的数据拿走,那么主机B的接收缓冲区就还剩下0字节的剩余空间,那么主机B发给主机A的携带ACK应答的TCP报文中,它的16位窗口字段就是0字节,此时因为主机B给主机A发了ACK了,所以主机A就知道自己发的624字节被收到了,于是主机A的win_start就会向右偏移624个下标的位置,而win_end=win_start+主机B的接收能力,但接收能力又是0,于是win_end就会从win_start的位置开始向后偏移0个位置,那么滑动窗口也就为0了)

问题:滑动窗口一直向右滑动, 会越界吗?

答案:是不存在的,如下图1所示,TCP的发送缓冲区实际上是一个环形数组,当win_start或者win_end下标大过了发送缓冲区char sendbuffer[num]的最大下标num后,会通过模运算让win_start或者win_end下标表示的位置重新回到有效范围内,就像下图2这样,当win_end下标大过了发送缓冲区char sendbuffer[num]的最大下标num后,就会回到数组的首部,蓝色的区域表示滑动窗口。

  • 图1如下。
  • 图2如下。

所以综上可以发现,因为TCP协议引入了滑动窗口以及围绕滑动窗口建立的一些机制,所以双方通信时发送方可以一次性连续给接收方发送多条TCP报文,接收方也可以连续接收发来的所有TCP报文(比如因为发送方滑动窗口的大小会受到接收方的接收缓冲区中剩余空间的大小的限制,所以发送方中滑动窗口里的数据是全部可以被接收方接收的),进而双方能在较短时间内交换更多的TCP报文,提高通信效率。这就是最初的问题【通过设置滑动窗口可以提高网络发送效率,如何体现这一点呢?】的答案了。

同时因为TCP协议引入了滑动窗口以及围绕滑动窗口建立的一些机制,双方通信时重传TCP报文信息的策略也就不仅仅只有超时重传策略了,还可以通过高速重发控制策略(也简称为快重传策略)。举个例子说明一下快重传策略,如下图所示,如果主机A发送的32位序号为2000的TCP报文(即序号或者说下标号为1001到2000的数据)在网络中丢包了,那么后序主机A发送TCP报文时,也只会收到32位确认序号为1001的TCP报文,当连续收到3次32位确认序号为1001的TCP报文后,主机A就知道了是1001到2000的数据在网络中丢失了,于是就会重传32位序号为2000的报文。

问题:根据上文中叙述的超时重传策略可知在这种策略下想要重传一次至少等待500ms,这个时间相比于【发送一次TCP报文并接收一次TCP报文(即数据在网络中一去一回)】所花的时间已经是很漫长的了,在这种背景下,因为快重传机制只需连续接收到3次32位确认序号相同的TCP报文即可触发,所以相比于快重传,超时重传策略的效率一定是更低的,那么为什么在有了高速重发控制策略(即快重传策略)后,还要有上文中的超时重传策略呢?

答案:因为他俩并不是敌对关系,而是协作关系。想要通过快重传策略进行重传是需要条件的,即必须连续接收到3次32位确认序号相同的TCP报文,少一次都无法达成条件,无法进行重传。这样的条件就比较苛刻了,比如说当历史上主机A发送的某条报文在网络中丢失后,如果目前主机A的滑动窗口比较窄,能够发出去的报文只有2个,那么此时能收到的报文最多也只会是两个,也就无论如何都无法触发快重传,此时就需要超时重传策略来兜底了,即主机A等待500ms后自动重传历史上丢失的那个报文(主机A通过之前收到的报文的32位确认序号就能知道自己哪个报文丢失了)可见如果没有超时重传策略,那一定是不行的,这两种策略的工作模式是:主机A发出去的报文丢失后,如果后序能达成快重传的条件,主机A就会立刻进行快重传,而不再进行超时重传;只有后序一直无法达成快重传的条件,主机A等待一段时间达成了超时重传的条件后,主机A才会进行超时重传。

以上就是滑动窗口的大部分内容了,还有最后一个非常重要的关于滑动窗口的知识点需要在下文中讲解拥塞控制策略的时候再说明,可以说这个知识点如果不知道,你所认识的滑动窗口就是错误的。

拥塞控制策略(包含拥塞窗口、慢启动策略的知识点)

走到这里,我们已经学完了超时重传策略、快重传策略、流量控制策略、连接管理策略(即3次握手和4次挥手)、确认应答策略(其中又包含了通过32位序号和确认序号衍生出的按序到达机制、去重机制)

以上学的这些策略都只是用于解决数据从主机A到主机B的可靠性问题和效率问题,我们还有一个很重要的点没有注意到,那就是网络的状态(主机A和主机B通信时,数据会经过网络,这个网络表示的就是信道中的全体路由器和交换机或者其他网络设备)。举个例子,如果主机A给主机B发TCP报文时,只有很少一部分报文丢包了,那么大概率是主机A或者主机B的问题,进行重传即可;但如果是大部分报文都丢包了,那我们就要知道此时很可能并不是两个主机的问题,而很可能是网络出问题了,即网络拥塞了,那么问题来了,此时主机A需要重传吗?

答案是主机A绝对不可以重传,第一,因为网络已经拥塞了,你重传了数据也到达不了另一端。第二,因为丢包的时候是大部分报文都丢包了,如果重传大量的报文,则势必会让已经很拥塞的网络更加拥堵,雪上加霜。综合这两点原因,是绝对不可以进行重传的。有人可能会说【我一台主机重传大量的报文,即使这个量再大,相比于整个网络所能容纳的数据量(说具体点就是整个网络中全体路由器以及其他网络设备所能容纳的数据量)不也是沧海一粟吗?凭什么说主机A一台主机重传就会让已经很拥堵的网络雪上加霜呢?】,这里笔者想说的是,这里想表达的意思并不是说只有主机A一台主机会进行重传,而是说,我们需要从局部看到整体,如果网络发生拥塞了,那么网络中的大量主机发送报文时大概率都会丢失大部分的报文,此时如果TCP协议中没有引入拥塞控制策略,因为整个网络中绝大多数主机在进行通信时使用的传输层协议都是TCP协议,那么整个网络中的大多数主机根据TCP协议就都会无脑重传大量的报文,这个数据量就很容易让网络更加拥堵了。

所以虽然TCP协议有了滑动窗口这个大杀器,能够可靠并且高效的发送大量的数据,但如果在刚开始就发送大量的数据,就仍然可能引发问题,比如因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵,在不清楚当前网络状态下,贸然发送大量的数据是很有可能导致网络更加拥堵的,于是TCP协议就引入了一种慢启动策略,策略为:在处于情况1、即【双方刚刚建立完连接准备开始通信之初】时,或者处于情况2、即【双方已经进行了一段时间的通信但突然发送方发现大量的回复报文都收不到,识别到可能网络比较拥塞】时,发送方就会先发送少量的数据探探路(如果是情况2,则这里的发送改为重传更贴切),摸清当前的网络拥堵状态后再决定按照多大的量发送数据(如果是情况2,则这里的发送改为重传更贴切),举个例子,如下。

  • 比如说主机A给主机B发送(如果是情况2则是重传)信息时,先只发送(如果是情况2则是重传)1字节的数据看看能否发送成功,如果没有收到应答,则根据超时重传策略需要等待500ms后重发,如果还是收不到应答,则等待2*500ms后重发第二次....,如果一直重发一直收不到应答,当重发几次后这个最大超时时间变大到一定的程度后,发送方主机的OS根据TCP协议就会认为【网络已经极度拥塞甚至完全瘫痪了】或者【对端主机出现了异常】,然后就会强制关闭自己的这条连接,即强制销毁连接对象;
  • 再比如说主机A给主机B发送(如果是情况2则是重传)信息时,先只发1个字节的数据看看能否发送(如果是情况2则是重传)成功,如果收到了应答,则说明网络中还可以发送(如果是情况2则是重传)数据,主机A下次就会发送(如果是情况2则是重传)2倍的数据、即2个字节的数据看看能否发送(如果是情况2则是重传)成功,如果还是收到了应答,则说明网络中还可以发(如果是情况2则是重传)数据,主机A下次就会再发(如果是情况2则是重传)2倍的数据、即4个字节的数据看看能否发送(如果是情况2则是重传)成功,后序依此类推。

此时可能又会有人说【当网络真的处于比较拥塞的状态时,你一台主机A执行慢启动策略所能减少的发送到网络中的数据量相比于网络中的数据总量只不过是杯水车薪,这有什么用呢?】,这里笔者想说的是,网络中的绝大多数主机在通信时底层使用的都是TCP协议,如果TCP协议引入了慢启动策略,当网络发生拥塞时,网络中的绝大多数主机在识别到网络拥塞后,就都会根据TCP协议执行慢启动策略,即都只会发送或者重传少量数据测试网络的拥堵程度,那么进入到网络中的数据就骤然减少(或者说进入到整个网络中全体路由器以及其他网络设备的数据量就会骤然减少),那么就会给网络中的所有路由器、交换机或者其他网络设备充足的时间把自己缓存的、正在排队的数据交付给目标,从而变得能够再接收新的数据,换句话说也就是在一定程度上缓解了网络拥塞的程度(说明一下,网络是否拥塞取决于网络中的全体设备是否已经快被数据填满,网络中的全体设备把自己的数据交付给目标后,自己就没有数据了,就可以接收新的数据了,网络当然就不会拥塞了),让网络有一个“喘息”的机会。

上文咱们对慢启动策略有了基础的认识,接下来咱们来进一步认识慢启动策略。

先引入一个“拥塞窗口”的概念,拥塞窗口的大小和滑动窗口的大小的单位一样,它们都是以字节为单位进行衡量的,主机A的拥塞窗口表示的是目前网络最大能接收多少主机A发出的数据,如果主机A发送到网络中的数据大过了拥塞窗口,则就可能导致网络拥塞。引入了拥塞窗口的概念后,以后我们就要知道,主机A给主机B发送数据时,能够发送的数据的上限不光会受到【主机B的接收缓冲区中剩余空间的大小】的限制,还会受到拥塞窗口的限制,发送方滑动窗口的大小实际上等于【发送方的拥塞窗口的大小】和【接收方的接收缓冲区中剩余空间的大小】的较小值。

所以根据上一段的内容可以发现,假如当【接收方的接收缓冲区中剩余空间的大小】一直大于【发送方的拥塞窗口的大小】时,发送方最多能发送到网络中的数据上限就等于拥塞窗口的大小,比如如果发送方拥塞窗口的大小为1(单位是字节)时,那么发送方最多只能发送1字节的数据到网络中。根据前面这句话又能发现,正是因为有拥塞窗口以及围绕拥塞窗口建立的相关机制存在、能够控制发送方往网络中发送的数据上限,所以上文中说的【发送方根据慢启动策略,会先发送少量的数据、比如先发送1个字节的数据探探路】这样的操作才能实现,比如只要将拥塞窗口设置成1,那么发送方就最多只能往网络中发送1个字节的数据了。

在慢启动策略中,会直接把第一次发数据时的拥塞窗口的大小定义为1,往后每收到一个ACK应答就让拥塞窗口的大小乘以2,以让发送方最多能往网络中发送是原来两倍数据量的数据(这也是为什么上文中说的【根据慢启动策略,主机A给主机B发送信息时,先只发1个字节的数据看看能否发送成功,如果收到了应答,则说明网络中还可以发送数据,主机A下次就会发送2倍的数据、即2个字节的数据看看能否发送成功,后序以此类推】这样的操作能够实现的原因),当拥塞窗口的大小达到一个阈值时,再收到ACK就不会乘以2了,而是每收到一个ACK就让拥塞窗口的大小加1,以让发送方能往网络中发送更多的数据(“更多”表示:和原来相比多了一个字节)

说明一下慢启动策略的合理性,如果发送方发送一个报文后立刻就收到了应答,说明网络并不是很拥塞,此时发送方继续发送报文时发送两倍的量以摸清网络的拥堵程度是合理的;如果发送一个报文后很久才收到应答,说明网络比较拥塞,只不过没有完全瘫痪,那么发送方再次发送报文时发送两倍的量以摸清网络的拥堵程度也是合理的,这是因为当网络比较拥塞时,发送方需要过很久才会收到应答,那么发送方再次发送一次报文的周期会变得很长(即每一次发送数据的时间间隔会变得很长),这段时间足够让网络“喘息”一会,缓解自己的拥塞程度,所以可以发现慢启动策略还是一个自适应的策略。


问题:为什么在【双方刚刚建立完连接准备开始通信之初】时或者是在【双方已经进行了一段时间的通信但突然发送方发现大量的回复报文都收不到,识别到可能网络比较拥塞】时,要让发送方的拥塞窗口的大小是2^x,成指数级增长呢?

答案:如果按照这样的方式,那么前期拥塞窗口的大小会是1、2、4、8、16等等比较小的数字,拥塞窗口增长的速度是非常缓慢的,但等到中后期基数变大了后,每乘以2得到的数字都是非常大的,那么拥塞窗口增长的速度就非常快了。

在情况1、即在双方刚刚建立完连接准备开始通信之初时,我们的策略是先让发送方只发送少量数据,如果前期每次发送都收到了应答,没有因为频繁收不到应答导致双方的连接断开(这是因为发送方频繁收不到应答就会频繁重传,而根据超时重传策略,重传几次后最大超时时间就会达到阈值,然后就会销毁发送方的连接对象,断开连接),成功地撑到了中后期,那么说明网络没有拥塞,此时就应该尽快让拥塞窗口变大以能够让发送方发送更多的数据,避免影响双方通信的效率。

在情况2、即在双方已经进行了一段时间的通信,但突然发送方发现大量的回复报文都收不到,识别到可能网络比较拥塞时,我们的策略是先要让网络有一个缓一缓的机会,让发送方只发送(或者是重传)少量数据,如果前期每次发送都收到了应答,没有因为频繁收不到应答导致双方的连接断开(这是因为发送方频繁收不到应答就会频繁重传,而根据超时重传策略,重传几次后最大超时时间就会达到阈值,然后就会销毁发送方的连接对象,断开连接),成功地撑到了中后期,那么说明网络自己缓过来了,已经变得不再拥塞了,此时就应该尽快让拥塞窗口变大以能够让发送方发送(或者是重传)更多的数据,避免影响双方通信的效率。

综上可以发现,拥塞控制策略一方面想解决网络拥塞的问题,另一方面又想尽快恢复双方通信的效率,而慢启动策略中让拥塞窗口的大小是2^x、成指数级增长的特征是很符合当前的需求的,比如说让拥塞窗口成指数级增长时,前期拥塞窗口增长得慢,就符合让网络恢复的需求,中后期拥塞窗口增长得快,就符合让通信恢复的需求,所以慢启动策略才选择了让拥塞窗口成2^x指数级增长,这就是问题的最终答案。


显而易见的是,拥塞窗口每次增长时不能一直无脑的乘以2,否则就会导致【拥塞窗口大得无法想象,从而失去限制滑动窗口大小的作用,滑动窗口又只等于接收方的接收缓冲区中剩余空间的大小,而不需要考虑网络的拥塞程度】,为此,拥塞窗口就引入了一个阈值,当拥塞窗口增长到这个阈值后,每次再增长时就不会乘以2,而是每次只加1了。可以发现,即使没有乘以2,但也每次加了1,即只是限制了拥塞窗口增长的速度,但是拥塞窗口的确是一直在变大的,问题来了,难道拥塞窗口会一直变大吗?

答案是当然不会,在实际中网络可能是过一段时间就会抖动、发生拥塞的,只要发送方因为大量的丢包识别到网络可能拥塞后,发送方的拥塞窗口就会立马从当前的较大值变成1,然后【重新先进行指数级增长,增长到阈值后再每次只加1进行线性增长】,在增长的过程中如果网络再次发生拥塞,被发送方识别到后,发送方的拥塞窗口就会再变成1以及进行和之前同样的操作,所以拥塞窗口是不会一直变大的。实际上如果你的理解很深,在阅读完上文的内容就应该知道这个问题的答案了,因为在上文中说过,【双方已经进行了一段时间的通信但突然发送方发现大量的回复报文都收不到,识别到可能网络比较拥塞时,发送方就会先发送少量的数据、比如先发送1字节的数据探探路,摸清当前的网络拥堵状态后再决定按照多大的量发送数据】,既然拥塞窗口表示的是发送方能发送到网络中的数据量的上限(但也别忘了实际往网络中能发多少数据也是需要受到接收端接收缓冲区的限制的),作用是控制发送方能够往网络中发送数据的上限、那么为了控制发送方只能先发送少量的数据、比如只能先发送1字节的数据探探路,当然拥塞窗口不能一直变大,而要让拥塞窗口变小、重新变成1(从这里也可以看出,如果没有拥塞窗口以及围绕拥塞窗口建立的相关机制,发送方识别到网络比较拥塞时就不会具备先发送少量数据的能力)

如下图所示,接下来我们看看拥塞窗口具体是如何变化的。ssthresh表示的值就是上文中所说的阈值,当处于情况1、即【双方刚刚建立完连接准备开始通信之初】时,或者处于情况2、即【双方已经进行了一段时间的通信但突然发送方发现大量的回复报文都收不到,识别到可能网络比较拥塞】时,拥塞窗口就会变成1,从1开始以指数级2^x增长,当增长到阈值16后(这里暂且不必关心为什么是16,到后面就知道了阈值是如何选定的了),每次增长时就只会加1,变成线性增长,(回顾一下拥塞窗口为什么会增长:发送方给接收方发送报文后,如果收到了应答,发送方的拥塞窗口就会增长一次,以让发送方能够发送更多的数据进入网络),当发送方发送着发送着又识别到网络拥塞后,拥塞窗口就又会变成1,重新进行增长,并且注意,此时的阈值会变成之前拥塞窗口最大值的一半,比如说在下图中拥塞窗口重新变成1前的最大值是24,那么重新变成1后,阈值就是12了。


以上就是拥塞控制策略的全部内容了,可以发现拥塞控制归根结底就是TCP协议想尽可能更快地把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。

延迟应答策略

如果接收端的接收缓冲区在收到数据后,接收端就立即进行ACK应答,此时应答报文中的16位窗口字段可能会比较小,举个例子,如下。

  • 假设接收端的接收缓冲区中剩余空间的大小为1MB,当接收端的接收缓冲区收到500KB的数据后,那么剩余空间的大小就变成了500KB,如果接收端立即进行ACK应答,那么此时返回给发送端的ACK应答报文的16位窗口大小字段上填充的值就是500KB,发送端收到这个ACK后,如果【发送端拥塞窗口的大小】大于【接收端的接收缓冲区中剩余空间的大小】,那么发送端的发送缓冲区中的滑动窗口的大小就会是500KB,表示发送端一次性可以向接收端发送500KB的数据(这500KB可能会先被分成多个TCP报文,然后再被一次性发送出去)
  • 但假如发生了这样的情况,即【接收端处理数据的速度很快,会频繁调用recv或者read从接收缓冲区中拿数据继续处理】,那么只要接收端的接收缓冲区在收到500KB的数据后,接收端不立即进行ACK应答,而是延时一会再应答,那么接收端的接收缓冲区中的数据就有可能会被上层应用层调用recv或者read拿走,那么接收端的接收缓冲区中剩余空间的大小就会大于500KB,那么此时再返回给发送端的ACK应答报文的16位窗口大小字段上填充的值就是一个大于500KB的值了,发送端收到这个ACK后,如果【发送端拥塞窗口的大小】大于【接收端的接收缓冲区中剩余空间的大小】,那么发送端的发送缓冲区中的滑动窗口的大小就会是这个大于500KB的值,该值表示发送端一次性可以向接收端发送这么多的数据(这大于500KB的数据可能会先被分成多个TCP报文,然后再被一次性发送出去)。可以看到【当前这里可以一次性发送出去的数据】就比【上一段中可以一次性发送出去的数据】要多了,如果发送端和接收端都采用延时应答这样的策略,那么就能提高双方的通信效率,说具体点就是提高双方一次性可以发出去的数据总量(“一次性”并不代表只是一个报文,而是说可能是多个报文被一次性发出去)

可以发现,延迟应答策略并不是为了保证TCP协议的可靠性,而是和【滑动窗口机制、拥塞控制策略、捎带应答策略】一样,是为了提高基于TCP协议进行的通信的效率而存在的。所谓延迟应答就是先留出一点时间让接收端的接收缓冲区中的数据尽可能被上层应用层拿走,然后再进行ACK应答的时候报告的窗口大小就可以更大,从而增大网络吞吐量,进而提高数据的传输效率。

注意,接收端收到TCP报文后,并不是对所有的报文都可以进行延迟应答的,而是有限制的,如下。(说一下,下面的数量限制和时间限制条件并不一定对,只是笔者理解的是这样)

  • 数量限制:每隔两个包才对应答延时一次。
  • 时间限制:接收端在等待一个报文时,如果等待时间超过了最大延迟时间200ms,接收端才会对应答延时一次(这个时间不会导致误超时重传)。

延迟应答具体的数量和超时时间,依操作系统不同也有差异,一般N取2,超时时间取200ms。

对TCP协议的总结

走到这里,我们从上到下就学完了确认应答策略(其中又包含了通过32位序号和确认序号衍生出的按序到达机制、去重机制)、捎带应答策略、流量控制策略、连接管理策略(即3次握手和4次挥手)、超时重传策略、快重传策略、滑动窗口机制、拥塞控制策略,延迟应答策略。以前我们说过,当应用层把数据向下交付给网络安全层、再交付给套接字层、再交付给传输层中的内核缓冲区后,这些数据什么时候发(会根据流量控制策略、滑动窗口机制、拥塞控制策略、延迟应答策略决定什么时候发),发多少(会根据流量控制策略、滑动窗口机制、拥塞控制策略决定发多少),出错了怎么办(会根据超时重传策略、快重传策略、连接管理策略、确认应答策略、去重机制、按序到达机制决定出错了怎么办),完全由TCP协议说了算,以前我们只是知道这一句话,现在我们就能更加深刻地体会到这一点了。


问题:为什么TCP协议设计得这么复杂?

答案:如下图所示,因为既要保证基于TCP协议进行的通信的可靠性,还要提高基于TCP协议进行的通信的性能。

TCP协议的粘包问题

什么是粘包?

首先要明确,粘包问题中的“包”,是指的应用层的数据包,或者换句话说是TCP报文中的有效载荷,即如果是TCP请求,则是请求正文部分;如果是TCP响应,则是响应正文部分。

在TCP报文的报头中,没有如同UDP一样的表示“报文总长度”的字段。站在接收端的内核传输层的角度,TCP报文是一个一个过来的,OS根据TCP协议会将收到的报文按照32位序号排好序放在接收缓冲区中,但站在接收端的应用层的角度,我们在应用层调用read或者recv函数拿接收缓冲区中的数据时,想到的、看到的都只是一长串连续的字节数据,因为我们不知道别人给我们发了什么,所以我们在应用层就不知道从哪个部分开始到哪个部分结束是一个完整的数据,比如说当别人往我接收缓冲区中发送了数据【你好吗?你在干什么?】时,因为我们不知道接收缓冲区中的数据是什么,所以我们没法在应用层知道该怎么调用read才能拿到一个独立的数据,比如如果read多读了一点数据时,拿到的数据就可能是【你好啊?你在】,这样的数据就并不独立,这是因为第二个独立的数据【你在干什么?】的一部分和第一个独立的数据【你好吗?】黏在了一起。

如何解决粘包问题

在上一段中我们说,【因为我们不知道别人给我们发了什么,所以我们在应用层就不知道从哪个部分开始到哪个部分结束是一个完整的数据】,但实际上只要双方约定一个协议以明确报文和报文之间的边界,我们压根不需要知道别人给我们发了什么就能知道从哪个部分开始到哪个部分结束是一个完整的数据,所以要解决粘包问题,本质就是要双方约定一个协议以明确报文和报文之间的边界。那么如何约定以明确边界呢?如下。

比如如果在设计客户端和服务端时,我们作为编码者知道要收发的数据正文(即请求正文或者响应正文部分)都是定长的,那么就可以约定双方的上层应用层在调用read函数从各自的接收缓冲区中读取数据时每次都直接按固定大小读取。

再比如如果在设计客户端和服务端时,我们作为编码者知道要收发的数据正文(即请求正文或者响应正文部分)都是变长的,那么我们编码者就可以设计一个包含数据正文的、可以明确不同数据之间的边界的格式,约定双方在调用write函数把数据正文发到发送缓冲区时,都要先将数据正文转化成这种格式,然后再把转化后的数据发到发送缓冲区以明确不同数据之间的边界,当接收端的接收缓冲区收到一个个这样格式的数据、然后接收端的应用层再调用read或者recv把随机若干个这样格式的数据读取到应用层后,因为双方都知道约定的格式是什么样的,双方的应用层都有办法区分边界,所以接收端就能识别出一个数据是不是这样格式的完整的数据,所以在应用层中就能把若干个这样格式的数据中的数据正文一个个单独提取出来,也就解决了粘包问题。举个例子深入理解前面的内容:

  • 比如说位于应用层的HTTP协议就是这样做的,HTTP报文的格式就是这里我们所说的【包含数据正文的格式】的一种,协议规定双方在调用write函数把数据正文发到发送缓冲区时,都要先将数据正文转化成HTTP报文,然后再把该HTTP报文发到发送缓冲区,比如要发的数据正文是【你好啊?】时,就会先将【你好啊?】填充进HTTP报文的正文部分中,然后把HTTP报文的其他字段也补充上,最后把该HTTP报文调用write拷贝进发送端的发送缓冲区里,因为双方都知道不同的HTTP报文的边界是什么(根据程序员编码控制,比如可以使用现有的HTTP库或框架来处理这些细节,而无需手动解析和处理边界,双方是能知道边界是什么的),所以当接收端的接收缓冲区收到一个个HTTP报文、然后接收端的应用层再调用read或者recv把若干个HTTP报文读取到应用层后,在应用层中是能识别出一个HTTP报文是否是完整的报文的,所以也就能把若干个HTTP报文中的数据正文一个个完整地提取出来,也就解决了粘包问题。如何提取呢?在<<应用层协议——http协议>>一文中其实已经进行过详细说明了,这里就简单说一下:HTTP报文的格式中每一行的属性都是固定的,在报头中有一行Content-Length属性表示数据正文的长度,先找到这一行,然后通过字符串切割手段把它提取出来后转化成整形,然后找到表示数据正文的首行,通过字符串切割手段从首字符开始向后提取长度个字符即可提取出数据正文。
  • 再比如如果我们知道一个特殊字符绝对不会出现在数据正文中,那么可以让该字符作为分隔符,假设该字符是@,那么可以约定双方在调用write函数把数据正文发到发送缓冲区时,比如发送的数据正文是【你好啊?】时,都要先在数据正文末尾添加上@,然后再把转化后的数据【你好啊?@】发到发送缓冲区,当接收端的接收缓冲区收到一个个这样格式的数据、然后接收端的应用层再调用read或者recv把随机若干个这样格式的数据读取到应用层后,因为双方都知道约定的格式是什么样的,双方的应用层都有办法区分边界(@就是边界,读到@了则说明read读取到了一个完整的数据,如果没有遇到@,则不一定读到了完整的数据),所以接收端就能识别出一个数据是不是这样格式的完整的数据,所以在应用层中就能把若干个这样格式的数据中的数据正文一个个单独提取出来,也就解决了粘包问题

走到这里,我们就能理解为什么以前编写网络计算器时客户端和服务端双方在应用层中调用write前都需要将结构化的数据序列化,比如客户端需要将数据转化成_xSPACE_opSPACE_y的格式,服务端需要将数据转换成_statusSPACE_result的格式了,这是因为发送端如果不转化,那么接收端在接收到数据后就没法识别出这个数据是不是完整的一个数据,也就没法继续向后进行业务处理。同理,也能理解为什么客户端和服务端根据应用层的HTTP协议进行通信时,客户端给服务端发送数据时需要将数据填充在http请求的请求正文部分中、服务端给客户端发送数据时需要将数据填充在http响应的响应正文部分中,这也是因为只要双方发送的是http报文,那么双方就都可以分辨收到的不同报文之间的边界,双方就都能识别一个http报文是否是完整的报文,从而也就能把收到的若干个http报文中的数据正文一个个完整地提取出来,也就解决了粘包问题。

UDP是否存在粘包问题?

对于UDP协议,它和TCP协议不一样,UDP协议没有发送缓冲区,只有接收缓冲区,并且其中存储的不同UDP报文的有效载荷并不像TCP的接收缓冲区一样是连续存储的,而是不连续的,这是因为UDP的接收缓冲区是一个链式队列的结构,不同UDP报文的有效载荷压根就不会存储在一起(如何做到的会在下面的问题和答案中说明),是有明确的边界的,所以站在应用层的角度,在使用recvfrom的时候,要么收到完整的有效载荷,要么不收,不会出现收“半个”的情况。

问题:那么接收端的OS在收到不同的UDP报文后,是如何识别出不同UDP报文的边界并将每个UDP报文的有效载荷完整地提取出来,最后挨个链进链式队列以让不同UDP报文的有效载荷不会存储在一起的呢?

答案:OS收到若干个UDP报文后,根据UDP报头当中的16位UDP长度字段记录的UDP报文的总长度即可将不同UDP报文划分出来,然后通过每个UDP报文的总长度减去报头固定的8字节,就能提取出每个UDP报文的有效载荷,然后挨个链进链式队列即可。

从这里我们可以发现为什么说【UDP是面向数据报的,发送端发送了多少次,接收端就要接收多少次,比如根据UDP协议传输100个字节的数据时,如果发送端调用一次sendto一次性地发送100字节,那么接收端的应用层也必须调用一次recvfrom一次性地从接收缓冲区接收100个字节,而不能循环调用10次recvfrom,每次只接收10个字节;再比如发送端分别发送三次报文,第一次1K,第二次2K,第三次6K,那么接收端的OS就会接收三次并放入接收缓冲区】,这也是因为UDP的接收缓冲区在接收不同报文的有效载荷时会直接把它们一个个链进链式队列,后序应用层调用一次recvfrom又只会从链式队列里取一个元素出来,当然push多少次,就要pop多少次了,所以才说UDP通信时,发送端发送了多少次,接收端就要接收多少次。

因此UDP是不存在粘包问题的,可以发现其根本原因就是UDP报头当中的16位UDP长度字段记录了UDP报文的总长度,导致OS根据UDP协议在底层的时候就能把报文和报文之间的边界明确,进而能在把不同UDP报文的有效载荷放进接收缓冲区时将他们隔开,取出来就都是一个个完整的数据正文了;而TCP存在粘包问题就是因为TCP是面向字节流的,接收缓冲区中的数据全部连续在一起。

TCP异常情况

1、进程异常终止:

在上文中说过,文件描述符的生命周期是随进程的,无论一个进程是正常退出还是异常退出(比如进程崩溃了就是异常退出的一种情况),OS都会close关闭该进程的文件描述符表中所有的文件描述符,就包括sockfd,所以如果某个进程正在和另一端通信并且因为某种原因崩溃掉了,是异常退出的,那么该进程就会主动向另一端发起FIN,进行4次挥手,于是双方操作系统在底层会正常完成四次挥手,然后释放对应的连接资源。所以也就是说,进程崩溃等进程异常退出的情况也会释放文件描述符,TCP底层仍然可以发送FIN,和进程正常退出没有区别。

2、机器关机或者重启:

假如机器A上有进程A,机器B上有进程B,进程A和进程B正在通信,当我们选择关机或者重启机器A时,其操作系统就会先杀掉所有进程然后再进行关机或者重启,因此机器关机或者重启和【进程正常或者异常终止】的情况是一样的,此时双方操作系统也会正常完成四次挥手,然后释放对应的连接资源。

额外说一下,我们根据生活常识可以发现如果在电脑上启动了很多软件,在不关闭这些软件的情况下直接关机,有时候就会弹出一个框框,提示我们有进程正在运行,是否选择继续关机,如果我们选择是,则会感受到此次关机的速度是比正常关机的速度慢的,这是因为这些软件本质都是客户端,都正在和服务器进行网络通信,如果选择关机,此时OS是需要先将这些客户端进程都杀死才能继续关机的,杀死这些进程又会导致很多客户端都进行4次挥手断开连接,4次挥手是需要时间的,所以关机的速度就慢了。

3、机器掉电/网线断开:

第一种情况,假如机器A上有进程A,机器B上有进程B,进程A和进程B正在通信,如果此时机器A因为电线老化或者故意拔电源导致被关闭,那么此时的情况和上面机器关机或者重启的情况是不一样的,因为上面让主机A的OS执行关机或者重启的步骤时,OS是会先杀死进程A、让和【主机B上的进程B】建立了连接的进程A完成4次挥手以断开连接的,这样一来进程A和进程B都能断开连接、销毁自己的连接对象;而这里的情况是机器A直接断电了,此时主机A上的所有进程和连接对象的确都被销毁了,但问题在于主机A的进程A在被销毁前压根没有哪怕一丁点的时间给主机B的进程B发送FIN,从而让主机B也断开自己的连接、销毁自己的连接对象,所以主机B的进程B就依然认为进程A是“在线”的,主机B依然会维护这条连接(并不会一直维护,详情在下面)

第二种情况,假如机器A上有进程A,机器B上有进程B,进程A和进程B正在通信,如果此时机器A因为网线老化或者故意拔网线导致进程A掉线,那么进程B也是无法感受到进程A掉线了的,因为主机A已经没有网络了,进程A无法再向进程B发送任何TCP报文,进程A也就无法发送FIN和进程B完成4次挥手了,所以即使进程A掉线了,进程B也不知道,进程B依然认为进程A是“在线”的,依然会维护自己的这条连接(并不会一直维护,详情在下面)

当然无论是第一种情况,还是第二种情况,这条连接都不会一直被主机B维护,否则如果主机B是服务器,主机A、C、D...等等大量的主机上的客户端和服务端建立连接后都直接拔电源或者拔网线并且后序也不再访问服务器,那么服务器上需要维护的无用连接对象就越来越多、导致服务器可用的内存资源越来越少,服务端进程可用的文件描述符也越来越少,最后导致服务器崩溃。

问题:那上文所说的这条连接什么时候断开呢?

答案:答案如下。

  • 比如说如果主机B有给主机A发TCP报文的动作,那么此时是绝对收不到应答的,那么根据超时重传策略,重发个几次后,主机B就会自己断开这条连接了。
  • 再比如说,即使主机B没有给主机A发TCP报文的动作,主机B也是有办法断开这条连接的,这是因为TCP协议内置了一个保活定时策略,即如果通信双方中,有一方长时间不给另一方发送TCP报文,那么另一方就会认为对方的连接断开了,然后另一方也断开自己的连接,所以当主机B长时间没有收到主机A的信息时,主机B是会断开自己的这条连接的。根据定时保活策略,我们还能发现一个现象出现的原因:即长时间不使用QQ客户端时,有时候QQ客户端就会变灰,这就是因为长时间不使用客户端访问服务端、长时间不给服务端发信息,然后QQ服务端就主动向客户端发起4次挥手,断开了自己的连接(当然QQ客户端也是进行了4次挥手断开了自己的连接的)
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值