TCP核心机制
TCP基本特点:有连接,面向字节流,全双工,可靠传输(TCP最核心的机制)
核心机制一(确认应答):
在网络中,可能我们传输的消息会因为诸多原因导致发送到对方手中的顺序不一样,举个例子:
在这张图中,就是一个消息顺利传输的案例,数据包在网络中经过的路径可能不同,路径不同就可能导致后发的数据包先到达了对方的手中,这就是后发先至(也不难理解,当一个结婚车队,按道理头车总是先到达,后面的车是跟在头车后面的,可是此时遇到了红绿灯,头车看是绿灯已经过去了,后面的车却还在等红灯,此时头车就与后面的车分开了,于是他们就各自想办法到达新娘家,由于头车的这条路偶遇突发事故遭遇的堵车,而后面的车队因为走的是另一条小路,畅通无阻,所以当头车还没到达新娘家的时候,后面的车已经到了,这就是后发先至),下面这张图就是对后发先至的解释
在网络通信中可能会遇到许多阻碍导致传输的顺序发生改变,“后发先至”是一个客观的情况,我们无法改变,但是可以给传输的数据添加“编号”,来区分发送数据的先后顺序,但是由于TCP是面向字节流的,所以并非按照上图所示的“第一条,第二条”这样的顺序来添加序号的 ,而是按照“字节”,比如第一个字节,第1000个字节.....每个字节都有自己独立的编号,并且字节和字节之间编号是连续的,递增的,按照字节编号这样的机制,就称为“TCP的序号”,在应答报文中,针对之前收到的数据进行对应的编号就称为“TCP的确认序号”
这个32位序号表示的就是TCP数据报载荷中的第一个字节的序号,由于序号是连续递增的,知道了第一个字节的序号后面每个字节的序号也就知道了
对于普通报文来说,序号是有效的,确认序号是无效的
对于应答报文来说,确认序号和序号都是有效的,确认序号会按照收到的数据的最后一个字节+1的方式来填写,并且把 ack 这个 标志位设为 1
那么对于数据传输的顺序和接收到的数据不同我们是如何处理的呢?
接收方收到数据之后,就要给发送方返回一个“应答报文”(ack/acknowledge),但是由于网络通信中会发生各种变化导致后发先至的问题,于是引入了“序号”和“确认序号”,使应答报文和传输的数据能够对应上
如果这里确认应答返回的是1001,就说明 <1001 的序号的数据全部都收到了
那如何解决后发先至的问题?
在B接收方这边,操作系统内核里会有一段内存空间作为接收缓冲区,收到的数据会现在接收缓冲区里面等待,只有开头的数据到了,应用程序才会真正的读取里面的数据(结合图示,如果B先接收到2001~3000这条数据就会先在接收缓冲区里面等待,直到接收到了1~1000这条开头的数据才会依次按照顺序进行读取)
核心机制二(超时重传):
在网络传输中,并不会一帆风顺,而是可能出现“丢包”这样的情况
那么产生丢包的原因是什么呢?
原因很多这里主要说两个比较容易产生的
1.数据传输过程中,发生了 bit 翻转(磁场影响等等),收到这个数据的接收方/中间设备,计算校验和的时候,发现校验和对不上
2.数据传输到某个节点(路由器/交换机),这个节点负载太高(某个路由器单位时间内只能转发n个包,但是由于现在是网络高峰期,单位时间内要转发的包超过了n个,这个时候后续传过来的数据就可能被直接丢弃掉)
那么对于这种丢包的情况我们应该怎么处理呢?答案是进行超时重传
TCP对于丢包的这种情况,能做的就是感知对方收到了没有(也就是接收方告诉你一声收没收到),如果丢包了,那就再重新发一次
具体可以通过应答报文来区分,如果收到应答报文说明数据没丢包,如果没收到应答报文,就说明数据丢包了
问:什么情况下算没收到?是暂时没收到还是一直没收到?
答:发送方发送数据之后,会给出一个“时间限制”,如果在这个“时间限制”内 ack 没有收到,那么就视为数据丢失了
ACK丢了
数据丢了
发送方无法区分是因为 ack 丢了,还是因为数据丢了,发送方(主机A)所以对于上述两种情况都会进行超时重传
如果对于数据丢了重新传那是理所应当的, 但是如果是因为接收方发送过来的 ack ,发送方没有收到,对于发送方来说会然后是数据丢失了,于是就又重新传了一份数据过去,可是对于接收方来说这个数据是已经存在的,所以当发送方又传了一份相同的数据过来,首先就会先根据序号,在缓冲区里面找到对应的位置(上文所提到的排序),当发现1~1000这个数据已经在缓冲区里面了,就直接把这个新收到的数据给丢弃掉,并且重新返回 ack
超时重传时间设定
这里的时间不是固定值而是动态变化的,发送方第一次重传,超时时间为 t1 ,如果重传之后仍然没有 ack 还会进行第二次重传,超时时间为 t2 ,每重传一次,超时时间的间隔会变大,重传的频率会降低,所以 t2 的时间要比 t1 的时间长,但也不是无休止的重传
当重传达到了一定次数后,TCP会先尝试发送一个“复位报文”,如果此时网络恢复了,复位报文就会重置连接,通信就可以继续使用,如果“复位报文”没有得到回应,那么TCP就会单方面放弃连接(连接就是通信双方各自保存对方的信息,放弃连接就是把对方的信息给删除掉)
核心机制三(连接管理):
三次握手
- 首先主机A向主机B发送一个同步请求(发送syn),也就是想和主机B建立连接
- 然后主机B收到这个请求并答应(返回ack),并且告诉主机A我也想和你进行连接(发送syn)
- 主机A收到答复(收到ack),并且知道了主机B也想和自己进行连接(收到syn),于是答应了并且向主机B(发送 ack 用来表示自己收到)
在发送 syn 的时候会同时将 syn 这一个标志位设置为1,告诉对方我想和你建立连接
三次握手的作用:
- 投石问路,初步验证通信的链路是否畅通,如果过程中发现数据丢失或者网络不可达则连接失败
- 确认通信双方各自的接收和发送能力是否正常
- 让通信双方在进行通信之前,对通信过程中需要用到的一些关键参数,进行协商
针对第3点这里再进行解释一下,请看下图
当主机A和主机B建立连接的时候,主机A向主机B发送了许多的数据,但是由于某种原因,某些数据就“迷路”了,找不到主机B,也就无法正常发送过去,于是主机A和主机B断开了连接,当主机A和主机B再次进行连接的时候,这个“迷路”的数据找到了主机B,但是这个“迷路”的数据已经不再是主机B需要的数据了,所以主机B会把这个“迷路”的数据给直接丢弃掉
对于B来说就需要区分当前收到的数据是“当前连接”的数据还是“之前连接”的数据,于是给每个连接都协商好了不同的起始的序号,如果发现收到的起始数据和最近收到的数据序号,都差别很大的话,就视为这个数据是“之前连接”的数据
问:三次握手可以变成四次握手吗?
答:可以变成四次握手,因为对于三次握手来说,中间的两次, ACK+SYN ,都是在内核中,由操作系统负责进行的,都是在收到 SYN 之后,同一时机就合并了,如果拆开变成四次也是完全不影响的
四次挥手
- 首先主机A向主机B发送一个 FIN ,意思是我要和你断开连接了
- 主机B收到主机A发来的 FIN ,并且同意了(发送一个 ACK),并告诉主机A,我也要和你断开连接(发送一个 FIN)
- 主机A收到了主机B发来的 FIN 和 ACK,也同意了主机B和自己断开连接于是也同意了(发送一个ACK)
上述是一个简化版的四次挥手的过程,如果考虑状态的话请继续往下看
问:四次挥手可以变成三次挥手吗?
答:大概率不行,因为 ACK 是内核控制的,但是 FIN 的触发则是通过应用程序调用 close/进程退出,来触发的,如果 FIN 触发的时间和发送 ACK的时间差不多也是有可能合并的
TCP的几种常见状态
LISTEN:服务器进入的状态,此时服务器已经把端口绑定好了,初始化完成了,告诉客户端随时可以进行连接了
ESTABLISHED:客户端和服务器都会进入的状态,此时TCP连接已经建立完成(客户端和服务器各自保存了对方的信息),接下来就可以进行业务逻辑的操作了
CLOSE_WAIT:被动断开连接的一方会进入这个状态(比如客户端主动给服务器发送一个FIN,此时的服务器就会进入CLOSE_WAIT状态),如果服务器存在大量的CLOSE_WAIT状态那么就需要我们注意服务器这边是否写了close操作或者是及时执行了close操作
TIME_WAIT:主动断开的一方会进入这个状态,进入这个状态之后一定时间内就会自动进行close操作,那么为什么不直接close呢?因为假如客户端值主动的一方,如果客户端给服务器发送的 ACK丢了,那么进行重传操作的话,服务器就找不到客户端了,也就无法进行重传操作,所以我们必须得保证在 ACK成功送达对面之前这个连接还存在
核心机制四(滑动窗口):
没使用滑动窗口的时候是如下情况:
主机A每次都需要花费大量的时间来去等待主机B发送过来的 ACK ,这样才能发送下一个数据,这样的办法十分低效,于是引入了滑动窗口来改变
我们把“发送一条等待一条”变成了“发送一批等待一批”,意思就是,一股脑的把“1~1000”,“1001~2000”...这几条数据全部发送过去,然后等 ACK ,这样做的好处就是把多次等待 ACK 的时间合并成了一次等待 ACK 的时间
我们把批量发送数据不需要等待的数据的量称为“窗口大小”,但是需要注意的是这里的单位是批量发送数据的字节数,而不是“条”
下面这图就是一张滑动窗口
那么我们来思考一个问题,是收到一个 ACK 之后继续等待其他的 ACK ,直到该次的 ACK 全部收到再发送下一组数据呢,还是收到一个 ACK 之后就发送下一条数据呢?
答:第二个是正确的,结合上图来看当我们收到2001这个 ACK 之后就继续发送5001~6000这个数据,窗口大小依旧保持不变,只是窗口位置往后移动了
丢包问题:
滑动窗口的前提是可靠性,但是如果在滑动窗口传输中,出现丢包了怎么办?
数据包到达,ACK 丢了
确认序号的含义是:
表示收到的数据的最后一个字节的下一个序号,进一步可以理解成确认序号之前的数据已经全部收到了,结合上图来看,1001这个确认序号丢了,但是2001这个确认序号成功送达,这也就意味着2001之前的全部数据都成功送达了
数据包丢了
首先,如果是1001~2000这条数据丢了,那么主机B就会一直向主机A索要1001~2000这条数据,直到主机A多次收到同样的确认序号,这才意识到1001~2000这条数据没有成功发送过去,于是就会重新发送1001~2000这条数据,当主机B收到1001~2000这条数据之后,就会返回一个6001这个确认序号,表示之前发送的数据已经全部收到了(这里可以理解成主机A一直在向主机B发送数据,主机B也是收到了这些数据的,但是由于缺失了一条数据,所以就会一直向主机A索要这条数据,直到这条数据被发送过来,才会告诉主机A,你刚才发送的所有数据我都收到了)
上述这个操作也就是滑动窗口中的“快速重传”操作
核心机制五(流量控制):
流量控制其实就是用来制约滑动窗口发送速度过快的问题,让我们来思考一下,如果发送方发送的很快,但是接收方接收的很慢,此时会不会出现丢包的情况呢?请看下面解释
当发送方源源不断的发送数据到接收缓冲区,但是由于接收方处理数据的速度比较慢,所以接收缓冲区很快就满了,此时如果发送方再发数据,由于接收缓冲区满了的缘故,新发来的数据就只能被丢弃掉了
对于上面这种情况,我们就需要让接收方的处理能力反向制约发送方的发送能力
当我们把这个水池想象成一个接收缓冲区 ,把水容量想象成接收缓冲区里面的数据,把空闲空间想象成接收缓冲区没有数据的空间,把注水管想象成发送方,用来发送数据到接收缓冲区里面,把放水管想象成接收方,用来处理接收缓冲区里面的内容
如果空闲空间越大,就说明应用程序处理数据的速度越快,此时就可以增加窗口大小,来让发送方发送的快一点;如果空闲空间越小,就说明应用程序处理数据的速度越快,此时就可以设置一个更小的窗口大小,来让发送方发送的慢一点
问:那么如何来告诉发送方此时的剩余空间大小呢?
答:当接收方接收到了数据之后,就会把接收缓冲区的剩余空间大小通过 ACK 数据报来反馈给发送方,此时的发送方就可以依据这个数据来设置发送的窗口大小了
结合这张图不难看出,当我们的接收缓冲区剩余空间大小还有3000个字节的时候,此时就会将3000这个数字返回给接收方(意思就是告诉接收方我还有3000字节的剩余空间),于是发送方就将窗口大小设置为3000,当这几条数据依次被发送过去之后,接收缓冲区的剩余空间大小为 0 ,于是接收方就把这个 0 返回给发送方,发送方收到之后就把窗口大小设置成了 0,但是如果窗口大小被设置成了0 ,也就意味着不能发送数据了,那么我们什么时候才能知道接收缓冲区里面的数据还剩多少呢?
解决这个问题其实有两种方法:
第一种就是发送方主动发送一个窗口探测包(不携带任何业务数据),这个只是用来触发 ACK ,通过这个来查询接收缓冲区的剩余空间
第二种就是当接收方处理了一些数据之后,主动发送一个“窗口更新通知”这样的数据报,用来告诉发送方,你可以重新设置窗口大小来发送数据了
核心机制六(拥塞控制):
前面所说的流量控制是站在接收方的视角来控制发送方的
现在所说的拥塞控制则是站在传输链路的视角来限制发送方的速度的
如果接收方处理数据的速度很快,那么发送方就能无限速度的发吗?答案是不行的,因为中间链路上的设备可能顶不住,让我们结合上图来看
- 由于中间节点非常多,发送方每次发送数据走的路线可能就不一样
- 如果中间某个节点遇到了问题都是不好排查的
- 中间节点传输的数据不止有你一个发送方的数据,还有可能有其他发送方的数据
如果中间某个节点本身已经负载很高了,此时如果发送方再以很快的速度发送数据,那么就会导致这个节点直接丢包了,为了解决这个问题,我们可以将这一块的中间设备视为一个整体,通过“做实验”的方式来找到一个合适的发送速度
- 首先先按照一个比较小的速度来发送数据,如果速度非常流畅没有丢包,就说明网络上传输数据的整体是比较畅通的,此时就可以加快传输速度
- 增大到一定速度之后,如果出现了丢包,就说明网络上出现了拥堵了,此时就需要减慢传输的速度
- 减数之后发现又不丢包了,继续再加速
- 加速之后发现又丢包了,继续减数
上述的操作是一个持续的动态变化
既然流量控制会限制发送的窗口大小,拥塞控制也会限制发送的窗口大小,那么最终的窗口大小会取决于谁呢?答案是取决于哪个限制的窗口大小更小,取一个较小值
对于拥塞控制来说对应的就是下面这张图:
核心机制七 (延时应答):
对于左边的图来说,如果立即返回ack,那么此时的窗口大小就只有3kb,但是当我们发送 ack 的时间稍微晚一点,可能窗口大小就会变大,如右图所示变成5kb(解释:假如让 ack 在100ms之后再返回,这也就意味着,此时的100ms之内,应用程序可能就又消费掉了2kb的数据,所以此时返回的ack携带的窗口大小也就是5kb了)
问:如果进行了延时应答之后,返回的窗口大小就一定比不进行延时应答的窗口大小更大吗?
答:1.这取决于应用程序的代码是如何写的,是否在不断的读取数据,如果应用程序不是一直读取数据或者读取数据的速度很慢,那么在过了指定的时间之后窗口大小还是可能不变的
2.如果在这延时期间内不但没有处理掉剩余数据反而发送方发来的数据把剩余空间给消耗掉了一些,此时的剩余空间大小就会变得更少,那么ack携带的窗口数据大小也就更小了
但是更多情况下延时应答还是有效的
数量限制:每隔N个包就应答一次
时间限制:超过最大延时时间就应答一次
核心机制八(捎带应答):
在延时应答的基础上,引入了提升效率的机制
把返回的业务数据和 ack 合二为一了
ack 是由操作系统内核返回的,是收到请求之后立刻就返回 ack
响应则是应用程序返回的,在代码中,根据请求计算得到相应,再把相应写回到客户端
正常情况下,ack 和响应是不同的时机,是不能合并的,但是 ack 涉及到了“延时应答”,延时应答就会让 ack 返回的时间往后推一推,这样一延时就可能赶得上接下来发送响应数据的操作了,并且延时的时间越长越有可能合并
核心机制九(面向字节流):
假如我们读一个100字节的数据,我们可以分很多种情况:
情况一:一次读一个字节,分100次读完
情况二:一次读10个字节,分10次读完
情况三:一次读50个字节,分2次读完
情况四:一次读100个字节,一次搞定
粘包问题
应用层数据包在 TCP 的接收缓冲区中,连成一块/黏在一起,这种就称为“粘包问题”,比如下图
那么为了解决上述的情况,我们可以指定分隔符,比如约定好请求和响应都是以/n结尾,但是如果对于纯文本来说的话用 \n 来作为分隔符是不合适的,所以就需要我们用一些不常见的来作为分隔符,适合文本类的数据,比如下图
在ascll码表中,这些都是不常见的,所以我们一般可以用这些来作为分隔符
还有一个方法就是约定数据的长度
约定每个应用层数据包,开头的 2/4 个字节(自己规定),表示数据包的长度,尤其是针对二进制数据,这个方案就很好用
核心机制十(异常处理):
主机关机(正常流程关机):
正常流程点关机按钮,此时操作系统会先干掉所有进程,干掉进程的过程中也会进行四次挥手
1)四次挥手非常快,四次挥手已经完成了,关机动作才真正完成,正常情况,无需过多关注
2)四次挥手还没来得及挥完就已经关机了,对于这种情况来说,可以通过下图来进行了解
对于这张图来说,如果A进行了关机操作,在发送了 FIN 之后,因为 ACK 是立即返回的所以A有可能会收到,但是由于 FIN 的发送是由操作系统内核完成的,所以可能 FIN 还没有发送过来就已经关机了,所以此时的B无论等多久都不会等到A发送的 ACK,于是B就会触发超时重传(这里的B只是知道A要和他断开连接,并不知道A是要关机了)
主机掉电(拔电源):
接收方掉电:
A 给 B 发送了数据,就不会再收到 ACK,收不到 ACK 之后就会触发超时重传,如果重传的次数多了之后,A就会尝试重置连接,如果重置操作也没有收到 ACK ,那么A就会单方面的释放连接(A把保存的B的信息删除掉)
发送方掉电:
如果是下图的这种情况,A 发着发着就挂了,但是在 B 的视角看来是不知道 A 是挂了,还是暂时休息一下,此时 B 就会向 A 发送一个数据包(不携带任何业务数据,只是为了触发ACK),如果发了探测报文之后,A 返回了 ACK 就说明 A 只是歇一会,没有挂,但是如果发了探测报文之后,A 没有发送 ACK ,甚至发了多个 探测报文之后 A 都没有发送 ACK ,此时就可以认定为 A 已经挂了
这样的报文是探测报文,是周期性的,同时这个报文是用来探测对方“生死”的,所以也就把这样的报文称为“心跳包”
网线断开
和主机掉电一样,只不是是把那两种情况结合起来了
在A的视角看来: 在B的视角看来:
A 收不到 B 发的 ACK A突然不吱声了
于是进行超时重传 发送的心跳包也没有反应
还是没有,于是进行重置连接 重复发送多次心跳包
重置之后还是没有,于是就单方面的释放 发现还是没有反应,于是就单方面释放