计算机网络(九) —— Tcp协议详解
目录
一,关于Tcp协议
二,Tcp报头字段解析
2.0 协议字段图示
2.1 两个老问题
2.2 16位窗口大小
2.3 32位序号和确认序号
2.4 6个标记位
三,Tcp保证可靠性策略
3.1 确认应答机制(核心)
3.2 超时重传机制
3.3 Tcp连接管理机制
3.3.1 操作系统对连接的管理
3.3.2 三次握手(重点)
3.3.3 四次挥手
3.4 流量控制
四,Tcp提高传输效率策略
4.1 滑动窗口
4.2 快速重传
4.3 延迟应答
4.4 捎带应答
五,拥塞控制
六,扩展
6.1 面向字节流 VS 面向数据报
6.2 数据包粘包问题
6.3 Tcp连接异常情况
七,Tcp总结
一,关于Tcp协议
Tcp全称“传输控制协议(Transmission Control Protocol)”,是当今互联网使用最广泛的传输层协议,因为它基于通信时保证可靠性,并且对于高效传输也有一定策略,是目前应用层底层使用的协议中非常常见的一种协议
我们来认识下“传输控制协议”的“控制”二字含义:
- 之前实现的网络版计算器,用的是http/https,底层就是Tcp,在通信双方内部都有着发送缓冲区和接收缓冲区,我们调用write,read,recv,send等操作其实不是把数据都发送到网络中,而是把数据拷贝到操作系统的内核缓冲区中
- 至于数据什么时候发送,发送多少,出错了怎么办?这个完全由Tcp协议自主决定,所以上面那些函数本质应该叫做拷贝函数
- 这和我们文件操作一样,用write通过文件描述符写入时,都是先把数据拷贝到文件缓冲区里,然后数据从文件缓冲区刷新到磁盘文件上,完全由操作系统和磁盘驱动决定。这么看来,只要把磁盘设备换成网卡,就可以完成数据远程发送或者IO了。
- 而数据发送,本质也就是把数据从我们的发送缓冲区拷贝到对方的接收缓冲区,所以网络发送,“本质也是拷贝”。所以发送就是要通过网络,而传输距离很长,网络可能会出错,所以TCP就是为了应对这些出错做的策略
- 因为Tcp既能接收也能发送,所以双方的地位是对等的,服务器构建响应,其实也就是把响应处理好,然后通过应用层的各种接口把响应拷贝到TCP发送缓冲区,而双方也有接收缓冲区和发送缓冲区,所以发送也是拷贝,你给我拷,我给你拷,你不给我拷,我也可以给你拷,这个过程就叫做“全双工”。
- 所以,文件描述符和套接字既可以写也可以读,因为双方的发送和接收缓冲区是独立的,不会相互影响
背景:缓冲区也是内存空间,操作系统为了管理内存,把整个内存在逻辑上分成很多的4KB的空间,所以内存内部有大量的内存块,为了管理内存块就有很多struct_page这样的结构。所以发送接收缓冲区是由很多个4KB的内存块和struct_page构成的,然后再先描述,再组织。所以打开一个网络套接字就是打开了一个文件描述符,而打开的文件也一定有struct_file对象,而这个对象在以前里面就有指针指向磁盘,而有了网络之后,struct_file就直接指向网卡,此时就能上层不变,下层直接切换成网络这样的功能
所以“传输控制协议”的“控制”,应用层把数据拷贝到发送缓冲区里,什么时候发,发多少,出错了怎么办,其实本质也就是在控制如何发送的问题,这些工作由Tcp协议自主决定,所以我们把TCP叫做“传输控制协议”。
二,Tcp报头字段解析
2.0 协议字段图示
三部分,标准报头(前20字节),选项(暂时忽略),有效载荷(要传输的数据)。前两个字段16位源端口号和16位目的端口号和UDP一样,能够讲报文数据交付给上层的某个协议,
Tcp报头在内核代码中就是一个位段类型,给数据封装Tcp报头时,实际上就是用这个位段类型定义一个变量,然后往该变量中填充Tcp报头的各个属性字段,这个步骤和我们之前填充 sockaddr结构体 的步骤大差不差
可以看到,Tcp协议的报头字段相比Udp多了几倍,因为Tcp要保证可靠性,所以Tcp会在底层做更多的动作,下面我们来详细解释下各字段的作用
2.1 两个老问题
前面说过,学习任何协议,都离不开两个问题:
- 报头如何和有效载荷分离
- 有效载荷如何交付给上层?
- 在TCP协议中,标准报头的长度是20个字节,在缓冲区中我们把前20个字节信息分离就拿到了报头
- 报头中有一个“4位首部长度”,表示报头的总长度是多少,因为我们还有选项(可以不带),4位首部长度范围为(0000,1111)--> (0,15),15是小于20的,乍一看不能表示报头的长度,因为4位首部长度在计算的时候,有基本的大小单位:4字节(报头的宽度),所以真正的表示范围为0 -- 60个字节,报头长度是20,所以选项最长是40个字节
- 大部分时间我们不谈选项,所以4位首部长度一般为0101,是5,就能准确代表标准报头长度,就能有效分离报头了
- 然后就和Udp一样,对有效载荷做解析,就能得到16位端口号,然后就可以交付给上层应用了
2.2 16位窗口大小
- 客户端要发送数据给服务器,先把http请求拷贝到发送缓冲区,拷贝到服务器接收缓冲区,在这个过程中,客户端和服务器基于TCP协议进行通信的时候,发送的是完整的报文,即一定携带完整的TCP报头
- 如果发送方一直发数据,但是接收方不读,所以客户端是并不知道服务器对数据的读取情况的,所以客户端就会一直发,但是服务器来不及收导致服务器接收缓冲区被写满,就会出现数据丢包
- 所以双方必须想办法在服务器“接收缓冲区快满”的情况下让客户端发慢一点或者干脆不发了,这种由发送方往接收方发消息,通过控制发送方发送的速度,来让对方来得及接收,从而防止大面积丢包的情况,我们叫做“流量控制”
- 所以我们现在的重点就是“如何让客户端知道接收端缓冲区还要多少剩余空间?”
重谈通信过程:
- 发送消息后,对方会立即对该报文进行相应,对方就会给我发送确认应答,所以这个确认应答一定也是一个完整的TCP报文,可以不带数据但至少要有报头。
- 那么服务器让客户端发数据慢一点点,那么慢一点的依据是什么,由谁决定?由对方的接收缓冲区的剩余大小决定。
- 那客户端咋知道接收方剩余空间大小呢?确认应答机制也是完整的报文,这个报文的16位窗口大小填充的就是对方接收缓冲区的剩余大小
- 而双方互发消息 ,互相进行确认应答,那么双方就能都及时知道对方接收缓冲区的大小,这样双方就能都进行流量控制 --> 所以窗口大小填充的就是自己的接收缓冲区的剩余大小,它的作用就是来进行流量控制的
第一次给对方发报文不知道对方的缓冲区剩余空间大小该咋办呢?我们不管,只要一段时间对方没给我发响应,我就认为对方没收到,重发
TCP有重传机制,如果有大面积丢包,TCP可以重传,但是不合理,因为网络传输是有代价的,数据已经消耗了资源到达了对方,但是却要无缘无故丢掉,这就是一种浪费,所以“流量控制”比“丢包重传”更好
2.3 32位序号和确认序号
问题:这个世界上存不存在100%可靠的网络协议?
场景:我收到了消息,然后我要告诉对方我收到了,然而我告诉了对方“我收到了”这条消息,我无法保证对方也收到了,所以对方也要再次给我发“我收到了你收到了的消息”,然后这条消息再次需要应答。。。。。。没有应答的数据,我们无法保证可靠性,所以,最新的一条消息,是没有应答的
解答:我们无法保证发出去的消息是100%可靠的,所以这个世界上是不存在100%可靠的协议;虽然我们无法保证最新消息的可靠性,但是我们可以保证最新消息之前的消息是可靠的
- 客户端发tcp数据给服务器,服务器会给客户端发应答,当服务器发了应答后,就能保证服务器到客户端这条方向传输的可靠性
- 服务器发数据给客户端,客户端发应答给服务器,当服务器接收到应答,也就能保证服务器到客户端这条方向的可靠性
- 所以我们宏观上最新的消息不能保证可靠性,但是在局部上两个应答可以间接相互弥补可靠性
- 一段时间客户端没有收到应答,客户端认为数据丢失,会进行重传,服务器同理
下面我们来正式介绍一下序号的作用:
捎带应答:
场景:“我们一起吃饭吧?”,“好的,我们吃什么鸭?”,“我们吃饺子吧”,“好的”,这样聊天效率有点低,所以我们一般是这样搞:“我们一起吃饺子吧”,“好的”。把四次交流缩短至两次交流
- 我们的应答报文中同时携带应答和要发送的消息,这叫做捎带应答,就能够提高效率,
- 所以客户端发数据时,直接发送一批报文,只要这批报文我全部收到应答,也能保证对方收到了我的一批报文
如果捎带应答同时发多个报文,也会有乱序问题,而乱序本身也是不可靠的一种,所以会在报文带上序号,保证数据的按序到达,保证可靠性,所以这个32位序号就是保证数据的按序到达
问题:什么是序列号?
解答:用户层有tcp发送缓冲区:char outbuffer[N],上层拷贝下来的数据在这个数组一个字节一个字节存储的,而天然每个字节都会有自己的编号(本质就是数组下标),发送的数据块的最后一个字符的下标就是序号:
- 至于确认序号,就是填充的是收到报文的序号+1,假如我发1000,2000,3000,4000,那么服务器给我发的确认序号就是1001,2001,3001,4001,为什么要这样规定呢?
- 确认序号的意义:表示确认之前的数据我已经收到了,我给客户端返回1001,表示前面的1000我已经收到了,下一次发送“请从确认序号指定的数字开始发送”,所以下次客户端就开始从1001开始发
- 应答是允许有少量的丢失:假如我发了1000和2000和3000,但是服务器没有给我发1001和2001,只发了3001,也是可以的,3001表示3000的数据我已经收到了,因为3000包括1000和2000,所以Tcp允许应答有少量的丢失,具体的细节我们后面再讲
面试题:客户端把序号搞成1000,然后服务器可以把1000变成1001然后返回给我,为什么还要搞一个32位确认序号呢?
场景:服务器给我应答,可能服务器给我的应答也会携带数据(捎带应答),假设客户端发1000,然后服务器的捎带应答中包含了2000数据,所以需要同时客户端发1001和2000的序号因为tcp双方地位是等同的,所以协议必须把两个序号分开,不能复用
应答可能是捎带应答,有双重身份,既有应答也有数据,所以需要分开,服务器在给客户端发消息,同时客户端可能也在对服务器发消息
对于丢包问题,我们到滑动窗口再详细解释
总结:
- 32位序号作用就是保证数据按序到达,同时也是作为对端发送报文时填充32位确认序号的根据
- 32位确认序号,也是为了告诉当前已经收到的字节数据有哪些,发送发下一次发送数据时该从哪一字节序列开始发送
- 序号和确认序号是确认应答机制的数据化表示,确认应答机制就是由序号和确认序号来保证的
- 此外,序号还可以判断是否有数据丢包
2.4 6个标记位
问题:为什么有标记位?
解答: 一个服务器可能会收到来自多个客户端的的请求,Tcp通信时要建立连接,最后Tcp还要断开连接,在这中间它还要进行正常的数据通信,所以Tcp有的是建立连接的,有的是进行数据发送的,有些是进行断开连接的,所以服务器收到的Tcp报文本身是有类型的,不同的报文类型决定了服务器要做不同的动作。
所以接收方如何得知报头的类型是什么呢? --> Tcp里面存在6个标志位。标志位存在的意义:区分Tcp报文的类型
①SYN
- 当报文中的SYN被设置成1时,表面该报文是一个请求建立连接的报文,就是三次握手时发送的报文
- 只要在三次握手阶段才会设置SYN,正常通信时SYN不会被设置
②ACK
- 报文中的ACK被设置为1时,标明该报文是一个确认应答报文
- 而且除了三次握手的第一次握手没有设置ACK,往后其余的报文都会设置ACK,因为在进行响应时,会把响应和下一次要发送的数据一起发过去,而且我们发回去的数据本身,就对对方发过来的数据具有一定的确认能力
③FIN
- FIN被设置成1,表示这个报文是请求断开连接的报文,就是四次挥手时发的
- 只有在断开连接阶段才会被设置,正常通信不会设置
④PSH
- 该标志位表示:提醒接收端应用立刻从TCP缓冲区把数据读走,因为流量控制,导致上层一直把数据不读走,那么缓冲区的数据会变得越来越多,可用空间越来越小
- 操作系统把接收到的数据放到缓冲区里,然后用户去读取,这也是一个生产者消费者模型,所以所谓的流量控制就是对发送过程的一个同步的过程,当对方缓冲区写满的时候,我写进程就阻塞了,然后我就要等,但是要等多久我不知道,对方缓冲区啥时候会有空位我也不知道,就出现了僵持。
- 所以有两种策略:1,发送方定时询问对方接收缓冲区大小 2,一旦接收方缓冲区空间有更新,给对方发一个通知。两种策略同时存在。
- 但是如果接收方就是不把数据拿走,所以我们可以继续给对方发TCP报文,把PSH标记位设置成1,表示提示对方马上把缓冲区的数据读走,你要是不读走,我就认为你当前不想和我通信了,可能会关闭连接等其它操作
⑤RST
问题:TCP是保证可靠性的,那么三次握手和四次挥手必须要是成功的吗?也就是建立连接必须得成功?
解答:虽然tcp保证可靠性,但是也允许连接建立失败。
一个服务器可能对应多个客户端,所以服务器可能会有多个对应的客户端建立连接的文件描述符,所以服务器就要对这些连接进行管理
建立连接就是服务器生成对应的描述该连接的结构体,然后先描述再组织。
三次握手:
第一次客户端给服务器发请求报文,会带一个SYN标记位表示请求建立链接(第一次握手),然后服务器发回SYN+ACK,表示服务器同意和客户端链接(第二次握手),ACK是对上一个报文的应答,然后客户端再次发送ACK表示确认(第三次握手),这就是三次握手(经过网络)
- 由于三次握手的每次握手都有时间差,所以重点不在“握手”而在“三次”上,那么客户端认为链接建立好了,是第三次握手我把ACK发出去就确认了,还是我要确认服务器把消息收到了才确认呢?事实上,最后一个ACK是没有应答的,所以客户端只要把第三次报文发出去了就认为链接建立好了,所以三次握手其实是在“赌”,因为第三次ACK可能会丢,前面两个都有应答不怕丢,所以Tcp是允许链接建立失败的
- 当客户端发送第三次ACK成功,但是服务器没有收到ACK,那么客户端和服务器对与链接是否已经建立好了的认知不一致,导致客户端认为三次握手已经完成直接开始传输,但是服务器还停留在第二次握手上,此时服务器直接收到了来自客户端的数据咋办?
- 服务器就会猜测以上情况可能发生,于是服务器在下一次给客户端应答的报文中,将RST标志位设置为1,告诉客户端刚刚的链接没有建好,就让客户端重新发起三次握手
- 所以RST标志位的作用就是“当链接异常情况下,让双方重新建立链接”,上面说的“第三次握手报文丢失”只是众多链接异常情况下的一种(比如:浏览器连接被重置,服务器压力过大等)
⑥URG
URG表示:紧急标记位,一般在某些特殊情况下才会使用
- 场景:TCP是按序到达的,对方会根据序号排序,但是一些情况下,我们想让一些数据优先处理,也就是“插队”被优先处理,在原始TCP规定下,“插队”情况是不可能存在的,但是我们就是想让一些数据优先处理,那么我们就可以设置URG标记位
- 当没有数据要优先处理时,URG为0,16位紧急指针无效,当URG为1时,16位紧急指针就有效,紧急指针表示这个报文中要优先处理的数据在有效载荷中的偏移量
- 但是紧急数据多大呢?在TCP协议中紧急数据默认只允许携带一个字节
- 什么样的情况下我们才会用这个URG呢?一个机房,机房里有一个服务器,服务器上搭载了一个服务,里面有接收和发送缓冲区。有一个客户端连接了,但是用着用着服务器突然不给我响应了,但是没有四次挥手,服务器也没挂,所以客户端很疑惑服务器为什么不给我响应,所以客户端要询问服务器发生了什么
- 所以我们需要让服务器支持“读取紧急数据”,然后我在服务器里面的软件功能添加某些服务状态(计算,IO,同时做等),给每个状态编号;当服务器又卡顿时,客户端就给它发一个紧急数据,所以服务器会优先处理紧急数据,服务器就会把当前状态也用紧急指针发给客户端(紧急数据在应用层上叫做“带外数据”)
- 报头中的“16位紧急指针”代表的就是紧急数据在报文中的偏移量,只能表示数据端的一个位置,所以Tcp的紧急数据只能是一个字节
recv函数的第四个参数flags有一个叫做MSG_OOB的选项可供设置,OOB表示带外数据(out-of-band)的简称,就是紧急数据,就可以在使用recv函数进行读取,并设置MSG_OOB选项:
与之对应的send函数的第四个参数也有MSG_OOB选项,就可以用send函数写入紧急数据:
三,Tcp保证可靠性策略
3.1 确认应答机制(核心)
确认应答机制就是由Tcp报头字段中的32位序号和32位确认序号来保证的
注意:确认应答机制不是保证双方的通信的可靠性,而是通过收到对方应答,来保证一方通信的可靠性,但是如果双方都这样搞,就是间接保证了双方通信的可靠性
确认应答机制前面已经大量涉及了,这里不再赘述了,只要记住,确认应答机制是Tcp保证可靠性的核心,或者说是基础,其余绝大部分的保证可靠性机制都是建立在确认应答机制基础上的
3.2 超时重传机制
定义:双方在进行网络通信时,如果发送方发出数据,如果在一个特定时间间隔内收不到应答,那么发送方就认为“我发的数据对方没有收到”,就会进行数据重发
附:Tcp保证可靠性一部分是通过Tcp协议报头体现出来的,还有一部分是通过实现Tcp的代码逻辑实现出来的,比如超时重传机制就是在发送方发出数据后开启了一个定时器,这就是通过Tcp的代码逻辑体现出来的,这在Tcp报头中是体现不出来的
上面的问题就是“丢包”,丢包有两种情况:
- 发送方发出去的数据直接丢了,接收方没收到数据
- 接收方收到数据了,但是发给发送方的应答报文丢了
- 但是无论是什么情况,统一规定为超时或者丢包,都会进行补发;但是如果补发次数多了,会判定链接出问题了,会申请重新建立链接
- 所以服务器可能会收到重复报文,所以服务器需要“去重”,所以报文的序号还有一个重要的作用就是“去重”
- 当发送缓冲区的数据发出去后,操作系统不会立即将数据从缓冲区中删除或覆盖,会暂时保存,在需要时进行超时重传,具体实现可以看后面的“滑动窗口”
问题:这个规定的超时时间是如何设置的?
解答: 超时时间不能设置太长,会影响整体效率;也不能设置太短,会造成资源浪费。
所以最理想的情况,就是找到一个最小时间,使“确认应答能在这个时间内返回”,所以这个时间是和网络环境有关的,网络好的时候重传时间可以设短一些,网络卡的时候重传长一些,所以重传时间是动态计算的:
- Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时 时间都是500ms的整数倍
- 如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传
- 如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增
- 累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接
3.3 Tcp连接管理机制
3.3.1 操作系统对连接的管理
面向连接是Tcp可靠性的一种,只有在通信建立好连接才会有各种可靠性机制,而一台服务器上可能会存在很多连接,此时操作系统就不得不对这些连接进行管理
- 操作系统在管理这些连接时需要“先描述,再组织”,所以在操作系统中一定有一个描述连接的结构体,里面包含了关于连接的各种属性字段,最后有很多个这样的结构体,就通过数据结构组织起来,这之后操作系统对连接的管理就变成了对该数据结构的增删查改
- 建立连接,在操作系统层面就是定义了一个结构体变量,然后填充各种属性字段,最后将其插入到管理连接的数据结构当中
- 断开连接,也就是将对应的结构体从数据结构中删除,然后释放该连接增加占用的各种资源
- 所以连接的建立管理和释放都是有成本的,就是管理结构体的时间成本,以及存储连接结构体的空间成本
3.3.2 三次握手(重点)
前面我们一直在说三次握手,但都只是简单的概括了,所以下面我们来详细了解下三次握手的过程
双方在进行网络通信前需要先建立连接,这个建立连接的过程我们称之为三次握手:
上面的SYN和ACK,就是Tcp报头字段中的那两个标志位,而对于第二次握手,除了应答,服务器也要主动建立连接,所以服务器也要发个SYN给客户端,因为Tcp协议中,双方主机地位是相等的:“你要和我通信,那么我也要和你通信”,总结:Tcp是全双工的
问题:为什么是三次握手,而不是四次五次或者更多次?
解答:有两个理由:
①三次握手是验证双方通信信道的最小次数,能够快速建立连接:
- 在客户端客户端收到服务器发来的第二次握手后,就说明第一次握手成功,在客户端看来,从我到服务器,以及服务器到我的这两条通信道路没有问题;此时从服务器看来,客户端发给我的消息我能收,但是我发给客户端的消息不知道,所有就有了第三次握手
- 于是客户端再次给服务器发ACK,只要服务器收到,就证明三次握手成功
- 那么既然三次握手就已经能够验证双方通信是否正常了,也就没必要在进行更多次的握手了
②奇数次握手,可以确保一般情况下握手失败的连接成本是嫁接在客户端的,能保证服务器本身的稳定性:
- 服务端维护链接是有成本的,每一个连接都要被服务器消耗内存管理起来,如果一次握手可以的话,客户端直接给服务器发大量的报文,那么服务器就要全部接着
- 当这样的客户端数量一多,服务器挂掉是迟早的事,我们称为“SYN洪水”;
- 如果是两次握手,绝对是服务器必须得先把链接建立好,万一客户端出了问题直接挂了,服务器不知道,它就得一直维护这个链接,维护链接是有成本的,造成资源浪费。
- 服务器是一对多的,不应该把客户端出问题造成的后果嫁接到服务器上,这样不好;今天我们三次握手,一旦第三次握手失败了,那么客户端认为它把它的链接维持住了,服务器认为链接没有成功,这样就把问题转移到了客户端上,服务器在握手失败的过程中没有付出太多的代价,能保证服务器本身的稳定性
总结:三次握手次数是确认全双工的最小次数
三次握手时,双方的状态变化
- 最开始客户端和服务器都处于CLOSED状态,但是服务器为了能够接收客户端发来的连接请求,所以服务器变为了LISTEN监听状态
- 客户端发起三次握手,当客户端发出第一个SYN后,状态变为SYN_SENT
- 处于监听状态的服务器收到SYN后,将连接放进内核等待队列中,并向客户端发起第二次握手,发出SYN+ACK后,服务器状态变成SYN_RCVD
- 当客户端收到服务器的第二次握手,紧接着发送三次握手的最后一个ACK,之后客户端状态变为ESTABLISHED
- 服务器收到最后一个SYN后,状态也变为ESTABLISGED
下面是一系列问题,有点长,但是如果理解了,对三次握手的细节了解更进一步:
问题:sock API和三次握手的关系是什么?
解答:
- 服务器最开始是CLOSED状态,然后调用listen监听函数,就会进入LISTEN状态
- 然后客户端就可以发起三次握手了,此时客户端调用的套接字接口就是connect函数
- 需要注意的是,connect只负责发起三次握手,三次握手底层的工作都是操作系统做的,所以当connect函数返回时,要么三次握手建立成功,要么底层三次握手失败
- 如果三次握手成功,服务器就建立了一个描述连接的结构体,但是此时这个连接还在内核的等待队列中,所以服务器就需要调用accept函数将这个连接拉取到上层进行操作
- 当服务器将获取到的连接拉上来后,就可以用read/recv和write/send函数进行数据交互了
问题:什么是全连接队列?listen函数的第二个参数有什么用?
解答:我们在Tcp简单网络程序中,就提到过,listen的第二个参数和一个队列有关:
计算机网络(四) —— 简单Tcp网络程序_tcp client 收发 程序-CSDN博客,现在我们就来谈谈:
- 我们申请链接时,服务器就一定会存在大量已经建立好的链接,但是如果服务器的上层没有accept把链接拿到上层去,此时这些连接叫做“全连接”,所以操作系统就必须对这些“临时维护”的联机管理起来
- 每一个链接都是是数据结构,所以底层采用队列的形式来管理,所以这个队列也类似于生产者消费者模型,所以listen的第二个参数表示这个已经建立好的链接队列的最大长度,这个队列叫做“全链接队列”,长度为 backlog + 1
- 队列里的链接都是三次握手全部完成的链接,然后等待上层accept调用,但是当队列满了,即使三次握手成功了,服务器也无法把新连接放到队列里了。
问题:什么是半连接队列
解答:
- 那么服务器链接从SYN_RCVD状态变为ESTABUSHED是服务器收到了客户端的SYN就变状态还是服务器发出SYN+ACK后才会变状态呢?是否变状态取决于下一个状态是否就绪,所以SYN_RCVD变为下一个状态就是看下一个状态是否就绪
- 所以,当全连接队列满的时候,服务器为了保证一直在SYN状态,服务器就完成第二次握手之后,对于客户端的第三次握手不做响应,也就是客户端发ACK过来我服务器直接丢弃(假如服务器接收ACK,就变为ESTABUSHED状态),所以前两次三次握手允许握手完成,但是第三次就不准你握手完成,所以服务端就一直是SYN状态
- 但是服务器不能长时间呆在SYN状态,因为客户端判定链接没成功就会一直发SYN,而服务器就一直丢SYN,一定时间后,服务器就会把这个链接直接释放掉
- 服务器端不会长时间维护SYN_RECV,被建立连接的一方,处于SYN_RECV状态,我们称为半链接,所以也有半连接队列,它里面的节点不会长时间存在,操作系统会不定时释放,它的长度由内核自己决定
- 上面的问题都统一称为:客户端和服务端链接建立不一致问题
- 本质上,全连接都是由半连接变来的,类似递进的关系,由于半连接也有长度,所以你来多少链接我都不怕,怕的就是一堆非法链接占着半连接队列,消耗半连接队列的长度,非法链接多了,其它正常的链接是连不进来的,这个就是“SYN洪水”的细节,“服务器繁忙,请稍后再试”,就是两个队列满了,然后不给我访问,SYN洪水就是非法链接占着队列,不让正常用户访问
问题:listen的第二个参数为什么不能太小也不能太大?
解答:
- 太长会导致服务器会存在有些链接来不及被上层处理,但依旧需要操作系统花代价维护,服务器很忙的时候,没有时间从底层拿数据,那么服务器本身很忙了,你还要占用系统资源不做事,这不合理,所以第二个参数不能太长是因为没必要太长,会降低系统整体效率。
- 而且也不能没有全连接队列。
- 故事:一个餐厅,很忙人很多,没空闲桌子了,这时候来客户了,接待员说店里没位子了,你去别家吃吧。这时候来一个就走一个,但是这时,一个桌子空了,我想让外面的客人立马进来吃饭,但是这个时候外面的客人全被接待员打发走了,造成资源浪费;所以老板会在门口摆一些凳子让客户坐着等一下,等店里面有空桌子了,就能让外面等的客人立马去补上位置,就能保证资源最大化利用
3.3.3 四次挥手
由于双方维护连接都是需要成本的,所以Tcp通信结束之后需要断开连接,这个 过程我们称之为四次挥手,其过程如下图,按照时间轴,每一个箭头都是一次挥手:
问题:为什么挥手是四次,而不是三次挥手?
解答:
- 三次握手的第二次握手时服务器给我发的报文是SYH+ACK的,同时设置了两个标记位,这是捎带应答,所以三次握手本质也是四次握手,只是把第二三次的握手放在一起了。
- 所以四次挥手的第二三次也可以放在一起变成“三次挥手”,所以从朴素的角度来看,挥手和握手其实是一来一回的可靠性,保证双方都至少给对方发了一次消息,所以原因非常朴素,就是为了保证TCP协议的可靠性。
- 握手被设置成三次是因为服务器就是要为客户端服务的,服务器必须无条件同意,不存在时间差上的协商,必须保证服务器立马对客户端做应答。
- 挥手被设置成四次是因为断开连接是有协商在里面的,客户端要断开连接,代表客户端要发的消息发完了,但是服务器可能还有数据要给客户端发,所以一方想断开连接另一方不想断开,数据没法玩
- 所以要想四次挥手变成三次是有巧合性的,所以四次挥手的第二次和第三次一般是分开,就变成了四次挥手。
四次挥手时,双方主机的状态变化
- 在挥手前,客户端和服务器都处于正常通信的ESTABLISHED状态
- 客户端最先发起断开连接请求,把FIN标志位设置的报文发给了服务器,然后客户端变为FIN_WAIT_1状态
- 服务器收到了FIN,发送ACK应答给客户端,同时状态变为CLOSE_WAIT状态
- 当服务器没有数据再发给客户端时,轮到服务器主动断开连接了,所以服务器主动发送FIN给客户端,之后状态变为LASE_ACK
- 客户端收到了第三次挥手,然后向服务器发送最后一个ACK应答,之后客户端进入TIME_WAIT状态
- 服务器收到最后一个响应报文后,就彻底关闭连接,也就是将该连接的结构体从数据结构中删除,再释放资源,变为CLOSED状态
- 而客户端变为TIME_WAIT状态后,并不会直接关闭连接,而是会等待一个2MSL(Maximum Segment Lifetime,报文最大生存时间),才会进入CLOSED状态
套接字和四次挥手之间的关系
- 客户端主动断开连接,对应的就是客户端先调用close函数;服务器同理,所以一个close对应两次挥手,双方都要close,所以是四次挥手
- 但是要注意的是,服务器只有调用close才会使状态由CLOSE_WAIT变为LASE_ACK,如果服务器忘记调用close,或者由于某些错误导致close调用失败,会导致系统内出现大量处于CLOSE_WAIT状态的连接,会占用服务器资源,是一种内存泄漏问题
问题:主动发起断开连接的一方会变成TIME_WAIT状态 ,那么为什么要等待一段时间才会变成CLOSED状态呢?
解答:原因有很多,我们一个一个来
- ①假设客户端发送第四次挥手后直接变为CLOSED,万一第四次挥手的报文丢了,服务器会进入超时重传,但是此时服务器无法再收到应答了,这样四次挥手就会失败,并且把失败的成本转移到了服务器上,这样不好
- ②一个报文被服务器发出被客户端接收,中间都在网络里,所以MSL就代表报文在网络里存活的最大时长。呆的最长时间就是一个MSL,它也是动态的,取决于网络状况,所以一般等待是2个MSL,发出报文和接收应答是两段路程。
- 断开连接时,网络上可能还会有报文存活着,所以需要等待双方通信的数据在网络里进行消散。
总结:为什么要有TIME_WAIT状态等待?
- 让通信双方历史数据得以消散(为了让服务器和客户端丢弃历史报文,不让它对后续通信产生影响)
- 让我们断开连接的四次挥手具有良好的容错性
一个报文的在网络里的最大存在时长在协议的规定里一般是两分钟,一个报文的最大传送时长一般是30到60秒之间可以通过命令:cat /proc/sys/net/ipv4/tcp_fin_timeout 命令来查看系统的默认等待时间:
一个服务器只能有10个连接,当第11个连接来的时候,服务器终于不堪负重,挂掉了,第11个连接自然请求失败,但是对前面10个连接来说,服务器就变成了主动断开连接的一方,服务器立马就会有10个连接处于TIME_WAIT状态,带来的后果就是服务器无法立即重启,重启时间取决于TIME_WAIT消失的时间,大约为30 -- 60秒,但是我们不允许服务器重启时间这么久,所以我们需要设置套接字属性,让服务器允许我们进行地址复用:
int opt = 1;
setsockopt(_listensockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)); // 防止偶发性的服务器无法立即重启,TIME_WAIT状态时,立即重启,不要再等了
客户端不会出现这样的问题是因为客户端每次启动用的都是随机端口,但是服务器每次启动都必须绑定一个端口号,而且主动断开连接的一方大部分都是客户端
3.4 流量控制
定义:Tcp根据接收方的接收能力来决定发送方发送数据的速度,这个机制叫做流量控制(Flow Control),具体的解释,前面的2.2 16位窗口大小,已经讲解过,这里不再赘述
- 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段, 通过ACK端通知发送端
- 窗口大小字段越大, 说明网络的吞吐量越高
- 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端
- 发送端接受到这个窗口之后, 就会减慢自己的发送速度
- 如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端
问题:三次握手成功后第一次发送的时候怎么保证发送数量是合理的?
解答:不要理解三次握手只是建立链接,三次握手期间双方也是交换了TCP报头了,所以在三次握手期间双方已经协商了双方的接收能力
问题:第三次握手的时候,可以携带数据吗?
解答:可以的(捎带应答),因为前两次就已经协商好双方的接收能力了,间接证明上一个问题(保证可靠性的时候也提高效率)
问题:流量控制,属于“可靠性”,还是属于“效率”?
解答:直接上保证可靠性,因为可以防止大面积丢包,但是流量控制也间接地提高了效率,会让正常报文不会再重传了,超时重传少很多,所以也间接提高了效率
四,Tcp提高传输效率策略
4.1 滑动窗口
双方主机在Tcp通信时,可以一次性发送多条数据,这样可以将等待多个响应的时间重叠,进而提高效率
滑动窗口背景:
- TCP允许发送方一次性能发送多个报文,然后接收方再一个个应答回去,如果没收到应答就会进行超时重传 --> 已经发出去,暂时没有收到应答的报文,要被TCP暂时保存起来
- 所以发送方就会存在着多个已经发出去但是暂时还没收到应答的报文。那么这些已经发出去但是还没收到应答的报文会被保存到哪里呢?根本不需要保存,因为报文就是在缓冲区里的,所以我们只需要“把缓冲区做一个简单的区域划分”即可。
- 把缓冲区当作数组来看的话,只需要有一个数组的下标就可以标记区域了,所以缓冲区可以分为三部分:已发送已确认,已发送未确认,待发送,三个部分
- 对于已发送已确认的部分,这部分可被覆盖,用简单的话说就是这个部分里面的数据可以从缓冲区移除或者设置成无效
- 对于已发送未确认,理解为:可以发/已经发,但是就是没有收到应答,我们就把这部分区域叫做“滑动窗口”,是发送缓冲区的一部分
问题:如何理解区域划分?
解答: 由于区域划分可以用数组来搞,所以区域大小的变化就可以用“双指针”来完成,可以参考双指针算法,至于滑动窗口作用两端如何移动的细节,也可以参考滑动窗口算法:
双指针算法——部分OJ题详解_指针oj题集-CSDN博客
滑动窗口算法——部分OJ题详解_滑动窗口 题目-CSDN博客
滑动窗口描述的就是发送方不用等待ACK,一次能发送的数据最大量
问题:既然有滑动窗口,那么为什么不把报文一次性全部发出去,而是一次发多个报文呢?每一次发送本质就是一次拷贝和IO,那么不是说IO次数多的话效率会降低的吗?
解答:滑动窗口越大,意味着网络吞吐量越高,同时往对方发送的数据量越大。但是,滑动窗口的大小,都是根据对方接收缓冲区的剩余大小来定的,不能超过对方的接收能力即应答报文的窗口大小。
问题:如何理解向左移动,向右移动?移动的时候大小会变化吗?如何变化?大小会保持不变吗?
解答:不能向左移动,左侧是已经发送已经确认的,只会往右滑;大小根据对方的接收能力来定,所以窗口大小是会动态变化的:
- 变大
- 变小
- 不变
所以向右移动也有三种方式:
- 右指针不变,左指针一直移动,说明对方上层一直不取数据,然后就是窗口变小的情况
- 左指针移动,右指针也移动,说明对方接收能力提高,窗口会变大;
- 同样的,如果左指针移动比右指针慢,表示对方接受能力减小,所以右指针移速也会动态减少,窗口也会动态变小
对于滑动窗口的范围大小,在代码层面上可以理解为两个指针int* start = (确认序号) 和 int* end = (确认序号 + 应答报头里的窗口大小,也就是win)
所以流量控制就是通过滑动窗口实现的:流量控制不仅仅是限制发送接收,如果主机的接收能力非常强,流量控制也可以把滑动窗口搞大,使其能发送更多数据,提高效率
问题:如果窗口一直往右移动,会不会越界?
解答:
- Tcp对于滑动窗口的时间采用了类似“环状算法”,到了结尾后通过环形算法重新计算start和end的位置,从左边再次开始移动
- 双方开始通信时,序号并不是从0开始的,假设我断开链接了,我进入TIME_WAIT等待数据消散,但是如果我断开连接后后悔了,于是立马由重新建立连接,但是数据可能还没有消散,于是服务器对新链接可能会收到旧数据(概率很低但是也有)
- 所以三次握手时也会协商序号,当发送端收到对方响应时,读取确认序号x和窗口大小win,此时就可以将start更新为x,将end更新为start + win:
4.2 快速重传
我们先来讲讲,滑动窗口对于下面两种丢包问题做的措施:
情况一:数据包已经抵达,ACK应答丢包
- 假设发6个报文,1000,2000,3000,4000,5000,6000假设我收到了1001,2001,4001的应答,3001和5001的没收到,那么滑动窗口应该是往右滑动到2001就停下来的
- 但是,序号的定义是假设我收到了6001的应答,那么前面6000的报文我全部收到了,所以滑动窗口会直接滑到6001的位置
- 如果6001也丢了,那么也没关系,滑倒5001,然后等待5001和6001超时重发即可
- 如果全丢了呢?那就全超时重发
所以发送端连续发送多个报文数据,部分ACK丢包不要紧,可以根据后续的ACK进行确认
情况二:发送方发送的数据丢包,接收方接受的数据不完整
- 6个报文,序号是1到6000,接收方只有1001 - 200的报文没收到丢包了,2001后面的的全收到了,那么后面所有的报文返回应答时,确认序号就全填的1001,表示2001的报文没收到
- 此时发送方会受到很多相同序号的应答,所以发送方有个原则,当收到了3个相同序号的应答报文,会立即对丢包的数据进行重发,就把1001到2001重发,后面的不重发了
- 万一5001丢了也没关系,直接针对5001进行重发,这种策略我们叫做 ——“快重传”
- 当发送端补发了1001-2000的数据后,对方发来的确认序号就会变为6001,表示1-6000的数据我全部收到了
问题:已经有了快重传了,为什么还有超时重传呢?
解答:主要是因为快重传是有条件的:收到三个相同序号的应答,快重传的本意是为了提高效率的,而一旦数据丢失了,但是我收不到对方三次重复的应答,此时快重传机制就不能触发,只能进行超时重传
所以超时重传也不能丢弃,因为超时重传是兜底的,是底线
4.3 延迟应答
客户端和服务器都有接收和发送缓冲区,双方的发送缓冲区里都有滑动窗口,客户端发消息给服务器,服务器要给客户端应答
- 发送方一次发送更多的数据,代表它发送的效率越高(也就是一次IO往网卡里塞更多数据,效率高)
- 但是发送方一次发送多少数据,取决于对方告诉我它能接收更多数据,
- 如果接收方,给发送方通告一个更大的窗口大小(TCP报头那个),发送方才能发更多数据
问题:如何让接收方给发送方通告一个更大的窗口呢?
解答:那么就在服务器收到报文的时候,我不立即发应答,我等一等(这个等不是上层等,是TCP协议层在等),在不超时的情况下,我收到第二个或者第三个报文时,再给客户端应答,而在我等的时候,上层就有较大的概率把数据取走,缓冲区的剩余空间就会变大。
我们把这种收到报文不着急应答的策略,叫做:延迟应答,是一种提高效率的方式,但是不是一定提高效率,如果上层一直不取数据,那么效率会降低,所以这种应答提高效率是有概率的。
- 假设接收端缓冲区为1M. 一次收到了500K的数据
- 如果立刻应答, 返回的窗口就是500K
- 但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了
- 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来
- 如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M
编程推荐建议:以后我们写TCP服务器的时候,建议尽快把通过read,recv尽快把数据全部从内核拿上来。
4.4 捎带应答
- 捎带应答是Tcp通信时最常规的一种方式
- 主机A给主机B发送了一条消息,当主机B收到消息后要发送ACK
- 但是如果此时主机B刚好要发数据,那么这个ACK就可以“搭个顺风车”,此时就把“数据 + ACK”一起发过去了,完成了数据发送,又完成了ACK应答,这叫做捎带应答
- 所以捎带应答不仅能确保发送的数据被对方可靠地收到了,同时也确保了捎带地ACK应答也被对方收到,在保证可靠性地同时又提高了效率
五,拥塞控制
发送数据如果出现问题,可能是一方主机出现问题,也可能是网络出了问题,两种情况:
- 出现少量丢包
- 出现了大量丢包
场景:一个班有30个人,考C语言只有两个人挂科,后面考网络,挂了28个人,所以前面考C语言可能就是我的问题,后面考网络可以认为是校方问题,对应的也就是客户端-服务器的问题和网络的问题。
而网络出现问题:
- 硬件设备出问题
- 网络中数据吞吐量太大引起阻塞。
如果通信双方出现大量数据丢包问题,Tcp就会判断网络出了问题 --> 我们叫做“网络拥塞”。
问题: 如何发现是网络出了问题?
解答:滑动窗口内大量数据都超时了。
问题:当识别到网络拥塞,发送方应该怎么办呢?
解答:
- 肯定不能对报文进行超时重发,因为会加重网络的拥塞,而且一个网络也不仅仅是一个CS在通信,会有很多个CS在通信,而且网络资源本身是共享的,都是用的TCP/IP协议
- 网络拥塞影响的不是你一个主机,影响的是大家,所以是大家都减少对数据的发送,使网络拥塞的数据快速消散,你好我好大家好 --> “用TCP协议实现了多主机面对网络拥塞时的共识”
- 可能也不是所有的发送方都能识别到网络拥塞了,比如我只发两个报文,那么也就丢两个报文,所以不是所有主机都能立马识别到网络拥塞,但是能保证一个主机一旦识别到网络拥塞就立即减少发送
- 所以网络这个东西是搏概率的,拥塞严重程度的不同也是自适应的。
- 识别到网络拥塞时,启动慢启动机制,发送少量数据,如果又丢了,进入超时重发;如果得到了应答,第二次发送多一点点数据,然后重复前面的操作(每次重发时都发送上一次发送的两倍报文)
所以我们现在就要推出一个概念:拥塞窗口;
- 每次开始的时候,定义拥塞窗口大小为1,每次收到ACK应答后,拥塞窗口大小 *= 2,
- 拥塞窗口大小是主机判断网络健康程度的指标,超过拥塞窗口,会引发网络拥塞,否则不会。因为网络是动态的,所以拥塞窗口本身也是动态的
- 所以滑动窗口大小 = min(16位窗口大小 ,有效数据, 拥塞窗口);16位窗口大小是对方主机的接受能力,但是拥塞窗口考虑的是动态的网络的接收能力
- 通过调整拥塞窗口的大小来动态控制滑动窗口的大小,让它的发送数据量按照我们的要求指数级增长,叫做“慢启动”
问题:指数级增长咋叫“慢启动”呢?
解答:
- 这个慢是指初始的时候慢,网络出拥塞的时候,发送少量的报文,如果都OK,那么表示网络已经趋于健康了,应该尽快恢复正常通信,而且为了不让后期拥塞窗口过大,所以也不是单纯地让拥塞窗口加倍增长
- 慢启动具有阈值,当拥塞窗口大小超过阈值时,就以线性增长不再指数增长了。而这个阈值这个数字,代表最近一次发生网络拥塞时拥塞窗口的大小/2 作为慢启动“阈值”,然后将拥塞窗口直接干到1,从1重新开始增长
滑动窗口 + 接收窗口 + 拥塞窗口,三个窗口相互配合,就可以在Tcp传输的时候既考虑接收问题,又考虑网络问题
六,扩展
6.1 面向字节流 VS 面向数据报
- 写数据和读数据互不相关。数据发多少,读多少,没有那种我发多少你就必须收多少的那种匹配机制。UDP就是你发几次我就必须收几次,所以UDP就是面向数据报
- 发送本质就是把数据从我的发送缓冲区“拷贝”到对方的接收缓冲区,但是对方数据被上层取走多少由用户决定,但是发送方发送多少数据由操作系统和TCP协议决定,
- TCP也不管你上层要发送的数据是什么,在我缓冲区里它就是二进制,就是字节数据,TCP的任务就是保证这个数据能成功被对面接收到
- 同时对方的TCP也只认字节数据,分离报头等等工作由用户层自己做 --> TCP只管发送,对发送的数据不做任何处理,全由上层自己做 --> 所以TCP只有字节的概念
- UDP报头里面是有数据长度的,但是TCP没有,因为TCP的序号能保证数据段本身的按序性,TCP也不区分什么报头和载荷,那是上层的事情,TCP只有字节流的概念,你的缓冲区里什么我的缓冲区里就有什么
- 就像家里的自来水管,自来水公司只负责把水送到你家,你怎么用这个水自来水公司不管。字节流也是类似的概念
- 用户对报文进行处理必须一个一个处理,需要将字节流变成一个一个完成的请求,那就是应用层的事了,与Tcp无关了,这样也很好地进行了功能解耦
6.2 数据包粘包问题
我收到的报文有时候并不是一个完整的报文,可能是半个,或者一个半个报文,这时候上层对报文边读边解析,那么上层就可能读到半个报文的情况,这时候再进行处理时,会多处理或少处理请求,叫做粘包问题
场景:蒸包子,包子可能黏在一起,我拿一个包子可能拿出三个包子。
Tcp没有粘包问题,它是上层的问题。要解决粘包问题就是定协议(自定义协议的时候搞过)
问题:如何解决粘包问题?
解答:明确报文与报文之间的界限:
- 采用定长报头
- 定特殊字符作为报文的边界
- 使用自描述字段+定长报头
- 使用自描述字段+特殊字符
完成分离后再进行反序列化,用户才算真正拿到的请求
6.3 Tcp连接异常情况
进程终止
- 链接其实和进程没有直接关系,它本身和文件是直接相关的,因为获取套接字其实就是获取一个文件描述符,也就是打开了一个文件
- 而我们曾经讲过,文件的生命周期是随进程的,所以链接间接上也是和进程相关的,所以进程退出 --> 关掉文件描述符 --> 链接也进行正常的四次挥手自动断开
机器重启
- 如果是正常重启,操作系统会在关机前干掉所有进程,而这也是进程退出,所以机器重启和进程终止地情况是一样地
机器断电/网线断开
当客户端正常访问服务器时,客户端突然s了,但是服务器在短时间内不知道,所以会维持与客户端地连接,但是不会一直维护,因为Tcp是有保活策略的:
- 服务器会定期检查客户端的在线情况的,如果连续多次没有ACK应答,服务器会自动关闭连接
- 此外,客户端也会定期向服务器“报平安”,所以如果服务器一段时间内没有收到客户端的消息,服务器也会关闭连接
七,Tcp总结
可以看到Tcp比起Udp复杂了不止一点点,因为Tcp既要保证可靠性,又能尽可能提高效率
可靠性保证:
- 检验和
- 序列号
- 确认应答(核心)
- 超时重传
- 连接管理
- 流量控制
- 拥塞控制
提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
Tcp当中还设立了各种定时器:
- 重传定时器:为了控制丢失的报文和丢弃的报文,也就是对报文段确认的等待时间
- 坚持定时器:专门为对方零窗口通知而设立的,也就是向对方发送窗口探测的时间间隔
- 保活定时器:为了检查空闲连接的存在状态,也就是向对方发送探查报文的时间间隔
- TIME_WAIT定时器:双方在四次挥手之后,主动断开连接的一方需要等待的时长
下面是一些基于Tcp的常见的应用层协议:
- HTTP(超文本传输协议)
- HTTPS(安全数据传输协议)
- SSH(安全外壳协议)
- Telnet(远程终端协议)
- FTP(文件传输协议)
- SMTP (电子邮件传输协议)
当然也包括我们自己Tcp程序时定义的应用层协议:
计算机网络(五) —— 自定义协议简单网络程序-CSDN博客