什么是TCP协议
在上一节中,我们已经提到过了UDP协议的详细内容,这一节我们就继续了解传输层协议中另一个很重要的协议——TCP协议
先来看TCP是如何定义的
TCP 是⾯向连接的、可靠的、基于字节流的传输层通信协议
不难看出,TCP有三个重要的特征
第一,它是面向连接的,这个在我们之前实现TCP服务器的时候,也有体会,双方交互有一个连接的过程,服务器端只有成功获取到客户端的连接请求,双方才能正常进行交互
换言之,它还隐含了一个重要的特点,是UDP不具备的.
TCP做不到像UDP协议一样,可以⼀个主机(服务器端)同时向多个主机(客户端)发送消息(无连接),TCP有连接,并且连接一定是一对一的,连接成功后,再给连接成功的不同主机发送消息.
第二,它是可靠的,⽆论⽹络链路中出现了怎样的链路变化,TCP 都可以保证⼀个报⽂⼀定能够到达接收端
第三,它是基于字节流的,消息的传递是有序的,我这边发送什么,TCP协议就能保证对面也能收到相同的有序数据,而且不会重复
TCP报头详解
在回顾完之前的重复知识后,我们下面就是以TCP报头为主线,了解TCP的报头设计,还有与TCP相关的,诸如超时重传,连接管理等等机制.
TCP协议格式
我们先来看一下TCP协议的整体格式:
(图来自图解网络小林Coding)
其中各个字段的含义简述如下:(后面会一个个了解)
源/目的端口号:表示数据是从哪个进程来,到发送到对端主机上的哪个进程.
32位序号/32位确认序号:分别代表TCP报文当中每个字节数据的编号以及对对方的确认,主要是用来解决网络包乱序与不丢包的问题.(后面会详细讲)
4位首部长度:表示该TCP报头的长度,以4字节为单位.
6位保留字段:暂时未使用的6个比特位.
16位窗口大小:保证TCP可靠性机制和效率提升机制的重要字段.
16位检验和:由发送端填充,采用CRC校验。接收端校验不通过,则认为接收到的数据有问题.(检验和包含TCP首部+TCP数据部分)
16位紧急指针:标识紧急数据在报文中的偏移量,需要配合标志字段当中的URG字段统一使用.
和UDP协议格式不同,TCP协议中是允许有选项字段的
TCP报头当中的6位标志位:
URG:紧急指针是否有效。
ACK:确认序号是否有效.
(ACK为1,则「确认应答」的字段变为有效,TCP 规定除了最初建⽴连接时的 SYN 包之外,该位必须设置为 1)
PSH:提示接收端应用程序立刻将TCP接收缓冲区当中的数据读走。
RST:表示要求对方重新建立连接。我们把携带RST标识的报文称为复位报文段.(假如置为1,则表示连接过程中出现异常,需要强制终止连接)
SYN:表示请求与对方建立连接。我们把携带SYN标识的报文称为同步报文段
FIN:通知对方,本端要关闭了,想要终止连接,我们把携带FIN标识的报文称为结束报文段
下面,我们将以报头为主线,尽快将报头相关的每个特征讲清楚
如何分离与交付
谈协议,无法避免的第一个问题就是,如何将报头和有效载荷分离
回忆我们UDP协议,它的报头是固定八个字节大小的,所以只要读取前八个字节,就能获取到报头,剩下的就是我们的数据
TCP协议也一样,我们可以注意到TCP协议格式中,前20个字节是固定大小的,那直接读取前二十个字节,获取到的就是首部长度字段
但是两者又有区别
TCP协议中多了一个选项的字段,而且是不定长的,这就很难办了,怎么将它和我们的有效载荷分离开呢?
答案就是我们的4位首部长度
4位首部长度范围是【0000-1111】,假如直接表示为10进制数的话,范围就是0到15?这显然是❌的,首部长度字段都20字节
所以实际上,首部长度计算的基本单位是4字节
header_len * 4 = 最终长度
所以4位首部长度范围实际是【0000-1111】× 4 = 0到60个字节,除去20字节的首部长度字段,选项最终能有40个字节
假如我们TCP报头中没有选项呢?即标准报头,那4位首部长度应该填充几?
答案就是20/4 = 5,用四位二进制表示就是0101
Q1.回顾我们的UDP协议格式,直接提取前八个字节,就可以做到报头和有效载荷分离,为什么还要有一个16位的UDP长度呢?
答案在这节就可以揭示,首部长度计算的基本单位是4字节
假如除去这16位UDP长度,那就是48/8 = 6字节,不符合我们基本单位为4字节的要求,更进一步讲,本质上是⽹络设备硬件设计的要求,和处理⽅便(与TCP协议报头格式统一)
Q2.那实际上整个报头和有效载荷分离的过程是怎样的呢?
A.提取报文前20个字节,同时获取到4位首部长度字段
B.首部长度字段*4 - 20 == 0?假如等于0,则报头完毕,否则,有几个字节的选项,继续读取就完了
C.剩下的就是有效载荷
Q3.我们前面已经提到过报头(协议)的本质就是一个结构化数据!(Struct结构体),即报头实际就是一个C语言实现的位段类型,那结构体的大小可以随意进行变动吗?为什么还会有一个选项的功能?如何实现的呢?
答案就是我们之前C语言学过的柔性数组!
在C99标准中,定义结构体时允许我们创建一个空数组(例如:arr [ 0 ] ),该数组的大小可在程序运行过程中按照你的需求变动
就能完美解决这个问题~当然,这个数组的大小在我们之前的叙述中也知道,最大为40个字节
Q4.如何进行有效载荷的向上交付呢?
知识回顾:
应用层的每一个网络进程都必须绑定一个端口号
服务器端进程是显示绑定的,而客户端进程是系统动态随机分配的.
不难发现,在我们TCP协议中,有着源端口号和目的端口号这两个字段
所以,和UDP协议类似,有端口号,我们就能找到对应应用层进程,进而将有效载荷交给对应应用层进程进行处理
通过上面的讲述,我们已经了解了如下字段
序号and确认序号字段
确认应答机制
当谈到TCP特性的时候,我们第一时间可能会想到的词语就是可靠性
如何保证可靠性呢?
我们第一步应该反问我们自己,有哪些是不可靠的表现?
假如这些情况都可以很好的处理,我们就可以"自信地"说我们是可靠的!
下面列举了一些大家都可以想到的,一些网络传输不可靠的场景
丢包,乱序,重复,校验失败,发送太快/太慢,网络出问题,…
在解决它们之前,还有一个问题需要反问我们自己
为什么会出现这么多不可靠的问题呢?
这个问题很简单,单纯的就是因为通信双方,距离变长了!
假如硬件设备之间彼此挨得很近,传输数据出错的概率实际上很低,但是现在发送数据可能相隔千里,这个"线"可能很长,出错的概率就相应飙升,所以我们才需要TCP协议去保证可靠性
现在我们来看丢包的问题,所谓的丢包,就是数据在发送的过程中,出现了遗失
那我们怎么知道在发送过程中,这个报文丢包了呢!?
有人可能会对上面这个问题有疑问,对方没有收到相应的数据,不就发生丢包了吗?
但是这在网络当中并不是理所当然的,会有下面两个问题:
第一,对方怎么知道你发了数据呢?他也可以认为你没有发数据,一切岁月安好
第二,你怎么知道对方收到了你发的数据呢?
所以,想要解决丢包问题,我们必须先对可靠有个正确的理解,才能了解什么是确认应答机制
当我们向对方发送数据的时候,我们是通过对方是否有回应(ACK)来判断对方是否收到了我们的数据
只要C(client)在一定的等待时间内收到了应答,C向S(Server)发送的数据,S一定100%收到!
假如没有收到ACK,C就直接认为,报文丢了(不考虑是发送的信息丢了,还是ACK丢了)
确认应答机制:
可靠性是通过收到应答保证的!
只要一方收到了另一方的应答消息,就说明它上一次发送的数据被另一方可靠的收到了
但是这有个问题,ACK也是一个报文啊?谁又来保证ACK没有丢包呢?
所以,在客观上,我们无法保证任何报文是可靠送达的,不然就会陷入相互说我收到你发的应答啦!的死循环里面
但是我们能做到的是局部保证可靠性!我们只要保证双方通信时发送的每一个核心数据都有对应的响应就可以了.
而对于一些无关紧要的数据(比如响应数据),我们没有必要保证它的可靠性.因为对端如果没有在规定时间内收到这个响应数据,会判定上一次发送的报文丢失了,此时对端就可以将上一次发送的数据进行重传,问题就转变为如何补发的问题
进一步推广我们上面的说法
TCP的client端和server端,双方的地位是对等的!
所以,我们就可以采用同样的机制来实现双方的发送数据是可靠的,假如应答没有在规定时间内收到,再考虑补发问题即可
但是像我们上述的说法,是串行传输的,C发数据,S给应答,这样的效率是非常底下的,所以实际上实现并不是串行传输,而是并行传输
在同一时间内,发送多个报文,这就是并行传输
发送多个报文时间上,实现时间重叠,就能大大提高效率
但是这样就又会出现两个问题
1.多个报文经过网络,发送顺序,不一定是收到的顺序!
2.当C收到多个ACK的时候,又如何得知哪个应答,对应哪个报文呢?(这很关键,缺什么补发什么,总不可能全部重发吧)
解决问题
所以,由于并行传输所导致的问题,这才导致了我们TCP协议格式中序号和确认序号字段的出现
报头中的序号字段,解决的就是报文经过网络后仍然有序的问题(解决网络包乱序)
报头中的确认序号,解决的就是得知哪个应答对应的是哪个报文(解决不丢包)
PS:
无论是请求还是响应,双方在进行交互的时候,通信发送的都是TCP报头+有效载荷(如果有的话)
我们先来看报头中的序号字段
所谓的序号字段,用通俗语言表示,就是我们给每一个我们发送出去的每个字节的数据都进行了编号. 即为序列号
它就好像我们高中军训的时候,每个人在队伍中都有着自己对应的序号,假如需要集队,不管队伍此时有多乱,我们都可以按照每个人的序号,立马一个个重新集结起来整齐的队伍
在实际网络中也是如此,每个字节数据都有对应自己的序号,当然一个有效载荷不可能只有一个字节,所以实际上TCP报头32位的序号字段填充的是发送数据(有效载荷)中首个字节的序列号
假如我要发送5000个字节的数据,每次发送端发送1000个字节数据,就需要5个TCP报文,每个TCP报文对应的确认序号就是1,1001,2001,3001,4001
当接收端接收到对应的五个报文后,即便由于各个报文在进行网络传输时选择的路径可能是不一样的,进而这些报文到达对端主机的先后顺序也可能和发送报文的顺序是不同的
但我们依旧可以通过序号字段重新排序(传输层),进而恢复原来发送报文的实际顺序
这就成功解决了网络包乱序问题
接下来,我们看报头中的确认序号字段
确认序号定义为X-1编号之前的报文已经全部收到了,下次发请从X编号开始发送
还是拿刚刚的例子举例,假如此时接收端收到了发送端发来的1到1000字节数据,此时就会给发送端回复对应的应答ACK,对应的确认序号字段就会填充为1001,表示1到1000字节的数据,我已经全部收到了!下次发送请从1001字节开始发送
为什么要这样定义,或者说规定确认序号呢?
这样定义的一大好处,是可以允许我们少量的ACK丢失,更细粒度的确认丢包原因
比如在发送过程中,我们出现了丢包,接收端此时只收到了三个报文(1-1000,1001-2000,3001-4000),而不是五个报文,我们2001-3000和4001-5000,两个TCP报文发生了丢失
此时接收端给发送端发送的TCP报头确认序号字段,填充的就是2001字节,表示前两个TCP报头(1-1000,1001-2000)我都收到了!下次发送请从序号为2001的字节数据重新发送
这样就做到了锁定具体哪个数据包(更细粒度)发生了丢失,进而方便我们发送端进行补发(重传)
假如没有做这个规定,发送端就很难受了,它无法确定接收端前面的数据是否全部收到,有可能它收到了,但是应答ACK在传输途中发生了丢失,发送端就不知道从哪个字节数据开始补发,全部重新开始补发,那效率按我们之前说的,就太低了!
有了这个规定后,发送端就知道,我能收到对应的确认序号X,至少证明我X-1序号之前的数据,接收端都是收到的,补发不用重头开始
这就成功初步解决了网络不丢包问题
Q1.为什么要用两个字段呢?序号和确认序号字段可以合并为一个字段?
发送端发送数据的时候,把这一个字段看作是序号字段
接收端接收到后,给的回应中,把该字段看作是确认序号字段
这样不就可以解决并行传输所导致的问题了吗?
原因就在于
TCP是全双工的
即接收端同时也是发送端,发送端也是接收端,你打电话和对面聊天的时候,你在说话的同时,对面同样可以说话给你听
双方发出的报文当中,不仅需要填充32位序号来表明自己当前发送数据的序号.
还需要填充32位确认序号,对对方上一次发送的数据进行确认,告诉对方下一次应该从哪一字节序号开始进行发送
这样就可以使TCP报头,压缩成一个应答+请求的形式,完成TCP全双工的要求
所以才导致两个字段的产生
窗口大小
经过上一节我们的讲述,我们就了解了序列号和确认序列号的作用功能,我们便继续根据头部格式,继续了解其特征
发送缓冲区/接收缓冲区
在了解窗口大小字段之前,无法避免需要先科普一个常识
我们实际在应用层接收和发送的数据,都不是直接发送到网络或从网络中直接读取,而是有一个缓冲区的概念
当我们上层调用write/send这样的系统调用接口时,实际是将数据从应用层拷贝到了TCP的发送缓冲区当中
当我们上层调用read/recv这样的系统调用接口时,实际是将数据从TCP的接收缓冲区拷贝到了应用层
图来自2021dragon博主
它就好比我们调用read/write进行文件读写的时候,实际上根本没有直接对磁盘的数据进行操作,OS操作系统大哥才不放心你直接对硬件进行操作,而是将我们要操作的数据放到一个缓冲区当中,它来替我们负责管理数据的读写
在网络中也同样如此,什么时候发数据,不是我们用户进行管理的,你只需要管理好你上层应用层往下要交付的数据是什么就好了,自己放到对应的缓冲区当中!
具体什么时候发,如何发,由我TCP协议来管理操作
除了解耦作为一大好处外,我们之前提到过当发生数据丢包的时候,发送端要进行超时重传,那重传的这部分数据放在哪呢?难不成要用户重新向下交付吗?
答案就是发送端的发送缓冲区当中,只有对面接收端确定收到对应数据后,缓冲区的数据才会被覆盖
同样的,上层处理数据也需要时间(消耗资源),那此时发送端发过来的数据,接收端要放到哪呢?难不成直接丢弃吗?那就太浪费了,要知道数据传输是需要耗费资源的!
此时放到接收缓冲区当中,就可以完美解决这个问题,同时我们之前说的根据序号进行数据重排,也是在缓冲区进行操作的
这么看来,有一个缓冲区机制,带来的好处是多多的
PS:在网络中,通信双方的地位永远是对等的,所以无论是哪方,都有着自己需要维护的发送缓冲区和接收缓冲区
解决问题
但一件事物并无绝对的优劣,带来好处的同时,肯定也会同时带来问题
凡是我构建的TCP报文,必定是我要给对方发消息(不管是ACK还是发消息,还是两者都有)
发消息根据我们之前说的,就是往对面接收端的接收缓冲区放数据
那缓冲区大小是有限的啊!
在linux系统下(C语言写),它本质就是一个char buffer[N]的数组,有着限定的字节数据大小(这也是我们说的TCP面向字节流的原因之一)
假如对面缓冲区都已经满了,我还继续发消息,那就等着数据包被丢弃,然后苦闷的重传等一系列连锁反应吧
因此,为了解决这个问题,让接收端告诉发送端,自己此时的接收能力还有多少是非常有必要的
TCP报头格式中的窗口大小便应运而生
16位窗口大小,填充的永远是自己的接收缓冲区剩余空间大小!
窗口大小越大,说明剩余空间越多,那发送端此时就可以提高发送数据的速度;
相反,窗口大小越小,说明剩余空间越小,那就要适当减慢发送数据的速度
指的一提,不能片面认为限制速度就是好,而是该快的时候快,该慢的时候慢,对面窗口大小为0,就不要继续发了,合适自己的才是最好
反思这个过程,能够达到控制发送端收发数据快慢,即实现我们的流量控制(自适应),需要满足下面两个条件
1.每个报文都有对应的应答(可靠性)
2.只有自己才知道自己的接受能力(缓冲区机制)
由此也可以看到,TCP格式的设计是环环相扣,互相配合的!
16位校验和(简略)
在了解完窗口大小后,我们继续了解16位校验和字段
当然,这里只是简单提一嘴,不进一步深挖
校验和字段会对TCP报头,或者有效载荷,或者两者结合,都进行校验,校验成功当然无事发生
一旦检验不通过,会将它直接全部丢弃;
此时发送端因为没有收到对应的ACK应答,就会基于超时重传机制,重新发数据给接收端
标志位
为什么会出现
接下来我们继续谈标志位字段
在聊每一个标志位有什么作用之前,我们先谈三个重要的问题
第一.标志位的实现(HOW)
每一个标志位都是只占一个bit,在C语言中实际就是位段,设定为1个字节就好,为0表示假,为1表示真
第二.标志位的本质是什么?(WHAT)
我们在学习网络的时候,需要避免一个巨大的思维误区——在同一时刻只有我一台主机(客户端)给服务器发消息
实际上,在同一时刻,服务器是给很多台主机(服务器)提供服务的,而服务的类型也并不一定相同,有些是要和主机建立链接的,有些是要断开连接,还有些是要和服务器之间进行交互,获取它对应的资源
所以,对于服务器来说,一个基本事实:
它在同一个时刻/时间段会收到各种各样的报文请求
这就好比我们的餐厅,每一天都会有各式各样的人来餐厅,有可能是外卖员,给我们餐厅提供外卖服务的;也可能是顾客,甚至有可能是来问路的…
而我们餐厅要为这些不同的人提供不同的处理动作
报文也是如此,报文也是有类型的!那我们如何标识分辨这些不同的报文呢?
这就是标志位出现的原因!
标志位的作用就是标识不同类型的报文!
第三.为什么会有各式各样不同的标记位? (WHY)
Server会在同一个时刻/时间段会收到各种各样的报文请求,不同类型的报文需要我们提供不同的处理动作
我们需要标识不同类型的报文,从而提供不同的处理动作
不管是三次握手,还是四次挥手…或者说提供对应的资源等等
这才是各式各样不同标志位出现的原因
具体设计
ACK(acknowledge):该报文是一个确认应答报文
一般除了第一个请求报文没有设置ACK(0)以外,其余报文基本都会设置ACK(1),因为发送出去的数据本身就对对方发送过来的数据具有一定的确认能力
双方在进行数据通信时((捎带应答)),可以顺便对对方上一次发送的数据进行响应
SYN(synchronize):标识是一个连接请求的报文
只有在连接建立阶段,SYN才被设置,正常通信时SYN不会被设置
在双方建立连接的时候,需要经过3次握手过程(三次握手过程后面详说)
整个简易过程如下:
1.发送端报文携带SYN
2.接收端返回的报文中除了有SYN,还有ACK接收应答
3.发送端报文携带ACK,完成双方连接
FIN(finish):是一个链接断开的请求报文
只有在断开连接阶段,FIN才被设置,正常通信时FIN不会被设置
对应的是我们断开连接时,4次挥手中被设置(四次挥手过程后面详说)
PSH(PUSH标志位):被设置为1,是提示接收端应用程序尽快从TCP缓冲区把数据读走
它就好像我们做作业时,父母催促我们快点把作业做完
当接收端缓冲区快满了,或者说已经满了,返回的窗口大小为0(极端情况),此时发送端发送的报文中PSH就会置为1,催促接收端上层尽快把数据取走
当然除了缓冲区快满的情况,还可以有另外一种应用
我们知道调用系统接口,诸如read/recv等等,都是有资源消耗的,不可能多次频繁调用,所以,实际上,我们在操作中,上层并不是直接缓存区一有数据就读取,而是达到某个数据量,我们称之为水位线,才一次性读取,而PSH置为1,可以无视这个水位线,通知对面的OS,尽快将接收缓冲区当中的数据交付给上层,尽管接收缓冲区当中的数据还没到达所指定的水位线
当然具体如何实现催促对面尽快取走数据,这里我们先留一个伏笔,后面再讲
RST(RESET):报文当中的RST被设置为1,表示需要让对方重新建立连接,进行连接重置
我们说双方建立连接,需要完成TCP三次握手,此时就提出一个问题,是不是一定能握手成功呢?
答案我们说不一定,有可能三次握手是失败的
就算我们双方建立连接成功,但是有一方释放了连接,对端并不知道,还以为依旧保持连接,这个情况存不存在呢?
我们说也是存在的,比如说我们握手的时候,双方建立好连接后,直接把服务器端的网线拔掉,那服务器端立马就掉线了,甚至四次挥手的报文也没机会发出去
但客户端并不知道,还在傻傻的发消息(依旧维持旧的链接)
当服务器端重新接上网线时,用户端发过来消息,但并没有经过三次挥手,此时服务器端就意识到客户端还在维持旧的连接
我们就需要重新开始进行双方连接
客户端识别到RST==1的报文时,会重新发起三次握手
这就是我们有时候访问浏览器,遇到"无法访问该网站,连接已被重置"报错的原因
URG(urge):紧急指针标志位(平时用的非常少)
假如我们把接收缓冲区看作一个队列,按序(报头中的序号从小到大进行排序)入队(生产消费者模型),这样就保证了数据包不会乱序
但是假如我们想要插队呢?
比如说报文里面有特殊(紧急)的数据,要上层尽快把这个报文处理掉
URG标志位的作用就是告诉我们这个报文里面是否含有紧急数据
或者换句话说指示我们的紧急指针是否有效
只有当URG标志位被设置为1时,我们需要通过TCP报头当中的16位紧急指针来找到紧急数据,否则一般情况下不需要关注TCP报头当中的16位紧急指针
那16位紧急指针字段是什么呢?
在C语言中指针就是地址,但其实只要有类似定位资源(地址),对资源进行唯一性确认的,在广义上都能称为指针(下标)
16位紧急指针也是如此,它的本质是一个偏移量,指示紧急数据在我们有效载荷的偏移量
但是由于只有1个字段,所以紧急指针只能标识数据段中的一个位置,因此紧急数据只能发送一个字节
但究竟有什么用呢?一个字节的数据可以能带的信息量直观上会比较少
这里可以简单举两个用途
第一.我们平时作为用户上传数据到云盘的时候,上传到一半的时候,可能发现上传错误的数据,于是按下取消键,此时就会向对面服务器端发送一个取消的请求,但是此时对面的接收缓冲区可能还有数据,我们的取消请求此时就要排队,这显然就不太合理,我想让你取消传送数据,结果你还继续把剩下的数据上传
或者说不是取消,而是紧急暂停操作,等会再上传数据,我们的暂停请求也需要排队,这也是一个问题
我们就可以用这一个字节作为状态码,用0表示暂停,1表示取消,2表示继续
当对面服务器检测到状态码发生变化时,就能新开辟一条通信链路,别人走的是普通道路,你走的是VIP通道,直接送达给对面服务器上层处理,从而解决暂停,取消等信息传送滞后的问题
所以这也是为什么这一个字节的数据有时也被称为带外数据,它和主传输流不走同一个传输流,是另外一条"数据带"
第二.我在公司写了一个服务,平时这个服务器的IO压力特别大,我作为管理员有时候需要不定时去检测服务器的工作状态,使用率的情况如何,为什么有时候服务器没有反应等等情况,这时候假如还需要排队,由于IO压力特别大,那不定时检测这个任务就是天方夜谭
但是我们的带外数据,可以先自己进行设定,0表示服务器正常工作,1表示使用一半,2表示使用繁忙等等,就可以开辟新的数据传输流,来实现检测服务器工作状态的目的
说了这么多,我们如何实现设置URG标志位呢?
recv函数的第四个参数flags有一个叫做MSG_OOB(带外数据out-of-band)的选项可供设置,因此上层如果想读取紧急数据,就可以在使用recv函数进行读取,并设置MSG_OOB选项
与之对应的send函数的第四个参数flags也提供了一个叫做MSG_OOB的选项,上层如果想发送紧急数据,就可以使用send函数进行写入,并设置MSG_OOB选项
TCP报头策略
在介绍完TCP报头后,我们剩下的任务有两个
一.补充复习我们之前提到过的策略,比如说确认应答策略,超时重传机制等等
二.补充没有覆盖到的 ,没有体现在报头中的策略内容
确认应答机制
我们再强调一遍,A给B发消息,B一定可以收到数据,这并不是可靠性
在网络中消息丢失是常见的事情,并不一定要说收到!
真正的可靠性是A能够知道消息没有发成功,对面给出相应的应答
它由TCP报头当中的,32位序号和32位确认序号来保证的.
这里还可以补充一个知识点,TCP是面向字节流的,究竟是什么意思?
我们可以把缓冲区看成一个数组,它是以字节为基本单位
此时上层应用拷贝到缓冲区当中的每一个字节数据天然有了一个序号,这个序号就是字符数组的下标,只不过这个下标不是从0开始的,而是从1开始往后递增的
发送方发送的TCP报头中填充的序号其实就是发送的若干字节数据当中,首个字节数据在发送缓冲区当中对应的下标
接收方回复的TCP报头中填充的确认序号其实是接收缓冲区中接收到的最后一个有效数据的下一个位置所对应的下标
双方通信的本质其实就是将数据从发送缓冲区拷贝到对应接收缓冲区的过程,由于是以字节为基本单位进行拷贝,所以被称为面向字节流
发送的字节数据由于选择不同的链路,可能到达对面的顺序发生了变化,但是我们说没关系,因为可以按照序号重新进行排序,保持数据有序这是序号其中一个重要的功能
超时重传机制
TCP保证双方通信的可靠性,一部分是通过TCP的协议报头体现出来的,还有一部分是通过实现TCP的代码逻辑体现出来的
接下来我们讲解的超时重传机制就是通过实现TCP的代码逻辑体现出来的
超时重传机制用最简单的话说,就是我发出消息,隔了一段时间后,没有收到对面的应答,不管是我发送的信息丢失,还是ACK应答丢失,我都重新发送数据包,保证我发送的数据,对面可以全部收到
这里有几个细节需要注意:
1.有可能我发送的报文只是因为网络问题,所以到达对方的时间比较晚,这个时候超时重传,对面就可能收到重复的报文,这种情况是我们需要避免的,即TCP协议需要能够识别出哪些包是重复的包
这个问题也很好解决,我们发送的TCP报头中有序号字段,拥有相同序号的字段我们直接去重即可,这里也体现序号的另一个重要的功能——去重
2.一个被发送出去的数据,必须暂时被TCP保存起来,不会立即清除,以支持超时重传,不可能重新让上层用户给你下层提供,这样效率就太低了,直到收到该数据的响应报文后,发送缓冲区中的这部分数据才可以被删除或覆盖,这也是缓冲区一大重要功能——缓存数据
3.时间设置
超时重传机制设定的超时重传时间(RTO Retransmission Timeout )是有要求的,不能太短,也不能太长
绝对不能太短,有可能数据只是因为网络问题没有到达而已,你发一大堆,虽然说去重,但也是有消耗的!不必要的重传将会导致网络负荷的增大
也不能太长,这样效率就太低了,服务器端等了半天才收到你补发的TCP报文
图来自小林网络coding
所以,理想状态的时候,我们需要找到一个合适的时间点,当到达这个时间,我们才重传对应的信息
而这个合适的时间点是和网络环境强相关的,不能简单说超时重传时间 RTO 的值应该略⼤于报⽂往返时间 RTT 的值,毕竟RTT的值其实也是随网络浮动变化的.
网好的时候重传的时间可以设置的短一点,网卡的时候重传的时间可以设置的长一点,等待时间点一定是上下浮动的,不是一个确定的值
又由于网络环境是不同的,所以注定了网络超时重传时间间隔是动态计算的!
Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制
每次判定超时重发的超时时间都是500ms的整数倍. 如果重发一次之后, 仍然得不到应答, 等待 2500ms 后再进行重传.
如果仍然得不到应答, 等待 4500ms 进行重传. 依次类推, 以指数形式递增.
累计到一定的重传次数,TCP认为网络或者对端主机出现异常, 强制关闭连接
总结一下:每当遇到⼀次超时重传的时候,都会将下⼀次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境
差,不宜频繁反复发送;假如重传达到一定次数(对端主机都下线了,就没必要再继续发消息了),强制关闭连接.
快重传机制
但是超时重传有一个很显著的问题
我们说确认序号表示的是X-1编号之前的报文已经全部收到了,下次发请从X编号开始发送
所以假如发送方此时发送了序号为0,1,2,3,4,5,一共五份数据,接收端收到了1,2,于是回复ACK 3,然后收到了序号为4,5的报文(注意此时序号为3的报文没收到),此时的TCP会怎么办?
ACK的时候,不能跳着确认,TCP只能确认最大的连续收到的包,不然,发送端就以为之前的都收到了,所以它只能继续回复ACK 3给对面的发送方
但是此时我接收方是收到序号为4,5的报文的!
那我采用超时重传机制的时候,应该只补发序号为3的报文呢?还是从序号为3的报文开始,将后面所有的报文4,5全部一起补发?
这两种方式有好也有不好。第一种(只补发序号为3的报文)会节省带宽,但是慢,有可能确实序号为4,5报文,对端没有收到
第二种会快一点,但是会浪费带宽,也可能会有无用功。(对端其实收到了报文4,5,全部重发只会浪费资源去重)
但总体来说,两种方法都不好
于是后面有人提出了快重传(Fast Retransmit)机制
它不以时间为驱动,⽽是以数据驱动重传
图为小林网络coding
当序号为2的数据包丢了,我们会一直给发送方回复ACK 2
但是此时我们将不再等待一段时间(timeout),而是收到三次同样的ACK,直接触发快重传,补发对应缺失的数据包
如上图所示,由于对端的确收到了序号为3,4,5的报文,所以会返回给发送方ACK 6
由于没有timeout(或者说在定时器过期之前)直接补发对应的序号2报文,快重传就没有超时重传那一份定时器的消耗;并且没有采用全部补发的方式,而是迅速补发缺失的报文,不管后面要不要继续补发序号4,5,6的报文,提高了传输的效率。
但是,严格意义上来说,它也只是解决了timeout(耗时)的问题
但是它同样可能面临浪费带宽的问题,假如对端的确没有收到序号4,5,6的报文,很显然直接全部补发才是最佳策略
问题的关键在于:
发送端并不清楚这连续的3个ack(2)是谁传回来的
也许发送端发了20份数据,是#6,#10,#20传来的呢?这样,发送端很有可能要重传从2到20的这堆数据(这就是某些TCP的实际的实现),如果这个时候,还全部补发,那就得不偿失了.
SACK选择性确认机制
所以,也有人提出了对应新的重传机制SACK ( Selective Acknowledgment 选择性确认)
这种方式需要在 TCP 头部「选项」字段(终于用到选项字段了)里加⼀个称作 SACK 的东西,它可以将缓存的地图发送给发送方,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据
图来自小林网络coding
发送方通过接收发来的SACK,就可以知道对端其实只有序号为200-299的报文发生丢失,此时只重传丢失的那部分数据即可.
PS:如果要支持SACK ,必须双方都要支持。
在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功(Linux 2.4 后默认打开)
当然,这里还需要注意一个问题
发送方不能完全依赖SACK,能够完全信任,保证可靠性的,还是只有ACK和维护Time-Out,如果后续的ACK没有增长,那么还是要把所有东西重传
另外,接收端这边永远不能把SACK的包标记为ACK
假如完全信任依赖SACK,那我们完全可以做到给数据发送方发一堆SACK的选项,这就会导致发送方开始要重传甚至遍历已经发出的数据,这会消耗很多发送端的资源,这就非常危险!
连接管理机制
接下来,我们讲解同样在TCP报头中没有直接体现的机制,但是TCP核心策略之一——连接管理机制
简单可以概括为下面这句话
三次握手,四次挥手
下面我们将分别进行讲述
PS:接下来我们图片中涉及到发送的信息,比如SYN,SYN+ACK等等,它的实质是一个TCP报头,只不过对应的标记位被置1而已,并且可以携带数据的!这个需要注意
三次握手
三次握手的本质是双方建立连接
什么是连接
这也引发我们直接的一个灵魂拷问,什么是连接?为什么我们需要连接?
在计算机网络编程中,“连接"通常是通过一些数据结构来表示和管理的.
在Linux内核中,TCP连接是通过struct sock和struct tcp_sock等结构体来表示的.这些结构体包含了TCP连接所需的各种参数和状态信息,如序列号、确认号、窗口大小、超时信息等
有了这些结构体,双方才能进行稳定一对一的"对话”,假如不存在连接,那服务器端就会只有一个缓冲区,此时多个客户端连接对应的服务器,所发的消息,就可能相互干扰,而有了连接,就可以对不同客户端所发的消息分隔开来进行管理
总结一句话:
连接是一个抽象的概念,它在不同场景下指代的具体事物不同,在linux内核中,它指代的是诸如struct sock和struct
tcp_sock等结构体,有了连接,我们才能实现tcp稳定性以及各种可靠性.
整个过程,是OS帮我们建立链接,我们不需要关心底下进行了什么操作
不过我们说"先描述,再组织"
现在我们已经用结构体描述好对应的连接,由于存在多个客户端,那在OS内一定同时存在多个建立好的连接,OS要不要把这些已经建立好的连接管理起来呢?
答案是肯定的!
OS内为了管理连接,会维护对应的数据结构 struct tcp_link[…] 诸如双链表,于是对链接的管理,变成对数据结构的增删查改操作
当然,我们说建立连接,是OS在其内部创建结构体,然后往里面填充连接的各种属性字段;然后管理连接,是进行对应的增删查改结构体
这也侧面指出了创建维护连接是有成本的!
结构体需要我们占据我们的空间内存,进行对应的增删查改效率,也需要消耗时间成本(内存+CPU)
PS:我们之前所学的accept系统调用接口,它的本质其实是在等待三次握手完成(OS建立对应的连接),然后把连接获取上来,双方就可以通过调用read/recv函数和write/send函数进行数据交互了
三次握手的过程
双方在进行TCP通信之前需要先建立连接,建立连接的这个过程我们称之为三次握手.
虽然前面已经提过,不过还是重复强调一遍
一.3次握手的过程,由双方的OS系统中的TCP层自主完成
我们所用的系统调用——唯结果论,具体操作根本不是我们自己做
比如说在客户端发起连接建立请求之前,服务器需要先进入LISTEN状态,此时就需要服务器调用对应listen函数;
connect系统调用,它的作用是触发连接,等待三次握手,即连接完成,又比如说我们的accept系统调用,它的作用是等待连接建立完成,把对应连接获取上来
二.双方交互过程中所发的实际都是一个个tcp报头,只不过对应的标志位会被设置为1,我们将其简化为SYN,SYN+ACK而已
三.网络中双方地位对等,TCP是全双工通信,因此服务器在收到客户端发来的连接建立请求后,服务器也需要向客户端发起连接建立请求,请求建立从服务器到客户端方法的通信连接
对应的图片如下:(图来自小林网络coding)
在第一次握手期间,客户端向服务器端发送SYN,只要把SYN发出去,状态就会变成SYN_SENT(宏)
只要服务器端收到SYN,状态就会变为SYN_RCVD
在第二次握手期间,服务器收到客户端发来的连接请求报文后,紧接着向客户端发起连接建立请求并对客户端发来的连接请求进行响应,此时服务器向客户端发送的报文当中的SYN位和ACK位均被设置为1
只要客户端收到对应的SYN+ACK报文,状态就会变为ESTABLISHED,连接建立
在第三次握手期间,客户端向服务器发来的报文进行响应,再发送一个ACK,表明我收到了对应你对应的SYN+ACK报文,当服务器端收到对应的ACK报文后,状态就会变为ESTABLISHED,连接建立
PS:
1.图中所画的报文,都是斜着向下画,这其实隐含了一个潜在维度—— 时间,报文还要在路上跑
2.第三次握⼿是可以携带数据的,前两次握⼿是不可以携带数据的
为什么是三次握手?2,4次可以吗?
没有人说三次握手一定能成功,但是第一个,第二个报文丢失,我们说双方都不会担心,因为有应答ACK存在,假如没有收到对应的ACK,我们利用我们超时重传机制进行补发即可
具体讨论如下:
第一个丢失,服务器端不会受影响,因为都没收到消息;要说有影响的只有客户端,但客户端可以超时重传!
第二个丢失也是同理,服务器端发送的报文ACK+SYN一直没有回应,我们就说服务器端进行超时重传即可!
关键核心是第三个ACK报文,第三个ACK报文可是没有ACK应答的,假如第三个ACK报文在传输过程中发生了丢失,虽然我们说客户端只要把第三个ACK报文发出去,客户端就建立了连接,但是服务器端是需要收到第三个报文才能建立连接!此时连接就会建立失败(只有双方都成功建立连接,才是真正建立成功)
换句话说,由于超时重传结合ACK应答机制(可靠性),我们并不担心连接建立过程中,前面几次握手所发的报文对方是否收到,核心关键在于最后一次报文,它并没有应答,客户端是不知道服务器端是否收到我们发送的最后一次报文的
所以,三次握手的本质其实是一个赌博,在赌服务器端收到我发出的ACK
当然也不是完全的赌,我们前面提到过,假如客户端建立了连接,而服务器端并没有,此时客户端给服务器端发送对应的消息,服务器端一看没有对应的连接,就知道此时发生了错误连接的情况,就会返回一个携带RST连接重置的报头,对TCP连接异常进行处理(重新三次握手)
但上面的例子揭示了一个很重要的观点,无论是几次握手,由于最后一次报文都是没有对应的ACK应答的,即不知道对方收没收到消息,所以可靠性都是得不到保障的!
因此,选择几次握手建立连接,我们并不从可靠性的角度出发,而是从其它角度,诸如哪个好处更多,坏处更少,综合来看待
假如我们选择两次握手就建立连接,那对应的服务器端在第二次握手期间,发出对应的报文后,就会进入ESTABLISHED状态,成功建立连接
这就会存在巨大的安全隐患,非常容易受到攻击
假如想要弄死你这台机器,只要发送海量的SYN,一瞬间服务器就会建立大量连接,而维护连接是有成本的!此时用单片机都能将你搞死
我们称这种攻击为SYN洪水
当然我们不是说TCP设计出来就是为了防止攻击的,无论是几次握手,3次,4次等等都会受到攻击!但是假如出现这样明显的设计漏洞,也是我们不允许的
出现这种问题的原因就在于最后一个报文是谁先发的,谁就先建立连接
更进一步推广,偶数次握手都会出现这样的问题,面对多个客户端连接时,成本负担落在了服务器端上,这会造成极大的浪费
当年百度贴吧进行的网站爆破其实也是同样的操作,数十万网民点进一个网站后,不断按刷新键,向服务器发起连接请求,假如此时是偶数次握手建立连接,我不管客户端连接是否建立,但是服务器端肯定在一瞬间建立大量连接,甚至连正常用户都无法连接上去,网站就会直接挂掉
而奇数次握手可以将成本嫁接到客户端上,异常连接是挂在客户端的,而不会影响到服务器,服务器端的成本较低
三次握手能够保证连接建立时的异常连接挂在客户端(风险转移)
但是我们说偶数次握手容易被攻击,并不能说明奇数次握手就好
还有另外一个原因,TCP是全双工的,也就是在建立连接的过程中,我们需要保证双方收发能力都没有问题
还是拿两次握手举例
Client端向服务器端发送tcp报头,并受到对应的ACK报文,可以侧面论证Client端收发都没有问题
但是服务器端呢?它只验证了自己的收能力没有问题,它不知道自己发送能力可不可以,毕竟已经没有ACK报文给服务器端做应答了
所以,至少要三次握手才能验证双方收发能力没有问题,同时,我们希望握手次数尽可能小,这样建立连接的效率才高
于是挑选三次握手才变得顺理成章起来
三次握手是验证全双工通信信道通畅的最小成本
当然,还有一些其它的说法,比如小林网络coding里面提出的防⽌历史连接初始化了连接,假如只有两次握手,客户端即便发现服务器端连的是旧有的连接(网络堵塞原因,旧有连接请求比新连接请求先到达,完成三次连接),也无法发送报文进行重连;
但是有三次握手的话,就可以在第三次握手时,不发送ACK报文,而是发送RST报文进行重连
ISN号(序号+确认序号)
又回归到我们之前所说的序列号和确认序号字段,我们说这两个字段的出现,目的是为了解决网络包乱序和不丢包的两大问题
有了序号字段,接收⽅便可以根据数据包的序号按序接收,同时在接收缓冲区中去除重复的数据;
而有了确认序号字段,我们便可以标识发送出去的数据包中, 哪些是已经收到的,告诉对方下次发从哪发,更细粒度的确定丢包问题
但之前其实我们一直都回避了一个问题,那就是双方的序列号首先要进行同步,假如没有同步,就会出现诸如误认为某些数据包是重复的,从而丢弃这些数据包,导致数据丢失;错误地将某些数据包插入到错误的位置等等问题,这不是我们希望看到的
而双方进行序列号的同步,其实就是在三次握手中完成,这在我们讲述三次握手过程中其实已经提过,这里不再赘述
现在我们思考的一个问题是,为什么客户端和服务端的初始序列号 ISN 要分别随机初始化呢?两者保持一致不行吗?
我们说不可以,原因主要有两点
第一.从安全角度来看(分别的原因)
假如客户端和服务端的ISN相同,那么攻击者可以更容易地预测序列号,此时⿊客伪造的相同序列号的 TCP 报⽂被对⽅接收,这就问题大了.
第二.从数据保护角度来看(随机的原因)
在网络中,由于各种原因,如网络延迟等,可能会出现旧的数据包在新的连接中到达的情况.
如果新连接的ISN与旧连接相同,那么这些旧的数据包可能会被误认为是新连接中的数据,从而导致数据混淆
比如说,我们有两个连接A和B,它们都是从同一个客户端到同一个服务器的.在连接A中,客户端发送了一个序列号为100的数据包,但是由于某种原因(例如网络延迟),这个数据包迟迟没有到达服务器.
然后,连接A被关闭,连接B被打开,连接B的初始序列号也被设置为100.(没有随机重新初始化),此时,如果那个延迟的数据包到达了服务器,服务器就会认为这个数据包是属于连接B的,因为它的序列号与连接B的初始序列号相匹配.
然而,实际上这个数据包是属于连接A的,它在连接B中是没有意义的,这就可能导致数据混淆
那如何实现初始化呢?
图来自小林coding
四次挥手
四次挥手的本质是当双方不用再沟通交流了,维护连接是有成本的,所以此时就要及时断开连接
四次挥手的过程
还是以服务器和客户端为例,当客户端与服务器通信结束后,需要与服务器断开连接,此时就需要进行四次挥手。
大体过程如图所示:
图来自小林coding
在第一次挥手期间,客户端向服务器发送的报文当中的FIN标志位被设置为1,表示请求与服务器断开连接
一旦发送报文后,客户端立马进入FIN_WAIT_1状态
在第二次挥手期间,服务器收到客户端发来的断开连接请求后对其进行响应,回复一个ACK应答
一旦ACK报文发送,服务器端立马进入CLOSED_WAIT状态
同时客户端收到对应的ACK应答,客户端立马进入FIN_WAIT2状态
当服务器端没有数据要发送给客户端了,服务器会向客户端发起断开连接请求,等待最后一个ACK到来,此时服务器的状态变为LAST_ACK
此时我们开始进行第三次挥手
在第三次挥手期间,服务器会收到客户端断开连接的请求FIN,进入TIME_WAIT状态,等待2MSL时间才进入关闭CLOSE状态(不是立马关闭)
而第四次挥手期间,客户端收到服务器发来的断开连接请求后对其进行响应发送一个ACK应答,服务器端收到后,从LAST_ACK状态进入CLOSE状态,双方正式完成连接断开
为什么是四次挥手?可以三次挥手吗?
TCP是全双工的,两次挥手(一方发一个FIN请求,另一方发一个ACK表示收到)可以关闭一个方向的通信信道
所以总共需要四次挥手关闭服务器端到客户端的通信信道与客户端到服务器端的通信信道
那我们可以把第二第三次挥手合并在一起吗?像我们三次握手一样?
我们说不可以!
原因就在于服务器端可能还有数据要发给我们的客户端,此时还需要等待一会儿,把所有数据发完,才进行第三次挥手
三次握手是不存在这种问题的,从另一个角度来看,三次握手实际是四次握手,不过第二三次握手我们合并到一起而已,当然这个说法并不准确
四次挥手对应状态变化
我们说在三次握手过程中,系统调用是唯结果论,具体操作根本不是我们自己做,是底层OS帮我们搞定
四次挥手也同样如此
调用close系统接口,只是发起断开连接请求
具体的断开操作,并不由我们负责
一个close对应的就是两次挥手,双方都要调用close,因此就是四次挥手
CLOSED_WAIT状态
在第二次挥手期间,服务器收到客户端发来的断开连接请求后对其进行响应,回复一个ACK应答
一旦ACK报文发送,服务器端立马进入CLOSED_WAIT状态
如果此时只有客户端调用了close函数,而服务器不调用close函数,此时服务器就会一直保持CLOSED_WAIT状态(持续时间很长)
客户端反而会在变为FIN_WAIT_2状态,等待一段时间后,假如服务器端依旧冷战,也没发数据,也不申请断开,则客户端会直接主动释放连接
被动断开连接的一方(一般为服务器端),假如没有关闭对应的文件描述符,会维持很长一段时间的 CLOSED_WAIT状态
主动断开连接的一方(一般为客户端),进入TIME_WAIT状态,过一会直接就没了
但只有完成四次挥手后连接才算真正断开,如果服务器没有主动关闭不需要的文件描述符fd,此时在服务器端就会存在大量处于CLOSE_WAIT状态的连接,而每个连接都会占用服务器的资源,最终就会导致服务器可用资源越来越少,而这其实是一种内存泄漏
当然文件描述符我们说它的生命周期是随进程的,所以当服务器端进程被强行终止,对应所有的连接也会强制被释放,但是服务器进程一般不会强行终止,我们用微信这么久,也很少说突然用一下,整个微信崩掉了吧
因此,假如编写TCP代码时发现服务器端存在大量的CLOSE_WAIT状态,此时我们就要考虑是不是服务器端没有关闭不必要的文件描述符fd
当被动断开连接的一方,有及时释放对应的文件描述符(调用close函数),会立马由CLOSE_WAIT状态进入LAST_ACK状态,然后发起ACK请求(第三次挥手),释放资源S,断开连接
如果主动断开连接的一方,已经不在了,在等待一段时间后,被动断开连接的一方依旧会由LAST_ACK状态进入CLOSE状态
还有一个细节需要补充,之前我们服务器退出后,是无法重新用相同的端口号绑定运行的原因也在于此
虽然双方的进程都已经结束,但是底层依旧有一个TIME_WAIT状态连接在维持,端口号依旧被占用
所以需要更改端口号,服务器才能重启;或者要等TIME_WAIT状态消失,才能再次使用原来的端口号连接
主动断开连接的一方会进入TIME_WAIT状态,假如此时主动断开连接的一方是Server呢?
但是我们此时不想等待或者更换端口,而想直接直接服务器迅速重启,现实中存在这种服务器端主动关闭连接的场景吗?
我们说是存在的
比如双十一购物的时候,有很多人去电商平台购物,由于人流量过大,服务器此时就可能被动挂掉,从而存在大量TIME_WAIT连接
此时我们需要的不是查服务器为什么会挂掉,而是立即重启服务器,毕竟晚启动一秒,所造成的损失都是难以估计的
事实上,在我们的系统接口中也同样可以对sock套接字进行设置,从而解决因为TIME_WAIT状态引起的bind失败问题
一般我们在套接字创建成功后,调用setsockopt系统接口,就可以实现地址复用的功能
int opt = 1;
setsockopt(_sock,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
设置完成后,即便此时服务器挂了,也可以迅速重新绑定原来的端口号,完成重启,而不用重新更新端口号
MSL
我们说CLOSED_WAIT状态存在是必然的,因为我们需要等待上层调用close系统接口,我们才开始断开连接,不是你说想断开连接就断开连接的
一.但是为什么我们需要TIME_WAIT状态呢?四次挥手不是已经完成了吗?
原因主要有两点:
1.保证历史报文从网络中消散
客户端发出最后一次挥手时,双方历史通信的数据可能还没有发送到对方,等待一段时间保证了双方通信数据不会因为网络延迟的原因没有及时送达到对方
2.保证最后一个ACK报文被对端收到
客户端在进行四次挥手后进入TIME_WAIT状态,如果第四次挥手的报文丢包了,客户端在一段时间内仍然能够接收服务器重发的FIN报文并对其进行响应,不然服务器一直重发FIN报文,对方却没有响应,虽然过一段时间,依旧可以关闭连接,但是这样资源消耗就有点多了,我们还是希望尽可能保证最后一次报文被对方收到
二.TIME_WAIT持续时间是2MSL,那MSL是什么东西呢?
MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报⽂在⽹络上存在的最⻓时间,超过这个时间报⽂将被丢弃
我们可以用下面的指令来查看对应系统默认设置的时间
cat /proc/sys/net/ipv4/tcp_fin_timeout
可以看到在Centos7上默认配置的值是60s
那为什么要设置为2MSL呢?
这个也很好理解,发出一次消息,最大生存时间为一个MSL,给出相应的ACK,也需要一个MSL
因此TIME_WAIT状态持续存在2MSL的话,就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失
MSL定义在linux内核代码里的名称为TCP_TIMEWAIT_LEN
#define TCP_TIMEWAIT_LEN(60*HZ) /*how long to wait to destroy TIME-WAIT state,about 60 seconds*/
如果要修改TIME_WAIT的时间长度,只能修改linux内核代码里TCP_TIMEWAIT_LEN的值,并重新编译Linux内核,单纯变为管理员,直接ehco修改文件是没有效果的
图来自小林图解coding
流量控制机制
我们说三次握手建立连接的过程,其实是互发对应的TCP报文,而不仅仅是对应的标志位,而报文里面除了确认序号,序号等等字段,还携带其它字段,它们有些也发挥重要的功能
比如最基本的一个问题
我们说缓冲区的大小是有限的,那我发送端怎么确保首次发送的时候,不会出现过快或过慢的情况呢?
假如发送过快,此时对面接收缓冲区被打满,那就很危险!会出现丢包等问题;假如太慢,效率又会非常低下,半天消息都发不出去
解决这个问题,其实是双方在三次握手,建立连接期间,已经通过报文的形式,将16位窗口大小发送给对方
窗口大小的字段越大,说明网络的吞吐量越高,那发送速度就可以往上提,反之,则将速度降下来
PS:16位窗口大小理论上表示65535个字节,但实际40位选项里面包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移M位得到的,还可以更大!
建立连接后,双方就是一个双向奔赴的过程
发送方会不断进行窗口探测,定期发送TCP报头(不携带数据),接收方回复的ACK会告知对应剩余的窗口大小
接收方则会进行窗口更新,取走数据后,发送的TCP报文里面,告诉对方自己窗口大小为多少
滑动窗口
但是我们说在网络中,更多情况并不是串行收发,这样效率太过低,而是并行收发,有多个用户端同时访问服务器
但是服务器是有接受能力上限的,发送方的并行发送必须要在对方能够接受前提下进行
那对于我们接收方来说,目前一次最大可以向对方发送多少数据呢?
一次最大能发送多少数据,就是我们滑动窗口的定义
一般情况
在网络双方进行交流的时候,发送方和接收方都会各自维护对应的缓冲区
滑动窗口,其实是发送缓冲区的一部分
图来自2021dragon博主
发送缓冲区可以分为三个部分
第一个部分,是已经发送,已经确认,可以被覆盖的无效部分
第二个部分就是我们的滑动窗口,这部分数据暂时不用收到应答,可以直接发送,它的大小和对方的接受能力有关(应答报文中16位win窗口大小)
当然这部分数据也是我们前面提到过的缓存区域,假如要超时重传,就是从这部分取数据进行重发,即滑动窗口支持TCP的超时重传机制
第三个部分是我们尚未发送的数据区域
整个模型其实和我们在多线程部分所说的生产者与消费者模型有异曲同工之妙,用户往后放数据,滑动窗口取一批数据进行发送
更进一步说,缓冲区不就是一个字符数组吗?
当发送方发送出去的数据段陆陆续续收到对应的ACK时,就可以将收到ACK的数据段归置到滑动窗口的左侧,并根据当前滑动窗口的大小来决定,是否需要将滑动窗口右侧的数据归置到滑动窗口当中(调整对应的winstart,winend下标)
特殊情况
下面我们讨论一些特殊情况,对一些问题做出解答
1.滑动窗口只能向右移动吗?它可以向左移动吗?
我们说根据对面发来的win大小,滑动窗口大小可能不发生变化,但是它绝对不可能往左移动
因为我们的定义本身就指出了最左边的数据部分,是已经发送,已经确认,可以被覆盖的无效部分
滑动窗口仅仅指可以直接发送,未收到ACK的数据部分
假如向左移动,则直接和我们的定义冲突了
2.滑动窗口能变大,它能变小吗?假如变为0,又意味着什么?
我们说滑动窗口既能变大也能变小,这取决于对方win窗口大小,它是浮动的
假如变为0,则意味着对方接收缓冲区已经被打满,不能再接受数据了,此时发送方就不要再白费心机在那发数据了
3.滑动窗口能一直滑动吗?越界了怎么办?
我们说缓冲区是可以设计为环形结构的,即滑动窗口的下标我们可以自己调整,这也是我们说为什么第一部分数据可以被覆盖的原因,用户上层将数据交付下来,OS根据滑动窗口位置,将对应的数据放到对应的正确位置,假如滑动窗口已经到缓冲区末尾,那新数据不就将原数据覆盖了吗?
4.滑动窗口的大小怎么更新呢?依据是什么?(如何实现)
我们说滑动窗口大小,从编程的角度,其实是对应下标相减winend - winstart
所以更新的本质,其实就是对下标winstart与winend进行操作
ACK应答有序到达(其中带有对应的Seq确认序号),确认序号定义为X-1编号之前的报文已经全部收到了,下次发请从X编号开始发送,它实际不就和我们第一部分的数据定义相同吗?
假如我收到了填充2001字节确认序号字段的TCP报头,这说明前面1-2001字节的数据我都已成功接收!下次发请从2001字节数据之后发送
即winstart = seq
同时对面会给出对应的16位窗口大小win,我们可以通过它来调整我们的滑动窗口
即winend = winstart + win
5.滑动窗口内部的数据既然可以多个同时直接发送,假如第一个丢失了咋办?中间的丢失了咋办?最后丢失了咋办?
假如应答丢了,我们不怕,因为应答不仅仅只有一个!而是有多个,假如确认序号为1001的ACK报文丢了,但是我收到了确认序号为2001,3001等等ACK报文,这同样意味着对面收到了1001字节之前的数据,只不过对应的ACK报文丢失了而已
我们说数据丢失也不怕,有TCP重传机制进行保证
假如数据丢了,会收到很多重复的ACK报文,此时保存在滑动窗口中的数据,就可以用来重新发送
比如说对面没有收到1001-2000的数据包,但是收到了对应2001-3000,3001-4000等一系列数据包,接收端会重复发确认序号为1001的响应报文,提醒发送端“下一次应该从序号为1001的字节数据开始发送”
发送端连续收到三次相同的应答,直接迅速补发对应的数据,而不用等待一段时间再补发(超时重传),我们称为快重传
当然,理论上应该将后面1001字节后面的数据全部进行重发,但是这效率就会比较低下,所以具体情况还需要根据接收端回复的ACK报文中的确认序号来进一步采取对应措施
拥塞控制机制
过程
但是我们说上面我们讨论的只是简化的版本,在现实中往往是有多个A(客户端),它们同时会向网络中发送消息,遵守着TCP/IP协议
当网络中出现多台主机时,但网络又不好时,问题就来了
网络不好时,就会发生大量丢包问题
对于发送方来说,发送方不会认为是自己的问题,而是网络问题
当丢包数目少的话,我们可以快重传或者超时重传;
但是丢包数目多的话,采取这样的策略就完全没用了,多台主机一直超时重传或者快重传,但网络本身又不好,这样只会加剧网络拥塞问题
(这就好像挂科一样,挂少挂多是不同问题,同样的,丢包数目的多少也是不同的事情,需要区别对待)
所以作为"聪明"的发送方,在遇到网络拥塞问题时,发送报文是有自己的原则需要遵守的
1.保证网络拥塞不能加重
2.在网络拥塞有起色的情况下,尽快恢复网络通信
在上述两个原则下,拥塞窗口cwnd才应运而生
拥塞窗口cwnd的重要作用,就是对当前网络情况进行衡量(衡量网络健康状态的指标)
进一步说,网络状态是变化的,所以拥塞窗口cwnd的大小也一定是变化的!
作为主机的你,你怎么知道网络的健康状态是什么样的呢?
答案是尝试与探测
整个过程如下:
一.慢启动机制:(初期慢,但增长速度非常快)
刚开始发送数据的时候拥塞窗口cwnd大小定义为1,表示可以传⼀个 MSS 大小的数据
当收到一个ACK应答,拥塞窗口的值就加一,于是一次可以发送两个MSS 大小的数据
当收到 2 个的 ACK 确认应答后, cwnd 增加 2,于是就可以比之前多发2 个,所以这一次能够发送 4 个
以此类推,呈指数型增长
二.线性增长(拥塞避免)
拥塞窗口显然不可能无限制增长,当拥塞窗口超过我们设定的某个阈值,此时就会变为线性增长
官方的说法是慢启动门限 ssthresh (slow start threshold)
当 cwnd < ssthresh 时,使用慢启动算法
当 cwnd >= ssthresh 时,使用线性增长
在刚开始TCP启动时,这个慢启动阈值等于窗口的最大值
假如发生网络拥塞(不管是连续收到3个ACK还是timeout指示的丢包事件),在每次超时重传时,慢启动阈值将会变为原来窗口大小的一半,同时将拥塞窗口大小重新置为1
ssthresh 设为 cwnd/2
cwnd 置为 1
比如拿下面这个图举例
一开始慢启动,指数型增长探测,当达到设定的阈值,即窗口最大值16时,会变为线性增长(加法增大),也可以说是拥塞避免
一旦网络发生拥塞(不管是连续收到3个ACK还是timeout指示的丢包事件),阈值一律将会被设为当前cwnd的一半,也就是24的一半12
然后cwnd变为1,重新开始慢启动过程
但是这种方式太激进了,反应也很强烈,可能会造成网络卡顿,而且有时候其实网络并没有发生拥塞,只是部分数据包丢失了而已,其它数据包都收到,但是由于确认序号的定义,我们只能不断重复发对应确认最大的连续序号ACK
比如发送方此时发送了序号为0,1,2,3,4,5,一共五份数据,接收端收到了1,2,于是回复ACK 3,然后收到了序号为4,5的报文(注意此时序号为3的报文没收到),此时的TCP会不断重复发ACK 3的响应报文给发送方,但此时并没有发送网络堵塞,网络好好的呢,只是部分数据包运输过程中丢失了而已,这也是我们快重传机制解决的问题
三.快速恢复
所以,当连续收到3个冗余ACK报文时,我们显然不要主观判定网络拥塞(TCP 认为这种情况不严重,因为大部分没丢,只丢了⼀小部分),而是采用快速恢复算法(也就是没有像之前超时重传采取的策略一样,⼀夜回到解放前,而是还是将cwnd维持在比较高的值,后续呈线性增长)
cwnd = cwnd/2 ,也就是设置为原来的⼀半;
ssthresh = cwnd ;
进⼊快速恢复算法
快速恢复算法整体步骤如下:
1.调整拥塞窗口:
cwnd = ssthresh + 3 ( 3 的意思是确认有 3 个数据包被收到了,这3个报文段不再消耗网络资源而是停留在接收方的接收缓冲区中,可见此时网络中不是堆积了报文段而是减少了3个报文段,因此可以适当把拥塞窗口扩大)
PS:也有直接减半的:cwnd = ssthresh/2;
2.重传丢失的数据包;
如果再收到重复的 ACK,那么 cwnd 增加 1;
如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态;
3.假如处在快速恢复状态,还继续收到冗余的ACK,cwnd继续加1,并且如果允许的话,发送新的数据报文;
(继续默认网络没有问题,对端还是可以收到新的数据包的,网络中不是堆积了报文段而是又减少了一个新报文段,因此可以适当把拥塞窗口扩大)
图来自湖科大的网课:
这里附上对应TCP拥塞控制的FSM描述:
补充
于是我们就可以进一步完善我们之前的滑动窗口大小理论
每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为滑动窗口的大小
这样不但考虑对方接收端的接受能力,并且考虑了网络情况
滑动窗口 = min(对端主机的接收能力win,网络的拥塞窗口)
对应缓冲区的下标操作:
winstart = seq
winend = min(win,拥塞窗口)
不过需要注意的是,不是所有的主机都是同时在进行指数增长、加法增大的,拥塞窗口的大小也不一定是一样的
在同一时刻,网络中可能一部分主机正在进行正常通信,而另一部分主机可能已经发生网络拥塞了
补充1(一些提高效率的做法)
延时应答
我们说网络中双方进行交互的本质
其实是把发送端对应发送缓冲区的数据拷贝到对应接收方对应接收缓冲区中,然后上层从接收缓冲区中取出数据进行对应处理
当然假如成功收到对应的数据,接收方要给发送方返回对应的ACK应答
所谓的ACK应答,实际是一个TCP报文,报文里面还包含了16位的窗口大小win属性,用来通告接收方此时的接收能力
为了提高传输效率,使发送方可以一次并发更多的数据,TCP还采取了一种叫做延时应答的策略
我们不直接给出此时对应剩余接收缓冲区的大小
而是再缓缓,给上层更多的时间来进行读取,这样就可能更新出更大的接收能力,返回更大的窗口大小win,从而增大网络吞吐量,进而提高数据的传输效率
这就好比我现在只剩余500字节空间,先别急着告诉对面我还剩500字节空间,而是再缓缓,等上层将缓冲区数据进行读取,当上层一下子将几千字节数据读取完毕后,我再告诉对方我还剩的空间大小,这样就能在较大概率上,提高效率
但是,我们说不是每个数据包都进行延时应答,而是有一定限制
数量限制:每隔N个包才延时一次
时间限制:超过最大延迟时间(依操作系统不同也有差异,一般为200ms)就应答一次
捎带应答
捎带应答是TCP协议中最常规的一种方式
它就好像我们新年过节会去亲戚家拜年,我们带着一些新年礼物去,一般来说,除了带回亲戚感谢外,还会带回亲戚的回礼回家
捎带应答也是如此,主机A给主机B发送一个TCP报文后,主机B要给出对应的ACK应答(本质是一个TCP报文),恰巧此时B也要给主机A发数据,那返回的这个TCP报文中,其实可以直接携带要发的数据,而不用再重新发一个新的TCP报文
这样对于主机A来说,既收到主机B的数据,又知道自己发送的报文对方已经收到了,这是一举两得的事情
总结
TCP提高效率有以下几种方式:
1.基于滑动窗口的多数据并发收发
2.每一个报文都携带捎带应答
3.快重传机制降低数据全部重发概率
补充2
面向字节流
现在回看回来,为什么TCP叫做传输控制协议呢?
这逻辑其实是自洽的
位于传输层,负责传输数据的任务,所以叫做传输;
发送快了我要慢点,发慢了我要快点,丢包了我要超时重传/快重传;拥塞了我要拥塞避免…,这叫做控制
每一个主机都有接收缓冲区,也有发送缓冲区,可以做到收发同时,这是全双工
更进一步说,不同层看待数据的角度不同
TCP层解决的是如何发送的问题,在它眼里,只有一个个字节的数据(二进制流),至于这些数据是什么,它并不关心,它负责的只有传输
而从用户的角度看呢?
调用write时, 数据会先写入发送缓冲区中;
如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
然后应用程序可以调用read从接收缓冲区拿数据;
什么时候发?如何发?这些都是TCP协议解决的,用户不需要瞎操心,用户上层是需要负责对取到的数据进行解释和处理
我们写文件也是如此,这也是为什么我们一般把文件叫做文件流
读文件的时候,并不是直接从硬盘中读取数据
写文件的时候,也不是直接写到硬盘里
整个过程都是OS替我们和硬件打交道,一次从硬盘里面拷贝多少数据?重复多少次拷贝?这些都不是我们考虑关心的问题
有可能我们调用read函数,从缓冲区里面读取一次对应的数据,OS实际上已经和硬盘打过好几次交道了
网络也是如此,网卡也是硬件,和网卡打交道的自此至终只有OS
这就是面向字节流
所以什么是协议呢?协议实际就是一个约定俗成的事实
通过约定,我们上层可以对数据用统一方式进行解读
通过约定,我们传输层可以有效稳定的传输一个个字节流数据
从这个角度看,协议和编码其实是相同的,只不过编码设计得比较简单,只是用一些特定的字符编码对应特定的字符.
粘包问题
首先要明确的是,粘包问题中的"包",是指应用层的数据包
因为对于TCP来说,并没有数据包的概念,只有一个个字节流的数据
我们现在讨论的是,TCP成功将数据发送到对方后,对方上层如何对这一个个字节流数据做出解释呢?
这就好比我们蒸包子/做包子的时候,当包子刚刚新鲜出炉时,是很有可能包与包之间是黏在一起的,我们需要将它们分开,总不能你一个筷子将所有包子全部夹走吧
一般有下面这几种方式解决粘包问题
1.定长报头(如UDP)
在UDP协议中,有着一个16位UDP字段长度
我们之前说它是为了⽹络设备硬件设计的要求,所以加入这个字段
而现在我们便可以说出它另一个作用,解决粘包问题
即UDP是一个一个把数据交付给应用层. 有着很明确的数据边界
而TCP则没有对应的定长报头,当然也不需要,因为校验和保证数据不会出错,一系列的策略保证数据有序可靠,如何对数据进行解读(解决粘包)的任务就交到应用层来处理
2.用特殊的分隔符
对于变长的包,还可以在包和包之间使用明确的分隔符。因为应用层协议是程序员自己来定的,只要保证分隔符不和正文冲突即可
比如我们之前在HTTP协议中看到的类似\r\n等
3.在报头的位置,约定一个包总长度的字段
我们还可以在报头的位置,约定一个包总长度的字段,从而就知道了包的结束位置.比如HTTP报头当中就包含Content-Length属性,表示正文的长度
TCP异常情况
进程终止/机器重启:
会直接释放文件描述符, 仍然可以发送FIN. 和正常关闭没有什么区别.
机器掉电/网线断开:
假如此时我们访问一个网页,然后瞬间拔掉我们的网线,此时我们发送端就会立马检测到网络变化,显示网络异常
但是,接收端不知道,依旧会认为连接还在, 一旦接收端有写入操作, 接收端发现连接已经不在了, 就会进行reset.
即使没有写入操作, TCP自己也内置了一个保活定时器(基本小时为单位计算), 会定期询问对方是否还在. 如果对方不在, 也会把连接释放.
另外, 应用层的某些协议, 也有一些这样的检测机制.
比如QQ长时间不用, 没有发消息,它会变成灰色
早期QQ是用UDP协议进行消息传送的,后面改为TCP
像我们说的TCP协议虽然会有对应的检测机制,但是时间也很久
那我们大量用户每个人都登录QQ挂着,也不发消息,此时QQ服务器内部就会存在大量的连接,服务器肯定会崩溃
变成灰色,就说明QQ应用层检测到你已经长时间没发消息,所以直接把你断开,QQ服务器端就不用维护这种"无效"长连接,只有当你再次点进去时,要发消息时,才直接重连
基于TCP的应用层协议
HTTP 超文本传输协议
HTTPS 安全数据传输协议
SSH 安全外壳协议
Telnet 远程终端协议
FTP 文件传输协议
SMTP 电子邮件传输协议