学习网络编程No.12【传输层协议之TCP】
深入理解三次握手四次挥手,TCP各种策略,setsockopt接口的使用,shutdown接口的使用场景,粘包问题的来源与解决。
引言:
北京时间:2024/2/27/14:12,不知过了多久终于在今天上午更新了新的文章。促使好久没有登录CSDN的我回关了几个近期关注我的人,然后过了没多久有人就通过二维码加了我的微信,他问了我一个问题,如何学好操作系统和网络?然而因为当时我正在上学校开的Java课,我并没有着急回复他,而是等到了晚上才回复。在想着如何回复的过程中,我把我之前学过的知识简单回顾了一下,给我的第一感觉就是茫然。因为时间的流逝,导致很多知识掌握的没有以前那么清晰,脑袋很空,心里很忧。但当我打开了自己写的博客,我发现凭借自己当时在博客中的叙述以及内容的整理,无论是很多概念的理解,还是较为复杂的原理,在高度总结和经验的结合下,短时间阅读之后就能较为容易的重新掌握。并且我发现随着时间的积累,我的写博客能力逐渐增强,博客理解难度逐渐降低,所以我知道写博客能解决除了代码能力方面之外的非常多问题。最后我将有关操作系统和网络涉及到的知识对其进行了简单介绍,我告诉他最好的方法就是写博客。所以为了搞懂什么是TCP协议,就让我们通过该篇博客进行学习吧!
学习传输层协议
在此之前,我们已经将UDP协议相关知识学完了,我们明白因为UDP协议是一个不需要建立连接以及不需要满足可靠性的协议,所以它的报头,当然也就是UDP协议学习起来是较为简单的。因此有关UDP协议并不值得我们再回顾,然而对于UDP协议的封装和分用过程,因为其在整个网络协议栈都适用,所以此时我们简单回顾一下。同理上篇博客我们明确,在网络协议栈中定制协议就是在规定报头格式,而从代码角度来看,就是在规定结构体变量。而对于封装和分用,就是将该结构体初始化之后添加到上层报文的首部或者是按照固定大小将其分离。那么此时依据这个原理,我们就可以正式开始传输层另一个协议TCP协议的学习了,首先明确TCP也被称为传输控制协议(Transmission Control Protocol),顾名思义对数据传输进行控制,当然此时这个控制与TCP协议的可靠性、有连接肯定是息息相关的。
正式学习TCP协议
在之前学习套接字网络通信的过程中,由于传输层有两种协议的原因,所以在实现两种不同协议的套接字时,对于TCP协议我们已经有了一个简单的认识。明确其特点以及从特点出发产生的一系列策略,其中就包括确认应答机制、序列号编号、流量控制、超时重传、拥塞控制等。当然因为当时只是简单的认识,并没有深入的了解,所以我们并不知道这些策略的具体原理以及场景。而当我们想要真正的把TCP协议搞懂,上述知识的理解都是不可或缺的,因此在该篇博客中,我们将对上述策略进行着重讲解,彻底搞懂为什么TCP协议是一个可靠的数据传输协议。如下为TCP报头示意图:
如上图所示就是TCP报头示意图,一眼看过去可以发现比UDP报头复杂,当然无论它多么复杂,同理从代码角度去看的话,它的本质还是一个结构体以及很多的变量,只不过此时这些变量被赋予了不同的含义,最终起着不一样的效果。而对于报头而言,TCP报头和UDP报头除了复杂程度上的不同之外,最大的区别是TCP报头还有一个选项,所以此时TCP报头的大小不再像是UDP协议一样的定长的,而是需要依据选项来明确。那么问题就来了,我如何知道一个TCP报头是否有选项呢?或者说我应该如何知道选项的长度呢?答案显然易见,我们可以通过TCP报头中的首部长度(4位)来判断。但值得注意的是,由于TCP的首部长度最小是20,而TCP报头中的首部长度只占了4个比特位,也就是[0,15],显然该首部长度字段并不能直接用4位来表示TCP的报头长度,所以此时TCP协议就规定该首部长度以4字节为单位,也就是需要乘上4,最终使得TCP报头的范围在[20,60]之间。从而让我们可以在定长读取TCP报头之后读取其首部长度再减去20字节获取到选项长度的大小。举个例子,若在无选项的情况下,那么首部长度则为5,若选项长度为16字节,那么首部长度则为9。当然至于选项在TCP报文中起的作用有很多,我们目前不关心,只要知道选项可以提高TCP报文的灵活性和安全性就行。明白了选项有关知识之后,对于TCP报头而言,其中还有源端口号、目的端口号、序号、确认序号、窗口大小、紧急指针、校验和、标志位等字段。对于这些字段而言,部分字段不要结合某些特定的场景使用,所以这里我们先不做讲解,当然对于源端口号和目的端口号此时我们肯定是容易理解的,同理UDP协议中所说,端口号是操作系统交付数据时,匹配上层调用recv/send进程的一个抓手。接下来在讲解其它字段之前,我们先明确TCP报头的报头和有效载荷是如何分离的,通过上述对首部长度字段的分析,我们发现我们可以轻松的将TCP报头分离。但又通过TCP报头中其它字段的学习,我们发现在TCP报头和UDP报头不同,TCP报头中并不存在一个TCP报文长度字段。那么我们应该如何获取到TCP报文的长度呢?当然之所以TCP报文中没有长度字段,本质与TCP面向字节流有关。因为TCP协议是面向字节流的,也即是在TCP缓冲区中所有的有效载荷都是以连续的字节流存储的,它允许应用层读取/写入该TCP缓冲区任意字节的数据。也就是和UDP不同的是,UDP只能读取固定大小的数据,当然也就是UDP长度减去UDP报头大小的数据。当然值得注意的是,当应用层在读取TCP的接收缓冲区或者写入TCP的发送缓冲区,它虽然可以读取/写入任意字节数据,但它实际上并不会这种做,因为应用层对于一个完整的数据而言其自身是知道该完整数据具体采用的是何种格式进行控制的,所以如果应用层想要从TCP缓冲区中读取到一个完整的数据,那么它就会通过循环判断的方式不断调用特定接口(recv/read),从而间接的获取到TCP缓冲区中特定的某一段字节流。所以此时我们就明确,因为TCP协议面向字节流,其读取数据的方式由应用层自己完成,所以它不需要像UDP一样知道TCP报文的长度。当然对于面向字节流而言,为什么一定要面向字节流呢?好处非常多,最好理解的就是因为其在传输过程中根本不关心报文的完整性,只要对特定的字节流进行传输就行,既方便又高效。所以对于有关TCP报文有效载荷和报头的分离我们就明确了,剩下有关封装和分用的知识同理UDP协议我们不多说。下面我们就通过具体的场景,正式进入TCP报头中不同字段的理解。
TCP协议的可靠机制
同理TCP协议的特点可知,TCP协议最关键的就是理解其为什么是一个可靠的数据传输协议。根据上述所说的有关可靠机制,我们明确这些可靠机制本质就是为了避免一些数据传输过程中不可靠的现象,如:丢包、乱序、重复、发送太快/太慢等。当然之所以会产生这些不可靠的现象,本质是因为数据传输距离导致的,而因为距离是一个客观存在的问题,所以想要实现远距离可靠数据传输,此时就需要有很多的可靠机制,TCP协议应运而生。所以此时想要深入学习TCP协议,我们不仅仅只是学习它的报头和可靠机制,更为重要的是我们需要将其结合起来学习,从而真正明白为什么。因此接下来我们就结合这些可靠机制来学习TCP报头中的字段。
1.确认应答机制
从上述的描述中,我们知道当一台主机与另一台主机进行数据跨网络传输时,因为两台主机之间距离的问题,在数据传输过程中可能会因为各种问题导致一些不可靠的现象。而其中最为常见,最为严重的就是丢包问题,所以显然TCP协议作为一个可靠的协议,它首先需要解决的就是丢包问题,当然策略也就是使用确认应答机制,但更重要的是明白什么是确认应答机制。同理策略来源于生活,如上篇博客中我们谈到的寄快递,我们若是想要知道对方是否收到,最可靠的途径就是收到对方的回复。所以我们明确对端回复或者说对端应答是一种可靠的表现,当然并不是说快递一定要对方成功收到,也可以没有收到,而只要对方回复我或者说应答我,那么我就认为这个过程是一个可靠的过程。那么同理网络数据传输,我想要知道对方是否收到我发送的数据,换一个说法也就是我想知道是否发生了丢包行为,那么我也可以用上述方法,让对端主机给我一个响应,当然这种通过响应来实现可靠数据传输的方法也就是TCP协议中的确认应答机制。但值得明确的是,确认应答机制采用的是一种默认行为,也就是一种自以为的行为。如何理解呢?当数据从一台主机被发送到另一台主机,根据TCP协议规定,当然也就是确认应答机制规定,另一台主机必须响应,而因为这个响应也需要经过网络传输,那么它也就可能会存在丢包情况,所以为了保持一端的可靠性,确认应答机制就规定若是在规定时间内并没有收到对应数据的应答,那么我就认为对应数据发生了丢包,我就进行重传或者补发,当然该默认行为也就是在避免鸡生蛋,还是蛋生鸡的问题。当明白了上述过程,两端间数据传送一端的可靠性我们就能保证,那么同理另一端也使用该方法,发送数据时需要收到应答,若是在规定时间内没收到,那么也默认进行重传或者补发,这样两端之间的丢包问题就变成了一个可靠的行为。并且其中对于规定时间内而言,也被称为重传超时时间(RTO),明确由于网络环境等原因,该重传超时时间一定不是一成不变的,而是由操作系统动态规定的,本质也就是防止在RTO时间内收不到非丢包情况下的应答,所以就规定每次进行重传或者补发之后,该RTO时间需要随着固定指数进行增长。
2.结合报头深入理解确认应答机制
首先明确,两端通信无论是什么数据,它都需要经过网络协议栈,而需要经过网络协议栈,那么它就需要经过其每一层的封装和分用过程,当然本质也就是将每一层的报头或者说结构体变量添加到报文的首部并初始化。所以针对于传输层TCP协议而言,两端通信过程一定是通过发送TCP报文完成的。也就是无论是数据,还是应答,其本质都是TCP报文,而谈到TCP报文,那么肯定就离不开TCP报头,所以对于确认应答机制中的应答而言,其不过就只是报头中众多标志位的一个而已(ACK)。当然具体TCP报头中的标志位以及每一个标志位表示什么含义,后续我们结合具体场景来谈。此时我们需要知道的是,对于两端通信或者说数据的发送它不是单纯串行进行的,当然也就是为了提高数据传输的效率,对于两端通信而言,在数据传输过程中也可能采用的是并行传输,具体使用那种传输方式与后续学习的拥塞控制以及滑动窗口有关,具体如下图所示:
如上图所示,当两端在数据传输过程采用的是并行传输,也就是在同一时间发送了多个报文,那么根据确认应答机制,我们就会在同一时间收到多个应答,那么此时问题就来了,操作系统应该如何判断那个应答对应的是那个报文呢?所以面对这个问题,TCP协议的解决方案就是对报文进行编号,当然也就是TCP报文中序号和确认序号字段所起的作用。那么TCP协议是如何进行编号的呢?是固定给每个报文一个序号,当接收方收到该序号报文之后,响应一个相同序号的报文吗?答案显然不是,当然具体为什么不采用这种策略,TCP协议肯定是处于多个维度来考量的,站在我们的角度最好的理解方式就是效率问题,当然是区别于当前TCP的编号策略来看。所以接下来我们就正式理解一下TCP协议的编号策略。
3.TCP协议的编号策略
首先明确,TCP协议的编号策略本质是对确认应答机制的一个补充和完善。这部分知识本应属于上部分深入理解确认应答机制,但是因为这部分知识非常重要,所以我们对其重起一个标题。明确序号(SEQ)表示的是TCP报文中有效载荷的起始位置,这个序号通常是在TCP连接建立时初始化的,并在发送过程中逐步递增。也就是传输层在建立连接完成时,第一个报文的序号就被初始化完成了,之后每一个报文的序号都是以上一个序号和上一个报文有效载荷的长度相加来递增。而对于确认序号(SEQ)而言,它表示的是TCP接收缓冲区已存储数据最后一个位置的下一个位置。上述只是简单对其客观表示含义进行总结,在没有图示以及本质理解的情况下,对于序号和确认序号的理解还是非常抽象的,所以下面就让我们以两幅图来见识一下序号和确认序号的本质理解吧!
结合上图和我们上述对序号和确认序号的理解,可以看出,在两端连接建立完成之后,序号可能就被初始化为了1,然后因为有效载荷的长度为1000(单位/字节),所以最后对端发送的响应序号就为1001,并且发现下一个TCP报文的序号也为1001。所以这也就是为什么上述我们说序号就是有效载荷的起始位置,是通过上一个序号和上一个有效载荷长度相加递增获得,而确认需要就是接收缓冲区已存储数据最后一个位置的后一个位置的理解来源。当然从上图中的现象,也就是第二个发送报文的序号和第一个报文的应答序号相同,你也可以认为在连接建立之后的序号来源于应答序号。但是这样理解是不准确的,同理上述所说TCP报文的发送可以是并行传输,如果按照上述理解,就会导致你发送第二个报文,一定需要第一个报文的应答序号,显然是不可能的,所以对于序号来源的正确理解就是上述所说,同理对序号来源的理解,那么我们此时就明白,为什么TCP报文支持并行传输,本质也就是序号是源源不断产生的,你要我就可以按照规定给你计算一个。当然具体为什么允许这样计算,下述深入过程我们详谈,这里我们还要注意的是,明确传输层并没有数据接收/发送能力,只有硬件具备数据接收/发送能力,所以虽然序号是一个一个产生的,但并不影响下层发送数据时,按照并行的方式发送。下面我们再从另一个角度深入学习序号和确认序号。
想要深入理解序号和确认序号,那么我们就需要从TCP接收缓冲区和发送缓冲区来看。明确无论是TCP接收缓冲区,还是发送缓冲区,从代码实现角度其都只是一个以字节为单位的数组(队列)。并且因为TCP接收缓冲区和发送缓冲区支持上层直接获取数据和写入数据,所以单纯从报文角度出发,其内部存储的就是有效载荷。明白了这些之后,此时我们就可以理解为什么应答序号表示的是TCP接收缓冲区已存储数据最后一个位置的后一个位置。结合TCP协议可靠数据传输理解和编号策略,为什么这种设计序号和确认序号就能保持协议的可靠性呢?举个例子,当在图书馆整理书籍的时候,我想要按照书名第一个字的首字母的英文顺序来整理,那么每本书都有其对应的位置,而最不容易放错位置,也就是最可靠的方式一定是按顺序整理。那么同理在两端通信过程中,发送端和接收端为了实现一个可靠的确认应答机制,同理就需要有一个可靠的编号策略,也就是让接收端的接收缓冲区也按照顺序,也就是数组下标顺序接收数据。所以同理上图假设TCP报文序号为1,有效载荷长度为1000,且为第一个发送报文,那么此时对于接收端而言,这1000个字节的数据一定就按照顺序被存储到了接收缓冲区中,也就是数组中的0号下标到999号下标之中。所以当接收端接收完一次数据之后,下一次它期望接收的序号就是1001,当然对于数组下标而言,它期望的就是将数据存储在下标1000中,并且因为该次数据被成功接收,所以接收端在应答时,就会将自己期望接收的序号作为确认序号应答给发送端,同时告诉发送端,该确认序号之前的数据全部接收成功。而从数组的角度去看,之所以确认序号能表示之前发送数据全部接收成功的本质原因就是因为其是按照数组下标的顺序存储数据。当然值得一提的是,因为网络传输中网络设备的不同导致对于数据处理能力的不同,可能就会产生乱序到达的问题,但是因为接收端按照顺序存储数据的同时,它会设置期望序号,所以我们并不担心该乱序问题。举个例子,发送端同时发送3个长度为100字节的数据,序号分别为101/201/301,当然因为你此时想要发送101/201/301序号的报文,那么应答确认序号一定是101,也就是说接收端的接收缓冲区已经有部分数据了。而此时同上述所说301报文最先到达,其次是201,最后才是101,那么因为接收端需要按照顺序接收数据,也就是期望收到101序号的报文,当301被接收到时,接收端会将其暂时存储在一个临时存储区(乱序缓冲区)中,并继续等待期望序号,只有当其按照正确顺序接收到所有中间报文之后,也就是接收到101和201之后,301才会被处理。所以可以发现,确认序号是多少与序号没有任何关系,其只是一个应答策略,告诉发送端那些数据被成功接收,那些数据丢包,也就是如果发送端只收到301确认序号,那么就算没有101和201序号也没有任何关系,因为我知道接收端是顺序存储的,所以判断其应该是101和201应答丢失,不需要进行数据重传或者补发,反之若我只收到了101确认序号,那么此时就需要重传201序号的报文和301序号的报文。这也就是为什么该编号策略能完善确认应答机制以及提高数据传输效率,因为其大大减小了重传的概率。当然因为TCP发送缓冲去还有涉及有关滑动窗口的知识,所以部分细节问题我们在滑动窗口部分详谈。
补充:捎带应答
通过上述描述,此时对于有关序号和确认序号无论是简单理解还是本质认识的知识,我们都搞定了,但我们可以发现一个现象,按照我们所说,那么接收端和发送端每次都会在TCP报头中携带一个编号,当然也就是序号。那么问题就来了,为什么需要区分为序号和确认序号,也就是在TCP报头中设计两个不同的字段呢?为什么不直接搞成一个叫编号的字段呢?反正每次发送确认序号,序号字段不使用,发送序号则确认序号不使用。当然TCP协议这样设计肯定是有其它使用场景的,也就是接收端可能在应答的同时,也想要发送数据,所以在这种情况下,此时就不仅需要有序号,也要有确认序号了。因此这种应答方式也就是被称为捎带应答。同理这种应答方式可以大大提高数据传输效率。当然对于提高效率而言,TCP协议也算是煞费苦心,其中就还包括另一种应答机制,叫做延迟应答,当然具体延迟应答是什么,因为其涉及到有关通告窗口和滑动窗口的知识,下述详谈。
4.理解流量控制机制
当我们搞定了上述有关确认应答机制以及序号编号等问题,我们的脑海中就有了一个TCP报文传输的基本方式,当我们带着这个理解去学习其它的知识就会较为容易。首先同理从问题出发,想要理解什么是流量控制机制或者说什么是流量控制,我们就要搞懂为什么需要有该机制。同理我们都知道,对于TCP协议而言,它不仅有发送缓冲区,它也有接收缓冲区,应用层可以通过特定的系统调用接口send/recv对该缓冲区做数据拷贝。而若是在两端通信过程中,其中一端因为某些特殊的原因,导致其应用层发送数据太快或太慢,反之同理读取太快或太慢。那么此时就会导致一些不合理的现象发生,如接收缓冲区因为对端数据发送太快存储不下,导致接收数据被丢弃,或者是接收缓冲区因为对端数据发送太慢,导致上层读不到数据。由于这些都是低效的表现,所以对于操作系统而言,当然此时也就是对于TCP协议而言,它是不允许出现这种低效的表现的。因此流量控制机制应运而生。那么此时问题就来了,我们应该如何控制发送速度和接收速度呢?换言之也就是流量控制机制应该如何实现呢?通过这两个问题,我们就可以深入理解流量控制机制,首先明确流量控制机制是为了实现发送速度和接收速度相互协调统一,所以也就是说发送速度需要由接收端的接收能力决定。所以想要实现流量控制,那么发送端首先就需要知道接收端的接收能力,也就是说接收方在与发送方通信的过程需要将自己的接收能力告诉对方,当然也就是通过TCP报头中的某字段来实现。当然该字段也就是上述报头示意图中的16位窗口大小字段,也称为通告窗口字段。当然因为接收缓冲区的本质就是一个数组,所以对于该数组还有多少剩余空间,TCP协议或者说操作系统自身肯定是明确的,其只要在发送TCP报文时,将其读取出来并且初始化报头中的窗口大小字段就行了。这样当发送端每次都能获取到接收端接收缓冲区剩余空间的大小,那么发送端就有了控制发送速度的依据,从而让两端间的数据传输速率协调统一。当然对于发送太慢问题,也就是接收缓冲区剩余空间太大,同理流量控制机制。明白了上述所说之后,此时我们再解决两个较为容易忽视的问题。我知道因为我发送数据之后对端需要给我应答,从而可以从该应答中知道对端的通告窗口大小,那么我在第一次发送数据时应该如何知道对端的通告窗口大小呢?答案其实很简单,因为TCP协议是一个有连接的协议,所以在进行数据通信之前,两端在建立连接的过程中,发送端就已经明确了接收端的接收能力,当然也就是通告窗口大小。所以我们明确,TCP协议在进行三次握手建立连接的过程中,TCP报头中除了SYN,ACK等字段可能会被赋值,通告窗口也会被赋值。同理问题二,如果接收端缓冲区已经满了呢?明确此时发送端就会进行通告窗口探测,也就是会定时发送不携带任何数据的TCP报文来获取接收端的应答,从而定时知道接收端的接收能力。当然反之对于接收端而言,它也会定时进行通告窗口更新通知,也就是双向奔赴效率较高。
5.理解滑动窗口策略
对于当前的我们而言,我们知道想要实现网络通信,在应用层就必须使用各种套接字API,拥有属于自己的套接字描述符,想要实现TCP协议的网络通信,那么就需要建立连接(connect),所以对于客户端和服务端来说,它们因为使用套接字通信拥有属于自己的套接字描述符,所以它们之间的连接是独立存在的,也就是说它们之间的资源是独立存在的,服务器上对应的接收缓冲区中的数据来源一定是对应的客户端发送缓冲区。明白了这个点之后,我们正式来谈谈什么是滑动窗口。首先我们明确,TCP协议除了为了实现可靠性而形成了非常多的策略之外,它为了提高数据传输效率也产生了非常多的策略,如我们上述所说的捎带应答和并行发送数据。而其中TCP协议为了实现并行发送数据,此时它就产生了滑动窗口的策略。所以对于上述讲解确认应答机制以及序号完善过程中涉及到的数据并行发送,本质利用的就是滑动窗口。所以此时真正谈到滑动窗口时,我们就结合下图深入的来看一看什么是滑动窗口。
上述我们在理解序号编号问题时,引入了数据并发发送的场景,通过数据并发发送,我们明确了序号和确认序号的来源以及表示的含义。并在上述学习流量控制时,我们有一个共识,发送端一次可以发送的数据大小是由接收端的接收能力决定的,所以此时我们就可以发现发送端一次可以发送的数据就等于接收端通告窗口的大小,而因为发送端想要一次发送更多的数据它就需要使用并行发送,所以也就是使用滑动窗口策略进行数据发送。因此结合上图所示,在接收端通告窗口允许的前提下,我们就可以将1001字节到5001字节中的数据以滑动窗口的方式进行发送,也就是将1001到5001字节的数据分成序号为1001/2001/3001/4001四个大小为1000字节的报文进行并行发送。然后同理上述确认应答机制接收端通过设置期望序号实现有序接收的原理,我们明确被发送报文只能按照1001/2001/3001/4001的序号被接收,任何一个丢失都会导致后序报文无法被接收,导致接收端需要等待发送端重传。而就是因为发送端可能还会因为丢包现象发生超时重传,所以当我们并行发送完发送缓冲区中的某一段数据时并不能直接跳过该段数据,也就是当我们将滑动窗口中的数据全部发送出去之后,此时滑动窗口并不能立即移动,它需要考虑丢包问题,所以对于滑动窗口而言,它只有在收到对端的确认应答之后,它才能根据该应答中的确认序号以及通告窗口大小来进行滑动。如上图所示,当发送端成功收到了2001序号的应答,那么此时在通告窗口不变的情况下,发送端的滑动窗口就可以向右移动且移动大小为1000字节。所以因为我们可以直接并行发送发送缓冲区某段区间中的所有数据,也就是类似于一个窗口,且该窗口会随着确认序号移动,滑动窗口因此得名。而因为序号来源于前一个序号加上有效载荷长度,所以我们可以在发送端同时获取到多个序号,当然这也就是发送端支持并行发送的理由之一,所以我们明确对于滑动窗口而言,它不需要等待应答的确认序号,当然也就是不需要像固定窗口一样,每次发送数据都需要等待接收端的应答确认序号。当然具体为什么有滑动窗口的同时还需要有固定窗口,本质与拥塞控制机制有关,下述我们详谈。因此结合上述所说,此时我们就可以明白好几个点,滑动窗口只能向右移动,滑动窗口想要向右移动就需要首报文的应答序号,滑动窗口的大小由通告窗口更新,超时重传数据存储在滑动窗口之中,滑动窗口可以变大也可以变小。所以应该如何理解这些点呢?应该从什么角度来理解这些点呢?其实问题很简单,在上述过程中我们就谈到了TCP接收缓冲区和发送缓冲区的本质就是一个以字节为单位的数组。所以同理滑动窗口而言,其也就是数组,所以对于滑动窗口的起始位置(start)和结束位置(end),也就是滑动窗口的区间本质就是一个数组的子序列。而对于数组的某段子序列而言,想要控制其移动,只需要使用两个指针就行了,当然对于我们而言指针就是资源唯一标识,所以此时控制滑动窗口移动的本质就是两个数组下标,通过对下标做数值运算实现移动。所以此时对于理解滑动窗口只能向右移动,本质就是数组下标只能做加法运算,对于理解滑动窗口移动需要首报文的确认序号,本质也就是只有收到了确认序号,也就是保证了该部分数据传输成功start位置才会更新为确认序号,当然为什么start是更新成确认序号,本质同理确认序号的理解。对于理解滑动窗口大小由通告窗口更新,本质理解也就是在通告窗口大小不变的情况下,end位置会更新成start位置加上通告窗口大小的数值运算值。而对于理解重传数据是被存储在滑动窗口中,本质也就是滑动窗口会将发送缓冲区区分为已发送数据区,当然也就是滑动窗口的左区域,以及未发送数据区,同理右区域,以及已发送但还未收到应答数据区,同理滑动窗口数据区。最后对于如何理解滑动窗口的大小是可变的,此时需要从两个方面来理解,若接收缓冲区因为上层读取速度慢或者没有读取,那么因为我们在当时已经将接收缓冲区能接收的最大数据发送,所以此时就会导致通告窗口减小的大小等于start向右移动的大小,那么此时就会出现start不断向右移动,但end不变的情况,也就是滑动窗口减小的场景。同理滑动窗口变大会更好理解,这里不多说,值得注意的是明白start向右移动同时end不变,因为start移动就表示数据发送成功,因此并不会导致start错过某些数据。所以从代码的角度我们就可以更好的深入理解滑动窗口了。并结合上述开头所说,TCP接收缓冲区中的数据一定来源于发送缓冲区,所以如果将两端通信的两个网络协议栈抽象成两个传输层或者说两个缓冲区来理解,对于上述有关知识,也就是数据的发送接收就可以很好的理解成是数据的同层搬运或拷贝。最后明确不仅仅只有发送缓冲区因为并行发送有滑动窗口的概念,接收缓冲区本质也是一个滑动窗口,只不过滑动策略与发送缓冲区不同。而无论是那个滑动窗口,最后其都是环状结构,这个不难理解,也就是用数组实现一个环形队列嘛!
补充:快速重传
明确上述知识之后,滑动窗口全部搞定。此时我们再来谈一个与滑动窗口有关的概念,也就是快速重传(高速重发控制)。同理上述谈滑动窗口所说,TCP协议不仅是一个可靠的协议,它也是一个高效的协议,如此时的快速重传就是其中一个提高数据传输效率的策略。怎么理解呢?从滑动窗口并行发送数据来看,若是同时发送了多个TCP报文,那么因为接收端有序接收的规则,并且此时第一个序号的报文丢失了,也就是同理上述举例说发送101/201/301/401报文而101报文丢失。那么此时201/301/401报文到达时就不会被正常接收,而需要等待101报文的超时重传,那么因为超时重传有一个重传超时时间(RTO)需要等待,那么显然数据的传输效率就不高,所以为了解决这一问题或者说为了提高数据传输的效率,此时TCP协议就规定了若是发送方收到了三个相同序号的应答,那么它就不再等待RTO时间,直接将最近报文或者说滑动窗口的首部报文进行重传,以此来提高效率。所以对于我们来说,我们就发现当一个非期望序号报文到达接收端时,接收端除了将该序号报文暂时存储之外,它还会将期望序号作为该报文的应答序号响应给发送方,从而提高快速重传机制的触发概率。最后明确,超时重传和快速重传两者并没有区分和冲突,快速重传只是一种提高效率的策略,而超时重传才是TCP协议可靠的核心重传机制。
6.理解拥塞控制策略
同理我们一直在说,发送方一次发送数据的大小是由接收方的接收能力决定的,如果通告窗口够大我们就可以使用滑动窗口的策略发送任意数据,事实真的是如此吗?从一个目前已知的场景出发,在通告窗口允许的前提下,发送方采用的是滑动窗口并行发送多个TCP报文,那么此时问题就来了,我都知道通告窗口允许,那么我为什么不直接把滑动窗口中的数据作为一个TCP报文发送出去呢?所以想要清楚的搞懂这些问题,此时我们就需要理解一下什么叫做拥塞窗口,什么又叫做拥塞控制。回到问题一,在通告窗口允许的前提下,我们真的可以发送任意大小的数据吗?答案显然是否定的,明确两端通信虽然指的是两端,但是在整个互联网之中有无数的两端,而这无数的两端想要通信,那么数据都必须由网络进行传输,那么显而易见网络中的数据一定是一个非常庞大非常惊人的量。所以如果当所有主机都允许向网络中发送任意数量的数据,那么此时就会导致网络拥塞,而网络拥塞直接导致的结果就是数据大量丢失,而如果在数据大量丢失也就是网络拥塞的情况下,所有的主机还在不断重传大量的数据,那么显然就是一个趁其病要其命的举动,最后结局肯定就是网络崩溃。所以合理吗?显然不合理,那么如何解决呢?拥塞控制应运而生。所以到底什么是拥塞控制呢?想要理解拥塞控制,我们首先要明白为了避免网络崩溃,也就是避免上述行为的发生,任何一台主机在发送数据时,都不能盲目乱发,也就是不能直接根据接收方的通告窗口大小发送数据,而需要明确当前网络的健康状态,依据当前网络的健康状态发送合适数量的报文,当然还需要考虑接收端的接收能力,这是后话我们目前以网络健康状态为依据。所以此时TCP协议为了衡量当前网络的健康状态,就引入了拥塞窗口的概念,因此对于主机而言,拥塞窗口就是衡量网络健康状态的指标。而又因为网络健康状态不是一成不变的,受到时间地域等因素的影响,所以对于拥塞窗口而言,它一定是一个不断变化的值,因此TCP协议为了规范拥塞窗口的大小,实现前期发送数据慢,但增长块。它就采用了一种叫慢启动的机制来控制拥塞窗口的变化,不用多说对于TCP协议而言,这就是拥塞控制。而其中对于慢启动而言,如下图所示:
同理上述所说,任何主机发送数据都需要知道拥塞窗口的大小,而想要知道拥塞窗口的大小最直接的方法当然也是唯一的方法就是探测与尝试,也就是发送数据,只不过它不是乱发而是如上述所说使用慢启动的方式来发。如图所示也就是从小到大发,只不过此时这个从小到大的过程是一个TCP协议认为合理的过程,后续详谈。此时我们心中的疑问应该是,我知道拥塞窗口会不断变大直到发生网络拥塞为止,那么主机是如何知道当拥塞窗口为该值时,发生了网络拥塞呢?所以对于这个问题,TCP协议就又有了一系列的机制来判断网络是否发生拥塞。如我们上述学习滑动窗口中所说当主机多次收到同一个应答序号或者是同一个报文需要进行多次超时重传,主机此时就有理由认为网络可能发生了拥塞,从而触发拥塞恢复机制,具体什么是拥塞恢复机制后续详谈。当然对于TCP协议而言,判断网络是否发生拥塞的方法还有很多,如网络延迟,丢包率,网络吞吐量等。所以我们明确一个拥塞窗口是否代表网络拥塞,我们的主机能通过各种方法了解到,拥塞窗口自己并不关心,我们也不关心。而对于拥塞窗口而言它关心的是一个被称为阈值的值,我们关心的则是拥塞窗口从大到小的过程,具体如下图所示:
不多说上图就是一个拥塞窗口变化图,纵坐标为拥塞窗口的大小(单位/字节),正常情况下一般为几千到几万字节。横坐标为传输次数,也就是成功传输报文的次数。其中对于拥塞窗口而言,它有一个阈值(ssthresh),初始值为16,发现当拥塞窗口的值小于阈值时,其增长方式为指数增长,而大于阈值时呈线性增长。其中当拥塞窗口的值呈线性增长时也被称为拥塞避免阶段,并且随着拥塞窗口的值不断增大,最终当主机检测到可能存在网络拥塞时,此时就会发生上述所说的拥塞恢复行为,如图也就是将拥塞窗口的值重新置为一的同时,阈值也随上一个阈值发生乘法减小,随之开始第二次慢启动。并且从图中我们可以看出,拥塞窗口的增大是由成功发送报文的次数决定的,也就是说每一次报文发送成功,那么拥塞窗口就会增加一个MSS(最大分段大小),从而使得拥塞窗口在每次往返时间(RTT)内能高效地双倍增长,当然前提是在拥塞窗口小于阈值的情况下。明确了这些之后,此时值得我们注意的是,如图慢启动虽然没有经过几次指数级增长就达到了阈值,从而开始以线性方式增长,但这并不影响最终拥塞窗口的大小变成几千到几万字节,因为在正常情况下两端传输的速度是非常快的,而因为这点所以不能让指数级增长过快,不然就会导致在10次数据传输之后,拥塞窗口变得非常大。最后,当我们理解了上述有关拥塞控制的知识此时我们就能很好的回答上述开头处的问题,为什么不直接发送大报文,而是将其拆分成多个小报文呢?其中原因之一就是因为其要满足拥塞控制,不然若是在网络拥塞的情况下发送了一个非常大的报文,那么同理上述所说可能造成网络崩溃。当然还有很多其它的原因,如IP层最大传输单元(MTU)的限制,防止丢包重传效率低等。当然对于拥塞控制而言,它也有属于自己提高效率的方法,也就是对于使用上述慢启动机制控制拥塞窗口大小的策略在如今已经发生了变化。具体如下图所示:
从上图可以看出本质还是同一个套路,只不过当第一次慢启动完成之后,它不再是慢启动策略,而是使用了快速恢复策略,以此来提高数据的传输效率。并且当我们明白了什么是拥塞窗口以及什么是拥塞控制,此时我们就知道对于发送端主机而言,它一次可以发送数据的大小并不是只由接收端的接收能力决定,更重要的是它还要考虑拥塞窗口的大小。所以对于发送端实际一次发送数据的大小来看,其一定是取接收端通告窗口和当前网络拥塞窗口的最小值。最终明确,TCP协议除了帮我们考虑了对端接收能力的问题,当然也就是流量控制的问题,它也帮我们考虑了网络的问题,当然也就是网络拥塞的问题,充分体现出了TCP协议的可靠性以及设计的严谨性。
补充:延迟应答
同理上述所说,TCP协议不仅是一个可靠的传输协议,它也是一个高效的传输协议。所以对于延迟应答而言,首先我们明确的是它是一个提高效率的策略。所以当我们学到目前为止,TCP协议中我们能理解的、能提高效率的策略我们就全部认识了,也就是上述谈到的什么捎带应答、快速重传、快速恢复、滑动窗口,当然还有此时将要学习的延迟应答。所以延迟应答是如何实现提高效率的呢?其实很简单,也就是当接收端接收到数据需要应答的时候,我们不会立即应答,而是延迟应答。本质也就是采用延迟应答的方式,可以为应用层读取接收缓冲区增加一定的时间,从而使得接收方的接收缓冲区变大,最终在应答的时候可以应答一个更大的通告窗口。当然因为发送方发送数据还受到拥塞窗口的影响,所以这种通过增大接收缓冲区大小的方式并不一定能让发送的数据量增大,其只是为了提高发送窗口,当然也就是滑动窗口更大的可能性而已。当然之所以能够采用这种策略,本质除了超时重传为其提供了一定的可额外使用时间之外,更重要的是对于应用层读取缓冲区数据的速度而言不可谓不快。最终明确,窗口决定吞吐量,而吐吞量决定传输速率。
完善剩余知识:标志位
该篇博客从开篇到现在,还有一些剩余知识没有讲完,如上述讲解TCP报头时部分字段在具体场景中学完了,但是还有部分字段没有特定的场景,所以只能留到现在来进行补充,其中就包括紧急指针、校验和、标志位以及保留字段。而其中对于保留(6位)字段而言,这就是当时TCP协议设计时没有用到的六个比特位,没有具体含义但以后可能会用到。而对于检验和而言我们不详谈,只要知道它是用于校验对应报文是否完整,保证报文的完整性以及准确性就行。而对于紧急指针而言,它需要配合URG标志位使用,所以我们真正要完善理解的其实就是标志位字段而已。首先明确,为什么要有标志位?好比现实生活中的人,大部分人都有自己的任务需要完成,有的人的任务可能是相同的,也可能是不同的。所以对于TCP报文而言,不同的报文也是有类型的,不同类型的报文就代表着不同的处理动作。所以对于标志位而言,它就是用来区分不同TCP报文的类型,从而让该TCP报文可以完成指定的工作。接下来我们就来认识几个基础标志位,其中对于ACK而言,其表示的就是确认应答报文,而当其被置一时完成的工作就是告诉发送端对应报文发送成功。对于SYN表位置而言,其表示的就是连接请求报文,当其被置一时完成的工作就是告诉接收端某某发送端想要与其建立连接实现可靠数据传输。而对于FIN标志位而言,其表示的就是连接断开请求报文,同理不多说。而对于PSH标志位而言,其表示的是一个提示报文,当其被置一时完成的工作是建议接收端立即将接收缓冲区内的数据交付给上层。而对于RST标志位而言,其表示的是一个连接重连的报文,当其被置一时完成的工作就是告诉接收端某某发送端因为某些特殊的原因,需要与其重新建立连接,也就是连接重置。最后对于URG标志位而言,其表示的是一个提示报文,当其被置一时完成的工作就是告诉接收端此时紧急指针字段需要被处理。而对于紧急指针而言,它的作用是告知接收端,在接收到紧急数据时,应该立即处理这部分数据,而不需要等待其它数据的传输完成。紧急指针的值表示紧急数据在有效载荷中的偏移量,接收方可以根据这个偏移量来提取和处理紧急数据。
TCP协议的面向连接
该篇博客来到这里,有关TCP协议可靠性的问题我们就全部学完了。本质也就是博客开头所说有关确认应答机制、序列号编号、超时重传、流量控制、拥塞控制等,当然我们在强调TCP协议各种可靠机制的同时,也认识了TCP协议的各种提高数据传输效率的策略,也就是上述有关捎带应答、快速重传、快速恢复、延迟应答、滑动窗口的理解。所以接下来我们就来学习一下TCP协议另一个非常重要的特点:面向连接。当然对于面向连接而言,因为TCP协议采用的连接管理机制为三次握手和四次挥手的方式,而我们在之前使用套接字实现网络通信时对三次握手和四次挥手的过程有了一定的认识,明确我们在使用套接字API时connect接口就是我们在应用层实现与目标主机建立连接的触发接口,也就是说当我们调用connect接口时操作系统或者说TCP协议就会帮我们开始与目标主机进行三次握手过程,而对于connect接口或者说对于应用层而言此时就是一个等待连接建立成功的过程。并且同理对于目标主机或者说一般是服务端而言,它在应用层调用accept接口的本质也就是让操作系统帮其获取并管理相应连接,当然前提是该服务器处于listen状态时刻监听是否有请求建立连接的TCP报文。这也就是当时我们在学习套接字时各种API都有其固定用法的原因所在,一切都是操作系统规定好的。回顾完上述对于套接字API与三次握手四次挥手相关的知识,当然四次挥手对于应用层而言就是调用close接口关闭对应的套接字描述,此时我们不再从应用层的角度去看待三次握手和四次挥手了,而是从传输层TCP协议的角度去理解三次握手和四次挥手。首先第一个问题就是为什么一定是三次握手或者四次挥手呢?想要明白这个问题,我们先从连接是什么的角度来看,从上述所说可以发现若是两端想要建立连接,那么它们就需要调用系统调用接口,从而让操作系统帮助其去建立和管理连接,所以我们明确对于建立连接而言操作系统肯定是需要耗费资源的,如连接的管理和维护需要内存资源,连接的正常使用需要CPU资源。而当我们明白了连接就是资源的概念之后,此时我们就可以正式的来理解为什么一定是三次握手和四次挥手,如下示意图所示:
首相我们明确对于TCP协议而言,之所以需要面向连接本质还是为了保证数据传输的可靠性,也就是通过两端先建立连接,从而确保两端已经准备好进行数据传输且具备数据发送和接收的能力的同时验证对方目前的身份和状态。好比我们之前在流量控制中所说的TCP协议可以通过三次握手过程获取到对方通告窗口的大小以明确对方的接收能力。所以TCP协议面向连接就是在建立一个可靠的通信信道方案。因此对于三次握手而言之所以是三次而不是其它次,第一个原因就是三次握手是建立一个可靠通信信道的最小成本。那么问题来了,为什么三次就可以建立一个可靠的通信信道呢?我们要明白,对于一个协议而言它不是一天两天就能定出来的,它可能要经过几年甚至几十年的不断完善,因为协议中各种策略与策略之间是要相关挂钩相互影响的,如此时谈到的三次握手就与TCP报头中的标志位RST不可分割。也就是说在进行三次握手时,我并不担心第一次和第二次连接请求报文丢失,因为它们丢失并不会涉及到资源的利用并会发生超时重传,但如果第三次应答报文丢失那么此时就涉及到了资源的利用,也就是说当发起连接请求的一方收到对端的响应之后,其操作系统就会分配对应的资源来维护该连接进入ESTABLISHED状态表示连接建立成功。所以此时当第三次报文丢失就会导致两端连接不一致也就是连接建立失败的同时造成资源浪费。当然对于第三次应答而言,若是服务端第一次没有收到这个应答它会对第二次的请求报文进行重传,但如果一直都没有收到应答也就是超过了重传次数那么就会发生上述情况。所以TCP协议为了避免这种情况的发生,它就设计了一个RST标志位用于发送连接重置请求。也就是当发生上述现象,因为请求建立连接的一方认为双方的连接建立成功,所以它就会主动向对方发送数据,而当接收方或者说服务端发现自己接收到了非连接客户端发送的数据,此时它就意识到可能是连接建立异常导致的,最后服务端就会根据这个问题采取对应的动作,当然也就是使用RST标志位向对方发送一个连接重置请求报文,循环往复直到连接建立成功。所以上述就是为什么三次握手能够在任何情况下成功建立连接的原因。那么问题又来了,难道使用其它次握手就不行吗?因为存在确认应答机制,所以我们直接从奇数次和偶数次的角度来看,但注意此时有两个前提,其一我认为首先发送连接请求报文的都是客户端,其二是同理三次握手中的第二次握手我认为服务端的SYN报文当然也就是连接请求报文是通过捎带应答的方式发送的。有了这两个前提之后,此时我们就可以发现如果握手的次数是奇数次,那么首先建立连接的一定是发送连接请求的一方,也就是如果最后一次响应丢失,接收方因为该次应答丢失导致连接没有建立完成,所以接收方并不需要付出很高的成本而是将所有的成本转移给了发送连接请求的一方。而对于这个现象而言是合理的,因为对于客户端而言,它需要维护的连接数量远远小于服务端,所以允许我们将风险和成本交给客户端来承担。反之如果是偶数次的话,也就是客户端需要收到服务端发送的最后一个应答之后才建立连接,而服务端则一定在客户端之前建立连接,那么按照上述所说这样就是不合理的。所以这也就是我们使用奇数次握手而不使用偶数次握手的本质原因。而其中对于奇数次而言,同理上述所说三次是建立一个可靠通信信道的最小成本。当然之所以使用三次的理由以及原因还有非常多,这里我们就不再着重介绍,我们只要明确三次握手因为其并没有明显的设计漏铜,符合我们将成本嫁接到客户端的想法,且能够保证两端通信信道的可靠,所以我们用的就是三次握手。当然若是没有上述所说的第二个前提,也就是服务端SYN已捎带应答的方式发送,那么对于三次握手而言其也可以是四次握手,也就是客户端SYN服务端ACK,然后服务端SYN客户端ACK模式,当然TCP协议之所以不使用这种模式可能还是因为三次握手才是最小成本的原因吧!最后我们来认识一种网络攻击方式:服务拒绝式攻击。也就是通过不断发送连接建立请求,让目标服务器建立大量的连接消耗目标服务器资源,从而影响其某些正常连接请求建立的过程。明白了上述知识,有关TCP三次握手建立连接的所有知识我们就搞定,接下来我们同理从传输层的角度来看看四次挥手过程。首先明确四次挥手过程是一个两端断开连接的过程,至于为什么是四次而不是其它次的问题同理三次握手,因为四次挥手是断开两端连接的最小成本。我们更关心的是为什么不能是三次挥手,而必须进行四次报文传递,也就是客户端FIN服务端ACK,服务端FIN客户端ACK,当然具体谁先发送连接断开请求一般是由特定的场景决定我们并不关心。所以为什么四次挥手不能和三次握手一样在进行第二次挥手的时候直接将FIN通过捎带应答的方式响应给对端呢?原因有很多,其中最好理解的就是两端在断开连接这个问题上不能达成共识,造成一端想要断开连接但是另一端因为某些原因不想要断开连接,如数据还未全部发送完成等。所以如果在这种情况下,采用三次挥手就会导致数据丢失或者是连接断开失败。因此对于TCP协议而言它使用的就是四次挥手,因为四次挥手支持两端在未达成断开连接共识的情况下各自进行异步操作。当然对于四次挥手概念的理解就是如此,对于四次挥手而言,更重要的是要明确每次挥手之后两端之间的状态变化。因此下述我们就将围绕四次挥手过程两端之间的状态变化展开讨论。
四次挥手的状态变化
对于四次挥手的状态变化而言,首先我们明确无论是客户端还是服务端,它们若是想要断开某连接,只需要让操作系统关闭对应服务器或者客户端的套接字描述符,当然对于应用层而言就是调用close接口。所以从应用层的角度来看,对于四次挥手的本质理解就是两次调用close接口的过程。所以按照上述场景,也就是客户端想要断开连接而服务端暂时因为某些原因还不想断开连接,我们就可以通过去除服务端中的close接口实现,当然还有其它方案如使用shutdown接口,所以当服务端没有close接口,那么此时就会导致客户端首先发送FIN,从ESTABLISHED直接进入FIN_WAIT2状态,当然它会进入FIN_WAIT1状态但是因为服务端应答非常快所以我们大概率是看不到FIN_WAIT1状态只能看到FIN_WAIT2状态。而服务端则会进入CLOSE_WAIT状态,最后因为服务端没有close接口,所以服务端会一直处于CLOSE_WAIT状态,也就是连接没有断开或者说连接断开失败。而当客户端进入FIN_WAIT2状态并等待一会儿之后,发现服务端还未发送FIN连接断开请求报文,此时客户端就不会继续等待服务端的FIN,而是直接将对应连接关闭。而当我们在客户端将连接关闭之后,也就是FIN_WAIT2状态消失之后,将服务端进程直接kill,那么此时因为进程被kill也就是资源被回收,那么服务端的状态就会从CLOSE_WATI变成LAST_ACK。具体示例如下:
当然对于上述这种服务端不调用close接口的行为肯定是极其不合理的,首先它就不能正常完成四次挥手过程,其次会导致服务端一直处于CLOSE_WAIT状态,占用服务器连接资源,而如果当连接数量不断增大,也就是连接资源被不断占用,那么最终导致的就是该进程不能正常建立连接而崩溃。并且从上图中我们也可以看出,在这种情况下,若是想让服务端从CLOSE_WAIT状态变成LAST_ACK,那么就必须将该进程给终止。然后同理客户端机制一直发送FIN连接断开请求报文给对端,然而因为客户端早已经将连接关闭,所以此时服务端发送的FIN肯定是收不到应答的,当经过一定次数的超时重传之后,操作系统就会强制将该连接关闭。所以无论是服务端还是客户端,对于非正常情况下的四次挥手而言都会有对应的处理动作,以防止连接资源被占用。因此最后我们明白,对于FIN_WAIT2和LAST_ACK这种状态它们通常是不稳定的,也就是我们并不担心对应主机处于该状态,因为处于该状态一定时间之后操作系统一定会出手将其强制关闭,所以我们更担心的应该是CLOSE_WAIT状态,也就是该状态会长时间的占用系统资源。而当我们理解了CLOSE_WAIT状态之后,此时我们再来理解一下四次挥手的另一个重要状态TIME_WAIT状态,也就是当两端都正常进行四次挥手过程时主动发送断开连接请求的一方会进入TIME_WAIT状态,如下图所示:
通过上述对于四次挥手状态的理解,我们明白之所以被发送断开请求的一方需要进入CLOSE_WAIT状态,本质是因为我们要保证两端的异步性,从应用层的角度来说也就是我们在何时调用close接口应该由自己决定而不是对端决定。所以当某一台主机收到连接断开请求它就不能理解断开连接而是要等待上层调用close接口。那么同理此时对于TIME_WAIT状态来说,它为什么要处于TIME_WAIT状态呢?首先我们明确,根据四次挥手的过程而言进入TIME_WAIT状态的一定是主动断开连接的一方,并且从图中发现这个状态只会持续一段时间,一段时间之后便会消失同时对应连接关闭。所以我们的问题也可以理解成是为什么主动断开连接的一方需要等待一段时间呢?想要回答这个问题,首先我们明确对于系统内核而言这个时间一般是2MSL,也就是两次TCP报文最大生存时间,本质也就是保证一个在任意时间发送的报文的应答都可以在该时间内被接收。而当我们明白了这个时间具体表示的含义之后,对于TIME_WAIT状态的原因就有了,也就是说当一台主机发送完最后一个报文之后直接进入四次挥手过程,而因为网络设备的原因导致该报文被阻塞,也就是四次挥手过程会先进行。所以在这种情况下,若是没有TIME_WAIT状态那么两端就有可能会直接断开连接,从而导致迟到数据没有在网络中消散,站在服务器的角度来看,若是此时服务器立即重新启动或者处于监听状态,那么就有可能会导致服务器因为收到该迟到报文而影响其它正常报文的接收或者是因为判处出连接状态异常而采取连接重置动作。所以为了尽可能避免网络中还有未消散的报文,TCP协议就规定了四次挥手的最后一个状态一定是TIME_WAIT状态一个时间等待状态、一个等待迟到报文状态。当然不往数据方向想从应答方向想我们也可以看出,TCP协议为了保证四次挥手过程的完整性和确定性,它一定要让四次挥手的最后一次应答被对端接收到,也就是如果没有TIME_WAIT状态的话,那么最后一次应答ACK丢失了,就算有超时重传机制发送端对SYN报文进行重传,因为对端早已经将连接关闭,所以导致发送端永远收不到应答,同理四次挥手过程不完整不确定,最后还是要依赖操作系统的强制关闭机制。当然结合上述两个理由的理解,我们明白其实第二个理由本质就是第一个理由中的一种,也就是处于TIME_WAIT状态的一端因为超时重传机制不断响应ACK,保证ACK报文被对端接收,也就是保证ACK报文在网络中消散。明白了上述为什么四次挥手过程需要有TIME_WAIT状态之后,我们再来拓展两个知识点,首选是我们要明确对于四次挥手而言进入TIME_WAIT状态是否意味着四次挥手结束,从理论上来看当一端进入TIME_WAIT状态之后,另一端正常情况下就已经收到了应答,也就是四次挥手过程已经进行完了,此时我们可以理解在TIME_WAIT状态时四次挥手过程已经完成。但是若是从实际情况来看,四次挥手过程并没有结束,无论是从查看网络连接状态(netstat)还是对应端口号不能使用,我们都可以发现对应连接的资源并没有被释放。当然之所以连接资源不能被释放的原因也就是上述为什么处于TIME_WAIT状态的原因。其次我们知道若是服务器处于TIME_WAIT状态,因为资源没有被释放,所以我们就不能立即重启我们的服务器,也就是不能立即让对应进程绑定该端口号,而必须等待TIME_WAIT状态结束。但在某些特定场景下,也就是服务器因为某些原因想要主动与客户端断开连接,那么它就会处于TIME_WAIT状态不能立即重启,但是如果该服务器非常紧急,想要立即重启的话,那么此时它就可以使用 setsockopt
接口。当然某一服务器如果想要立即启动它可以使用不同的端口号来实现,但是注意无论是Web服务器,还是与某客户端绑定的服务器,它们的端口号都是不支持随意改变的,前者不说协议规定,后者因为客户端已经绑定了固定的端口想要更改必须实现同时更改也就是更新客户端。当然对于setsockopt接口而言具体的使用方法为:int opt = 1; setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
而之所以该接口能让服务器使用同一端口立即启动,本质是因为它实现了对对应端口和IP地址的复用,解决资源被占用问题。拓展完两个小知识点之后,最后补充一点,TIME_WAIT时间我们是可以更改的,但会发现没有任何效果,那本质是因为这个变量是一个内核变量,只有重新编译内核代码才能生效。
TCP协议的面向字节流
首先我们明确一个误区,对于我们的计算器而言,它传输的一定是二进制序列,所以只要数据没有被应用层读取进行特定的编码转换,那么它在应用层以下都是以二进制的形式存储的,当然也就是字节流的形式存储。所以对于UDP协议的面向数据报而言,表示的就不是它不能读取字节流了它读取的也是字节流,只不过读取的方式和TCP协议不同而已,而之所以说它是面向数据报的原因是因为它在读取/写入字节流的时候,都是以一个完整独立的数据包进行读取/写入,而不像是TCP协议的以任意字节进入读取/写入的方式。明白了这个误区之后,此时我们就知道对于传输层而言我缓冲区中只存储字节流,你上层可以使用UDP协议的方式读取/写入,也可以使用TCP协议的方式读取/写入,当然对于UDP而言就是通过recvfrom/sendto接口,TCP则是recv/send等接口。因此我们就明白,之所以UDP协议面向数据报支持我们每次写入/读取一个完整独立的报文是因为我们在上层通过调用特定的系统调用接口完成的,当然也就是之前学习UDP协议时所说的根据报头读取UDP长度,然后根据长度实现报头和有效载荷的分离或者是使用指针预留报头空间添加报头的过程。并且明白对于计算机大部分场景而言其都应该面向字节流,因为字节流才是计算机运行的基础,而UDP协议之所以不是,那是因为其想要实现一个在特定场景下使用的协议,也就是不要求可靠性高但要求效率时效性高的场景。所以这也就是为什么在网络通信时,TCP协议的套接字不仅支持recv接口读取数据,也支持read接口读取数据的本质原因,因为在操作系统看来,它们都只是在读取某缓冲区中的任意字节而已。最后我们明确当应用层读取到特定字节流时,无论是UDP协议还是TCP协议都必须经过解码和反序列化过程或者是编码和序列化过程,当然本质还是各种协议的定制,所以我们再次明确对于数据格式的控制完全是由应用层代码决定。而此时因为TCP面向字节流,所以对于TCP报文的读取就会面临一个叫做粘包的问题,下面我们重点来理解一下什么是粘包问题,当然对于这种粘包问题本质就是字节流之间无标识导致的,而我们上述也说过了对于这种问题应用层可以通过对数据格式做控制来解决。
什么是粘包问题
首先同理明确因为整个计算机体系结构除了应用层之外使用的都是字节流进行数据传输,当然上述我们也说过了使用字节流进行数据传输的好处,其中最好理解的就是它不需要关注数据的完整性,无脑转化成二进制传输就行,这种传输方式在计算机中非常适用既普遍又灵活又高效。所以因为TCP协议是面向字节流的,也就是没有任何标识符的如报文长度,因此其就会导致多个数据包,当然也就是多段字节流被合并成一个数据包接收或者发送,当然同理也可能是一个数据包被拆分成多段字节流发送或者接收。因此在应用层想要读取TCP缓冲区中的数据时就会发生粘包问题。也就是同理因为字节流与字节流之间没有标识符而导致对应系统调用接口read/recv只能读取任意字节数据而不能读取特定字节数据。而结合对UDP协议的理解,此时我们就可以发现TCP之所以要面对粘包问题,本质是因为在TCP协议中没有定制解决粘包问题的策略。那么问题就来了,为什么TCP协议不像UDP协议一样在报头中给我们提供一个粘包问题的解决策略呢?本质还要从TCP协议本身出发,明确设计TCP协议的本质是想要提供一个可靠的数据传输方案,所以TCP协议面向字节流的本质也是因为其要满足TCP协议的可靠性。而想要满足TCP协议可靠性的同时提高一定的效率,此时我们就需要支持数据包的拆分与合并,而想要让数据包可以随意的被拆分与合并,那么此时就不允许TCP协议像UDP协议一样在数据包之间添加标识符,因此TCP协议必须面向字节流,必须接受粘包问题的挑战。当然反过来想,这也就是为什么UDP协议没有粘包问题,当然再反过来想TCP协议想要解决粘包问题,我们只要模仿UDP协议就肯定能够解决。所以明确UDP协议之所以没有粘包问题,本质是因为其数据包之间当然也就是字节流之间有标识符以及定长的理念,如报头定长、报头中有UDP长度字段等。所以最后明确粘包问题就是TCP协议自己吃饱了之后留给我们的屁股,你应用层不擦谁擦?让操作系统去擦,可惜操作系统擦的没你漂亮,擦不出你想要的效果啊!因此为了解决这个问题,应用层通常就需要采取一些策略来区分不同的数据包也就是区分缓冲区字节流,如定长、分隔符、消息头+消息体等,从而确保数据的正确解析和处理。
总结:TCP协议和UDP协议谁才是传输层的老大不是你我决定的,而是由场景决定,并且对于解决TCP屁股问题在当今的应用层也是有固定方案滴!
更多推荐
所有评论(0)