【Linux网络】传输层协议TCP(4)拥塞控制、延迟应答

发布时间:2026/11/6 3:44:13
【Linux网络】传输层协议TCP(4)拥塞控制、延迟应答

hello~ 很高兴见到大家! 这次带来的是Linux网络中关于传输层协议TCP这部分的一些知识点,如果对你有所帮助的话,可否留下你宝贵的三连呢?
个 人 主 页: 默|笙


文章目录

  • 一、拥塞控制
    • 1.1 为什么要进行拥塞控制?
    • 1.2 慢启动与拥塞窗口
  • 二、延迟应答
  • 三、面向字节流与粘包问题
  • 四、TCP异常情况
  • 五、TCP小结
  • 六、UDP/TCP对比

一、拥塞控制

1.1 为什么要进行拥塞控制?

  1. 我们之前讲的 TCP 机制,比如确认应答、超时重传,主要关注的是发送端和接收端之间的可靠传输;但要真正完成通信,还有一个不可忽略的因素,那就是网络。拥塞控制正是围绕网络状态展开的。
  2. 在通信过程中,丢包是难以避免的。但丢多少算正常?可以打个比方:如果 500 个人参加考试,只有 2 个人没及格,我们通常认为这很正常;可如果 498 个人都没通过,我们就会怀疑试卷或改卷出了问题。丢包也是这样:一定范围内的丢包可以视为正常现象,但如果丢包明显增多,就可能说明网络出现了拥塞。拥塞严重时,数据会积压在网络中的各个路由器里,进而造成报文的超时。
  3. 网络拥塞之后,还要不要对超时数据进行重传?绝对不能像原来那样盲目、无节制地重传。因为网络不是只为某一台主机服务,如果每台主机都不断重传,网络里的报文只会越来越多,拥塞反而会更严重。所以 TCP 需要拥塞控制:在丢包或超时后降低发送速率,谨慎重传,避免把网络压垮。

1.2 慢启动与拥塞窗口

  1. 主机 A 发生拥塞时,会把拥塞窗口降得很小,比如超时后置为 1 个 MSS,然后开始慢启动:每收到一个确认,拥塞窗口就增加,效果是每个 RTT(往返时间)后 大致翻倍,比如 1、2、4、8 个 MSS 这样增长。如果没有收到应答,就超时重传;如果重传多次仍得不到应答,达到上限后才断开连接。那指数增长这么快,为什么叫“慢启动”?因为它的“慢”是指起点小、初始发送量少,而不是增长速度慢。并且拥塞控制不是一直指数增长,后面还会转为增长更慢的拥塞避免,之后会讲到。
  2. 为什么这种策略可以缓解拥塞?因为解决拥塞的关键是让网络中的报文减少。网络暂时排不出去,就要减少注入网络的新数据量,让已经积压的报文尽快被转发出去。就像蓄水池排水口暂时不能动,要尽快排干,就得关小进水口。拥塞时 TCP 降低发送速率、减小拥塞窗口,就是在“关小进水口”。当网络中的主机都采用拥塞控制时,就能较有效地缓解拥塞。注意:不是“重传”本身解决拥塞,盲目重传反而会增加报文;关键是降低发送速率、谨慎重传。
  3. 拥塞控制如何控制发送量?主要是通过调整拥塞窗口,进而影响发送窗口大小来实现的:滑动窗口大小 = min(对方接收能力rwnd,拥塞窗口大小cwnd)。
  4. 引入一个新概念叫拥塞窗口 cwnd。可以把它理解为一个表示“当前最多允许多少数据在途”的变量,通常以 MSS 为单位。发生严重拥塞(比如超时)时,cwnd 会被降为 1 个 MSS,然后随着收到确认逐步增大;但不是所有拥塞都一定置 1,比如快速重传/快速恢复时处理方式不同。

  1. 拥塞窗口在慢启动阶段起点小,但增长很快,每个 RTT 后大致翻倍;当达到慢启动阈值 ssthresh 后,进入拥塞避免阶段,增长变慢,通常每个 RTT 只增加 1 个 MSS,呈线性增长。所以是“慢启动起点小、增长快;拥塞避免增长慢”。
  2. 图中的 ssthresh 就是慢启动阈值。发生超时拥塞后,通常先把ssthresh 设为“发生拥塞时 cwnd 的一半”,再把 cwnd 减到 1 个 MSS,然后重新开始慢启动:cwnd 从 1 开始指数增长,到达新的 ssthresh(比如图中的 16)后,进入拥塞避免,从指数增长变成线性增长,直到下一次拥塞(24),下一次慢启动阈值就是 24 / 2 = 12。需要补充的是:不是所有拥塞都一定把 cwnd 置 1;如果是快速重传/快速恢复,处理方式不同,通常 ssthresh = cwnd/2,cwnd 降到 ssthresh 附近,而不是直接回到 1。
  3. 网络是否拥塞,主要取决于网络当前的状况,比如深夜上网的人少,网络往往比较好。但计算机怎么知道网络现在堵不堵?它只能通过发送数据、观察 ACK、丢包、RTT 等反馈,不断去“试探”网络状态。拥塞控制的本质,就是不断探测网络当前能承受多大的发送速率。所以通信过程中拥塞窗口是不断变化的,因为不同时刻的网络状况可能不同(发生堵塞的上限一定是变化的),需要持续调整,通信过程中它不断在探测。
  4. 拥塞控制归根结底就是:TCP 通过不断探测网络状态,动态调整发送速率,在尽量不压垮网络的前提下,尽可能高效地完成通信。

二、延迟应答

  1. 如果接收方每收到一个报文就立刻应答,那么返回的 16 位窗口大小可能比较小;但如果能等待一会儿,应用层就很可能把接收缓冲区里的数据读走,接收缓冲区空出更多空间,这时发送给对方的报文里通告的窗口大小就可能更大。这样既能减少 ACK 报文的数量,也有助于避免因窗口过小而频繁通告窗口更新。

假设接收端的缓冲区大小是1M,当一次收到了500K的数据时,如果立刻应答,返回的通告窗口大小就是剩余的500K;但实际上,应用层的处理速度可能很快,在10ms之内就把这500K数据从缓冲区里消费掉了。在这种情况下,接收端的处理能力远没有达到极限,即使通告的窗口再放大一些也能处理过来;因此,如果接收端稍微等一会再应答(比如采用延迟应答,等待200ms后再发ACK),这个时候返回的窗口大小就可以是完整的1M了。

  1. 延迟应答主要通过两种方式实现:一种是基于时间,超过最大延迟时间就应答一次;另一种是基于数量,每收到 N 个报文段就应答一次。时间一般取 200 ms,N 通常取 2。不过这不是绝对的,遇到乱序报文、紧急情况或接收缓冲区压力大时,通常要及时应答。

三、面向字节流与粘包问题

  1. 创建一个 TCP 的 socket,会在内核中同时创建一个发送缓冲区和一个接收缓冲区(全双工)。调用 write 时,数据会先拷贝进入发送缓冲区:如果数据太长,就会被拆成多个 TCP 报文段发出;如果发送的数据太短,发送方可能会开启 Nagle 算法,让小数据在缓冲区里等一等,或者等凑够了足够长度、或者等上一个报文的 ACK 回来后,再一并发出。接收数据的时候,也是从网卡驱动程序将数据读到接收缓冲区,之后由上层应用调用 read 来拷贝读取。
  2. TCP 发送数据是面向字节流的,写入和读取数据的量级都有我们自己来定,写入数据可以 10 字节一写,也可以 4 字节一写,不像 UDP 那样,必须严格保持应用层写一次就是一个完整的报文。面向字节流更像是取水问题:我们得到了一吨的水,如何取水、每次取多少,完全由我们自己来定。TCP 的读和写不需要一一对应(应用层发几次,接收方不一定读几次)。
  3. 因为 TCP 是面向字节流的,它本身不保留应用层的消息边界,所以接收端在读取时,就极有可能出现“粘包”或“拆包”问题(即几个包粘在一起,或者一个包被拆开读)。为了避免这种问题,就必须在应用层做好规定,明确好两个包之间的边界。而 UDP 是面向报文的,底层本身就保留了消息边界,发送端发几个包,接收端就按几个包收,不需要应用层再去切分。在应用层,常见的划界方式除了通过特殊符号分隔(比如 HTTP 报文用 \r\n 分隔,或者一些协议用特定字符结束),还有定长划分,以及最常用的“长度前缀法”(比如在包头写明报文总长度,或者像 HTTP 里的 Content-Length 字段)。

四、TCP异常情况

  1. 进程终止的时候连接会怎样?进程终止时,系统会回收它打开的文件描述符,而网络 socket 本身也是一种特殊的文件;但 socket 是否真正关闭取决于引用计数,只有最后一个引用关闭时才会真正关闭连接。关闭时通常会触发 TCP 的 FIN 关闭流程,但不保证一定是完整的“四次挥手”:FIN 和 ACK 可能合并,异常终止、缓冲区有未读数据或 SO_LINGER 设置等情况下也可能以 RST 结束。
  2. 关机重启会怎样?正常关机重启时,系统会逐个关闭当前打开的进程,之后大体就是进程终止后的情况。
  3. 如果客户端正常跟服务端通信的时候,直接把客户端网线拔了会怎么样?客户端系统暂时还认为连接在,但数据发不出去,会重传直到超时,超时之后本端会中止这条连接并向应用报错,而服务端则认为连接还在,反过来也是一样,只不过前者一般不会造成什么问题;而如果拔掉服务端网卡,服务端这边的连接状态通常还保留着,但网卡断了,数据发不出去,最终也会超时并中止连接,但是客户端认为连接还在,还会发数据,就会造成数据丢失,等网络恢复后,收到客户端的数据,它只会将 RST 置为 1,要求重新连接。
  4. 为了尽快发现上述连接异常,TCP 里面有一个保活定时器(TCP Keepalive),连接空闲到设定时间后,本端会向对方发送探测报文;如果对方主机还活着且这条连接仍然有效,通常会返回 ACK,收到应答后计时器刷新;如果连续多次探测都没有响应,本端就会关闭连接。若对方主机活着但连接已不存在,也可能返回 RST,同样会让本端关闭连接。不过 TCP Keepalive 默认空闲时间通常很长,而且只能判断 TCP 层连接是否存活,不能判断应用是否僵死,所以一般不会只依赖它,而是用应用层实现的心跳机制对服务器进行保活:定期发送心跳包,收到应答就刷新保活计时,超过阈值无应答就主动关闭并重连。

五、TCP小结

  1. 为什么TCP这么复杂,因为要保证可靠性,同时又要尽可能的提高性能。
  2. 可靠性:校验和、序列号(按序到达和去重)、确认序号、超时重传、连接管理、流量控制和拥塞管理。
  3. 提高性能:滑动窗口、快速重传、延迟应答、捎带应答。
  4. 其他:定时器(超时重传定时器, 保活定时器, TIME_WAIT定时器等)。

六、UDP/TCP对比

  1. UDP 和 TCP 并不存在绝对的好坏。不能把 UDP 的“不保证可靠交付”简单看作短处,而应看作它的一种设计特点。这两种通信方式都有适合自己的应用场景。

  2. TCP 面向可靠传输,适用于文件传输、重要状态更新等对数据完整性和顺序性要求较高的场景。UDP 则适用于对高速传输和实时性要求较高的通信领域,例如早期的 QQ、视频传输等。此外,UDP 还可以用于广播。

  3. UDP 本身不提供可靠传输。若要在 UDP 上实现可靠传输,需要根据具体场景,在应用层或用户态协议栈中选择性实现 TCP 中的可靠性机制,例如确认应答、超时重传、序号与去重排序、滑动窗口、流量控制和拥塞控制等。实时音视频等场景还可以采用部分可靠、FEC 和选择性重传等策略。实际中也可以直接考虑 QUIC、KCP 等成熟方案。


今天的分享就到此结束啦,如果对读者朋友们有所帮助的话,可否留下宝贵的三连呢~~
让我们共同努力, 一起走下去!