💡Yupureki:个人主页

✨个人专栏:《C++》 《算法》《Linux系统编程》《高并发内存池》《MySQL数据库》

《个人在线OJ平台》《Linux网络编程》《CMake自动化构建工具》《Redis数据库》


🌸Yupureki🌸的简介:


目录

1. TCP报头格式

2. TCP连接的建立

第一次握手:客户端 → 服务器 SYN

第二次握手:服务器 → 客户端 SYN + ACK

第三次握手:客户端 → 服务器 ACK

3. TCP连接的关闭

第一次挥手:主动关闭方 → 被动关闭方 FIN

第二次挥手:被动关闭方 → 主动关闭方 ACK

第三次挥手:被动关闭方 → 主动关闭方 FIN

第四次挥手:主动关闭方 → 被动关闭方 ACK

核心状态详解与资源管理

4. TCP三大核心机制

4.1 确认应答机制

4.1.1 累积确认

4.1.2 确认号字段与 ACK 标志

4.1.3 延迟确认与捎带确认

4.2 超时重传机制

4.2.1 返时间测量与 RTO 计算

4.2.2 超时后的重传与退避

4.2.3 快速重传(不等待超时)

4.3 连接管理机制

4.3.1 连接建立:三次握手

4.3.2 连接释放:四次挥手与 TIME_WAIT

5. TCP辅助优化机制

5.1 滑动窗口

5.1.1 窗口的组成

5.1.2 窗口的滑动

5.1.3 窗口大小

5.2 流量控制

5.2.1 接收窗口 (rwnd) 的通告

5.2.2 零窗口

5.3 拥塞控制

5.3.1 慢启动

5.3.2 拥塞避免 

5.3.3 拥塞检测与响应

5.3.4 现代拥塞控制算法

5.4 延迟应答

5.5 捎带应答

5.6 5种机制的协同工作

6. TCP的其他问题

6.1 面向字节流

6.2 粘包问题

6.3 TCP 通信中的异常情况

6.3.1 连接异常断开与 RST 报文

6.3.2 网络中断与超时

6.3.3 半开连接与半关闭

6.3.4 数据传输中的异常:乱序、重复与校验和错误

6.3.5 连接数限制与资源耗尽(环境异常)

7. 总结

7.1 UDP 与 TCP 的核心区别

7.2 TCP 如何实现可靠性?

7.3 用 UDP 模拟实现 TCP 的可靠性


1. TCP报头格式

在详细理解TCP的底层机制前,我们需要先认识TCP的报头格式

  • 源端口(16 位)
    发送方应用程序的端口号。

  • 目的端口(16 位)
    接收方应用程序的端口号。

  • 序列号(32 位)
    本报文段所发数据的第一个字节的序号。在建立连接(SYN)时,这是初始序列号(ISN)。

  • 确认号(32 位)
    当 ACK 标志为 1 时有效,表示期望收到的下一个字节的序列号,即对之前已收到数据的确认。

  • 数据偏移(4 位)
    TCP 首部的长度,单位为 4 字节。最小值 5(5×4=20 字节),最大值 15(15×4=60 字节)。这个字段也决定选项的长度。

  • 保留(4 位)
    目前保留不用,应置 0。早期标准中保留字段为 6 位,后因 ECN 等扩展,现在保留位为 4 位。

  • 标志位(8 位,从左到右)

    • CWR – 拥塞窗口减小(Congestion Window Reduced)

    • ECE – ECN 回显(ECN-Echo)

    • URG – 紧急指针有效

    • ACK – 确认号有效

    • PSH – 接收方应尽快将数据交给应用层

    • RST – 重置连接

    • SYN – 同步序列号(用于建立连接)

    • FIN – 发送方数据发送完毕,请求释放连接

  • 窗口大小(16 位)
    接收窗口的大小,以字节为单位。从确认号位置开始,告诉对方自己还能接收多少数据,用于流量控制。

  • 校验和(16 位)
    对 伪首部 + TCP 头部 + 数据 进行校验。伪首部包含源/目的 IP 地址、协议号(TCP 为 6)和 TCP 段总长度。

  • 紧急指针(16 位)
    当 URG=1 时,表示本报文段中紧急数据的最后一个字节的偏移量(相对于序列号)。

  • 选项(可变长,最长 40 字节)
    只有数据偏移 > 5 时才存在。常见选项:

    • MSS(最大报文段长度)

    • 窗口缩放因子(Window Scale)

    • 时间戳(Timestamps)

    • SACK(选择性确认)
      选项末尾会填充全 0 的字节,使整个 TCP 首部对齐到 32 位边界。

  • 数据部分
    紧随首部,长度由 IP 头中的总长度减去 IP 首部长度和 TCP 首部长度得到。

2. TCP连接的建立

TCP 建立连接的过程叫做握手,握手需要在客户和服务器之间交换三个TCP 报文段,称之为三报文握手,采用三报文握手主要是为了防止已失效的连接请求报文段突然又传送到了,因而产生错误。

TCP的连接建立要解决以下三个问题:

  • 1、使TCP双方能够确知对方的存在 。
  • 2、使TCP双方能够协商一些参数
  • 3、使TCP双方能够对运输实体资源(例如缓存大小连接表中的项目等)进行分配。

假设客户端主动打开,服务器被动监听在某个端口,初始状态分别为 CLOSED 和 LISTEN

第一次握手:客户端 → 服务器 SYN

  • 报文标志位SYN=1ACK=0

  • 序列号:客户端选择一个随机的 32 位初始序列号 seq = x(称为 ISN)。

  • 报文可携带数据吗不能SYN 报文不包含应用数据,但会占用一个序列号。

  • 可携带选项:通常会附带 TCP 选项,如 MSS(最大报文段大小)、窗口缩放因子、时间戳、是否支持 SACK 等。

  • 客户端状态变迁:由 CLOSED 进入 SYN_SENT

这个动作相当于客户端说:“我想和你同步,我的起始编号是 x。”

第二次握手:服务器 → 客户端 SYN + ACK

  • 报文标志位SYN=1ACK=1

  • 序列号:服务器同样选择一个随机的初始序列号 seq = y

  • 确认号ack = x + 1,表示“你发的 x 我已经收到了,期待你从 x+1 开始发送”。

  • 选项协商:服务器在自己能力范围内,对客户端提议的选项进行确认。例如,客户端提议 MSS=1460,服务器可能回复 MSS=1400;若双方都支持窗口缩放,则在响应中附上自己的缩放因子。

  • 服务器状态变迁:由 LISTEN 进入 SYN_RCVD

服务器相当于回复:“收到,我的起始编号是 y,确认你的编号 x。”

第三次握手:客户端 → 服务器 ACK

  • 报文标志位ACK=1SYN 位此时为 0)。

  • 序列号seq = x + 1(因为 x 被 SYN 使用,后续数据从 x+1 开始)。

  • 确认号ack = y + 1,确认服务器的 SYN

  • 此报文可以携带数据吗可以。因为它不是 SYN 报文,序列号空间已建立。不过多数应用层协议会在 accept 返回后让应用显式发送,所以第三次握手常是纯 ACK。如果携带数据,接收端会让其先在接收缓冲区等待,直到连接进入 ESTABLISHED 后再交付给应用。

  • 状态变迁

    • 客户端在发出这个 ACK 后,立即进入 ESTABLISHED 状态。

    • 服务器收到这个 ACK 后,也进入 ESTABLISHED 状态。

连接正式建立,双方可以开始双向数据传输。

为什么偏偏是三次?

其实核心在于:通信双方需要可靠地交换彼此的初始序列号并确认对方确实准备好了

  • 防止旧的重复连接请求造成混乱:这是最根本的原因。试想,如果只有两次握手,客户端发出一个积压在网络中的旧 SYN,服务器误以为是新连接,回复 SYN+ACK 后就直接进入 ESTABLISHED,等待接收数据。但客户端已经不要这个连接了,这样服务器会白白空等,浪费资源。引入第三次握手后,客户端通过最后一个 ACK 明确告知服务器“我确认要建立这次连接”,旧的 SYN 就不会导致服务器错乱。

  • 确认双方的收发能力

    • 第一次握手:服务器能确认 客户端的发送能力 和 服务器的接收能力 正常。

    • 第二次握手:客户端能确认 服务器的接收和发送能力 均正常,同时也确认自己的发送和接收正常。

    • 第三次握手:服务器收到 ACK,才最终确认 客户端的接收能力 也是正常的。
      三次握手一结束,双方都百分百确信双向通道是通的。

  • 可靠地同步初始序列号:这是为了防止因序列号“猜测”而造成的安全隐患(TCP 劫持),也与避免新旧数据混淆有关。两次握手无法让服务器确认客户端确实收到了自己的 SYN 和初始序列号。

3. TCP连接的关闭

四次挥手是 TCP 释放连接的过程,它比三次握手更复杂,因为 TCP 连接是全双工的,每个方向都必须单独关闭。理解四次挥手,就是理解“优雅地结束一段双向通信”这件事在不可靠网络上如何被安全实现。

假设客户端主动关闭,服务器被动关闭。初始状态均为 ESTABLISHED

第一次挥手:主动关闭方 → 被动关闭方 FIN

  • 报文标志位FIN=1ACK 可能根据之前的数据流未置或同时置1(通常有 ACK)。

  • 序列号seq = u,此处的 u 是主动关闭方之前发送的最后数据字节序号+1。

  • 确认号:如果有 ACK,则确认对端的数据。

  • 含义:“我的数据已经发完了,我要关闭我这个方向的连接了,不会再发送数据了,但我还可以接收数据。”

  • 状态变迁:主动关闭方由 ESTABLISHED 进入 FIN_WAIT_1

FIN 段也占用一个序列号(u),和 SYN 一样,即使不承载数据。

第二次挥手:被动关闭方 → 主动关闭方 ACK

  • 报文标志位ACK=1

  • 序列号seq = v,被动关闭方的当前序列号。

  • 确认号ack = u + 1,表示已收到 FIN 段。

  • 含义:“知道你要关了,我确认收到了你的关闭请求。”

  • 状态变迁

    • 被动关闭方由 ESTABLISHED 进入 CLOSE_WAIT

    • 主动关闭方收到这个 ACK 后,由 FIN_WAIT_1 进入 FIN_WAIT_2

此时,主动关闭方到被动关闭方这个方向已经关闭。但被动关闭方到主动关闭方的方向依然开放,被动关闭方可能还有剩余数据需要发送。应用层通常会在此时收到一个 read 返回 0(EOF),但 write 依然可以使用。

第三次挥手:被动关闭方 → 主动关闭方 FIN

  • 触发时机:当被动关闭方的应用层决定关闭连接(调用 close/shutdown),或者应用已经退出,由内核代为发送。

  • 报文标志位FIN=1, ACK=1

  • 序列号seq = w,如果之前发送了剩余数据,w 可能大于 v

  • 确认号ack 可能仍是对上次收到数据的确认,通常仍是 u+1(因为主动关闭方说过不发数据了)。

  • 含义:“我这边的数据也发完了,我也要关闭了。”

  • 状态变迁

    • 被动关闭方由 CLOSE_WAIT 进入 LAST_ACK

    • 主动关闭方收到这个 FIN 后,由 FIN_WAIT_2 进入 TIME_WAIT

第四次挥手:主动关闭方 → 被动关闭方 ACK

  • 报文标志位ACK=1

  • 序列号seq = u+1(确认号不变,自己的序列号还是上次位置)

  • 确认号ack = w + 1,确认收到了被动关闭方的 FIN

  • 含义:“知道你也关了,我确认。”

  • 状态变迁

    • 主动关闭方在发出此 ACK 后,立即启动 TIME_WAIT 定时器,开始等待 2MSL。

    • 被动关闭方收到此 ACK 后,进入 CLOSED 状态,连接彻底释放。

为什么是四次而不是三次?

本质原因是:TCP 是全双工通信,且应用层数据并非同时结束。

  • 被动关闭方收到 FIN 后,只意味着对端没数据要发了,但自己的应用可能还没通知自己关闭,可能还有未发送完的响应数据要处理。

  • 因此被动方需要两步:

    1. 发送 ACK 告知“我收到了你的关闭请求”。(第二次)

    2. 等待自己应用决定关闭后,再发 FIN 告知“我这边也可以关了”。(第三次)

  • 这两步无法合并,因为被动方可能在发送 ACK 后还需要一段时间继续发送数据。所以必须用四个报文段才能完成全双工的关闭。

如果被动方在收到 FIN 时恰好也马上要关闭,没有数据要发,内核有时会采用“三次挥手”的优化:将第二次的 ACK 和第三次的 FIN 合并为一个 FIN+ACK 报文发出。但协议设计上将它们视为独立步骤。

核心状态详解与资源管理

CLOSE_WAIT(被动关闭方)

  • 此状态的应用层必须显式调用 close() 才会进入 LAST_ACK,从而发出 FIN

  • 常见问题:如果应用逻辑没有正确处理对端关闭事件(即未在 read 返回 0 后关闭连接),连接会长时间停留在 CLOSE_WAIT,造成资源泄漏(fd 未释放)

FIN_WAIT_2(主动关闭方)

  • 连接已半关闭,本端等待对端发送 FIN

  • 风险:如果对端一直不发 FIN,本端会永远停滞在这个状态。为防止这种情况,Linux 有 tcp_fin_timeout 参数(默认 60 秒),如果在指定时间内未收到 FIN,连接会直接强制关闭。

TIME_WAIT(主动关闭方)—— 最精心设计的状态

这是四次挥手中最关键、最容易产生疑问的状态。

为什么要有 TIME_WAIT?

  • 原因一:保证最后的 ACK 能被对端收到,实现可靠的全双工关闭。
    如果主动关闭方发送的第四次 ACK 丢失了,被动关闭方(处于 LAST_ACK)会因为超时重发它的 FIN。这时主动关闭方必须还“记得”这个连接,才能重发 ACK。TIME_WAIT 维持了足够长的连接状态来重发这个可能丢失的 ACK

  • 原因二:让所有属于这个连接的陈旧报文在网络中彻底消失,避免干扰后续新连接。
    假设连接关闭后立刻在相同 IP:Port 之间建立新连接。如果一个旧的数据段被网络延迟,恰好在新连接建立后到达,如果它们的序列号范围重叠,新连接就会收到错误数据。等待 2MSL 可以确保双向所有残存报文都因 TTL 耗尽而被丢弃。

为什么是 2MSL?
MSL(最大报文段生存时间)是 IP 包在网络中的最大存活时间,通常为 30 秒、1 分钟或 2 分钟(Linux 下 MSL 写死为 30 秒,所以 TIME_WAIT 默认 60 秒)。等待 2 倍 MSL,确保:你发出的最后一个 ACK 可能让对方重传 FIN(最多一个 MSL 到达对方,对方再发来的 FIN 也最多一个 MSL 到达你),这样双向所有包都能消亡。

TIME_WAIT 的影响与优化

  • 对于主动关闭方(一般都是客户端),每个短连接都会残留一个 TIME_WAIT 状态的内核控制块,占用少量内存,但最大的问题是端口占用

  • 如果客户端频繁建立和断开连接到同一服务器,本地端口会很快耗尽(因为此时不能以相同五元组新建连接)。

  • 解决方法:

    • SO_REUSEADDR:允许绑定刚刚释放但还在 TIME_WAIT 状态的本地地址(仅限 TCP)。

    • tcp_tw_reuse(需谨慎):允许将处于 TIME_WAIT 状态的连接用于新的外出连接(客户端)。

    • 使用长连接连接池,从根本上避免频繁握手挥手。

    • 如果是服务器,TIME_WAIT 出现在主动关闭方(比如服务器因为超时主动踢连接),大量 TIME_WAIT 会影响服务器端口复用吗?服务器端口是固定的,时间等待状态是以(IP,Port)对存在的,只要请求的来源 IP 不同,不会影响接受新连接。

4. TCP三大核心机制

TCP 的可靠性大厦主要由三根支柱撑起:确认应答超时重传连接管理。它们环环相扣,让数据在一个不可靠的 IP 网络上能够无差错、不丢不乱地传输。

4.1 确认应答机制

确认应答是 TCP 最核心的反馈机制,发送方据此知道接收方“拿到了什么”。

如同老师在群里发送通知,有时候学生需要在群里回复:"收到",即反馈给老师,知道自己收到信息了。

4.1.1 累积确认

TCP 的确认是累积的。

有时候,发送端发送一条消息,而接受端不一定会立即收到,可能在发送端发送了好几条才收到了,因此TCP发送的消息在对端是累积的

那我发送端假设发送了很多条数据,中间有一条丢失了,发送端如何知道自己发送的消息丢了?

答:接收端返回的报文中,会表示:在最先丢的那条数据前面的所有的数据,都接受到了

即报文头中的 确认序列号 表示:小于该序号的所有字节都已连续、无误地收到,我现在期望接收该序号开始的字节。

这意味着一个 ACK 可以确认所有之前未确认的连续数据块。比如发送方发出 1~1000,1001~2000,2001~3000,如果只收到对 2001 的确认号(即 ACK=2001),就代表 1~2000 全部收到,无需单独回复。

4.1.2 确认号字段与 ACK 标志

  • 确认号字段有效的前提是 TCP 头中的 ACK 标志位 为 1。除了初始 SYN 报文,所有数据报文和之后的握手挥手报文,ACK 位都是 1。

  • 接收方生成 ACK 的规则:

    • 按序到达的数据段,会尽快回复 ACK。

    • 如果收到高于期望序号的突发段(即出现空洞),会立刻回复一个重复 ACK,确认号仍是期望的那个缺失序号,告知发送方“这里缺了”。

4.1.3 延迟确认与捎带确认

为了减少纯 ACK 报文的数量,TCP 实现了:

  • 延迟确认:收到数据后不立即回 ACK,而是等待约 200~500ms,看是否有数据要反向发送,让 ACK “搭车”捎带出去。如果没有数据,超时后再单独发 ACK。

  • 捎带确认:当双向都有数据流时,回复给对端的数据报文可以同时承载对已收数据的确认,此时报文头中的确认号就是那个“顺路捎带”的 ACK。

4.2 超时重传机制

确认应答是“被动反馈”,超时重传则是主动探测修复。TCP 为每个发出的报文段维护一个重传定时器,超时即重传。

4.2.1 返时间测量与 RTO 计算

超时重传的前提是准确判断“多久没收到 ACK 才该重传”。这个时间叫 RTO(重传超时)

  • TCP 持续测量每个报文段的往返时间 RTT。

  • 为避免 RTT 瞬时抖动导致过早重传,使用 Jacobson/Karels 算法

  • 这种动态调整保证了 RTO 随网络状况自适应,既不太短误判,也不太长迟钝。

4.2.2 超时后的重传与退避

  • 一旦超时,TCP 认为发生了较严重的拥塞,立刻重传这个“未被确认”的最早的那个段。

  • 指数退避:超时后 RTO 会翻倍(上限通常 60 秒),下一次超时就是 2×RTO,再超就是 4×RTO……直到恢复或放弃。这避免了在网络本就拥塞时,疯狂重传加剧拥堵。

  • 重传成功后,RTO 会根据后续成功的 RTT 重新平滑计算。

4.2.3 快速重传(不等待超时)

超时重传代价太高。TCP 设计了快速重传:只要发送方连续收到3 个重复 ACK(即第一个 ACK 加上重复的三次),就认为该序号开始的数据段已丢失,不等超时定时器到期,立即重传该段。这能大幅缩短丢包恢复时间。

4.3 连接管理机制

连接管理确保通信两端在正确的时间建立、维持和拆除状态,是 TCP 最显性的“流程控制”。

4.3.1 连接建立:三次握手

  • 同步初始序列号:随机 ISN 防止新旧数据混淆。

  • 协商选项:MSS、窗口缩放、时间戳、SACK 等,只在握手期间完成。

  • 防止旧连接请求导致的错误:三次握手最终确认客户端确实要建连接,避免服务器因滞后的 SYN 报文直接进入 ESTABLISHED 而空等。

4.3.2 连接释放:四次挥手与 TIME_WAIT

释放连接的管理核心是全双工半关闭TIME_WAIT

  • 半关闭允许主动关闭方不再发送数据,但仍能接收,直到被动方也关闭。这保证了全双工通道任一方向都不会被提前中断。

  • TIME_WAIT 是管理最精妙的部分:主动关闭方进入 TIME_WAIT,等待 2MSL。

    • 可靠终止:确保最后发送的 ACK 能被对方收到,如果对方重传 FIN,这边能重发 ACK。

    • 消去网络中残留报文:让旧连接所有滞留的段都因 TTL 耗尽而消失,防止这些“幽灵报文”被下一个相同五元组的新连接误接收。
      这是 TCP 为“干净关闭”付出的必要代价。

5. TCP辅助优化机制

5.1 滑动窗口

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

如同老师发通知,每发一条,你都回答收到,这不显得很麻烦吗

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

5.1.1 窗口的组成

发送方维护一个发送窗口,它覆盖了发送缓冲区中的一段连续字节序列。这个窗口将数据分为四类:

[已发送并确认] | [已发送未确认] | [允许发送但尚未发送] | [不允许发送]
              |<---------- 发送窗口 ---------->|
              |   (大小由 min(cwnd, rwnd) 决定)  |
  • 已发送并确认:可以从缓冲区中删除。

  • 已发送未确认:窗口内的“飞行中”数据,等待 ACK。

  • 允许发送但尚未发送:窗口内剩余空间,应用可以继续写入并立刻发送。

  • 不允许发送:窗口之外,必须等待窗口右移。

5.1.2 窗口的滑动

当接收端返回一个新的 ACK,且确认号推进,窗口就向前滑动:

  • 左沿右移:收到累积 ACK,已确认的数据移出窗口。

  • 右沿右移:接收方通告的窗口大小(rwnd)增加或发送方 cwnd 增加,窗口扩张。

  • 如果 ACK 不新(重复 ACK),左沿不移动,但可能有乱序导致窗口内的空洞。

5.1.3 窗口大小

实际发送窗口 = min(rwnd, cwnd)

  • rwnd(接收窗口):接收方在 ACK 中广告的剩余缓冲区大小,用于流量控制

  • cwnd(拥塞窗口):发送方维护的拥塞控制变量,用于拥塞控制

5.2 流量控制

流量控制是接收方驱动的机制,唯一目标是防止发送方撑爆接收方的缓冲区。

5.2.1 接收窗口 (rwnd) 的通告

每次发送 ACK 时,TCP 头部的“窗口大小”字段都告诉对方:我的接收缓冲区现在还空出多少字节。其计算逻辑为:

rwnd = 接收缓冲区总大小 - (已收到但应用未取走的数据量)

发送方必须确保:已发送但未确认的数据总量 ≤ 对方通告的 rwnd

5.2.2 零窗口

当接收方应用读取慢于发送速度,rwnd 会逐渐减小直至变为 0。此时发送方停止发送数据,避免丢包。

  • 零窗口探测:发送方启动坚持定时器,周期性地向接收方发送一个只有 1 字节(实为序列号探针)的窗口探测报文,看窗口是否重新打开。

  • 避免死锁:这保证了如果接收方打开窗口的 ACK 丢失,双方不至于永远死等。

5.3 拥塞控制

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

拥塞控制是发送方驱动的机制,核心目的不是保护接收方,而是感知并避开网络的拥塞点(路由器缓冲区),防止整个网络过载。

它引入了拥塞窗口 (cwnd),发送窗口 = min(rwnd, cwnd)。拥塞程度越大,cwnd 越小。经典 Reno 算法拥塞控制包含四个核心阶段:

5.3.1 慢启动

  • 连接建立或超时发生后,cwnd 初始化为 1~2 个 MSS。

  • 每收到一个确认新数据的 ACKcwnd 增加 1 MSS。效果是:每经过一个 RTT,窗口大小翻倍(指数增长)。

  • 目的不是“慢”,而是迅速探测网络可用带宽。当 cwnd 达到慢启动阈值 ssthresh 时,切换到拥塞避免。

5.3.2 拥塞避免 

  • cwnd >= ssthresh 后,转为线性增长:大约每个 RTT,cwnd 增加 1 MSS。实现方式为每收到一个 ACK,cwnd 增加 (MSS/cwnd) * MSS

  • 这是在接近网络瓶颈时,逐步小心试探。

5.3.3 拥塞检测与响应

  • 超时重传发生:表明严重拥塞。ssthresh = cwnd / 2cwnd 归零或重置为 1 MSS,重新进入慢启动

  • 收到三个重复 ACK(快速重传触发):表明轻度拥塞,个别包丢但后续包可达。执行快速恢复

    • ssthresh = cwnd / 2

    • cwnd = ssthresh + 3 MSS (因为每个重复 ACK 意味着已有一个包离队)

    • 每再收到一个重复 ACK,cwnd 临时加 1(维持传输)

    • 一旦收到新的 ACK(确认重传成功),cwnd = ssthresh,进入拥塞避免阶段。

5.3.4 现代拥塞控制算法

  • CUBIC:Linux 默认算法,窗口增长不再依赖 RTT,而是一个三次函数,对高带宽长距离网络友好且在不同流间更公平。

  • BBR:谷歌提出,不依赖丢包信号,而是通过探测瓶颈带宽和最小 RTT 来主动估算网络能承载的速率。在有少量随机丢包的环境(如无线网)下性能远胜传统算法。

5.4 延迟应答

延迟应答是接收方的性能优化策略。每次收到一个数据报文段就立刻发 ACK,会产生大量小包(纯 ACK)。延迟应答的思路是:

  • 收到数据后,不立刻回复 ACK,而是等待 200~500ms

  • 在这个窗口期内,如果:

    1. 另一个数据段到达:可以立即发 ACK,一次性累积确认两个段。

    2. 应用进程产生反向数据:可以将 ACK 捎带在反向数据包上发走(即捎带应答)。

    3. 超时到期:无论如何都会发送 ACK,避免发送方超时。

优点:减少了纯 ACK 包的数量,提升了网络效率,也让接收窗口有机会变得更大再通告。
缺点:增加了反馈延迟,可能导致发送方 RTT 估算偏大或短暂等待,某些交互式应用会关闭它(开启 TCP_QUICKACK

5.5 捎带应答

捎带应答是 TCP 报文段结构实现的一种自然优化:

  • 当双方有双向数据流时,接收方需要向发送方发送应用数据,同时它也需要确认之前收到的数据。

  • TCP 报文头部的 ACK 标志和确认号字段 可以和数据放在同一个报文中。于是回复给对方的数据包,顺带就把对之前数据的确认信息带过去了,不额外消耗一个单独的 ACK 报文

这极大降低了全双工通信中的报文开销。延迟应答经常作为“搭车”的契机:接收方故意等一小会儿,看有没有刚好要发出去的数据,如果有,就让 ACK “搭”这趟车,否则才发单独的 ACK。

5.6 5种机制的协同工作

假设 A 向 B 发送一个大文件:

  1. 连接建立:三次握手协商了窗口缩放、MSS 等。

  2. 发送阶段

    • A 的发送窗口受 B 通告的 rwnd(流量控制)和 A 自己的 cwnd(拥塞控制,开始慢启动)的共同约束。

    • B 每收到一批数据,可能会启动延迟应答,200ms 后或收到后续段时发送一个累积 ACK,ACK 中包含最新的接收窗口(流量控制)。

    • 若 A 连续收到三个重复 ACK,快速重传并进入快速恢复,cwnd 减半。这既是拥塞控制的反应,也借助了累积确认和 SACK 的信息。

    • 若 RTO 超时,cwnd 降为 1,重新慢启动。

  3. 交互中的捎带:如果 B 也有数据要回复,它发出的数据包会顺带捎带对 A 的数据确认,同时 A 对 B 的 ACK 也可捎带在对 B 的后续数据中。

这五者中,滑动窗口是空间容器,流量控制和拥塞控制是限制容器容量的两个阀门(一个来自接收方,一个来自网络),而延迟应答和捎带应答则优化了阀门调节信息的传递方式。它们让 TCP 在可靠的基础上,达到了带宽利用与网络公平性的精妙平衡。

6. TCP的其他问题

6.1 面向字节流

TCP 把发送端应用一次次 write 写入的数据,看作一个连续不断的字节序列,就像一条水管里的水流。它不关心应用每次写入多少个字节,也不关心这些字节代表什么消息。它只保证:

  • 顺序:字节以写入的顺序到达。

  • 可靠:无丢失、无重复。

  • 无边界:接收端读取时,无法区分数据是发送端哪一次 write 写入的。

举个例子:

UDP送的是快递,不允许出现"半个快递","4分之一个快递"这样的名词出现,必须是完整的

TCP送的是水流,不存在"一块水"这样的名词,只在乎送到了没有,能不能用

6.2 粘包问题

“粘包”是 TCP 面向字节流特性导致的一个应用层现象:接收端无法分辨发送端原始消息的边界,导致多条消息的数据粘连在一起被一次读出,或者一条消息被分次读出产生“半包”。

原因

  • 发送端:Nagle 算法会将多个间隔很小的小数据合并成一个 TCP 段发送。

  • 网络:网络拥塞、重传等会导致数据以任意块到达。

  • 接收端:应用读取的速度可能跟不上数据到达速度,导致多条数据被一起从内核缓冲区取走;或者读取缓冲区太小,无法一次取完一条完整消息。

本质:TCP 没有内置的消息边界信息。

TCP相当于是在水管里面输送水,但是假设我只需要1平方米的水,我怎么精准拿到?他送过来的水可能不足1平方米或者多出一部分

应用层如何解决粘包/半包?

因为 TCP 不提供边界,应用协议必须在字节流之上自建消息边界。常见三种方法:

(1) 固定长度消息

每个消息长度固定。接收端每次读取固定字节数,即为一个完整消息。

  • 优点:实现极简。

  • 缺点:浪费带宽(内部填充),灵活性差。

(2) 分隔符

消息以特定字符或字符串结束,如 \r\n(HTTP 头)、\0

  • 解析方式:接收端不断读取字节,扫描分隔符,分割消息。

  • 优点:适合文本协议。

  • 缺点:消息内容需转义,扫描有开销。

(3) 长度前缀(最常用)

在消息头部固定几个字节,标明后面消息体的长度。
如:[4 字节长度] + [消息体]

6.3 TCP 通信中的异常情况

TCP 设计了一系列机制来应对网络中的各种异常,但应用层也需要感知并正确处理这些情况。

6.3.1 连接异常断开与 RST 报文

除了正常的四次挥手,TCP 会用 RST(复位)报文 来中断连接,表示出现严重错误,需要立即中止,不经过正常的 FIN 流程。

RST 产生的常见场景:

  • 向一个未被监听(或已关闭)的端口发起连接:对端内核直接回复 RST,应用 connect 失败,错误码 Connection refused

  • 服务端崩溃后重启:当客户端向一个旧连接发送数据时,服务器已无该连接的信息,回复 RST。客户端应用会收到 Connection reset by peer 错误,read 或 write 返回 ECONNRESET

  • 半开连接:一端未通知对方就关闭或崩溃(如断电),另一端仍然发送数据,接收方会回复 RST。

  • SO_LINGER 选项设置:设置 on=1, linger=0 时,调用 close 不经过四次挥手,直接发送 RST 丢弃发送缓冲区的数据并关闭,避免进入 TIME_WAIT。

  • 连接被防火墙或 NAT 设备超时切断:中间设备丢弃连接信息,导致后续数据包触发 RST。

应用层处理 RST

  • read() 返回 ECONNRESET,连接已不可用,必须关闭套接字。

  • write() 也可能返回 EPIPE 或收到 SIGPIPE 信号(若对方已发 RST 且本端继续写)。

6.3.2 网络中断与超时

如果网络电缆被拔、WiFi 失联、中间路由器故障,连接会处于“悬空”状态,双方收不到任何包。TCP 没有水晶球,只能通过超时来发现。

  • 重传超时:发送方持续发送数据但收不到 ACK。重传定时器触发后,以指数退避重试。在达到最大重试次数后(如 Linux 下 tcp_retries2 默认 15 次,实际会动态计算一个大致 10 几秒到几分钟的超时),TCP 认为连接断裂,关闭连接并返回错误。

    • 此时应用 write 会返回 ETIMEDOUT 或 ECONNRESET

  • 保活定时器 (Keep-Alive):对于长时间空闲的连接(没有数据来往),上述超时机制无法触发(因为没有包要发)。TCP 提供可选的 保活机制,在空闲一段时间后,发送探测报文确认对端是否存活。

    • 参数:tcp_keepalive_time(空闲多久开始探测,默认 2 小时)、tcp_keepalive_intvl(探测间隔)、tcp_keepalive_probes(探测次数)。

    • 多次探测无响应,连接关闭,应用收到类似超时的错误。

    • 但默认保活过于迟钝(2 小时),实时应用通常在应用层实现心跳包(如 WebSocket 的 Ping/Pong),以更早探测和维持连接。

6.3.3 半开连接与半关闭

  • 半关闭 (Half-close):四次挥手中,主动关闭方进入 FIN_WAIT_2 等待对端 FIN,这是正常的半关闭,应用可通过 shutdown(SHUT_WR) 主动进入。

  • 半开连接 (Half-open):异常情况,一方崩溃或网络中断后重启,其 TCB 丢失,但另一方仍认为连接存在。此时若存活方发送数据,对方通常回复 RST,连接被重置。

6.3.4 数据传输中的异常:乱序、重复与校验和错误

  • 乱序:TCP 通过序列号重排,对应用透明。应用读取的永远是排好序的字节流。但如果乱序严重,可能导致接收缓冲区不得不暂存乱序段,可能延后递交。

  • 重复:网络层可能产生重复包,TCP 通过序列号识别并丢弃重复段,应用无感知。

  • 校验和错误:TCP 校验和覆盖头部和数据。若检测到错误,报文段被静默丢弃,发送方未收到 ACK 将超时重传。对应用无影响,只是表现为临时延迟。

6.3.5 连接数限制与资源耗尽(环境异常)

虽然不是协议机制,但应用开发需知:

  • 文件描述符耗尽:每个连接占一个 fd 和内核资源,超过系统限制(ulimit -n)会导致 accept 或 connect 失败。

  • TIME_WAIT 耗尽端口:大量短连接主动关闭方出现 TIME_WAIT,导致本地端口快速占满,无法建立新连接。

  • SYN 泛洪:半连接队列被占满,正常连接被拒,借助 SYN Cookie 等防御。

7. 总结

7.1 UDP 与 TCP 的核心区别

维度 UDP TCP
连接性 无连接。随时发,无需握手。 面向连接。必须经过三次握手建立连接,四次挥手释放连接。
可靠性 不可靠。不确认、不重传,丢包是永久的。 可靠。通过确认应答、超时重传、校验和保证无丢失、无差错。
顺序性 不保证顺序,数据报可能乱序到达。 有序字节流。序列号保证接收顺序完全与发送顺序一致,内核会自动重排。
消息边界 面向数据报,保留消息边界。一次 sendto 对应一次完整接收。 面向字节流,无消息边界。应用必须自己处理粘包/半包。
头部开销 固定 8 字节。 最小 20 字节,通常加上时间戳、窗口缩放、SACK 等选项可达 40 字节。
传输模型 数据报。每个包独立路由,不进行分段(依赖 IP 分片)。 字节流。TCP 自动将应用数据切割为合适长度的段(基于 MSS),并封装在 TCP 报文中。
流量控制 无。接收缓冲区满即丢弃新包,不通知发送方。 有。滑动窗口 + 接收窗口通告,发送方发送量不得超出对端接收缓冲区剩余空间。
拥塞控制 无。应用可以任意速率发送,易造成网络拥塞。 有。慢启动、拥塞避免、快速恢复等机制,感知网络拥塞并自适应调整发送速率。
通信模式 支持单播、组播、广播。 仅支持单播(点对点)。
传输效率 极高。无连接建立的延迟,无状态维持,实时性好。 相对低。有握手延迟,状态管理、确认和重传增加 CPU 和内存开销。
适用场景 实时音视频、在线游戏、DNS 查询、IoT 传感器数据、组播直播。 文件传输、Web 浏览、邮件、数据库远程访问等要求数据完整准确的场景。

7.2 TCP 如何实现可靠性?

TCP 的可靠性由以下六大机制协同实现:

  1. 三次握手——建立连接,同步序列号

    • 在通信之前建立双向逻辑连接,并交换双方的初始序列号(ISN)。

    • 防止旧的连接请求造成混乱,确保双方收发能力均正常。

  2. 序列号与累积确认

    • 每个 TCP 段携带一个 32 位序列号,映射数据流的每一个字节。

    • 接收方通过累积确认号告知发送方:该序号之前的所有字节都已连续收到。

    • 重复 ACK 快速提示丢包的起点,触发快速重传。

    • 选择性确认 (SACK) 扩展了确认信息,使发送方可知晓具体缺失和不连续已收到的块。

  3. 超时重传

    • 每个发出的段都会启动重传定时器,超时未确认即重传。

    • RTO 通过 Jacobson/Karels 算法根据 RTT 平滑值和偏差动态计算,避免过早/过晚重传。

    • 超时后 RTO 指数退避,防止拥塞崩溃。

    • 结合 快速重传(收到 3 个重复 ACK 立即重传)大幅缩短恢复时间。

  4. 校验和

    • 覆盖 TCP 伪首部、TCP 头部和数据,检测传输中的比特错误。

    • 校验失败的段被丢弃,发送方通过超时/快速重传恢复。

  5. 流量控制(接收方驱动)

    • 接收方每次 ACK 都携带当前可用的接收窗口(rwnd)。

    • 发送方确保“飞行中”的数据量 ≤ rwnd,防止接收缓冲溢出。

    • 零窗口时启动坚持定时器探测窗口打开,避免死锁。

  6. 拥塞控制(发送方驱动)

    • 维护拥塞窗口(cwnd),实际发送窗口 = min(rwnd, cwnd)。

    • 慢启动(指数增长探测带宽),拥塞避免(线性伸缩接近瓶颈),基于丢包(超时/重复 ACK)调整 cwnd 和慢启动门限。

    • 现代算法如 CUBIC、BBR 在不同场景下保持网络公平与高吞吐。

其他辅助机制:

  • 连接管理:状态机、四次挥手和 TIME_WAIT 确保所有数据可靠交付且旧报文不影响新连接。

  • 定时器族:重传定时器、坚持定时器、保活定时器、TIME_WAIT 定时器,覆盖所有异常死角。

这六层环环相扣:前两层(握手、序列与确认)构建了基本的“收发确认”闭环;第三层(超时重传)是闭环的修复器;第四层(校验和)排除内容错误;第五、六层(流量与拥塞控制)则在保证可靠性的前提下,防止发送方滥用网络/接收方资源,实现效率与公平的平衡。


7.3 用 UDP 模拟实现 TCP 的可靠性

如果想在 UDP 上自己实现一套可靠的传输协议,本质上就是在不可靠的数据报文服务之上,重新发明一套“连接管理 + 序列/确认/重传 + 流控/拥控”的状态机。这正是 QUIC、KCP、UDT 等协议所做的工作。

1. 连接管理(替换三次握手/四次挥手)

  • 握手包:定义连接控制报文(类似 SYN),携带随机生成的 Connection ID + 初始序列号。

  • 状态机:为每个连接维护状态(类似 TCP 的 TCB),记录序列号、对端状态、协商参数。

  • 握手确认:三次(或更高效的设计,如 QUIC 的 0-RTT/1-RTT 握手)以保证双方存在且愿意通信。

  • 连接关闭:类似 FIN / FIN+ACK 的流程,保证双方数据发完再释放。

  • 心跳/超时:设定 idle 超时,如果长时间无数据,发送探测包(Keep-Alive),超时后释放连接。

2. 序列号与确认

  • 自定义数据报文头:每个报文必须包含一个发送序列号

  • 确认报文:接收方必须发送 ACK 报文,内容至少包括:

    • 累积确认号(已成功连续收到的最大序列号)。

    • SACK 块(可选,但强烈建议,用于精确重传)。

  • 确认触发策略:每收到一个数据报文立即 ACK,或使用延迟 ACK 减少包量(但要平衡 RTT 估算精度)。

3. 重传机制

  • 超时重传:每发出一个包就启动定时器,基于采样 RTT 计算 RTO(同 TCP 算法)。超时重发,且 RTO 指数退避。

  • 快速重传:当收到连续 3 次相同累积 ACK(即期待某个包)时,立即重传该包,不等超时。

  • 选择性重传:利用 SACK 信息,只重传明确丢失的报文,避免重传窗口。

4. 流量控制

  • 接收缓冲区窗口通告:在 ACK 报文中携带接收方剩余的缓冲区大小(rwnd)。

  • 发送方限制:未确认的数据量 ≤ rwnd。

  • 零窗口探测:当 rwnd==0 时,定期发送窗口探测包,防止通告丢失造成的死锁。

5. 拥塞控制

  • 慢启动:初始发送窗口很小,每收到一个 ACK 成倍增加。

  • 拥塞避免:到达阈值后线性增加。

  • 丢包响应:识别超时或重复 ACK,减半拥塞窗口,进入快速恢复。

  • 可以直接移植 TCP 经典算法(Reno/CUBIC),或者根据应用类型自定义(如卫星链路适合 BBR)。

6. 消息边界与分片

  • 应用消息边界:UDP 本身有边界,但如果你要模拟 TCP 那样的字节流,需要自行实现消息分帧。

  • 分片:如果单个应用消息大于底层 UDP 的安全载荷(避免 IP 分片,建议 ≤ 1400 字节),必须在可靠层面自做分片和重组。在自定义头中加入分片 ID 和偏移量,所有分片必须全部可靠传输后才重组递交给上层。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐