之前已经说过了tcp也是会考虑网络的情况的,也就是当网络出现问题的时候tcp不会再对报文进行重传。当所有的用户在网络不好的时候都不会对丢失的报文进行重传。这样就会防止网络瘫痪。
这样的机制也就是tcp会进行拥塞控制。
拥塞控制
所谓的慢启动看下面这张图就能知道是什么意思:
当A向B发送信息的时候一开始是一个报文,然后是两个,然后是4个然后是8个,在之后就是16个等等。
这张图就是定义了一个拥塞窗口:
但是之前的学习,不是说过发送报文的数量是由滑动窗口来决定吗?怎么现在又出现了一个拥塞窗口呢?
现在就需要了解一个滑动窗口和拥塞窗口之间的关系了。
在主机c向主机s发送数据的时候主机c不仅要考虑主机s的接收能力,也要考虑不要发送过多的报文引起网络拥塞。
所以拥塞窗口是发送方定义出来的一个数字。因为谁也不能判断当前的网络状态是好的还是坏的,所以这个数字更多的是计算机动态生成的一个经验型的数字。
然后又是由滑动窗口决定了,一端主机发送数据量的多少。
而这个数字也就是拥塞避免算法得到的数字。这种启动方式也就是慢启动:
所以当出现了拥塞窗口这个数字之后1,滑动窗口大小的计算也就变化了:
当拥塞窗口大小大于对方的窗口大小的时候,代表网络好,就可以以对方的窗口大小为主,当网络不好但是对方的窗口大小多的时候,也不能按照对方的窗口大小发送报文,因为需要考虑网络大小。这也是为什么上图中选用的是min。此时这样也就可以动态调节滑动窗口的大小,进而调整发送方发送的数据量,这样就完成了考虑网络的同时也考虑对方的接收能力。
以上就是三种窗口之间的关系:
之前已经说过了拥塞避免算法是按照2^n次方的幅度进行报文数量的不断增加的,但是2^n次方这个速度真的很慢吗?
这个2^n次方算法有一个特点就是前期这个数字是很小的,但是越到后面这个数字也就越大。这就能够保证在网络通信的时候:
这个就是拥塞避免算法,非常符合网络通信的要求(前期保证稳定,中后期保证速度)。那么当中后期当拥塞窗口太大的时候,我们需不需要担心,发送的数据太多了呢?
答案是不需要担心,因为影响滑动窗口大小的还有一个窗口,,那就是接收端的窗口大小。
虽然现在不会出现发送信息超出对方窗口或者影响网络的情况,但是还有一个问题,拥塞窗口的大小是一个数字啊,我总不可能让一个数字不断的以2^n的增长率不断增大吧,这样就不合理了。所以又增加了一个东西:
这样就会让发送报文的数字呈现下面这样的图像:
一开始拥塞窗口从1开始按照指数级别的规律不断增加,当增长到阈值之后就会让指数增长变成线性增长。当线性增长遇到网络拥塞的时候会直接将这个拥塞窗口的大小设置为1。重新又开始按照指数级别增长,同时在遇到一次网络拥塞之后阈值的大小也会减少(乘法减少更新了阈值大小)。例如上图就是从16修改为了12。当再次开始指数增长的时候很明显变成线性增长需要的传输轮次也就减少了,也就是减少了指数增长的轮次。也就是让增长变慢的时间变长了,让增长变快的时间变短了。
这就和两个情侣一样。恋爱开始的时候两人的好感度指数级别增长。到达一定地点之后,会线性增长。之后爆发矛盾之后好感度减低为0.之后一方认错,好感再次增长,但是平稳增长的点提前出现了,这样也就曾增大平稳增长的时间,让爆发矛盾的时间延后。用到网络上情侣就是两端主机,矛盾就是网络拥塞,平稳增长点就是阈值,通过不断调整阈值就能够延长到达网络堵塞的时间。
但是对于这个图:
还有一些问题需要解决:
答案是不一定,因为之前就已经说明过了,真正的发送数据的总量还要受到对方的窗口大小的影响。上面那个图有一个大的前提就是对方的接收能力是无限大的。
但是现实中对方的接收能力并不是无限大的。所以当拥塞窗口的大小为24的时候,真正发送的数据大小可能只有16(对方的截接收能力只有16)。
上面的那个图只不过是在用户的接收能力无限大的时候拥塞窗口大小和拥塞窗口的阈值的变化的规律。
当拥塞窗口的大小在不断的增大的时候,其实本质是在进行探测
这个拥塞窗口不断的变化就是需要算出当前网络状态的一个拥塞值。通过拥塞值再去不断的更新阈值。
当拥塞窗口的大小大于了对方的接收能力的时候,使用拥塞窗口去衡量发送报文数量的意义已经不大了,但是这个拥塞窗口依旧能够衡量网状状态的变化,所以当拥塞窗口大于/小于对方窗口的时候,拥塞窗口都有一个作用(当对方接收窗口大于拥塞窗口的时候还能衡量发送报文数量的多少)衡量当前网络状态的变化。
由此就能知道了:
这个拥塞窗口的大小也即是将网络的状态具象化为了一个数字。当然这个折中的方案不仅要考虑网络,还要考虑对方的接收能力。
延迟应答
什么是延迟应答呢?
依旧是这张图当主机A向主机B发送了一个信息之后,主机B在接收了这个信息之后,并不会马上发送ACK而是等待一会,因为这个等待的时间我的应用层就可能会拿取我接收缓冲区中的更多的数据,也就有一定的概率让我ACK返回给主机A的接收缓冲区大小能够更大了,也就有一定的概率让主机A能够发送更多的报文。但是这个概率就说明了即使接收缓冲区的大小能够变得更大了,但是发送的报文依旧不会增加,因为发送报文的多少还会受到拥塞窗口大小的影响(网络),有可能即使我的窗口变大了,但是网路拥塞了,此时发送的报文数据依旧不会增长,这也就是概率。
但是这个延迟的尺度也要设置好,如果尺度设置过短可能达不到效果,上层来不及收取数据,如果尺度过长,有可能会导致对方收不到应答,导致对方重发报文。一般有两种策略:第一种就是间隔报文:也就是我的主机B收到了一个报文先不做应答,当收到第二个报文的时候在做应答。发送了一个确认序号,而因为确认序号的含义(能够保证这个序号之前的报文都已经收到了),就能够完成对某一个报文回应的缺省(这里就是缺省了第一个报文)因为发送端是可以接收部分报文没有回应的。
第二个策略就是单纯的以时间为限制
总体来说就是具有两个方案:
一般来说都采用的是每隔N个包就应答一次的方案。并且这个N根据系统的不同N的数字也是不同的,时间也是不同的。
捎带应答
捎带应答这个策略在之前就已经说明过了。这里就不多说了。总结一下就是真实的情况一般都是,当A主机给B主机发送信息的时候,B主机也有可能想给A主机发送信息。此时对于B主机来说就有一个做法那就是将自己发送的信息写到给A主机的某个信息的应答中。此时就将两个报文合成了一个报文。这也是之前我说过的对于TCP的报头来说为什么序号和确认序号要分开的原因。
到这里TCP就整体学完了。
小小的总结一下:
可以看到TCP不仅有保证可靠性的策略,也有保证性能的策略,所以对于TCP和UDP的传输效率,UDP一定比TCP高吗?这个不一定。只不过UDP相比TCP更加的方便简单。
但是对于TCP还有几个小话题需要了解一下。
面向字节流
在学习这个之前,首先复习一下之前学习到的概念:
UDP是不需要这样的缓冲区的,因为对于UDP来说,能够从内核就将报头和有效载荷分开,并且能够知道有效载荷的大小,并且知道某一个报文的有效载荷的具体数值为多少。由此交给用户的就是一个已经分开的报文。所以UDP叫做面向用户数据报。叫做用户数据报,因为交给用的已经是一个完整的报文了。应用层拿到报文之后直接做反序列化即可(UDP的报头中长度字段指的是整个报文的长度【包含有效载荷】)。
但是对于TCP来说就不一样了,因为在TCP的报头中是不存在这样的字段的。但是对于TCP报文来说这也不是TCP报文需要关心的问题,TCP报文只需要将数据放到接收缓冲区中就可以了(分开数据和报头的工作TCP是可以完成的)。但是在这个接收缓冲区中的数据有可能是一个报文的100字节数据,也有可能是10个报文的100个字节数据。所以对于这些数据应用层首先要做的就是将数据按照一个报文一个报文的形式分开。然后再去读取其中一个报文的数据。这也是之前在使用TCP协议的时候我选择了两种方式第一种按照json串的方式将数据序列化发送给对方(按照json串的方式写到文件中)。这种方式就是将从接收缓冲区中接收到的数据,按照json串的方式足量接收,然后再进行反序列化,这样就能收到对方发送的一个报文中的信息了(也是按照json串的方式读取数据)。还有一个方法就是自定义协议,在写文件的时候就按照自定义协议的方式写到文件中,未来对端拿到数据之后,读取足量的数据,进行反序列化,就可以按照协议的方式读取数据了。
对于面向字节流其实还有一个东西也是面向字节流的,那就是文件操作。
对于文件操作来说写入是很轻松的,但是读取很麻烦即使有很多的文件操作函数给我使用(之所以麻烦就是因为少了一个序列化和反序列化的步骤)。因为在写入文件和读取文件之间少了一层序列化和反序列化。既然TCP可以通过定协议的方式。那么文件也是可以的,这个对于文件按照协议的方式进行的写入和读取操作就是持久化。
现在我有一大批的数据我可以将这些数据按照协议的方式将其写到文件中,这个过程就 叫做持久化。rades的持久化就是和文件定协议,不将数据发送给对端而是将数据按照协议写到本地文件中。未来如果我想要读取这些数据就可以将文件中的数据按照反序列化解出来。此时就将数据加载到内容中了。
更加详细的对于面向字节流的问题,之前已经说明过了,这里只是指出对于文件也可以使用序列化和反序列化的方式进行的,这个操作就是持久化。
数据包粘包问题
这个粘包问题举一个现实的例子就是现在有一堆的包子,你只想拿一个包子,但是因为有些包子黏在一起了,导致你拿起了多个包子。所以一般包子出锅的时候都是将粘在一起的包子分开。在TCP报文这里这个工作就是将报文和报文之间的边界进行明确。这样再拿TCP报文的时候才不会出现你拿一个报文最后拿到的确实2/3以上的数据。
这也是为什么在自定义协议那里我需要使用一个字段记录需要读取多少的字段是一个完整的报文数据(记录数据的报文又和后面的数据使用了分隔符号隔开)。要做这个工作就是因为读取上来的报文数据可能是一个报文的数据也可能是多个报文的数据。所以需要先decode将各个报文使用自定义协议分开。如果不做这个工作,读到的数据可能是一个报文也可能是多个报文。在不做处理的时候遇到了读取上来的信息包含多个报文数据的情况就是粘包问题/或者读到数据不是一个报文(这种情况就是数据包粘包问题)。所以在之前的自定义协议那里除了序列化和反序列化,为了解决这个粘包问题还增加了一个简单的报头就是为了解决这个粘包问题。这个报头说明了再读到这个报头之后再读多少字节的数据就是一个完整的报文数据。(至于读完一个报头则是这个我写的报头和数据之间存在分隔符号)。
这个操作就是在TCP进行数据传输的时候解决数据包粘包问题。
这也是为什么http协议中使用空行分隔报头和有效载荷。在报头中又会包含当前http报文的有效载荷为多少。http协议使用count-lenth这种自描述字段+空行这种策略来解决基于TCP通信的数据包粘包问题(我的自定义协议使用的也是这种思路)。
最后的结论就是因为TCP是面向字节流的,所以会出现粘包问题,所以用户层需要解决TCP的数据包粘包问题。
所以如果你想在底层自己写一个协议进行通信需要解决的就是两组问题。第一组先解决粘包问题,第二组在读到一个完整报文的数据之后解决序列和反序列问题。由此一个协议你才能定制成功。由此就能够知道了UDP是没有粘包问题的,因为对于UDP来说你读取一个报文上层就能够收取一个报文的数据。读到的就是完整的报文,直接做序列和反序列化即可。
下面简单提一下粘包问题的解决方法:下面简单提一下粘包问题的解决方法:
第三个方法就是我的自定义协议使用的方法。
TCP链接异常情况
第一种:当1个进程正在使用TCP进行通信的时候,这个进程如果终止了/异常了,这个链接会怎么样呢?
这里需要知道网络通信的时候TCP的链接本质就是一个文件。而进程终止的时候,这个进程曾经打开的链接文件也会被关闭。所以当进程终止的时候,这个链接会被双方的os自动正常关闭(即便进程是异常的)正常关闭也就是会进行四次挥手。
所以当进程崩溃的时候不会影响进程底层的链接。
第二种:机器重启。如果正在通信的一端的主机直接重启了,那么这些在这台机器上的链接会怎么样呢?在重启的时候会直接提示用户,是否先关闭进程。
所以机器重启也并不影响,因为重启的时候依旧会关闭所有的进程。
所以如果你的电脑开机之后直接关机速度很快,但是有了上万行为的时候,再次关闭电脑关机的速度就变慢了,因为在关闭电脑的时候需要先去关闭链接这是需要耗费时间的。
第三种:主机掉电/网线断开。
现在将正在进行通信的一端主机的机器直接网线拔了。我拔网线的动作和我的客户端进行网络通信的动作是异步的。我拔网线的动作我的机器知道吗?当然不知道。那么我的客户端自然也没有时间去给服务端反应要去断开链接(四次挥手)所以当我拔网线的时候服务器端是不知道客户端已经掉线了的,因为没有机会。
如果在拔了网线之后再也没有上过网线的话,是没有关系的。因为在tcp的服务端那里对于链接都是具有对应的保活的策略的。服务端每隔一段时间会询问客户端(发送保活报文),断了网线那么客户端自然不会存在任何的应答。然后服务端就会将这个链接直接释放了。但是就怕客户端的网线拔了马上又插上了。客户端的电脑在了解到自己断网之后会直接释放这台机器上的所有链接。
保活也就是os还会对链接进行计时。这个和之前说的os内部是存在计时器的。tcp的保活的时间一般很长(几十分钟/小时),所以这个策略一般是在应用层做,在引用层设置时间短一点的保活策略。每隔短时间发送这个保活的报文,如果你不回说明对端异常直接关闭链接。一般来说大部分的协议中在应用层做保活是比较好的。一般的联网游戏在你挂机之后怎么知道你挂机的就是定时向你的角色发送这样的保活报文,如果你不在了就说明你挂机了。再去采取其他的操作。以上说明的都是在我拔了网线之后很长时间/再也不插上的情况,但是第二种情况就是我刚拔了网线然后我就直接插上了网线(对端没有发送保活,我就插上了网线)。
然后我的客户端又想和服务端建立链接,所以就直接建立链接。此时就可能兴起链接了。因为新启了一个链接所以源端口号就可能变化了。所以拔了网线之后重连依旧不会存在问题,老链接会因为保活最后被释放。即便最后还是使用了这个老链接向服务器发送了信息,服务器都是能解决的。
下面的这种情况要如何处理呢?在拔网线之后如果服务端给客户端发送响应呢?此时的客户端收到了这个响应之后就会发送一个RST报文,进行链接重置(说明信息在客户端这里写入失败了)。
所以链接异常的问题TCP全部都能解决
TCP和UDP的对比
两个协议使用的场景不同不要求可靠性,就可以使用UDP,如果要求可靠性就使用TCP,如果不知道要不要求可靠性(一般来说是要明确的),优先使用TCP。
最后有一个经典的问题,你如果要使用UDP来完成可靠性传输你要怎么做呢? 这道题目表面是在问UDP,其实问的是TCP,现在比较完善的可靠性机制的传输协议就是TCP。所以你要使用UDP实现可靠性不就是要将TCP保证可靠性的部分内容增加到UDP中吗(在应用层中实现)?不是全部因为如果全部可靠性保证都要加,那么我为什么不使用TCP呢?
在应用层如何实现呢?就是在应用层写一个新的报头包裹UDP,然后给这个应用层报头引入序号引入确认应答,引入超时重传。以上就完成了UDP的可靠性。上面的这个问题,主要要清楚在什么应用场景上
listen的第二个参数
这个函数是在使用套接字时使用的函数
在之前的代码中对于这个参数我一般写的都是,那么这个参数的作用是什么呢?
首先我修改一下我的服务端代码,让这个服务器不会获取链接。但是accept套接字已经创建好了(backlog为1)。这个服务器能否正常跑呢?
这里我查询一下这个链接的状态:
使用另外一个主机进行连接:
此时一个连接建立好了。
这里我在让一台主机连接上这个主机。
之后我让第三台机器也去连接:
这一次很明显没有现实连接成功了,而是在这里进行try。
当我在建立好连接之后不让上层去获取连接,这里能够得到一个结论:对于连接一定是底层先创建好了,然后才让上层accept,所以三次握手的过程是os自己做的,和上层有没有获取连接没有关系。当链接建立好之后才会允许上层去获取链接。但是通过上面的实验可以知道对于底层已经处于ESTABLEISHED的链接,允许上层拿取,但是不允许底层建立太多。因为常规情况下如果你的服务器很空闲链接一旦建立好了,这个链接迅速会被取走,不存在两个链接一直存在的情况。如果出现了两个链接一直在底层说明这个服务器已经很忙了,但是你在忙也会维护一下新到的链接,但是维护的链接不会特别长。这个在底层就是TCP链接在底层的全链接队列。
到这里就可以说明listen的第二个参数的作用了:
所以在上面的步骤中,如果上层来不及接收底层维护的最多链接数量就是2。如果再来一个要么就是客户端一直回去尝试,也就是第三个链接服务器就不会允许三次握手成功了。这种链接就是半链接,这种半链接在20秒或者30s之后还是没有三次握手成功就会被自动释放。
画图就是在底层会维护两个链接结构体对象:
这个全连接队列就是不用你上层去进行获取,自动在底层握手成功的队列。队列最多的个数也就是全链接队列的上限。这个队列的参数一般不要太长,也不能没有。具体的数目取决于你的应用场景。如果没有这个队列的话,如果服务器需要连接的时候底层无法进行上交,如果没有这个队列就会导致服务器在高负载的情况下出现没有任务的情况,很浪费资源,不合理。但是如果这个队列太长的话也不行。因为维护这些队列也是需要耗费资源的,与其耗费太多在底层的这个上,不如将资源交给上层,让上层能够快速的解决上面的连接的请求,让其去获取下一个连接不是更好。就相当于一个餐厅因为人数饱满但是对于有的客户没有等待区的话,会导致又空间位置了,但是没有人去,但是如果太长的时候不就相当于本末倒置了。与其让等待的区域过于长不如将资源放到上层,让上层能够更快的解决上一次的请求。这样不是更好吗?所以不需要太长也不能没有。所以这就是这个函数的第二个参数的作用也是底层的全连接队列。
还有半链接队列
对于那些三次握手没有成功的链接,底层也会进行短暂的维护一个储存半链接的队列这个队列也就是半链接队列。未来三次握手成功了会将半链接队列中的节点移动到全链接队列中。半链接队列的维护时间很短基本是秒级别的10多秒这种。
文件+socket+系统+网络联系
首先下面是一个进程以及一个进程对应的我呢见描述符表。
当一个进程打开一个文件的时候会在文件描述符表的三号位置填入一个指针这个指针指向的是一个struct file对象。对于3这个下标就会交给上层去进行文件的读取和写入。
然后无论是UDP还是TCP都存在一个struct sk_buff的结构体对象。这个对象中有很多的内容,但是主要就是存在一片空间用于完成对某一个报文的封装和解包:
之前在讲解信号的时候,如果键盘上存在数据那么os是如何知道的。是通过硬件中断完成的。那么网卡也是硬件也存在中断。
中断都是有自己的中断向量表的(在os内部),当网卡中存在数据了,网卡是直接告诉cpu的然后cpu就会得到网卡的中断号。然后根据这个中断号去执行中断向量表中的方法,这个方法也就是读网卡。到此报文就被收到了。
此时这个数据就到了缓冲区中。然后下一个步骤就是
底层将数据拿上来之后构建sk_buff然后对这个对象进行解包分用,所谓的解包也就是对指针进行移动。
现在所有的报文已经通过链表的形式管理起来了,然后呢?进程和这个sk_buff要如何关联起来呢?
这里直接通过一个Linux的内核代码来进行解释。
首先在os中存在一个struct file结构体(在之前的博客中我也进行过说明)
在这个结构体中包含了一个字段
void* private_data的字段:
然后在创建套接字的时候os一定会在内核创建的对象:
然后之前说明的private_data指向的就是这个结构体对象:
同时在这个结构体中也存在一个指针用于会指向自己的file对象。
然后在上面的那个结构体中还存在一个指针指向一张表:
这个表中的方法就是就是网络通信中使用的方法。
更加重要的是在socket中存在一个字段: Struct sock* sk指针
这个指针指向的对象中包含的字段:
在这个结构体中还包含一个结构体sk_buff_head就是一个双链表。这两个链表也就是接收缓冲区以及发送缓冲区。
此时从底层的信息到文件到进程就已经可以链接起来了。
通过中断读取的信息会被放到sk_buff对象中,这个对象会被链接到一个链表中,而在sock对象中就有这个链表的头指针。
这个struct sock又会被保存在struct file对象中。此时上层就可以通过文件描述符下标读取到输入和输出缓冲区中的内容了。
其中UDP在读取的时候整体使用一个报文读取上去就叫做面向数据报,TCP在读这些信息就可能只读取一个部分,虽然看起来在队列中这些数据都是一个一个的。但在逻辑上os可以把这些空间设置为一个连续的空间。
由此从下到上就基本清楚了。
但是还有一个问题对于这个socket我并不知道这是UDP还是TCP的啊。所以为了解决这个问题其实还存在其它的结构:
第一个结构inet_sock(可以理解为ip套接字)
这个结构体的第一个字段就是struct sock,然后还有一些其它的字段(IP地址等等)。
还有一个字段:
从名字就可以知道这是一个链接套接字的结构体,还有一些重试此时,时间等等的字段。
重点就是这个结构体的第一个字段就是一个struct inet_sock.
然后第二个字段request_sock_queue这个字段就是全连接队列。
最后一个结构体:
Tcp报头的长度,最近一次收到的时间戳,发送的窗口期望收到的窗口值等等字段都是存在的。
细节第一个字段依旧是inet_connection_sock。
所以在socket这个结构体中sock指向的不是sock结构体
而是tcp_sock结构体:
Tcp需要的信息通过将const struct proto_ops这个指针进行强转为所对应信息所在的结构体就能够读取对应的信息。将其强转为inet_connection_sock这个类型就能够访问链接中的数据。
这个技术就是多态。
为什么要多态的呢?因为除了TCP套接字还有一个UDP套接字:
这个套接字的第一个字段从inet_sock开始的,但是没有inet_connection_sock由此在UDP中就不存在全连接队列。所以UDP不面向链接
由此就能够理解从底层如何读取一个信息了。以上就是TCP协议。
希望这篇博客能对您有所帮助,如果发现了错误欢迎指出。