目录
一、tcp协议段格式
二、tcp协议的解包
三、tcp协议的分用
四、TCP可靠性问题
1. 不可靠存在原因
2. 常见的不可靠问题
3. 如何保证可靠性
4. 确认应答机制
5. 序号
五、tcp报头其余字段
1. 16位窗口大小
2. tcp的6个标记位
2.1 SYN
2.2 FIN
2.3 ACK
2.4 PSH
2.5 URG
2.6 RST
六、超时重传机制
1. 判断丢包
2. 丢包的两种情况
3. 数据维持
4. 超时策略
七、连接管理机制
1. tcp三次握手
1.1 tcp三次握手的客户端与服务器的状态转移过程
1.2 tcp三次握手失败应对
1.3 为什么要进行三次握手
1.4 tcp协议中要建立连接的原因
2. tcp四次挥手
2.1 tcp四次挥手过程
2.2 tcp四次挥手过程中的状态变化
2.3 CLOSE_WAIT状态
2.4 TIME_WAIT状态
八、超时重传
九、滑动窗口
1. 滑动窗口的概念
2. 滑动窗口的初始化大小和变化方式
3. 滑动窗口一定会向右滑动或向左滑动吗
3. 滑动窗口会一直不变吗?会变大或变小吗?
4. 收到的确认应答如果不是滑动窗口最左侧报文的ACK,而是其他位置的怎么办
4.1 数据没丢,只是应答丢了
4.2 数据丢失,接收方未收到数据
5. 滑动窗口一直向右滑动,滑动到了边界怎么办
一、tcp协议段格式
tcp,全称为“Transmission Control Protocol”,即“传输控制协议”。从名字上就可以看出来,tcp协议需要对数据的传输进行一个详细的控制。在上一篇文章udp协议中就已经讲过了,当我们调用send、recv等网络IO接口时,并不是直接将数据发送到网络或直接从网络获取数据,而是将要发送的数据拷贝发送缓冲区,将接收到的数据放到接收缓冲区,再从接收缓冲区拷贝到应用层。
通过这种方式,就将关于数据的处理交给了传输层。而tcp协议的一大特点就是可靠传输,所以在tcp中就需要为了维护数据的可靠而进行一系列处理。
在这里,就先了解一下tcp协议段格式:
上图就是一份tcp报文中所包含的内容。其中的数据就是应用层中要发送给另一台主机的内容。
16位源端口号与16位目的端口号,这两个端口号想必不用多说了,就是用于标识要发送的数据来自哪个进程,要发给哪个进程。
至于其他的字段,会在后面的讲解中逐一介绍。
二、tcp协议的解包
当主机接收到一份报文后,首先就需要将报头和有效载荷分离。在tcp中如何分离报头和有效载荷呢?其实和udp是一样的,tcp报文中也采用了“定长”的方式添加报头。在tcp中,报头的标准长度是20字节,因此,接收方在接收到tcp报文后,直接提取前20个字节,就可以拿到报头了。
注意,这里的20字节指的是报头的标准长度,而非报头的总长度。大家从上图中可以看到,tcp报文中还带有选项字段,该字段是一个变长字段,可填可不填。但是选项也是需要提取出来的。
提取方法很简单,大家从上图中应该可以看到tcp报文中存在“4位首部长度”字段,该字段是报头的自描述字段,即表示报头的总长度。
当接收方将标准报头的内容提取出来并转换为结构化数据后,就会立刻提取标准报头中的4位首部长度。4位首部长度是由4个bit位组成的,这说明它的取值范围为[0000, 1111],即[0, 15]。很明显,这个长度甚至还没有报头的标准长度长。因此,tcp的报头长度 = 4 * 4位首部长度,即[0, 60]。同时,由于标准长度为20,即最少也有20字节,因此4位首部长度的实际表示范围应为[20, 60]。这多出来的40字节,就是选项中包含的内容。而选项是可填可不填的,这就意味着选项的长度范围应为[0, 40]。
有了报头的总长度后,我们只需要用报文的总长度减去报头的总长度,就可以获得正文的长度了。但是大家仔细观察报头的字段后可以发现,在这份报头中并没有包含描述报文总长度的字段。但是在udp中就有报文总长度啊,为什么tcp中却没有呢?其实是因为tcp是面向字节流的,它只需要保证数据的可靠性并将数据放入到缓冲区内,至于这份数据如何解析,是由应用层解决的,不关传输层的事。
知道了如何解包,那么如何封装也就很简单了,就是将对应的字段组合形成报头,再添加到数据的首部即可,不再赘述。
三、tcp协议的分用
分用也很简单,从tcp报头中就可以发现,报头中存在16位目的端口号。因此当报头中的内容被提取出来后,就可以直接拿着这个目的端口号找到对应的进程了。这很好理解。
现在我们知道了数据可以通过目的端口号找到应用层中的特定进程,那么这份数据是如何找到它对应端口号的进程的呢?
大家知道,OS中存在大量的进程,OS为了方便管理这些进程,于是将它们以双链表的方式连接起来。但是在实际中还存在一些场景要求OS能够快速定位一个进程。而如果以双链表的遍历寻找进程,效率是非常低的。同时大家知道,hash表的时间复杂度为O(1),因此OS除了会维护一个双链表用于管理进程外,还会将一些进程添加到hash表中。
大家在写服务器时调用bind接口的过程,就可以看做是OS将特定的端口号与进程的指针以kv的形式插入到hash表中。当有一份数据到达时,OS获取它的报头中的目的端口号后就可以通过这个端口号到hash表中迅速找到对应的进程。
现在OS可以找到这份数据要交给哪个进程了。但是,OS如何将这份进程交给对应进程呢?
大家知道,一个进程在被创建之后是有属于自己的PCB的,在这个PCB中存在一个文件描述符表,表里面存储文件指针。同时我们知道,“linux下一切皆文件”,这就意味着网络资源同样是通过文件读写的。当我们写服务器的时候不是就用socket接口创建了一个文件并与网卡建立关系么。然后在bind的时候就将这个文件的文件描述符传进去了。此时,对应的进程的文件描述符表中就有了对应的文件指针,而文件在被创建后也是会形成自己的结构体的,这个结构体中就包含了文件的读写方法和缓冲区指针。
因此,当一份tcp报文传过来后,OS就可以拿着端口号去找对应的进程,然后通过PCB中的文件描述符表找到用于网络通信的文件的文件结构体。此时传输层就可以找到对应的文件了。然后传输层再通过调用文件结构体中保存的读写方法,将对应的数据拷贝到文件的读写缓冲区中,此时就成功将传输过来的数据写入到了文件中。写入完成后应用层再调用对应的读接口,不就可以从文件中拿到数据了么。
四、TCP可靠性问题
1. 不可靠存在原因
大家学习网络的时候应该经常听到这么一句话:数据在网络传输是不可靠的。那么,数据在网络传输中为什么是不可靠的呢?究其原因,其实就是传输距离太远。
这就好比我们和别人聊天的时候,如果是面对面聊天,那么双方都可以很清晰的知道对面说的话,遇到问题时也能够很轻易的交流。但如果双方隔着几十米乃至上百米交流,此时就可能因为出现各种情况,如声音太小听不清、遇到问题没法及时反馈等等。导致这些现象的原因,根本上就是双方的交流距离太远。
网络中也是如此。因此,大家在日常中只听到过网络传输中有不可靠问题,但要知道,内存和外设的交互也是有协议的,但为什么大家没有听到过内存和外设之间的协议存在可靠性问题呢?因为它们的传输距离很久,不存在不可靠的情况。
2. 常见的不可靠问题
在这里就介绍几个日常中比较常见的不可靠问题。
首先是丢包。丢包就是数据在网络传输中因为某些原因导致数据丢失了一部分。这个问题如果大家经常用加速器玩游戏的话就比较常见。在加速器启动的时候就有一个丢包率。有时你的网路抖动比较高的时候,丢包率也会变高。其实就是传输的数据在网络中丢失了部分。
第二个就是乱序。例如你在发一份数据的时候,这份数据过大,因此它分为多份报文发送。但是在发送的过程中,有些数据在经过某些路径的时候阻塞在了某个位置,但其他数据却能够正常到达接收端。此时就因为某些数据可以到达,而某些数据无法到达,就导致了乱序。
第三个就是校验错误。例如数据在经过长距离传输后因为各种情况出现了bit位翻转,此时就会导致校验错误。
第四个就是重复。重复就是数据在网络传输中并没有丢失,但发送方因为某些原因,例如判断超时,使得发送方以为数据丢了,此时发送方会再发一份数据给接收方,进而导致重复问题。
当然,可靠性问题还有很多,这里就不再一一介绍了。
3. 如何保证可靠性
首先大家要理解一个问题,当传输距离太长了之后,到底存不存在绝对的可靠性?
举个例子,假设你和你的一个好朋友相隔很远的打电话聊天,你的朋友问你“你吃饭了么?”,在你回答之前,你的朋友知道你到底听没听见这句话吗?当然不知道,因为你们当前相隔很远,并不知道对方处于什么状态,如果你不回答,那么他就会认为你没有听到。当你回答说“我吃了”之后,你的朋友就知道你听到他说的话了。但此时,你知道你回答的这句话被你的朋友听到了么?答案还是不知道,因为他还没有回复你。你的朋友听到你的回答后又说“太好了,快上号开黑”。当他做出回答后,你就知道他听到了你的话。但是此时依然会出现同样的问题,那就是在你回答之前,你的朋友依然不知道你有没有听到他的话。
在上面的例子中,一共存在两种消息。一种是历史消息,一种是最新消息。
通过上面的例子,就可以得到两个结论。
1. 我们可以认为,发出的消息只有收到应答的时候,才能100%确认历史消息被对方收到了。——确认应答,才算可靠。
2. 双方通信的时候,一定存在最新消息还没有被应答。——最新消息一般无法保证可靠性。
由此大家就应该知道,在长距离的传输中,只存在相对可靠性,并不存在绝对可靠性。
在网络中也是如此。为了确认接收方是否接收到数据,接收方就需要给发送方返回一条消息,告诉对方自己已经收到了消息。这个机制,就是tcp协议中的“确认应答”机制。
4. 确认应答机制
网络中的确认应答机制简单理解起来,其实就是发送方向接收方发送数据后,接收方需要返回一条数据告诉发送方自己收到了数据。
上图就是客户端与服务器之间通信时的一个简单的示例图。注意,不要将tcp三次握手和四次挥手与通信过程中的确认应答搞混了。虽然tcp三次握手和四次挥手都是使用的确认应答机制,但是在形式上还是与通信过程中的确认应答有所区别。
在上图中可以发现,客户端和服务器之间是发一条消息,返回一条应答,然后再发下一条消息。这种形式其实就是串行通信的。但实际中的通信并不是这样的,发送端会不断的给接收端发送消息,接收端也会不断地给发送端返回应答。
通过这种方式,通信中的请求与应答就是并发运行的了。
注意,在确认应答中,我们只需要对正常的数据段应答即可。在通信过程中,有些数据段可能就单单只是表明自己收到了消息,接收方并不需要应答这条消息。这就好比你和你的朋友聊天时,你给你的朋友发了个再见,你的朋友看见后也对你发了一个再见,当你看到他发的再见时,就不会再发一条消息告诉他,你收到了消息。因为他发的再见仅仅只是告诉你,他收到了你的消息,并不包含其他有效数据。这种无需再回复对方的消息,其实就是“确认报文段”。
5. 序号
上文中说了,数据在网络中放松时,基本都是并行的,即发送端会不断的向接收端发送数据,接收端从原则上来看,需要对每一条消息都做出应答。既然如此,这里就有一个问题了,接收端接收到数据的顺序,和发送端发出数据的顺序是一样的吗?很明显,并不一定。这就好比你在网上买东西,你的快递并不一定是按照你买东西的时间顺序到达的,这些快递可能因为快递发出的比较快、快递的中转比较少等各种原因都是到达的顺序不同。
既然接收端接收到数据的顺序和发送端发出的顺序不一定相同,那么如果发送端发送了4条消息,接收端却只返回了3个应答,此时发送端就必须要想办法知道是哪条报文没有收到应答。因此,tcp数据段就必须要有方式标识数据段本身。
tcp标识数据段本身的依靠,就是tcp报头中的32位序号:
要注意,在tcp通信中,无论是请求还是应答,它的本质其实都是一个tcp数据段,因此必定会有tcp报头。也因此,每个数据段也就可以用报头中的序号来标识。
当发送端发请求给接收端后,这条请求中会包含一个序号用于标识数据段。当接收端接收到消息后需要返回一个应答,而这个应答就必须要和发送端发送的数据段相对应。这也就是导致了应答报文中必定需要有一个确认序号用于与发送端发送的数据段相匹配。而这个确认序号的值在正常情况下就是序号+1。
举个例子,假设现在有两台主机在通信,客户端发送数据时的序号是从10开始的,后面的数据的序号分别为11,12,13。当然,这个序号分配其实是有问题的,实际中是会根据某些条件来分配序号的,这里只是为了方便解释,才这样划分。
当服务器接收到序号为10的数据段时,它的应答中的确认序号就为11;当收到序号11的数据段时,它的应答中的确认序号就为12;同理可知序号为12的数据段的确认序号为13,序号为13的数据段的确认序号为14。
上图中是正常通信的情况下的确认序号状况。那如果在通信过程中,有一条数据段没有到达服务器呢?例如10、11、13都到达并返回响应了,就12没有到达。此时序号13的响应中的确认序号就不能是14,而只能填12。
这个机制的原因和确认序号的含义有关。确认序号的含义是接收方已经收到了确认序号之前的所有(真实存在且连续)报文。
因此,当接收端中间少收到了数据段时,它的应答中的确认序号就不是此次数据段的序号+1,而是上一次接收成功的数据段的确认序号。
那么,既然客户端发送数据时只需要序号,服务器返回应答时只需要确认序号,那为什么在tcp报头中既有序号,也有确认序号呢?很简单,因为tcp协议是全双工的,客户端可能给服务器发送数据,服务器除了应答外,也可能给客户端发送数据。双方都需要给对方发送数据,由此在tcp报头中就既需要序号,也需要确认序号。
注意,序号不仅仅能保证每份报文有序,也能保证每份报文内的数据有序。当应用层的数据被添加到发送和接收缓冲区时,这个缓冲区大家可以把它看成字符数组,既然是字符数组,那么该数组内的每个字节天然就有了各自的编号。tcp将每个字节进行编号,进而形成了序列号。
因此,我们才能说,确认序列号表示在该序列号之前的数据已经全部接收。
五、tcp报头其余字段
1. 16位窗口大小
通过上图,大家可以看到tcp报头中有一个16位窗口大小的字段。该字段的作用其实就是用于标识对方的接收缓冲区剩余大小的。
举个例子,假设客户端和服务器在通信,客户端不断的给服务器发送数据,但是服务器的接收缓冲区是有限的,如果客户端发送的速度太快,就可能导致服务器的接收缓冲区被迅速填满,此时客户端如果继续给服务器发送数据,因为接收缓冲区已经满了,就会导致这些数据被丢弃,导致丢包。
但如果客户端发送数据的速度太慢了,服务器已经把客户端发送过来的数据处理完很久了,客户端才慢悠悠的把数据发送过来,就会导致服务器的接收缓冲区长期为空,拉低效率。
因此,客户端给服务器发送数据时,既不能太快,也不能太慢。那么如何让客户端以一个合适的速度给服务器发送数据呢?很简单,只需要让客户端知道服务器当前的接收缓冲区剩余大小即可。如果空间剩余比较大,服务器就发快点,如果剩余空间比较小甚至没有,服务器就发慢点乃至不发。
当然,由于服务器也可能给客户端返回数据,所以通过这个字段,就可以交换客户端和服务器的接受能力,让其自行控制发送数据的速度。
注意,在发送端向接收端发送的数据段中,16位窗口大小中的值应该填发送端的接收缓冲区剩余大小,以告诉对方自己当前的接收能力,而不是填接收端的接收缓冲区剩余大小。
大家可以发现,16位窗口大小的表示范围为[0, 65535],以字节为单位的话就是最多可表示65535字节,即64kb。这也就意味着传输层的发送和接收缓冲区的大小一般就为64kb。很明显,这个缓冲区在当前时代来看是很小的。因此,我们其实也是可以通过在选项中设置来扩大窗口大小的。这里就不再演示。
同时,由于接受和发送缓冲区的大小一般为64kb,一旦遇到需要发送的数据很大时,就需要将这份数据划分为一个个的小块数据进行发送。
因此,大家可以发现我们用的如send和recv接口中虽然带有要发送数据的长度,但却还有一个返回值用于返回发送了多少数据:
这其实就是因为参数中的len是我们期望发送的数据量,而返回值则是实际发送的数据量。缓冲区就那么大点,我们是无法保证发送和接收缓冲区可以一次性将要接收和读取的数据拿完的。这就要求我们在接收和发送数据时,是需要自行根据情况来判断是否需要循环发送和接收数据,以保证将一份数据完整的发送出去。这一点无论是udp还是tcp,都是需要处理的。
2. tcp的6个标记位
在理解tcp的6个标记位之前,大家要先知道,一个服务器是会同时与多个客户端建立连接的,这也就意味着,服务器可能会接收到各种各样的报文。例如服务器在和一个客户端交换数据的时候,又有一个客户端向服务器发起连接请求,此时服务器就不是与该客户端交换数据,而是开始三次tcp握手建立连接。同理,当服务器正常运行时,也可能有一个客户端发送数据段过来告诉服务器它要断开连接,此时服务器也不是和客户端交换数据,而是开始与该客户端执行tcp四次挥手。
由此,大家就应该知道,服务器在运行过程中,是可能会接收到各种类型的报文的。因此,服务器就应该有能够辨别这些报文是用来做什么的能力。辨别方法,就是tcp报头中的6个标记位。
这些标志位在正常情况下都默认为0,只有遇到需要的场景才会设置为1。
2.1 SYN
SYN,就是指同步报文段。当客户端要向服务器发起连接请求的时候,就需要将SYN标记位置1,以告诉服务器该报文是一份请求建立连接的报文,要执行tcp三次握手。
大家要注意,客户端向服务器发起连接请求的时候,其本质也是客户端向服务器发送一份tcp报文,这份tcp报文中就必然包含tcp报头,也就包含SYN标记位。
2.2 FIN
当客户端要与服务器断开连接的时候,客户端与服务器就需要执行tcp四次挥手。那么服务器如何知道客户端要与它断开连接呢?其实就是通过FIN标记位。当客户端要和服务器断开连接的时候,客户端就发送一条将FIN标记位置1的报文,以告诉服务器,它现在要断开连接了,需要执行四次挥手。
2.3 ACK
上文中说过了,tcp协议中通过“确认应答”机制来确认报文被对方收到。那么接收方如何知道这份报文是确认报文,还是其他报文呢?其实就是通过ACK来确认的。
在确认报文段中,ACK需要被置1,以告诉对方自己是一条确认报文段。但是大家知道,在网络通信中,通信双方是不断交换数据的,这就意味着很多数据段既能够用于正常通信,也能够进行确认。因此,在网络通信的过程中,除了客户端第一次对服务器发起请求的报文,其他报文中的ACK一般都是置1的,无论它是一份独立的确认报文段,还是一份拥有其他职能但是有确认能力的报文段。
2.4 PSH
在客户端和服务器通信的时候,双方是通过16位窗口大小来确认对方的接收缓冲区剩余大小以控制发送数据的速度。但也可能存在这么一种情况,那就是接收方的接收缓冲区被填满了,但是由于接收方还忙于处理上层的数据,所以来不及取走缓冲区内的数据。此时,接收方返回的报文段中的16位窗口大小就为0。而发送方看到对方的接收缓冲区剩余为0,也就不再向对方发送数据了。
但是,发送方难道就这样傻傻的一直阻塞住,不再继续发数据了吗?很明显这样是不合理的。因此,发送方在隔了一段时间后,就可能会再向接收方发送一份报文,这个报文里面什么都没有,仅仅是用于获取接收方的接收缓冲区大小的。
当发送方收到接收方返回的报文后发现里面的16位窗口大小依然为0,此时发送方可能就会不耐烦了,它此时就可能再向接收方发送一份报文,这份报文的报头中的PSH标记位就会被置1,以告诉接收方,让它赶紧将接收缓冲区内的数据读走,让自己好继续发送数据。
此时大家就可能有一个问题了,虽然发送方发送了带有PSH的报文来催促接收方尽快读走数据,那接收方能不能不管这条信息呢?当然是可以的,毕竟就算接收方当做没看见这条消息,依然我行我素,发送方也不能把它怎么样。但是,这种处理方式虽然可行,但是并不合理,接收方接收到这条消息后就必须要执行某些动作来处理这个请求,不然发送方发这条报文有什么作用呢?
大家要知道,我们在上层读取数据时,其实就是调用read、recv等IO接口来读取数据,这些接口可能因为某些情况,例如缓冲区内没有数据而被阻塞。所以当发送方发送了带有PSH标记位的报文时,其实就是让IO接口的执行条件尽可能的满足,以达到让接收方尽快读走数据的功能。
此时大家可能就又有一个疑惑了,在这种情况下接收缓冲区内已经被填满了,那这些IO接口怎么可能不满足执行条件呢?这里其实就涉及到了IO接口中的一些特殊机制了。在数据IO中,底层通知上层可以读取数据和上层接口正常读取是可以分开的,通过分开的方式,就可以让OS主动的告诉应用层哪些IO文件描述符已经就绪。这里不太好解释这些机制,大家只需要知道PSH标记位就是用于催促接收方尽快读走数据的即可。
2.5 URG
大家应该知道,在数据传输的过程中,数据在网络中传输时是可能出现乱序的,而乱序,本身就是不可靠的一种情况。因此,tcp就需要解决这一问题。解决方案也很简单,因为tcp报文中本身就带有序号,而序号是具有一定次序的,因此,当接收端接收到报文后,就会将这些报文按照序号进行排序,以此保证接收缓冲区内的数据是有序的。在拿取数据时,就是从头开始拿,即从较小序号的报文开始拿,这样也就保证了拿到的报文也是有序的。
既然tcp中的报文天然就是有序的,那如果出现了某些数据想要插队,提前被读取呢?此时就需要将该报文的报头中的URG标记位置1,表示该报文中的数据包含紧急数据,需要被优先处理。那这份紧急数据在哪里呢?这就要用tcp报头中的16位紧急指针来标识了。
tcp报头中的紧急指针是一个偏移量,表示当前报文中的紧急数据的起始位置。例如紧急指针中的值是10,那么紧急数据就是从这份报文中的数据的起点往后数10字节的位置。虽然我们知道了紧急数据的起始位置,但是我们并不知道这份紧急数据的长度啊?这一点不用担心,因为tcp中的紧急数据默认为只有1个字节,因此紧急指针指向的位置起始就是紧急数据。
这份紧急数据,在有些地方也被称为“带外数据”。可以理解为正常数据都是按序处理的,但是带外数据可以插队,优先处理。
那大家有没有想过,为什么有些数据需要插队呢?举个例子,假设你写了一个服务器,你在客户端中想知道它的运行状况,但是这个服务器当前处于高负载状态,很难立即处理你的请求。因为带外数据在接收和发送时不会通过tcp流,所以双方通信时就不会经过冗长的等待,可以直接高优先级处理。此时你就可以发送一份带外数据,让服务器优先处理。服务器在返回时也可以以带外数据的方式返回。
其实带外数据这个东西,在应用层中是很少使用的,几乎不会使用,只有一些偏底层的设计中才会用到。如果大家实在想使用,也是可以的。大家应该还记得在send和recv接口中存在一个flags参数:
在这个参数中填入“MSG_OOB”就可以接收或发送带外数据了:
但是带外数据在应用层中很少使用,甚至几乎不会使用,并不需要程序员自己去写。一般是偏底层的情况才需要考虑带外数据。
2.6 RST
大家都知道,在客户端和服务器通信之前,需要经过tcp三次握手建立连接后才能正常通信。但是tcp三次握手一定能保证成功吗?同样的,断开连接时的tcp四次挥手也一定能保证成功吗?并且就算tcp三次握手成功了,难道在双方通信的时候,连接就一定不会断开吗?答案是否定的。
在双方通信的过程中,可能会因为某些原因,导致连接但方面出问题。例如你的客户端在和服务器通信的时候,服务器因为某些原因断电关闭了。当服务器断电后,后台人员再次重启服务器,但是在服务器重启后,客户端中因为并没有和服务器执行四次挥手,所以客户端依然认为它和服务器之间是有连接的。但是服务器因为重启了一遍,在它内部并没有保存与客户端的通信连接。当客户端发送数据给服务器时,服务器此时就很奇怪啊,明明双方都还没有建立连接,你怎么就发数据过来了呢?此时服务器就会返回一条报文,这条报文中就将RST标记位置1,用来告诉客户端,它们之间还没有建立连接,要求客户端重新发起连接。
当然,反过来如果客户端异常关闭重启后, 也是一样的,客户端需要发送一条带有RST标记位的报文,用来申请和服务器重新建立连接。
大家遇见的“连接被重置”的情况,其实就是服务器中出了某种异常,例如连接太多处理不过来,或者说服务器异常关闭了,就会返回这种页面:
因此,带有RST标记位的报文,也被称为“复位报文段”。
六、超时重传机制
1. 判断丢包
不可靠中存在一种情况,那就是“丢包”,即数据包丢失。一般来讲,因为tcp中有流量控制策略,不太可能出现因为接收缓冲区满了而导致数据无法被接收的情况。所以现实中的丢包,一般就是数据在网络传输的过程中真的丢了。
现在我们知道什么是丢包了,那发送方如何判断有没有丢包呢?其实很简单。tcp中不是有确认应答机制么,那么发送方只需要设置一个定时策略,发出的数据在经过了某个时间段后还没有返回应答,发送方就可以判断这份数据丢失了。
要注意,发送方并无法知道这份数据是不是真的丢失了,只能按照某个标准来自行判断。这就好比你给你朋友打电话,但是对方一直不接,此时你并不知道对方是没有听见电话响,还是看到电话响了但就是不想接。所以你就只能等一段时间,如果这段时间过了对方还不接,你可能就会重新打一个电话过去。在你等对方接电话的这段时间内,你并不知道对方到底有没有听到电话响。
2. 丢包的两种情况
丢包,又分为两种情况。第一种情况就是发送方发送的数据在传输的过程中丢失了,没有到达接收方。
这种情况的处理方法很简单,发送方等一段时间,如果没有收到应答,就重发一份。
第二种情况就是发送方发送的数据被接收方接收了,但是接收方返回应答时,这份应答在传输的过程中丢失了。此时也会导致发送方判断数据丢失。
在这种情况下,如果发送方重新给接收方发送同一份数据,就会导致接收方接收到重复的数据。而接收方接收到重复的数据,其实也是不可靠的一种。因此,接收方就需要对数据去重。去重方法很简单,发过来的报文中不是有序号么,接收方就可以根据这个序号来判断该份数据是否重复。至于如何用序号去重,这里就不再赘述了。
3. 数据维持
在上面的例子中,不知道大家有没有注意到,既然发送出去的数据有可能丢失导致需要重传,这是不是就意味着发出去的数据不能够立即移除,需要保留一段时间呢?答案当然是肯定的,如果直接移除,那遇到需要重传的场景时,不就没有数据可以重传了么。那这份数据应该保存在哪里呢?也很简单,这些要发出的数据在发出去之前就已经被保存在发送缓冲区了,既然如此,当数据被发出后,就暂时不对这些数据进行覆盖不就好了么。当发送方收到接收方返回的确认报文段后,就可以让下一批数据进入发送缓冲区,覆盖原来的数据。
因此,大家也要知道,新的数据进入缓冲区后,并不是将缓冲区清空后再填入数据,而是直接在原来的基础上进行覆盖。
4. 超时策略
下一个问题,既然tcp中发送方是根据超过某个时间后没有收到应答就判断丢包,那这个超时时间如何定呢?
首先,肯定不能够定一个固定的时间,因为网络传输速度有快有满,如果定一个较短的超时时间,不就很可能出现数据并没有丢失,但是由于网络速度较慢导致应答在超时时间内还没有返回,此时就可能出现频繁重复发送数据的情况。而如果设一个较长的超时时间,就可能出现网络状况比较好时,你发出的报文早就丢了,但发送方却还在傻傻的等待接收方应答。降低效率。
因此,tcp为了保证无论在任何环境下都能比较高性能的通信,就采用了动态计算最大超时时间的方法。
在linux(BSD Unix和Windows)中,超时以500ms为一个单位进行控制。每次判定超时重发的超时时间都是500ms的整数倍。
如果重发一次之后,没有收到应答,就等待2*500ms后再进行重传。
如果重发两次后仍然得不到应答,就等待4*500ms进行重传,以此类推,以指数形式递增。
当累计到一定的重传次数后,tcp就认为网络或者对端主机出现异常,强制关闭连接。
七、连接管理机制
1. tcp三次握手
tcp三次握手是客户端与服务器建立连接之前需要执行的操作。tcp三次握手只能由客户端发起。
1.1 tcp三次握手的客户端与服务器的状态转移过程
在学习了tcp协议后,大家应该都知道,tcp协议中通信双方要进行通信,首先就需要进行tcp三次握手建立连接。
在tcp三次握手中,客户端首先向服务器发送一个带有SYN标记位的报文,当服务器收到这份报文以后,服务器就会返回一份带有SYN和ACK标记位的报文给客户端,当客户端接收到这份报文后,再给服务器返回一份带有ACK标记位的报文。通过如上三个步骤,双方就建立起了通信连接。
大家应该也注意到了,在上面的图中,客户端和服务器的状态也在随着报文的发送而不断变化。当客户端向服务器发送请求后,客户端进入“SYN_SENT”,即“同步发送”状态;服务器收到报文后,向客户端返回报文时,服务器进入“SYN_RCVD”,即“同步接收”状态;随后客户端收到报文并向服务器发送报文后以及服务器收到客户端返回的报文后,双方都进入“ESTABLISHED”,即“既定”状态,双方都认为完成了tcp三次握手,可以进行正常通信了。
同时大家要注意,客户端会比服务器先进入ESTABLISHED状态,因为客户端向服务器发出ACK报文后才会进入,而服务器则需要接收到客户端的ACK报文才能进入。
注意,tcp三次握手也可以理解为四次握手。因为在第二次握手时服务器会向客户端发送一条带有SYN和ACK的报文,如果将这两条报文分开发送,就可以是看成tcp四次握手。
1.2 tcp三次握手失败应对
大家知道,tcp三次握手并不一定会成功,那如果这三次握手的报文丢失导致握手失败怎么办呢?其实第一条SYN和第二条SYN + ACK报文丢失我们是不担心的,因为这两个报文都需要响应,当对方一定时间内没有相应时,发送方就可以判断报文丢失,重新发送了。
但是最后一条ACK报文是没有应答的啊,万一这条报文丢失了怎么办呢?要知道,客户端将ACK报文发送出去后,客户端就会进入“ESTABLISHED”状态,认为连接建立成功;但是服务器中是需要接收到ACK报文后才会进入“ESTABLISHED”状态,认为连接建立成功。这就意味着,客户端和服务器之间认为连接建立成功是有一个先后顺序的,中间存在时间差。
由此,如果最后一条ACK报文真的丢了,客户端中会有对应机制重新发送的。同时,就算客户端在服务器认为建立连接还没有成功的时候发送带有数据的报文,当服务器收到后,就会触发服务器的重传机制,返回一条带有RST标记的报文,让客户端重新请求建立连接。
由此,虽然tcp三次握手会失败,但是有配套的解决方案,无需太过关心。
大家还要知道一点,当服务器与客户端建立连接后,由于服务器可能与多个客户端建立连接,这就意味着服务器中可能存在大量的连接。由此,服务器中就要想办法将这些连接管理起来,方法就是“先描述,再组织”。由此可知,服务器与客户端建立连接是有时空成本的。为了降低成本,服务器与客户端出现建立连接失败时,最好就是让对方直接重连。
1.3 为什么要进行三次握手
我们一直说tcp中要建立连接,就需要执行tcp三次握手。那大家有没有想过,为什么要有tcp三次握手?为什么不是一次、两次、四次乃至更多握手呢?
(1)一次握手不行的原因
如果客户端与服务器只需要一次握手就能建立连接,那就可能出现这么一种情况:一台主机不断的向服务器发起连接。我们知道,服务器中为了管理连接,是需要将这些连接保存起来的。如果只用一次握手就建立连接,就可能出现一台主机频繁的向服务器器发送SYN,而服务器中的网络资源是有限的,就势必会出现在单个主机的情况下将服务器的所有网络资源占完。这一现象就是“SYN洪水”。由此,建立连接绝对不用一次握手就建立完成。
(2)两次握手不行原因
如果是两次握手,当客户端向服务器发起连接后,服务器在响应回报文的时候,服务器就会认为连接建立成功,将连接保存了起来。但如果客户端不接收这条响应,客户端就会认为连接建立失败。由此,如果有人通过这种方式恶意的用一台主机不断的向服务器发送连接请求,就会导致服务器中在短时间内建立大量的连接,进而出现和一次握手的“SYN洪水”相同的问题,极易受到他人攻击。
(3)三次握手可行的原因
那我们为什么说三次握手可以建立连接呢?
第一点就是三次握手的过程中,客户端需要向服务器发送一条报文,服务器再向客户端响应一条报文,最后客户端再向服务器发送一条报文。在这个过程中,服务器与客户端都分别接收和发送了消息,由此,就验证了客户端和服务器之间的全双工通信信道是通畅的。
由此,一次握手和两次握手不可行的原因除了SYN洪水外,还有一个原因就是无法验证连接双方通信信道是否可行。一次握手只能保证服务器能接受消息;两次握手则只能保证客户端能收发消息和服务器能收消息,无法保证服务器能发消息。因为服务器发送的消息没有确认报文响应。
第二个理由就是,三次握手可以有效防止单主机对服务器进行攻击。因为当客户端要与服务器建立连接时,当客户端接收到服务器的ACK报文并发出自己的ACK报文后,客户端就会认为建立连接成功。当服务器收到ACK报文后服务器才会认为建立连接成功。这也就意味着客户端要让服务器认为建立连接成功,首先就要让自己建立连接成功。通过这种方式,就有效防止了单主机不断向服务器发起连接以攻击服务器。
要注意,虽然tcp三次握手可以有效防止单主机不断发起连接攻击服务器,但是服务器受到攻击,本来就不应该是tcp握手应该解决的,它只负责在客户端与服务器之间建立连接并尽可能的规避某些明显漏洞。由此,tcp三次握手就无法防止多主机同时发起连接攻击服务器。
举个例子,假设有一个人它在网络上散布了大量的木马,这些木马进入其他用户的主机后什么都不干。当这个人觉得植入木马的主机数量够多的时候,就对这些主机发起命令,规定这些主机在某天的某一时刻同时向某个服务器发起tcp连接请求。
通过这种方式,就会让服务器在一瞬间建立大量的tcp连接挤占服务器网络资源,进而使得其他主机无法连接服务器。这种攻击就叫做“ddos攻击”,即“分布式拒绝服务攻击”。而这里面被操纵的主机就被叫做“肉鸡”。
ddos攻击是无法解决的,因为无论任何服务器,它所能容纳的tcp连接数都是有限的。从理论上来说,只要你拥有足够多的肉鸡,在世界上就没有任何服务器不受ddos攻击影响。因此,对于这类攻击,只能用其他方式进行防范和缓解,而无法解决。
大家在以前应该也听说过在某些重要时刻,比如春节订火车票,由于运行订火车票程序的服务器在短时间内收到了大量的请求导致服务器网络资源被占用完而崩溃,进而导致很多人无法登陆订票,其原因也是和上面一样的。
注意,在tcp三次握手的时候,客户端第一次向服务器发送SYN后,服务器中也是有资源占用的。因为服务器为了后续方便与客户端建立连接,在服务器收到客户端发来的SYN报文后,它需要维持一个“半连接”状态,该状态如果没有被使用,会在一定时间内关闭。但如果在“半连接状态”下有大量的SYN报文到达,同样会在服务器中生成大量的半连接挤占网络资源。对于这些问题,就不是tcp三次握手能解决的了,而需要依靠其他的安全措施。
(4)四次握手不可行原因
四次握手是可以成功建立连接的。但是相对于三次握手,它是服务器先建立连接,客户端后建立连接,由此很容易受到“SYN洪水”攻击。
同时虽然四次握手可以验证全双工,但三次握手也可以验证全双工,且消耗还比四次握手少。由此,四次握手虽然可以建立连接,但无论是安全性还是效率都比不上三次握手。五次及以后的握手也是同样的。
通过上面的例子就可以知道,tcp三次握手可行的主要原因有两个:
1. 可以以最小的成本验证全双工通信信道是否通畅。
2. 可以有效防止单主机反复连接对服务器进行攻击。
1.4 tcp协议中要建立连接的原因
我们一直说tcp中是需要建立连接的,那大家有没有想过,为什么tcp中需要建立连接呢?原因很简单,其实就是为了保证可靠性。
在上面我们所说的如确认应答、超时重传、流量控制、有序到达等机制其实都是建立在服务器中保存有tcp的连接结构体的基础上的,这些机制都被保存在tcp形成的结构体中。而tcp三次握手,则是创建连接结构体的基础。
2. tcp四次挥手
tcp四次挥手是通信双方要断开连接之前要执行的操作。该操作可以由客户端或服务器的任意一方发起。
2.1 tcp四次挥手过程
如果客户端和服务器想断开连接,由于tcp通信是全双工的,所以双方都需要关闭各自用于网络通信的信道。因此,当客户端不想给服务器发送消息,想与服务器断开连接时,客户端首先要告诉对方它要断开连接,客户端需要向服务器发送一条FIN报文告知服务器,服务器收到后就返回ACK报文给客户端。然后服务器此时也不再需要给客户端发送消息,但需要告诉客户端它也要与客户端断开连接,因此服务器也要向客户端发送一条FIN报文,客户端再返回一条ACK报文给服务器。当服务器收到客户端发送的ACK报文后,服务器就与客户端断开连接。与此同时,客户端也在随后与服务器断开连接。
在挥手的过程中,客户端和服务器都既有通知也有确认,由此就需要四次挥手。
大家应该注意到了,在tcp四次挥手的过程中,客户端在发送FIN报文后就表明自己不会给服务器发送消息了,那为什么在后面的时候客户端还可以给服务器返回ACK报文呢?其实是因为,在此时客户端与服务器还没有完全断开,客户端所谓的不再给服务器发送消息,指的是不再发送用户数据,而不是不发底层的管理报文。
那么tcp如何知道客户端什么时候不再给服务器发送用户数据呢?答案是tcp不知道,但是用户知道。当用户不再需要发送数据时,上层就会调用close(sock)关闭用于通信的文件描述符,此时tcp就可以知道需要断开连接,向服务器发起tcp四次挥手了。
注意,tcp四次挥手也可以看成tcp三次挥手。因为服务器返回ACK报文和发送FIN报文可以被压缩成一条报文,在这种情况下就可以看成是三次挥手。
2.2 tcp四次挥手过程中的状态变化
以客户端发出断开连接为例,当客户端发出了FIN报文后,它的状态就会进入FIN_WAIT_1;当服务器接收到报文,并发出ACK报文后,服务器就会进入CLOSE_WAIT状态;当客户端接收到ACK报文后就进入FIN_WAIT_2状态;随后服务器会再发送一份FIN报文给客户端,此时服务器进入LAST_ACK状态;客户端接收到这条FIN报文并发出ACK报文后,客户端进入TIME_WAIT状态;随后当服务器接收到这条ACK报文后,服务器进入CLOSE状态。此时服务器才算是真正关闭连接。而客户端则需要等待一段时间后才会进入CLOSE状态,真正断开连接。
从上图中我们可以发现,先发起断开连接的一方的最终状态是“TIME_WAIT”状态,而另一方在完成两次挥手后就会进入“CLOSE_WAIT”状态。在这几个状态中,这两个状态是最重要的,需要大家理解。因此,在这里就会介绍一下这两个状态的含义和作用。
2.3 CLOSE_WAIT状态
CLOSE_WAIT,从字面上来看就是“等待关闭”的意思。我们要知道,客户端和服务器在进入CLOSE状态之前,其实双方都是还维持着连接的。因此,在CLOSE_WAIT状态下,其实双方依然是可以通信的。
如果我们想实际看到连接进入CLOSE_WAIT状态,可以用什么方法呢?很简单,其实就是不运行close(sock)函数即可。因为连接要进入LAST_ACK,就需要发出FIN报文,如果我们在进程中不运行close(sock)函数关闭网络通信的文件描述符,就会使得服务器不会向客户端发送FIN报文,进而使连接停留在TIME_WAIT状态。此时就可以看到连接的CLOSE_WAIT状态了。
首先随便找一个以前写的可以网络通信的程序,将关闭文件描述符的代码屏蔽掉,并让执行函数死循环以方便我们看到CLOSE_WAIT状态:
然后启动服务端,并打开两个会话窗口启动两个客户端。
使用telnet工具,这个工具的使用在以前的文章中有讲过,就不再介绍了。用telnet工具连接服务器,输入“netstat -natp 进程名”查看目标进程的当前状态,可以发现当前的连接是ESTABLISHED状态:
然后执行quit命令让客户端退出:
再次执行“netstat -natp 进程名”命令,可以发现此时连接就进入了TIME_WAIT状态:
当然,如果我们自己强制关闭服务器,OS会帮我们完成tcp四次挥手关闭连接。
由此,如果未来大家发现自己的服务器中出现了大量的TIME_WAIT状态的连接,就说明可能出现了如下情况:
1. 服务器有bug,没有用close关闭对应的用于网络通信的文件描述符。
2. 服务器当前压力很大,可能一直在推送消息给client,来不及调用close处理不用的文件描述符。
2.4 TIME_WAIT状态
TIME_WAIT状态,从字面上来看就是“等待时间”。当发起断开连接的一方向对方发送ACK报文后,其实主动断开连接的一方就已经完成了tcp四次挥手,可以真正断开连接了。但是此时它不能断开连接,而是要进入一段时间的“TIME_WAIT”状态。
要看到这个也很简单,因为客户端发出ACK报文后会自动停留一段时间在该状态,所以只需要让客户端能够正常退出即可。
运行服务器,然后使用telnet工具连接服务器。随后输入“netstat -natp 进程名”查看当前的连接。可以看到,当前的客户端是处于ESTABLISHD状态:
此时再在用telnet连接的客户端中输入quit命令:
然后再次执行“netstat -natp 进程名”查看连接,此时就可以看到客户端处于TIME_WAIT状态了:
上文中说过,主动断开连接的一方发送了ACK报文其实就已经完成了tcp四次挥手了。那为什么此时它不能断开连接,而要进入TIME_WAIT状态呢?
从四次挥手的示意图中可以发现,在四次挥手中,前三份报文我们都不怕丢失,因为有超时重传等机制去解决。但如果最后一份ACK报文丢失了呢?如果主动断开连接的一方在完成四次挥手后就直接断开连接,那如果它发出的ACK报文丢失了,那另一方就会因为超时重传机制再向它发送一份FIN报文。此时如果主动断开连接的一方直接断开连接了,那么它就无法接收到FIN报文并返回ACK报文,此时另一方就会陷入不断重发FIN报文的情况,最终导致另一方无法断开连接。
因此,为了防止这种情况,主动断开连接的一方在完成tcp四次挥手后需要进入TIME_WAIT状态等待一段时间,尽可能确保对方真正断开连接。TIME_WAIT状态还有一个作用就是保证网络中滞留的报文消散。因为网络传输有快有慢,当双方在断开连接时,网络中可能还存在一些以前补发的滞留报文,通过TIME_WAIT状态也可以保证网络中滞留的报文消散。
那现在又有一个问题了,就是TIME_WAIT状态需要维持多长时间呢?答案是“2 * MSL”。
MSL指的是“单向传输数据时花费的最大时间”。这里的最大,只是一个从数学统计上的最大,并不是你的网络中真正的传输花费最大时间。
MSL在linux、windows等OS中一般都是设定为60s。想验证这点也很简单,直接在你的linux上输入“cat /proc/sys/net/ipv4/tcp_fin_timeout”命令就可以看到了:
因此,也可能出现在你断开连接之后还要滞留报文没有消散,此时你马上又建立连接,就可能会导致服务器接收到这份滞留报文,导致出现数据错误。对于这种情况,也是有对应的解决方案的,那就是客户端的序号的起始位置是用随机数生成的,如果服务器接收到的报文与序号对不上,也会直接丢弃。当然,这种情况由于TIME_WAIT是2 * MSL,再加上对应的解决方案,基本是不可能出现的。
大家在以前运行服务端的时候,应该也发现过这么一种情况:当你关闭服务端后,你不能立即重新以相同的ip + port再次启动服务端,而需要等待一段时间才行。
然后执行“echo $?”打印程序错误码:
这里的错误码显示为3。然后再打开程序的代码,查看3代表什么错误:
可以看到,在我们自己写的程序里面,3代表的就是bind错误。这其实就是因为我们在服务器和客户端连接状态时,先关闭了服务器,此时服务器为主动断开连接的一方,需要进入TIME_WAIT状态,进而导致当前的ip + port无法使用,只能更换port。
这个问题当前来看没有问题,但是在某些场景下,就可能出大问题。例如春节买火车票,如果购票程序所在的服务器因为某种原因没有抗住压力崩溃了,那么维护人员就需要立即将其重启。此时就是服务器主动断开连接,如果服务器重启后进入TIME_WAIT状态导致其他客户端连接不进来,不就出大问题了吗。因此,在这些可能出现高压的场景下,就要求服务器在重启后能立即投入使用。
要解决也很简单,直接调用setsockopt接口即可:
在这个接口中,listenfd是监听套接字,SOL_SOCKET是指网络层的套接字,SO_REUSEADDR是要设置的状态,opt是一个bool值,sizeof(opt)则是这个bool值的长度。
这个接口的作用就是启动SO_REUSEADDR状态,让服务器能够复用TIME_WAIT状态的端口号,无需等待TIME_WAIT结束了。
八、超时重传
通过上文大家应该知道,接收端处理数据的速度是有限的,如果发送端发的太快将接收端的缓冲区打满,此时如果发送端继续发送数据就会造成丢包,继而引起丢包重传等一系列连锁反应;而发送端如果发的太慢,又会降低传输效率。
因此,TCP需要支持根据接收端的处理能力,来决定发送端的发送速度,这个机制就叫做“流量控制(Flow Control)”。
为了支持这一机制,tcp报头中便有了16位窗口大小这一字段,接收端可以将自己的接收缓冲区剩余大小放入tcp报头中的“16位窗口大小”中,通过返回报文告诉发送端。
这就有一个问题了,发送方第一次给接收方发送数据的时候,发送方式怎么知道接收方的缓冲区剩余大小的呢?很简单,在正式通信之前,通信双方不是已经进行了tcp三次握手吗?在这三次握手的时候,通信双方就交换了双方的缓冲区剩余大小。
窗口大小字段越大, 说明网络的吞吐量越高。
接收端一旦发现自己的缓冲区快满时,就会将窗口大小设置成一个更小的值通知发送给发送端。发送端接收到这个窗口之后,就会减慢自己的发送速度。
如果接收端缓冲区满了,就会将返回的窗口设置为0。此时发送方不再发送数据,但是需要定义发送一个窗口探测数据段,让接收端把窗口大小告诉发送端。
除了窗口探测数据段,也可以采用接收方在接收缓冲区剩余大小达到一定数量后发送窗口更新通知给发送方。
九、滑动窗口
1. 滑动窗口的概念
上文中讲过了,因为确认应答机制,我们在发出一份数据后都需要等待接收方返回ACK报文,因此在这段时间内我们就不能销毁,而要将其保存起来,等待后续可能的“超时重传”。而这些已经发送但还没有收到ACK报文的数据,就保存在发送缓冲区内。
大家知道,在网络中发送数据时,并不是发一份数据,等收到ACK报文后再发下一份这样串行发送。而是多份数据同时发送,以并发的方式发送到网络中。
同时大家知道,发送的数据都是按序发送的。通过这一点,我们就可以将发送缓冲区内的数据分为三个大部分:
在这张图里面,蓝色的部分就是“滑动窗口”。
当发送缓冲区内的已发送数据如果收到了应答,那么这个窗口就会“整体”右移。通过这种方式,就将已经接收到ACK的数据和没有接收到ACK的数据以及需要发送的数据相区分。而那些未发送的数据经过发送后如果还没有收到ACK,就会被划入这个窗口中。
注意,这里的“整体右移”其实并不准确,因为这个滑动窗口根据不同的情况可能会出现很多的变化。
通过上面的这张图我们应该可以发现,其实发送缓冲区就可以看成是一个数组,所谓的窗口其实就是两个下标组成的范围;而所谓的窗口滑动,其实就是这两个下标在不断的更新。
当然,虽然现在大致了解了什么是滑动窗口和滑动窗口的形式,但肯定还有其他问题,在这里就会逐一解答。
2. 滑动窗口的初始化大小和变化方式
既然发送缓冲区中有一个滑动窗口,那初次发送数据时,这个滑动窗口的大小应该是多少呢?我们知道,滑动窗口中的数据时已经发送但还没有收到ACK的数据,这就意味着滑动窗口中的数据是一定在接收方的接收能力之内的。
而接收方的接收能力,不就是它的缓冲区剩余大小吗?所以,滑动窗口在的大小在最开始就应该是等于接收方的接收能力大小的。这样无论未来滑动窗口如何变化,都可以保证接收方有足够的空间接收数据。但是这个说法其实不太准确,但受目前知识量的限制,只能暂时这么理解。
此时大家可能就又会有一个问题,既然滑动窗口中的数据是接收方缓冲区必定能接收的内容,那为什么我们不直接把滑动窗口中的数据按一份报文发出去,而要分为几份分别发出去呢?这就好比接收方的缓冲区还可以接收5000字节,但发送方却按照1000字节分5份发过去,这不是没事找事吗?其实这个机制和发送方没有关系,而是由于底层的承载数据量上限有关。虽然tcp可以一次性发大量数据,但是底层却无法承受。
3. 滑动窗口一定会向右滑动或向左滑动吗
首先来看滑动窗口是否会向左滑动。滑动窗口左边的数据时已经发送且收到了ACK报文的数据,这就意味着如果向左移动,就势必要将已经收到ACK报文的数据视为未收到ACK报文,很明显是不合理的,所以滑动窗口一定不会向左滑动。
再来看是否会向右移动。滑动窗口右边的数据是未发送或没有数据的区域。这就意味着滑动窗口向右移动,势必会将未发送的数据变为已发送但未收到ACK报文的数据,就是发送方将数据发送出去后的场景。因此滑动窗口会向右滑动。
但是,滑动窗口一定会向右滑动吗?上文中说了,滑动窗口的大小等于接收方目前的接收能力,那如果接收方在返回某些数据的ACK报文后,突然就不从接收缓冲区拿数据了呢?此时就势必会出现接收方的接收缓冲区越来越多,直到填满。在这个过程中,滑动窗口的的起点需要变为返回的ACK中的确认序号所对应的下标,而终点则是起点加上接收方的接收能力。如果接收方一直不拿接收缓冲区内的数据,就势必会导致滑动窗口不断右移,直到到达某个临界点停止不动。
因此,滑动窗口绝对不会向左移动,可能会向右移动。
3. 滑动窗口会一直不变吗?会变大或变小吗?
如果大家理解了第二个问题中的内容,这个问题就应该很好理解了。
很明显,滑动窗口不一定会一直不变,因为滑动窗口的大小受接收方的接收能力影响。
滑动窗口会变大。例如接收方的接收缓冲区内的可接收能力是0,此时滑动窗口的大小也是0。如果此时接收方突然从接收缓冲区内拿走大量数据,那此时接收方不也就可以继续发送数据了么,滑动窗口也就会变大。
滑动窗口会变小。例如接收方接收数据到接收缓冲区后,不拿走接收缓冲区内的数据,此时接收方的接收能力越来越小,滑动窗口也就会越来越小。
滑动窗口的大小可能一直不变。例如接收缓冲区满的时候接收方就是不拿走数据,此时发送方判断接收方的接收能力为0,就不会发送数据。而此时发送方发出的数据又全部都收到了ACK,此时滑动窗口的大小为0,保持不变。当然,一般不会一直不变,只是存在这样的场景。
4. 收到的确认应答如果不是滑动窗口最左侧报文的ACK,而是其他位置的怎么办
大家知道,数据发出去之后可能会受网络波动、路径选择等等问题导致到达顺序和发出顺序不同。这就意味着可能收到的ACK不是滑动窗口最左侧的数据的情况,此时应该怎么办呢?
出现这种问题,就可以划分为两种情况。
4.1 数据没丢,只是应答丢了
在这种情况下, 发送端直接无脑将起点移动到返回的ACK报文的确认序号位置即可。
大家知道,确认序号的含义是该序号之前的数据必定已经被成功接收。所以在这种情况下, 接收方已经接收到了对应的数据,此时它返回的ACK报文中的确认序号后面的数据就必然是已经被接收到了,发送端无需关心应答是否丢了,直接移动起点即可。
4.2 数据丢失,接收方未收到数据
如果发送方的数据真的丢失了,此时接收方就无法收到对应的数据。在这种情况下,根据确认序号的定义,接收方后续返回的所有ACK报文中携带的确认序号就都是丢失数据的序号。
在这种情况下,发送方发现接收方频繁返回某个相同的确认序号,发送方就可以判定该确认序号对应的数据丢失,需要重传。
在上文中我们说已发送但未收到ACK的报文为了支持超时重传,需要保存在发送缓冲区内。现在大家就应该知道,这个保存位置,更准确来说应该是保存在发送缓冲区的滑动窗口内。
在一般情况下,如果发送方连续收到3个及以上的重复确认序号,就会触发它的重传机制。
5. 滑动窗口一直向右滑动,滑动到了边界怎么办
在上文中,为了方便大家理解,将发送缓冲区画成了一个线性数组。
但实际上,这个发送缓冲区并不是线性数组,而是被内核组织成为了环形结构。可以看成是“环形队列”:
在发送缓冲区内,根本不存在边界问题。