网络编程(五) ———— 万字详解TCP协议


TCP首部格式

在这里插入图片描述

在这里插入图片描述

  • 源端口号
    表示发送端端口号,字段长16位
  • 目标端口号
    表示接收端端口号,字段长 16位
  • 序列号

字段长32位。序列号(有时也就序号)是指发送数据的位置。每次发送一次数据,就累加一次该数据字节数的大小
序列号不会从0或1开始,而是建立连接时有计算机生成的随机数作为其初始值,通过SYN包传给接收端主机。然后再将每转发过去的字节数累加到初始值上表示数据的位置。此外,在建立连接和断开连接时发送的SYN包和FIN包虽然并不携带数据,但是也会作为一个字节增加对应的序列号。

  • 确认应答号

确认应答号字段长度32位,是指下一次应收到的数据的序列号,实际上,它是指已收到确认应答号减一为止的数据,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收

  • 数据偏移

这个字段表示所传输的数据部分应该从TCP包的哪个位开始计算,也可以把它看作TCP首部的长度


1.确认应答和序列号

确认应答也是保证可靠性传输的核心
发送方发数据给接收方了,接收方就回应一个应答报文,如果发送方收到了这个应答报文,那么认为是对方已经收到了。

由于网络上的传输,顺序是不确定的,可能出现后发先至的情况,因此不能就单纯的通过收到数据的顺序来确定逻辑,就需要对应答进行的编号。

实际上,TCP传输数据,不论条,而是论字节(面向字节流)

实际上,TCP的序号和确认序号,是以字节为单位进行编号的

比如下面这张图,第一个请求A给B发送了1000个字节的数据,序号就是1-1000(假设从1开始编号了),这个操作相当于是发了一个TCP数据报,这个数据报,这个数据报的序号是1,长度是1000,确认应答数据报,里面的确认序号是1001(意思就是1001之前的数据,B已经收到了,另外,也可以理解成,B在向A索要1001开始的数据)

在这里插入图片描述
针对每个字节分别编号即为序列号,依次进行累加(TCP的序号的起始不一定是从1开始的),每个ACK都带有对应的确认序列号,意思是告诉发送者,我已经收到了哪些数据,下一次你从哪里开始发

在这里插入图片描述
发送方,就可以根据确认的应答报文来确定接收方是否是收到了,只要发送方收到了应答,就认为接收方已经收到,可靠传输就完成了。

反之,在一定时间内没有等到确认应答,发送端就可以认为数据已经丢失,并进行重发。因此,即使产生了丢包,仍然能够保证数据能够到达对端,实现可靠传输

2.超时重传

确认应答机制中,这个是比较顺利,但是传输过程中还是可能会出现丢包的,一旦数据发生丢包,就要进入超时重传机制中了。

主机A发送数据给B之后,可能因为网络拥堵等原因,数据无法到达主机B
如果主机A在一个特定时间间隔内没有收到B发来的确认应答,就会进行重发

在这里插入图片描述
举个列子:

  1. A发消息给B,A发的消息丢了,B压根没看见
  1. A发的消息过去了,B也看见了,也回复了,但是B回复的消息丢了
    发送方无法区分,当前是发的数据丢了,还是应答数据丢了,发送方能做的事情,就是在一段时间之后,重发一条数据

超时的时间怎么确定呢?

  • 最理想的情况下,找到一个最小的时间,保证 “确认应答一定能在这个时间内返回”
  • 但是这个时间的长短,随着网络环境的不同,是有差异的 。
  • 如果超时时间设的太长,会影响整体的重传效率
  • 如果超时时间设的太短,有可能会频繁发送重复的包

TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间
在这里插入图片描述
这个等待,不同的系统实现的方式不一样,数据在网络上传输过程,是需要一定的时间的,不能说数据刚发出去,就期望得到回应,可能要经历一段时间之后,才能得到回应

Linux中(BSD Unix和Windows也是如此),超时以500ms为一个单位进行控制,每次判定超时重发的超时时间都是500ms的整数倍 ,发送方,把数据发出去之后,等待500ms,如果没有收到应答就认为是丢包了。
如果重发一次之后,仍然得不到应答,等待 2500ms 后再进行重传。
如果仍然得不到应答,等待 4
500ms 进行重传。依次类推,以指数形式递增
累计到一定的重传次数,TCP认为网络或者对端主机出现异常,强制关闭连接

超时时间会动态变化,不是一成不变的。
等待时间会逐渐延长,延长也就意味着让重试的频率尽量降低,只要重传也失败,其实就认为,大概率这个传输是通不了的。达到一定重发次数以后,如果仍没有任何回应,就会 判断为网络或对端主机发生了异常,强制关闭连接,并且通知应用通信异常强行终止。

在超时重传的时候,无法区分是发送过去的数据丢了,还是返回的ACK丢了,就一视同仁了,都要进行重传 ,以500ms为单位,依次增加、

如果数据重复了怎么办?

接收方收到的数据会先放在内核的”接收缓冲区中“,

接收缓冲区是一段内存,每个socket都有,按照序号来进行去重,此时在应用程序中读取的数据,读到的结果就是不带重复的

3.连接管理

UDP是一种面向无连接的通信协议,因此不检查对端是否可以通信,直接将UDP包发出去,TCP与此相反
TCP是有连接的,连接管理就是,如何建立连接(三次握手),如何断开连接(四次挥手)

  • 三次握手本质上就是:A向B请求连接,B给与回应,B也向A请求连接,A也给与回应

  • 本来应该是“四次握手”,但是中间两次操作,是可以合在一起的,这两个操作在时间上是同时发生的

  • 当A的SYN到达B的时候,B的内核就会第一时间进行应答ACK,同时也会第一时间发起SYN,这两件事同时触发,于是就没有必要分成两次传输,直接一步到位

在这里插入图片描述

为啥要三次握手,为啥要建立连接?

主要有两个目的

  1. 投石问路,通过三次握手的过程,来确认A和B之间的传输是通畅的,尤其是要确认,A和B各自的发送能力以及接收能力
  2. 协商参数:通过三次握手,让A和B之间通通气,选择一些传输中合适的参数,列如:TCP的序列号从几开始

如果网络出现了问题,此时,三次握手都会难以成功,此时也就没有必要进行后续的传输了

如果三次握手,握四次行不行,握两次行不行?

握四次,完全可以。没有必要,效果和3次是一样的。
中间的ACK和SYN是可以合并在一起的,如果分成两个,传输的开销就比一个大。

在这里插入图片描述

如果三次握手,握两次行不行?

握两次是肯定不行的,A 给 B发送一个 SYN,B再给A同时发送一个ACK和SYN就完成了两次握手。

此时A知道自己发送和接收能力没有问题,也知道B的发送和接收能力没问题

但是B只知道自己的接收能力没问题,但不知道自己的发送能力有没有问题

B只知道A的发送能力没有问题,并不知道A的接收能力有没有问题。所以并不能进行传输。

在这里插入图片描述
服务器和客户端

四次挥手中的ACK和FIN为啥不合并?

对于B来说 ACK 和 FIN 的触发时机是不一样的。

  1. B只要收到FIN就会立即触发ACK,这个事事内核完成的
  2. B发送FIN的时机是用户代码控制的(比如代码中出现了socket,close() 这样的操作的时候,才会触发FIN),有可能B的代码写出问题了,有可能一直不调用close。

在这里插入图片描述

  • CLOSE_WAIT:服务器收到FIN之后,进入的状态,等待用户代码调用close,来发送FIN
  • TIME_WAIT:表示的客户端收到了FIN之后进入了TIME_WAIT,这个状态存在的意义主要就是为了处理最后一个ACK包

在这里插入图片描述
在三次握手和四次挥手的过程中,同样可能会丢包,一旦丢包就会触发超时重传

  1. 第一个FIN丢了,A迟迟收不到ACK,就会重传FIN
  2. 第一个ACK丢了,A迟迟收不到ACK,就还会重传FIN
  3. 第二个FIN丢了,B迟迟收不到ACK,就还是会重传FIN
  4. 第二个ACK丢了,B迟迟收不到ACK,还是会重传FIN

假设,如果A收到FIN,并返回ACK之后,连接就销毁,而不是进入TIME_WAIT状态,会咋样?

此时一旦最后一个ACK丢了,此时就无法重传ACK了(连接已经销毁了)

TINE_WAIT即使进程已经退出了,TIME_WAIT状态仍然会存在(TCP连接不会立即销毁),TIME_WAIT 会等待一定时间,如果一定时间之内也没有重传的FIN过来,才会真正销毁

这个等待时间为2*MSI

MSI理论上是,主机A和B之间最长的一次通信时间

通常在Linux中这个MSL默认是1min,这个MSL默认是1min,当然这个MSL都是可以配置的

如果服务器上出现大量的CLOSE_WAIT,是啥情况?

这是代码出现bug,close,没有及时被调用到,

如果服务器上出现大量的TIME_WAIT,是啥情况?

主动发起FIN的一方会进入TIME_WAIT,就需要排查服务器是否应该主动断开连接

这个也可能是代码bug,但是不能石锤

哪方先断开连接,哪方就会进入TIME_WAIT,进程退出之后,TIME_WAIT状态仍然存在,TCP连接仍然存在

如果让服务器先退出,服务器这边就会进入到 TIME_WAIT状态(原来的连接占据着端口),接下来如果服务器立即启动,新的进程又会尝试重新绑定这个端口可能

会存在端口绑定失败的情况,如果使用原生的socket来试效果会非常明显,第二次启动就会启动失败。

在Java socket,一般来说第二次启动也是能成功的

在socket api 里有一个 REUSE ADDR 选项,如果把这个选项加上,就能够让我们绑定端口的时候复用TIME_WAIT状态中的端口,(Java socket里面应该是默认就设置了这个选项)

在这里插入图片描述

  • LISTEN:手机开机 ,信号良好,随时可以打入电话,服务器的状态,当我们创建好ServerSocket实例的时候,就进入了LISTEN状态
  • ESTABLISHED:接通了电话,双方可说话了,代码中accept放回了,得到了一个clientSocke
  • stable:稳定的

四次挥手一定是四次嘛?是否可能是三次呢?

有可能的,后面的:延时应答和捎带应答虽然ACK和FIN是不同时机,但是在延时应答和捎带应答的情况下是可能合并在一起的

四次挥手一定会执行嘛?也不一定

四次挥手是一个TCP正常断开的流程,但是实际上,有的时候TCP连接也会异常断开(比如网断了)

4.滑动窗口

TCP不仅仅是为了保证可靠性,还要尽可能的提高传输效率。
其实可靠性和效率,是矛盾的。TCP努力的在可靠性的前提下,又做出了很多性能优化的手段

下图的这个发送过程,发送方需要花很多时间来等,这个等待其实就浪费了大量时间

在这里插入图片描述
现在通过批量发送,一次发送一波,一次等一波的ACK,把多组数据的ACK的等待时间给重叠起来了。
在这里插入图片描述
一次批量发的数据的长度,就称为“窗口大小”

如果没有批量发送数据的长度限制(窗口无限大,完全不等,ACK就一顿发),其实就没有可靠性而言

如果窗口越大,其实整体的效率就越高

如果窗口越小,整体的效率就越低

在这里插入图片描述
当前窗口范围是1001 - 5000,也就意味着,发送方现在同时发送了(1001 - 2000;2001-3000;3001-4000;4001-5000),同时再等待着四组数据的ACK

假设,2001这个ACK先到,发送方就知道了1001-2000这个数据已经被对方收到了

发送方也就不用继续等这个数据了,接下来就立即再发一个5001 - 6000,仍然保证窗口大小时4份数据,仍然保证当前同时等待4份数据的ack

并不是把4份ack都等到,才发新的数据,而是随着收到ack,就随着往后发送。

假设有后发先至的情况

ack 2001,3001,4001,5001 都在网络上传输呢,不一定非得是2001先到,是否可能3001先到呢?

这是非常有可能的。

确认序号表示,从该序号之前,前面的数据都收到了

如果收到了3001这个ack,意思就是 1001-2000 和 2001-3000都被对方收到了,此时2001这个ack收或者不收,已经不关键。

如果滑动窗口的场景中出现丢包了,咋办?

情况1:数据包已经抵达,ACK丢失了
在这里插入图片描述
这种情况没有关系,只要不是全部ACK丢失就好了,哪怕丢个50%也没事

发送方:1001-2000,2001-3000

接收方:2001,3001

如果2001这个acck丢了,3001这个ack到了

此时发送方也就知道了,3001前面的数据都被正确收到了

此时1001-2000这个数据报也得到了一个确认应答

实际上,TCP为了偷懒(提高效率),滑动窗口下,并不是每一条数据都有ACK,会隔几条数据才有一个ACK

快速重传

快速重传,效率很高,尤其是不需要重复传输数据

在这里插入图片描述
如上图,因为1001丢包了,主机B就会一直索要1001,确认序号就仍然是1001

1001-2000这个数据丢了,2001-3000,3001-4000…还在继续发

发送方这边,如果连续看到几次1001这个ACK,就知道了是1001这个数据丢失了,接下来就会重传1001

前面的2001-7000这些数据已经到达了接收端了,值不过是在接收缓冲区里待着,当1001-2000这个数据到达的时候,B就知道了,7001之前的数据就都到齐了,此时就继续索要7001这个数据即可

5.流量控制

流量控制,本质上就是在控制滑动窗口的大小,也是保证可靠性的。

窗口大小决定了传输的效率,窗口越大,效率就越高,窗口越小,效率就越低。

既然如此,窗口大小取多少合适呢?

窗口越大,为了保证可靠性,资源开销就得越多

窗口太小,速度也得不到保证

流量控制是基于接收方的处理能力来限制窗口大小的

TCP这个传输数据的过程,其实也就类似于一个生产者消费者模型
在这里插入图片描述

主机A发送的数据就到达了主机B的接收缓冲区,此时主机A就是生产者,主机B的应用程序,通过socket api 来读取数据。被soket api 读到的数据就从缓冲区中删掉了,应用程序就是消费者,接收缓冲区就是交易场所(类似于一个队列)

所说的窗口大小,是指发送发(主机A)批量发多少数据,比如,主机A发的数据很快,窗口很大,此时接收缓冲区的数据也会增长很快,如果主机B的应用程序读取数据读的不快,随着时间的推移,接收缓冲区逐渐就满了,如果不加任何限制,主机A还是按照一样的速度发,此时新来的数据没有地方保存,就被内核丢了。

类似于一个水池,一边注水,一边出水,如果注水速度比出水大,池子很快就满了

流量控制这个机制,就是为了解决这个问题的,根据接收方的处理能力(接收缓冲区的剩余空间大小),来动态决定发送方的发送速率(控制窗大小)

在这里插入图片描述
接收缓冲区大小是4000

1-1000数据到达的时候,缓冲区这里面用了1000,还剩3000,返回的ack中就会把3000这个信息告诉发送方

发送方再次发送数据的时候,就按照3000作为窗口大小来进行发送

窗口大小(接收缓冲区的剩余空间)是如何放回给发送方的?

如果窗口大小为0 了(接收端这边满了),然后发送方就停了吗?

此时发送方式不再继续发数据了,但是为了能够查询当前接收方的窗口大小。每隔一段时间,还会再来触发一个窗口探测包,通过这个包(不传输具体的业务数据),触发ACK,在这个ACK中就能知道当前窗口的大小了

那么问题来了,16位数字最大表示65535,那么TCP窗口最大就是65535字节么?

64K窗口大小够吗?

TCP首部40字节选项中还包含了一个窗口扩大因子,实际窗口大小是 窗口字段的值 左移M位(左移以为相当于*2)

6.拥塞控制

拥塞控制是站在另外一个角度来限制发送方的窗口大小,站在一个宏观角度来看待这个问题,把整个中间链路都看成了一个整体,只看结果。

在这里插入图片描述
先使用一个比较小的窗口来传输数据,看看是否丢包,

如果不丢包,说明网络比较通畅,如果丢包,说明网络发送拥堵

当网络通畅的时候,就逐渐加大发送速率。当网络出现丢包的时候,就立即降低发送速率。

通过这样的方式,就可以逐渐实验出一个比较合适的窗口大小

真实的发送窗口大小 = min(流量控制的窗口,拥塞控制的窗口)

慢启动:刚开始启动的时候,给一个较小的窗口(比较慢的发送速率)

在这里插入图片描述
这个图描述了拥塞控制中,窗口大小的变化规则

指数增长速度是非常快的,由于刚开始启动的时候,窗口比较小,此时通过指数增长,就能在很少的轮次中就能把窗口大小给顶上去

如果达到阈值就从指数增长变成线性增长

7.延时应答

延时应答也是用来提高效率的(琢磨窗口大小)

让窗口大小,在保证可靠的基础之上,能尽量再大一点

流量控制来说,窗口大小就是接收缓冲区的剩余空间大小

在这里插入图片描述
主机A给主机B发送数据,如果接收方立刻放回ACK,此时放回的窗口大小,就是当下这个缓冲区剩余的空间。

但是如果接收方稍等一会,再返回ACK,稍等这个时间里,应用程序可能就会消费一部分数据,此时缓冲区的剩余空间就更大了

8.捎带应答

在延时应答的基础之上

很多的客户端/服务器的通信服务器的通信模式,都是这种“一问一答”

但是由于有了延时应答,返回的ACK不是立即返回,而是等一会。

正好等一会之后,服务器要返回业务上的response了,此时就可以把这个ACK和response合二为一,把两个包变成一个包

网络通信涉及到大量的封装和分用,针对每个包都要一顿封装,收到之后再一顿解析。

在这里插入图片描述
针对四次挥手来说,确实是可能四次变成三次的

捎带应答,是可能吧中间的ACK和FIN给合并成一个,于是四次挥手就变成了三次挥手。

四次挥手啥时候能变成三次?这是不能确定的。

在这里插入图片描述
捎带应答本身就是一个“概率性的机制”,当前ACK延时的时间正好要比接下来发业务数据的时间要更长一些。

列如,服务器收到请求到返回响应,这个过程消耗时间50ms

但是延时应答假设最多等20ms,这个情况就无法触发捎带应答了

但是延时应答假设是最多等60ms

第50ms的时候,此时触发了响应,ACK就可以和这个响应一起过去了,也就是触发了延时应答

9.面向字节流

在这种面向字节流的情况下,需要注意一个重要的问题:粘包问题(指的是应用层的数据报)
在这里插入图片描述
应用程序从接收缓冲区读数据的时候,就不知道从哪里到哪里是一个完整的应用层数据报

应用程序此时只能看到接收缓冲区中的一个一个字节,无法区分当前接收缓冲区里有多少个应用层数据报,以及从哪到哪是一个完整的应用层数据报

如何解决粘包问题?

通过设计一个合理的应用层协议来解决

  1. 给应用层数据设定“结束符”/“分隔符”
  2. 给应用层数据设定“长度”

方式一:设定结束符,约定每个应用层数据报一定以 ; 结尾
在这里插入图片描述
方式二:设定包的长度,约定每个应用层数据报的前4个字节,存储数据报的长度
在这里插入图片描述
在UDP中是不存在粘包问题的

在这里插入图片描述

10.TCP中的一些异常情况(心跳机制)

常见的异常情况

  1. 进程终止

    不管进程是咋终止的,本质上都会释放对应的PCB,也会释放对应的文件描述符,一样会触发 四次挥手

    "进程终止"不代表连接就终止,进程终止其实就相当于调用了 soket.close() 方法而已

  2. 机器重启

    机器重启的时候,其实也是先杀进程,仍然是进行四次挥手

  3. 机器掉电、网线断开

    突发情况,机器来不及进行任何动作的

    如果掉电的是接收方,

    此时另外一边还在发送数据,此时显然发送方不会再有ACK,于是就会超时重传.

    重传几次之后,就会尝试重置连接,这个时候,RST(复位报文段)就会设置为1

    再然后发送方就会放弃这个连接,把连接对应的资源就回收了。

    如果掉电的是发送方(心跳机制)

    此时另外一方在尝试接收数据,此时接收不到任何数据

    接收方如何知道,发送方式挂了?还是说发送方暂时还没发呢?

    此时接收方采取的策略,就是"心跳包"机制(也叫做“保活”)

    每隔一段时间,向对方发送一个 PING包,期待对方返回一个PONG包。

    如果PING包发故去,过了很久还没有PONG,并且重试几次也不行,此时就认为对方已经挂了

    心跳包是一个应用非常广泛的机制,不仅仅是在TCP

    在微服务中,如果某个主机宕机了,此时入口服务器就得即使发现这个事情,就需要把请求切走

    就可以使用心跳包机制,直接使用一个TCP连接时不行的,虽然使用TCP连接能够感知是哪个主机挂了,但是TCP感知的不够及时,如果希望能够更加及时更快速的发现问题

    就需要在应用层实现心跳机制
    在这里插入图片描述

11.如何基于UDP协议实现可靠传输?

这个问题其实是在考TCP

  1. 实现确认应答机制,把每个数据接收到了之后,都要反馈一个ACK(这就不是内核返回的了,而是应用程序自己定义了一个ack包发送过去)
  2. 实现序号、确认序号,以及实现去重
  3. 实现超时重传
  4. 实现连接管理
  5. 想要提高效率,实现滑动窗口
  6. 为了限制滑动窗口,实现流量控制、拥塞控制
  7. 实现延时应答,捎带应答,心跳机制…

啥样的场景中适合用TCP,啥样的场景中适合用UDP

  1. 如果需要可靠传输,肯定首选TCP

  2. 如果传输单个数据报比较长(超过64K),还是首选TCP

  3. 如果特别注重效率,优先考虑UDP

    典型的场景:机房内部的主机通信

    网络环境简单,带宽充裕,丢包的概率不大

    机房内部主机之间的通信,往往传输数据量更大,更需要速度

    尤其是在当下的"微服务"这样的环境中,其实特别需要

  4. 如果需要广播,优先考虑UDP

    一份数据同时发给多个主机

    UDP自身就支持广播的

    但是TPC自身不支持广播,就只能在应用程序中,通过多个连接,轮询的方式给每个主机发送数据(伪广播)

除了TPC和UDP之外还有很多其它协议,有的协议就可以尽可能的兼顾到可靠性和效率(兼顾可靠性和效率,付出的代价可能激素会更多的机器资源)

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱敲代码的三毛

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值