TCP传输机制探索:滑动窗口,流量控制、拥塞管理、快重传、延迟应答详解
目录
- `滑动窗口`
- `问题:为什么在网络通信的时候,上面滑动窗口每一次发送都是1000字节为单位的呢?`
- `流量控制`
- `十六位窗口`
- 拥塞控制
- `延迟应答`
- `那么所有的包都可以延迟应答么? `
- `面向字节流`
- `粘包问题`
- `那么如何避免粘包问题呢? 归根结底就是一句话, 明确两个包之间的边界.`
- `思考: 对于 UDP 协议来说, 是否也存在 "粘包问题" 呢?`
- `TCP 异常情况`
- `TCP 小结`
- `基于 TCP 应用层协议`
- `TCP/UDP 对比`
- `用 UDP 实现可靠传输(经典面试题)`
滑动窗口
我们讨论了确认应答策略, 对每一个发送的数据段, 都要给一个 ACK 确认应答. 收到 ACK 后再发送下一个数据段. 这样做有一个比较大的缺点, 就是性能较差. 尤其是数据往返的时间较长的时候.
- 既然这样一发一收的方式性能较低, 那么我们一次发送多条数据, 就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了).但是一次发的数据太多会引发问题,所以就有一个发送的数据的最大值,也就是滑动窗口的大小最大值。
滑动窗口
是维护在发送端的缓冲区内部的
,也就是两个下标,标记窗口的起始和结束的下标。滑动窗口的大小是变化的,滑动窗口的大小=min(对方缓冲区的剩余大小,拥塞窗口大小)
。- 滑动窗口的
start_win = 对方应答的确认序号
; 滑动窗口的end_win =start_win + min(对方缓冲区的剩余大小也就是应答报文里面的窗口大小),拥塞窗口大小)
;
其中,滑动窗口的是可以变大变小的,实际就是起始和结束下标的变化。
- 滑动窗口只能右滑,不能左滑。
- 滑动窗口左边为已经发送了的数据,对方已经确认接收到的数据,这个部分上层可以直接覆盖放新的数据了,窗口内的数据是可发送的数据,窗口右边的数据是待发送的数据。
- 滑动窗口向右滑动本质是将已经发送并且已经确认对方已经收到的数据纳入左边,将待发送数据纳入窗口内。
- 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值. 也就是如果没有超过最大值,就可以直接发送数据,不需要等待应答。上图的窗口大小就是 4000 个字节(四个段).
- 发送前四个段的时候, 不需要等待任何 ACK, 直接发送;
- 收到第一个 ACK 后, 滑动窗口向后移动, 继续发送第五个段的数据; 依次类推;
操作系统内核
为了维护这个滑动窗口, 需要开辟发送缓冲区来记录当前还有哪些数据没有应答; 只有确认应答过的数据, 才能从缓冲区删掉;- 窗口越大, 则网络的吞吐率就越高;
那么如果出现了丢包, 如何进行重传? 这里分两种情况讨论.
情况一: 数据包已经抵达, ACK 被丢了.
这种情况下, 部分 ACK 丢了并不要紧, 因为可以通过后续的 ACK 进行确认;比如1001的确认应答的ACK丢失,可以根据2001的确认应答ACK表明1001前面的数据已经接收到了。
情况二: 数据包就直接丢了.
- 当某一段报文段丢失之后, 发送端会一直收到 1001 这样的 ACK, 就像是在提醒发送端 “我想要的是 1001” 一样;
- 如果发送端主机连续三次收到了同样一个 “1001” 这样的应答, 就会将对应的数据 1001 - 2000 重新发送;
- 这个时候接收端收到了 1001 之后, 再次返回的 ACK 就是 7001 了(因为 2001 - 7000)接收端其实之前就已经收到了, 被放到了接收端操作系统内核的接收缓冲区中;这种机制被称为
"高速重发控制"(也叫 "快重传").
如果没有收到连续三次同样的确认应答,就会引发超时重传。
问题:为什么在网络通信的时候,上面滑动窗口每一次发送都是1000字节为单位的呢?
- TCP/UDP是在传输层的, 但是发送的数据会经过链路层,但是在链路层规定,发送的单个数据帧总大小不能超过1500字节。
流量控制
接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送, 就会造成丢包, 继而引起丢包重传等等一系列连锁反应. 也可以解决因为发送太慢导致效率低下的问题,因此 TCP 支持根据接收端的处理能力, 来决定发送端的发送速度
. 这个机制就叫做流量控制(Flow Control);
- 接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段, 通过 ACK 端通知发送端;
- 窗口大小字段越大, 说明网络的吞吐量越高;
- 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;
- 发送端接受到这个窗口之后, 就会减慢自己的发送速度;
如果接收端缓冲区满了, 就会将窗口置为 0
; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端.如果接收端的缓冲区有空间接收数据,也会给发送端发通知(告知服务端端可以接收数据了)。
十六位窗口
在 TCP 协议中,接收端通过 TCP 首部的 16 位窗口字段 来告知发送端当前的窗口大小。然而,这个 16 位窗口字段 最大只能表示 65535(即 2^16 - 1)
,这在许多高速网络环境下可能显得不足。
为了解决这个问题,TCP 协议引入了窗口扩大选项
(Window Scale Option),这个选项允许接收端通过一个扩大因子(Scale Factor, 通常表示为 M)来动态地增加窗口大小。这个选项是 TCP 选项字段(TCP Options Field)的一部分,它通常位于 TCP 首部的 40 字节选项区域中。
具体来说,实际窗口大小的计算方法如下:
-
窗口字段的值(Wnd):这是 TCP 首部中 16 位窗口字段 的值。
-
窗口扩大因子(M):这是接收端通过 TCP 窗口扩大选项通知发送端的值。
-
实际窗口大小(Window Size)计算为:
textWindowSize=Wnd×2^M
例如,如果 Wnd 的值是 12345,而 M 的值是 2,那么实际窗口大小就是:
textWindowSize=12345×2^2 =12345×4=49380 字节
拥塞控制
虽然 TCP 有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题.
- 因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据, 是很有可能引起雪上加霜的.
TCP 引入 慢启动 机制
, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据;
- 此处引入一个概念称为
拥塞窗口
; - 发送开始的时候, 定义拥塞窗口大小为 1;
- 每次收到一个 ACK 应答, 拥塞窗口加 1;
每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口
;
像上面这样的拥塞窗口增长速度, 是指数级别的. “慢启动” 只是指初使时慢, 但是增长速度非常快.
- 为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍.
- 此处引入一个叫做
慢启动的阈值
(阈值在系统中是有一个最大值的) - 当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长
- 当 TCP 开始启动的时候,
慢启动阈值等于窗口最大值
; - 在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回 1;
少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;
当 TCP 通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降;
拥塞控制, 归根结底是 TCP 协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案.
延迟应答
如果接收数据的主机立刻返回 ACK 应答, 这时候返回的窗口可能比较小.
-
假设接收端缓冲区为 1M. 一次收到了 500K 的数据; 如果立刻应答, 返回的窗口就是 500K;
-
但实际上可能处理端处理的速度很快, 10ms 之内就把 500K 数据从缓冲区消费掉了;
-
在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;
如果接收端稍微等一会再应答, 比如等待 200ms 再应答, 那么这个时候返回的窗口大小就是 1M;
一定要记得, 窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率;
那么所有的包都可以延迟应答么?
肯定也不是;
- 数量限制: 每隔 N 个包就应答一次;
- 时间限制: 超过最大延迟时间就应答一次;
具体的数量和超时时间, 依操作系统不同也有差异; 一般 N 取 2, 超时时间取 200ms;
面向字节流
创建一个 TCP 的 socket, 同时在内核中创建一个 发送缓冲区
和一个 接收缓冲区
;
- 调用 write 时, 数据会先写入发送缓冲区中;
- 如果发送的字节数太长, 会被拆分成多个 TCP 的数据包发出;
- 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
- 然后应用程序可以调用 read 从接收缓冲区拿数据;
- 另一方面, TCP 的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做
全双工
由于缓冲区的存在, TCP 程序的读和写不需要一一匹配, 例如:
- 写 100 个字节数据时, 可以调用一次 write 写 100 个字节, 也可以调用 100 次write, 每次写一个字节;
- 读 100 个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100 个字节, 也可以一次 read 一个字节, 重复 100 次;
粘包问题
- 首先要明确,
粘包问题中的 "包" , 是指的应用层的数据包.
- 在 TCP 的协议头中, 没有如同 UDP 一样的 “报文长度” 这样的字段, 但是有一个序号这样的字段.
- 站在传输层的角度, TCP 是一个一个报文过来的. 按照序号排好序放在缓冲区中.
- 站在应用层的角度, 看到的只是一串连续的字节数据.
- 那么应用程序看到了这么一连串的字节数据, 就不知道从哪个部分开始到哪个部分, 是一个完整的应用层数据包.
那么如何避免粘包问题呢? 归根结底就是一句话, 明确两个包之间的边界.
- 对于定长的包, 保证每次都按固定大小读取即可; 例如上面的 Request 结构, 是固定大小的, 那么就从缓冲区从头开始按 sizeof(Request)依次读取即可;
- 对于变长的包, 可以在包头的位置, 约定一个包总长度的字段, 从而就知道了包的结束位置;
- 对于变长的包, 还可以在包和包之间使用明确的分隔符(应用层协议, 是程序猿自己来定的, 只要保证分隔符不和正文冲突即可,如\r\n);
思考: 对于 UDP 协议来说, 是否也存在 "粘包问题" 呢?
- 对于 UDP, 如果还没有上层交付数据, UDP 的报文长度仍然在. 同时, UDP 是一个一个把数据交付给应用层. 就有很明确的数据边界.
- 站在应用层的站在应用层的角度, 使用 UDP 的时候, 要么收到完整的 UDP 报文, 要么不收. 不会出现"半个"的情况
TCP 异常情况
进程终止:
进程终止会释放文件描述符, 仍然可以发送 FIN. 和正常关闭没有什么区别.
机器重启:
和进程终止的情况相同.
机器掉电/网线断开:
接收端认为连接还在, 一旦接收端有写入操作, 接收端发现连接已经不在了, 就会进行 reset. 即使没有写入操作, TCP 自己也内置了一个保活定时器, 会定期询问对方是否还在. 如果对方不在, 也会把连接释放.
另外, 应用层的某些协议, 也有一些这样的检测机制. 例如 HTTP 长连接中, 也会定期检测对方的状态. 例如 QQ, 在 QQ 断线之后, 也会定期尝试重新连接.
TCP 小结
为什么 TCP 这么复杂?
- 因为要保证可靠性, 同时又尽可能的提高性能.
可靠性:
- 校验和
- 序列号(按序到达,去重)
- 确认应答
- 超时重发
- 连接管理
- 流量控制
- 拥塞控制
提高性能:
- 滑动窗口
- 快速重传
- 延迟应答
- 捎带应答
其他:
- 定时器(超时重传定时器, 保活定时器, TIME_WAIT 定时器等)
基于 TCP 应用层协议
- HTTP
- HTTPS
- SSH
- Telnet
- FTP
- SMTP
当然, 也包括你自己写 TCP 程序时自定义的应用层协议;
TCP/UDP 对比
我们说了 TCP 是可靠连接, 那么是不是 TCP 一定就优于 UDP 呢? TCP 和 UDP 之间的优点和缺点, 不能简单, 绝对的进行比较
- TCP 用于可靠传输的情况, 应用于文件传输, 重要状态更新等场景;
- UDP 用于对高速传输和实时性要求较高的通信领域, 例如, 早期的 QQ, 视频传输等. 另外 UDP 可以用于广播;
归根结底, TCP 和 UDP 都是程序员的工具, 什么时机用, 具体怎么用, 还是要根据具体的需求场景去判定.
用 UDP 实现可靠传输(经典面试题)
参考 TCP 的可靠性机制, 在应用层实现类似的逻辑;
例如:
- 引入序列号, 保证数据顺序;
- 引入确认应答, 确保对端收到了数据;
- 引入超时重传, 如果隔一段时间没有应答, 就重发数据;
- …
-
应用层确认机制
在应用层上,可以实现自定义的确认机制。发送方在发送数据后等待接收方的确认消息,如果在一定时间内未收到确认,则重新发送数据。这样可以确保数据的可靠传输。这种机制类似于TCP中的确认和重传机制,但需要在应用层自行实现。 -
数据校验和重传
在UDP数据包中添加校验和字段,接收方在接收数据时计算校验和并与发送方的校验和进行比较。如果不匹配,则要求发送方重新发送数据。这种机制可以确保数据的完整性,避免在传输过程中发生错误。 -
序列号和确认号
类似于TCP协议的序列号和确认号机制,发送方给每个数据包分配一个唯一的序列号,接收方收到数据后发送确认消息,并在其中包含确认号。发送方根据确认号判断哪些数据包已经被成功接收,可以进行相应的重传。这种机制可以确保数据包按顺序被接收和处理。 -
超时重传
发送方可以设置一个超时计时器,如果在指定时间内未收到确认消息,则认为数据丢失,触发重传操作。这种机制可以确保在数据传输过程中即使发生丢失也能及时恢复。 -
流量控制和拥塞控制
通过控制发送数据的速率和接收数据的处理速度,可以避免网络拥塞和数据丢失。这可以通过动态调整发送速率、使用滑动窗口等方法来实现。流量控制机制会根据接收端的接收能力来调整发送速率,避免数据堆积和丢失。
综上所述,通过应用层确认机制、数据校验和重传、序列号和确认号、超时重传、流量控制和拥塞控制等方法,可以在UDP的基础上实现可靠传输。