你真的知道TCP协议吗?滑动窗口是什么?有什么重传机制?拥塞控制又是什么?一篇文章带你吃透TCP协议

2024-09-08 12:36:33 浏览数 (2)

1.TCP协议段格式

光是看这个协议段格式就能知道TCP协议要比UDP协议复杂。

源/目的端口号: 表示数据是从哪个进程来, 到哪个进程去; 32位序号/32位确认号: 后面详细讲; 4位TCP报头长度: 表示该TCP头部有多少个32位bit(有多少个4字节); 所以TCP头部最大长度是15 * 4 = 60 6位标志位: URG: 紧急指针是否有效ACK: 确认号是否有效 PSH: 提示接收端应用程序立刻从TCP缓冲区把数据读走 RST: 对方要求重新建立连接; 我们把携带RST标识的称为复位报文段SYN: 请求建立连接; 我们把携带SYN标识的称为同步报文段 FIN:通知对方,本端要关闭了 16位校验和: 发送端填充, CRC校验. 接收端校验不通过, 则认为数据有问题. 此处的检验和不光包含TCP首部, 也包含TCP数据部分. 16位紧急指针: 标识哪部分数据是紧急数据; 40字节头部选项: 暂时忽略;

TCP协议如何解决报头和有效载荷分离的问题?

首先这个·4位首部长度的大小就是报头的大小,前面20个字节是固定的,但是还有个选项字段,我们可以计算一下,4位最大长度是15,但是报头的宽度是32,也就是4字节,15*4就是60,所以选项字段最大是40字节,所以我们就可以分离报头和有效载荷。

2.可靠性

2.1 确认应答(ACK)

当我们在应用层使用系统调用接口的时候,先将数据拷贝到传输层,然后经过发送缓冲区由网络层和数据链路层发给接收方,存放在接收方的接收缓冲区内,然后接收方的应用层使用系统调用接口将数据拷贝上去,这就是简单的一个通信过程。

我们需要明白一个点,每次发送的东西不擦擦只是数据,而是携带报头的数据,也就是报文,当客户端向服务端发送了"hello",那么服务端会向客户端返回一个ACK报文,表示自己已经收到,也就是说,每一个报文的发送都会收到应答,这就是ACK机制。

有了确认应答机制,就能保证上一条报文是可靠的,因为最后一条报文是没有报文的,所以没有百分百可靠的网络通信,只能确认上一条报文的发送是可靠的。

在网络中最常见的数据传输方式是多个数据一起发送,为了使报文能够有序到达,TCP将每个字节的数据都进行了编号. 即为序列号,这也就是报头字段中的32位序号,这是为了使报文能够有序到达接收端,也是可靠性的一种策略。 那么假设我发了100个报文,只收到了98个应答呢?我怎么知道哪些报文没有被收到呢?此时TCP在报头中定义了32位的确认序号,每一个ACK都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 下一次你从哪里开始发.确认序号就是序号 1.

TCP还规定了确认序号之前的报文一定是被收到了,因为允许少量的应答报文丢失,假设101,201,301全部丢失,只有401返回,那么401之前的报文也是全部被收到了的。

那么为什么不将序号和确认序号合并呢?因为TCP是全双工的,任何一端在发数据的同时可能也在收数据,此时就有了捎带应答机制

2.2 流量控制

如果发送方不断地发送数据给接收方,当接收方的接收缓冲区被数据打满了该怎么办呢?那么接下来发送的数据都丢弃吗?这是不合理的做法,因为数据经过了很多处理,直接丢弃的话会浪费资源,那么此时TCP协议有一种方法叫做流量控制

首先我们要明确流量控制是OS做的,我们用户不需要关心,是OS里面的TCP协议定制的方法。

什么是流量控制呢?

流量控制就是发送方通过知道接收方的接收能力来酌情考虑发多少数据,发不发数据,发数据的速度。

那么如何进行流量控制的呢?

其实就是利用了报头中的16位窗口大小已经ACK机制来完成,当客户端发送了报文之后,服务端发送ACK报文,那么客户端收到的ACK报文中16位窗口大小里面是服务端的接收缓冲区大小,此时我们就能知道双方的接收能力。也就是说,报文永远是发送给对方的,所以16位窗口大小永远填的是自己的。

接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送,就会造成丢包, 继而引起丢包重传等等一系列连锁反应.

因此TCP支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control);

接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 "窗口大小" 字段, 通过ACK端通知发送端; 窗口大小字段越大, 说明网络的吞吐量越高; 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;发送端接受到这个窗口之后, 就会减慢自己的发送速度; 如果接收端缓冲区满了, 就会将窗口置为0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端.

2.3 报文类型

服务端在大部分情况下肯定是要收到大量的报文的,然后对不同的报文做出不同的响应,那么服务器怎么知道这个报文的类型是什么呢?此时就需要用到报头中的报文类型标记位TCP报文一共有6中类型。

2.3.1 URG

要知道TCP发送的报文中是有序号的,接收端会根据序号按序取出,也就是有排队的行为。

TCP报头中有一个16位紧急指针的字段,这个字段就是为了告诉接收方我这个报文里面有紧急数据,要求立即处理,也就是插队,那么发送方就需要将URG这个标记位置1,这就相当于告诉接收方这个报文需要插队,那么16位紧急指针就是一个偏移量,作用就是将紧急数据放在有效载荷偏移量的位置紧急数据最大只有一个字节

2.3.2 PSH

当接收方的接收缓冲区满了,返回ACK报文中的窗口大小为0时,发送方会发送询问报文给接收方,接收方也会发窗口大小更新报文,那么询问报文不仅仅是在询问,还需要把PSH标志位置1,也就是告诉接收方赶紧把数据交给上层,方便我发数据。

PSH置1在很多场景都能使用,目的就是告诉接收方赶紧把数据交付

2.3.3 RST

RST就是reset重置的意思,TCP三次握手的最后一次ACK可能失败,因为这次报文是没有应答的。所以站在客户端的视角,当我把ACK发出时,我就认为建立连接成功了,但是站在服务端的视角,我必须收到ACK才能算成功。那么当这次ACK丢包的时候,客户端是默认服务端收到了,也就是连接建立成功。客户端就会开始发送数据给服务端,但是服务端就认为这次连接失败了,但是为什么还要給我发报文,此时服务端就会发送PST置1的报文,表示此次连接失败,你得给我重新建立连接。

我们不但行前两次报文丢了,因为都会有应答,没有应答也就代表出错了,唯独最后一次ACK是没有应答的,所以三次握手的最后一次ACK就是在赌,赌这次连接是成功的。

2.3.4 FIN

通知对方,本端要关闭了。

2.3.5 ACK

确认号是否有效,也就是应答报文。

2.3.6 SYN

请求建立连接; 我们把携带SYN标识的称为同步报文段

2.4 超时重传

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

但是, 主机A未收到B发来的确认应答, 也可能是因为ACK丢失了; 因此主机B会收到很多重复数据. 那么TCP协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉.去重的效果. 这时候我们可以利用前面提到的序列号

那么, 如果超时的时间如何确定?

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

TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间. Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时时间都是500ms的整数倍. 如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传. 如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增. 累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接

2.5 连接管理

在正常情况下, TCP要经过三次握手建立连接, 四次挥手断开连接

2.6 拥塞控制

TCP协议在通信过程中不仅考虑了双方主机的情况,还考虑了网络,如果数据出现了大面积的丢包,那么超时重传的策略不能够应用,因为在通信过程中是多台机器一起发送数据,并且都遵守TCP协议,那么每台机器都重传的话,本来堵塞的网络就可能直接瘫痪,此时就需要使用拥塞控制的策略来保证可靠性。

虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题.

因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据,是很有可能引起雪上加霜的.

TCP引入 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据;

  • 此处引入一个概念程为拥塞窗口
  • 发送开始的时候, 定义拥塞窗口大小为1;
  • 每次收到一个ACK应答, 拥塞窗口加1;
  • 每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口;

这里我们需要将拥塞控制和滑动窗口联系起来,滑动窗口本质上就是一次发送数据量的大小,这是只考虑了对方机器的接收能力大小,那么拥塞控制就是在此基础上考虑网络的问题,那么拥塞控制也有一个拥塞窗口,也就是网络接收能力的大小,如果发送的数据量超过了拥塞窗口的大小,那么可能会引起网络的拥塞。所以真实的滑动窗口大小=min(拥塞窗口大小,对方接收能力大小)。

慢启动(2^n):

  • 指数级增长
  • 前期慢
  • 增长快

同时我们不担心对方接受不了我的数据,因为当后期拥塞窗口大于对方的接收能力时,是以对方的接收能力大小为滑动窗口。

像上面这样的拥塞窗口增长速度, 是指数级别的. "慢启动" 只是指初使时慢, 但是增长速度非常快.

  • 为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍.
  • 此处引入一个叫做慢启动的阈值
  • 当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长
  • 当TCP开始启动的时候, 慢启动阈值等于窗口最大值;
  • 在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回1;

少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;

当TCP通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降;

拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案.

服务端状态转化:

[CLOSED -> LISTEN] 服务器端调用listen后进入LISTEN状态, 等待客户端连接; [LISTEN -> SYN_RCVD] , 一旦监听到连接请求, 就将该连接放入内核等待队列中,并向客户端发送SYN确认报文。 [SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文, 就进入ESTABLISHED状态, 可以进行读写数据了. [ESTABLISHED -> CLOSE_WAIT] 当客户端主动关闭连接(调用close), 服务器会收到结束报文段, 服务器返回确认报文段并进入CLOSE_WAIT; [CLOSE_WAIT -> LAST_ACK] 进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据); 当服务器真正调用close关闭连接时, 会向客户端发送FIN, 此时服务器进入LAST_ACK状态, 等待最后一个ACK到来(这个ACK是客户端确认收到了FIN) [LAST_ACK -> CLOSED] 服务器收到了对FIN的ACK, 彻底关闭连接.

客户端状态转化:

[CLOSED -> SYN_SENT] 客户端调用connect, 发送同步报文段; [SYN_SENT -> ESTABLISHED] connect调用成功, 则进入ESTABLISHED状态, 开始读写数据; [ESTABLISHED -> FIN_WAIT_1] 客户端主动调用close时, 向服务器发送结束报文段, 同时进入FIN_WAIT_1; [FIN_WAIT_1 -> FIN_WAIT_2] 客户端收到服务器对结束报文段的确认, 则进入FIN_WAIT_2, 开始等待服务器的结束报文段; [FIN_WAIT_2 -> TIME_WAIT] 客户端收到服务器发来的结束报文段, 进入TIME_WAIT, 并发出LAST_ACK; [TIME_WAIT -> CLOSED] 客户端要等待一个2MSL(Max Segment Life, 报文最大生存时间)的时间, 才会进入CLOSED状态.


3. 提高性能

3.1 捎带应答

当服务端又在收数据又在发数据的时候,可以将这两个报文合并成一个,序号是自己报文的序号,确认序号是对方的序号 1,这样就实现了即是数据·,又是对历史数据的应答。所以确认序号和序号必须要分开

3.2滑动窗口

对每一个发送的数据段, 都要给一个ACK确认应答. 收到ACK后再发送下一个数据段.这样做有一个比较大的缺点, 就是性能较差. 尤其是数据往返的时间较长的时候。

既然这样一发一收的方式性能较低, 那么我们一次发送多条数据, 就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了).

上图的窗口大小就是4000个字节(四个段),窗口大小指的是无需等待确认应答而可以继续发送数据的最大值 .

发送前四个段的时候, 不需要等待任何ACK, 直接发送; 收到第一个ACK后, 滑动窗口向后移动, 继续发送第五个段的数据; 依次类推; 操作系统内核为了维护这个滑动窗口, 需要开辟 发送缓冲区 来记录当前还有哪些数据没有应答; 只有确认应答过的数据, 才能从缓冲区删掉; 窗口越大, 则网络的吞吐率就越高;

3.2.1 滑动窗口在哪里

滑动窗口其实就在发送缓冲区里面,是里面的一块区域,它将发送缓冲区分成了三个部分,已发送/可发送/未发送,其中可以直接发送的区域是滑动窗口的区域。

3.2.2 如何理解滑动窗口

首先我们要理解发送缓冲区,其实可以当成一个字符数组。

那么在数组中确定一段范围就可以使用下标, 所以窗口滑动就是下标移动。

那么滑动窗口的大小由谁来决定呢? 当然是由对方的接受能力来决定的,那么也就是对方的接收缓冲区的大小,我们拿到对方发送的报文中的16位窗口大小就能知道对方的接收缓冲区大小了。

滑动窗口是如何更新的呢? 首先我们可以将滑动窗口的起始位置看作win_start,结束位置是win_end,那么这段区间就是滑动窗口的大小,win_start就是我们收到的确认序号,也就是对方想让我们从哪段数据开始发,win_end就是win_start win,也就是起始位置加上整个窗口的大小

还有就是第一次窗口的大小应该是多少呢?其实双方在三次握手期间就已经进行过窗口大小的协商了。

滑动窗口的大小是不变的吗?会变大还是变小?

其实都是可以的,甚至滑动窗口的大小可能是0。如下图,当我们发送前1000个数据的时候,收到的ACK是1001,然后返回的缓冲区大小是3000,那么当对方的接收缓冲区的数据一直没有被取走的话,那么下一次的数据也会不断堆积,滑动窗口不断变小直到win_start=win_end,此时的滑动窗口大小是0.那么当对方接收缓冲区的数据被一次性取走的话,滑动窗口的大小又变成了4000.

3.2.2 快重传

接下来我们讨论一下数据丢包的问题,那么丢包分为3种情况:

1.前面报文丢失

当1000-2000的报文丢失时,后面的报文ACK返回的是1001这个确认序号,像这样连续收到3个相同确认序号并进行补发的动作称为快重传,因为不知道后面的报文是什么情况,只知道0-1000的报文都是完整的被收到了,然后客户端就会补发1001-2000的报文,并且补发完成之后直接返回5001的ACK就行了。

2.中间报文丢失

中间报文的丢失其实跟前面报文的丢失是等价的,因为也是前面报文已经确认。

3.后面报文丢失

那么最后面报文的丢失也是一样的。

快重传vs超时重传

快重传和超时重传是不一样的机制,超时重传是用来兜底的机制,因为快重传是有条件的,只有连续收到3个相同确认序号并进行补发的动作称为快重传。快重传是用来提高效率的。

3.3 延迟应答

如果接收数据的主机立刻返回ACK应答, 这时候返回的窗口可能比较小.

  • 假设接收端缓冲区为1M. 一次收到了500K的数据; 如果立刻应答, 返回的窗口就是500K;
  • 但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了;
  • 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;
  • 如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M;

一定要记得, 窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率;

那么所有的包都可以延迟应答么? 肯定也不是;

  • 数量限制: 每隔N个包就应答一次;
  • 时间限制: 超过最大延迟时间就应答一次;

具体的数量和超时时间, 依操作系统不同也有差异; 一般N取2, 超时时间取200ms;

4.面向字节流

创建一个TCP的socket, 同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;

  • 调用write时, 数据会先写入发送缓冲区中;
  • 如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
  • 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
  • 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
  • 然后应用程序可以调用read从接收缓冲区拿数据;
  • 另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做 全双工

由于缓冲区的存在, TCP程序的读和写不需要一一匹配, 例如

  • 写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节;
  • 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次read一个字节, 重复100次;

5.粘包问题

  • 首先要明确, 粘包问题中的 "包" , 是指的应用层的数据包.
  • 在TCP的协议头中, 没有如同UDP一样的 "报文长度" 这样的字段, 但是有一个序号这样的字段.站在传输层的角度, TCP是一个一个报文过来的. 按照序号排好序放在缓冲区中.
  • 站在应用层的角度, 看到的只是一串连续的字节数据.
  • 那么应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分, 是一个完整的应用层数据包.

那么如何避免粘包问题呢? 归根结底就是一句话, 明确两个包之间的边界.

  • 对于定长的包, 保证每次都按固定大小读取即可; 例如上面的Request结构, 是固定大小的, 那么就从缓冲区从头开始按sizeof(Request)依次读取即可;
  • 对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包的结束位置;
  • 对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议, 是程序猿自己来定的, 只要保证分隔符不和正文冲突即可);

思考: 对于UDP协议来说, 是否也存在 "粘包问题" 呢?

  • 对于UDP, 如果还没有上层交付数据, UDP的报文长度仍然在. 同时, UDP是一个一个把数据交付给应用层. 就有很明确的数据边界.
  • 站在应用层的站在应用层的角度, 使用UDP的时候, 要么收到完整的UDP报文, 要么不收. 不会出现"半个"的情况.

6.TCP异常情况

进程终止: 进程终止会释放文件描述符, 仍然可以发送FIN. 和正常关闭没有什么区别.机器重启: 和进程终止的情况相同. 机器掉电/网线断开: 接收端认为连接还在, 一旦接收端有写入操作, 接收端发现连接已经不在了, 就会进行reset. 即期询问对方是否还在 使没有写入操作 . 如果对方不在 , TCP自己也内置了一个保活定时器 , 也会把连接释放. 另外, 应用层的某些协议, 也有一些这样的检测机制. 例如HTTP长连接中, 也会定期检测对方的状态. 例如QQ, 在QQ断线之后, 也会定期尝试重新连接.

今天的分享到这里就结束了,感谢大家的阅读!

0 人点赞