052 传输层 —— TCP(下)

传输层 —— TCP(下)

30 张图解: TCP 重传、滑动窗口、流量控制、拥塞控制 | 博客园

1. TCP 流量控制

1. 什么是流量控制?

先想一个比喻:你在和朋友聊天,对方打字太快,你还没看完一句他又发十条,你就“被淹没”了。TCP 里也一样,发送方(Sender)发数据太快,而接收方(Receiver)处理不过来,就会导致:

  • 接收缓冲区溢出(数据丢失)
  • 重传、拥塞、效率下降

所以 TCP 设计了 流量控制机制(Flow Control),让接收方告诉发送方:“我现在只能接收这么多数据,请你慢一点。”它是一种防止发送方发送数据过快,导致接收方来不及处理而造成数据丢失的机制,其核心目标是 匹配发送速率与接收能力

2. 实现方式:接收窗口

TCP 报文头部包含一个 16 位的窗口字段(Window Size),表示接收方当前还能接收多少字节的数据(即接收缓冲区剩余空间)。发送方根据这个窗口大小决定最多能发送多少未确认的数据。如果接收方缓冲区快满了,它会通告一个较小的窗口;如果缓冲区空了,就通告一个较大的窗口(甚至为 0)。

高性能 TCP 扩展

注意:窗口大小字段最大为 65535 字节(64 KB),这对早期的低速网络还行,但在高速宽带(比如千兆)环境下,64KB 窗口太小,会严重限制吞吐量。于是 RFC 1323 引入了“窗口缩放选项(Window Scale Option,RFC 1323)”,根据原始 RFC 1323(后来更新到 RFC 7323)在 三次握手阶段,双方可以协商一个窗口缩放因子 S,范围是:[0,14],也就是说,报文里的 16 位窗口字段只是一个“基值”,实际值还要乘上缩放因子。

1
2
3
4
实际窗口大小 = 通告窗口大小 × (2^S)
最大窗口 = 65535 × 2^14
= 65535 × 16384
= 1,073,725,440 字节 ≈ 1 GB

所以 理论上最大窗口 ≈ 1GB,但这样一来,就与 IBM、Linux 内核文档说的不一样了,这其实就是进入“理论 vs 实际实现差异”的问题:不同操作系统、内核或 TCP 栈为了性能、内存安全、兼容性,会 人为限制最大窗口,不会真的给我们用到 1GB。比如:

系统实际可配置的最大窗口
Linux (CentOS 7)通常 ≤ 16 MB(由 /proc/sys/net/core/rmem_max 限制)
Windows一般上限在几 MB
IBM AIX文档中说最大 1 MB(是默认配置限制,不是协议上限)

所以:RFC 是协议标准,定义了“理论上能支持到 1GB”,系统实现(比如 AIX、Linux)出于稳定性或内存限制,只允许配置到几 MB。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 查看TCP窗口缩放功能是否启用,输出1表示启用TCP窗口缩放(RFC 1323定义的扩展)
# 允许TCP接收窗口大小超过65535字节,提升高带宽长距离网络的吞吐量
cat /proc/sys/net/ipv4/tcp_window_scaling
1

# 查看系统所有socket接收缓冲区的最大大小(全局上限),单位:字节,所有socket的接收缓冲区配置不能超过此值
cat /proc/sys/net/core/rmem_max
212992 # 当前值为212992字节(208KB)

# 查看系统所有socket发送缓冲区的最大大小(全局上限),单位:字节,所有socket的发送缓冲区配置不能超过此值
cat /proc/sys/net/core/wmem_max
212992 # 当前值为212992字节(208KB)

# 查看TCP协议接收缓冲区的配置(三个值分别为最小/默认/最大),单位:字节
cat /proc/sys/net/ipv4/tcp_rmem
4096 87380 6291456
# 4096:接收缓冲区最小值(即使内存紧张也不会小于此值)
# 87380:接收缓冲区默认值(新建连接时的初始大小)
# 6291456:接收缓冲区理论最大值(实际受rmem_max限制,当前实际最大为212992)
# TCP 接收缓冲区的实际最大值 = min(tcp_rmem[2], rmem_max)

# 查看TCP协议发送缓冲区的配置(三个值分别为最小/默认/最大),单位:字节
cat /proc/sys/net/ipv4/tcp_wmem
4096 16384 4194304
# 4096:发送缓冲区最小值
# 16384:发送缓冲区默认值
# 4194304:发送缓冲区理论最大值(实际受wmem_max限制,当前实际最大为212992)

2. 滑动窗口

神奇的滑动窗口 | TCP 流量控制 | B 站

1. 什么是滑动窗口?

这里的滑动窗口和在算法中的滑动窗口算法可以认为是一致的,如果滑动窗口算法你有所了解,那么此处很容易理解,因为原理一致!

TCP 滑动窗口 = 流量控制的核心机制,用于让发送方根据接收方的处理能力动态调整发送速率,防止“发太快、收不动”。

PixPin_2025-10-06_15-31-06

滑动窗口是 TCP 实现 流量控制 + 高效传输 的关键机制。它允许发送方在 未收到确认的情况下 连续发送多个数据段,而不是“发送一个、等待一个”。其本质是一个“动态的发送许可”。

  • 本质:就是 TCP 为了提高传输 效率 设计的一种机制。
  • 目的:不用每发一个包就等确认,而是允许「一批包」在途,从而实现一次可以发送大量 TCP 报文。

可以理解为:“发送方最多能发多少还没被确认的数据(未 ACK 的数据)。”这个“能发的范围”由 接收方 决定, 接收方通过 TCP Header 里的 Window 字段(窗口大小) 通知发送方,举个直观例子(假设):

  • 接收方当前窗口大小为 4096 bytes
  • 每个数据包 1024 bytes

发送方就能 连续发 4 个包(4096 bytes)后,必须停下来等 ACK。 等接收方确认了第 1 个包,它的窗口“右移”,发送方又能继续发新的一个。

2. 滑动窗口的组成(发送方视角)

发送窗口通常分为四部分(按序列号顺序):

区域说明是否占用缓冲区
已发送且已确认(收到 ACK)可以丢弃,窗口向前滑动不再占用(可释放)
已发送但未确认(未收到 ACK)正在等待 ACK占用
未发送但可发送在窗口内,可立即发送占用(待发)
未发送且不可发送(存在感不高,可忽略,而且是窗口之外的空间,严格来说 不属于滑动窗口本身超出窗口,需等待窗口滑动占用(但被“冻结”)

发送方的 发送窗口 是一个区间,主要分为三块:

  1. 已发送且已确认的数据(窗口左边,已经安全了)
  2. 已发送但未确认的数据(窗口中间,在途数据)
  3. 允许发送但还没发送的数据(窗口右边,可以继续发)

重点:理论上的滑动窗口大小 = min(接收方通告窗口,拥塞窗口,发送方缓存大小),这是操作系统实际可用的发送窗口上限,包含内核资源限制;而协议标准中通常只考虑发送方当前实际使用的滑动窗口大小,实际窗口大小 = min(接收窗口大小,拥塞窗口大小)即 min(cwnd, rwnd)

3. 确认序号(ACK)的含义是什么?

TCP 的确认序号(Acknowledgment Number)表示:“我期望收到的下一个字节的序号是 x,也就是说,x 之前的所有字节我都成功收到了。” 例如:ACK = 1001 → 表示序号 1~1000 的字节都收到了,这是 累计确认(Cumulative ACK) 机制。

允许少量 ACK 丢失吗?

TCP 是可靠传输协议,ACK 丢了,接收方下次再发 ACK(累积确认)依旧能覆盖。所以只要不是连续大量丢失,就不会有问题。

允许! 因为 TCP 使用累计确认:

  • 即使某个 ACK 丢了,只要后续的 ACK 到达(比如 ACK = 2001),就说明 1~2000 都收到了。
  • 所以前面的 ACK 丢失不影响正确性(但可能影响性能,如触发重传)。
  • 普通数据 ACK 丢失通常无害,但携带窗口更新的 ACK 丢失可能影响性能。

4. 滑动窗口如何“向右移动”?移动时大小会变吗?

1. 向右移动:

  1. 当收到 ACK(比如 ACK = x),说明 x 之前的数据都确认了,窗口整体向右滑动。
  2. 发送窗口的 左边界 就移动到 x,释放已确认的数据空间,这意味着一些数据已经确认,不用再管了。
  3. 如果接收方缓冲区有空闲,还会在 ACK 中携带新的接收窗口大小,可能让 右边界也右移(窗口变大)。

2. 窗口大小会变化吗?

会!而且经常变!

  • 变大:接收方处理了数据,缓冲区空出 → 接收窗口大小增大 → 窗口右边界右移。
  • 变小:接收方缓冲区快满了 → 接收窗口大小减小 → 窗口右边界左移(但左边界不能左移!)。
  • 变为 0:接收方缓冲区完全满 → 接收窗口大小 = 0 → 窗口大小为 0,发送方暂停发送(进入“零窗口探测”状态)。

注意:窗口左边界只能右移或不动,不能左移(因为已确认的数据不能“反悔”)。

5. 滑动窗口会“越界”发送缓冲区吗?

TCP 采用了类似环状算法,始终保证滑动窗口不会越界!

不会。

  • 滑动窗口的右边界(即“可发送的最大序号”)永远不会超过发送缓冲区的容量
  • 如果应用程序写入的数据太多,而窗口太小(比如接收窗口大小 = 0),send() 系统调用会 阻塞(或返回 EAGAIN,如果是非阻塞 socket),直到窗口有空间。
  • 因此,滑动窗口始终被限制在发送缓冲区内,不会越界。

6. 流量控制是通过滑动窗口实现的吗?

是的!流量控制的核心就是滑动窗口!

流量控制的本质:防止发送方发得太快,把接收方“撑爆”。实现方式:接收方通过 TCP 报文头中的 Window 字段(即接收窗口大小) 告诉发送方自己还能收多少。发送方据此 动态调整滑动窗口的大小。当 接收窗口大小 = 0 时,发送方停止发送(除零窗口探测包外)。

滑动窗口是流量控制的执行机制,接收窗口大小是流量控制的控制信号。

7. 如果发生丢包,滑动窗口怎么处理?

丢包主要影响 拥塞控制,但也会间接影响滑动窗口:

  1. 发送方发现丢包(通过超时或重复 ACK):
    • 会触发重传(重发未确认的数据)。
    • 同时 减小拥塞窗口(比如减半)→ 导致滑动窗口变小。
  2. 滑动窗口本身不会“回退”
    • 已确认的部分不会撤销。
    • 未确认的部分继续等待 ACK 或重传。
    • 窗口左边界不变,右边界可能因 拥塞窗口 减小而左移(窗口缩小)。
  3. 接收方视角
    • 如果中间丢包,接收方会重复发送 最后一个正确 ACK(比如一直 ACK = 1001)。
    • 发送方收到 3 个重复 ACK 后,会快速重传序号 1001 开始的数据(快速重传机制)。

重点:滑动窗口只向前滑(左边界不回退),丢包通过重传 + 调整拥塞窗口大小来处理。

3. 拥塞控制

1. 什么是拥塞控制?

拥塞控制 是 TCP 协议中的一种 机制,用于 防止发送方因发送数据过快而导致网络过载(网络中的路由器或链路被过多数据淹没/拥塞),从而避免大量丢包、延迟剧增甚至网络崩溃。它的目标是:既要尽可能高效利用带宽,又要避免让网络“堵车”。

关键点:

  • 流量控制 → 保护 接收方(别发太快,我处理不过来),防止「发送方」把「接收方」撑爆。
  • 拥塞控制 → 保护 整个网络(别发太多,网络会堵),防止「所有发送方」把「网络」堵死。

2. 为什么要有拥塞控制?

如果所有主机都“无脑狂发数据”,网络会出现:路由器缓存溢出(包被丢弃)、延迟飙升、重传风暴(越丢越发),最终导致整个网络吞吐量下降,也就是所谓的“网络崩溃”。

  1. 网络资源有限:网络资源本质还是 共享资源,路由器缓存、带宽都是有限的。
  2. 无控制的后果
    • 多个发送方同时高速发包 → 路由器缓存溢出 → 大量丢包
    • 丢包触发重传 → 更多数据进入网络 → 恶性循环(拥塞崩溃)

因此,TCP 必须“感知”网络状态,并 自适应调整发送速率。

3. 如何判断网络是否拥塞?

TCP 无法直接看到网络状态,只能通过 间接信号 推断拥塞:

拥塞信号说明
超时(Timeout)数据包长时间未收到 ACK → 很可能已丢弃(严重拥塞)
重复 ACK接收方收到乱序包,反复确认最后一个正确序号(如连续 3 次 ACK = 1001)→ 中间包可能丢失(轻度拥塞)

这两个事件是触发拥塞控制算法动作的关键“警报”。

4. 拥塞控制的核心机制与关键概念

在热恋中,人总是“小心试探”——

  • 一开始不敢太快靠近(慢启动)。
  • 发现对方反应良好,就逐步增加接触(拥塞避免/快增长)。
  • 如果对方突然冷淡(丢包/超时),就立刻收敛,变得保守(快恢复/慢启动重启)。

TCP 也是这样:它“试探”网络承受能力,逐渐增加速度,一旦发现不对劲(丢包),马上退回去,避免网络“受伤”。

TCP 拥塞控制通过一个 拥塞窗口 来限制发送速率。

PixPin_2025-10-06_17-00-35

阶段/策略触发条件cwnd 增长方式说明
慢启动初始阶段或严重拥塞每 ACK cwnd × 2指数增长
拥塞避免cwnd ≥ ssthresh每 RTT cwnd + 1 MSS线性增长
快重传3 个重复 ACK立即重传丢失包不等超时
快恢复轻微拥塞cwnd = ssthresh,然后线性增长恢复阶段

1. 慢启动

就像“刚恋爱,试探性地快快加深感情”。

  • 目的:连接刚建立时,不知道网络承载能力,先“试探性”发送。
  • 规则
    • 传统初始拥塞窗口大小 = 1 MSS(Maximum Segment Size,通常 1460 字节)。注:现代 TCP 实现(如 RFC 6928)的初始拥塞窗口更大,通常是 10 个 MSS。
    • 在慢启动阶段,每经过一个 RTT,cwnd 大致翻倍(指数增长)。
    • 一直到达到慢启动阈值(ssthresh)。
  • 例子
    • 第 1 轮:发 1 个包 → 收 1 个 ACK → cwnd = 2
    • 第 2 轮:发 2 个包 → 收 2 个 ACK → cwnd = 4
    • 第 3 轮:发 4 个包 → 收 4 个 ACK → cwnd = 8
    • ……

虽叫“慢启动”,但增长其实很快(指数级)! 小故事:一个农名欠地主粮食,于是地主给出 2 个选择,一个是慢慢还,一个是第一天还 1 粒米,第二天 2 粒,以此类推……农民毫不犹豫的选择了第 2 个选择,让他没想到的是最开始的日子还能还的上,后来指数级增长的粮食竟压得他叫苦不迭……

2. 拥塞避免

稳定关系后,不再激进增加,逐渐探索极限。

  • 何时进入:当拥塞窗口大小大于等于慢启动阈值时(cwnd ≥ ssthresh)。
  • 规则
    • 每收到一个 ACK,cwnd += 1/cwnd → 每轮 RTT,拥塞窗口只增加 1 MSS(线性增长)
  • 目的:避免指数增长导致突然拥塞。

3. 快重传

  • 触发条件:收到 3 个重复 ACK,就认为某个包丢了。
  • 动作:立即重传丢失的包,不等超时
  • 优点:大幅减少重传延迟。

4. 快恢复

恋爱里遇到小矛盾,退一步,再慢慢靠近。

  • 触发条件:快重传之后,当检测到轻微拥塞时,不用回到最初的慢启动。
  • 动作:将慢启动阈值设为当前拥塞窗口大小的一半(把 ssthresh 降到 cwnd/2,然后线性增长)。
  • 目的:避免因单个丢包就“从头开始”,保持较高吞吐。

5. 如何解决网络拥塞问题?

TCP 的解决思路就是「自我降速 + 逐步恢复」:

  1. 动态调整 cwnd(拥塞窗口)。
  2. 根据 ACK 反馈推断网络状态。
  3. 丢包时快速响应,防止恶化。
  4. 利用慢启动和线性增长实现自适应调节。

现代系统还在此基础上发展出了许多变种算法,如:

  • Reno(经典)
  • NewReno(改进快恢复)
  • CUBIC(Linux 默认)
  • BBR(基于带宽估计,Google 提出)

4. 流量控制 VS 拥塞窗口

对比维度流量控制拥塞控制
控制目标防止 接收方 缓冲区溢出防止 网络 整体拥塞
控制范围端到端(发送方与接收方之间)全局(整个网络路径)
触发因素接收方缓冲区容量网络路由器队列溢出、分组丢失
关键参数接收窗口 (rwnd)拥塞窗口 (cwnd)、阈值 (ssthresh)
实现方式接收方反馈窗口大小发送方主动调整发送速率
典型算法滑动窗口协议慢启动、拥塞避免、快重传、快恢复
窗口增长根据接收方反馈动态调整指数增长 → 线性增长 → 快速下降
检测机制零窗口检测超时重传、重复 ACK 检测

TCP 是全双工通信(双方都能同时发数据和 ACK)。但发送 ACK(确认号)本身也要占用网络资源,如果对方每发一个小包,我就立刻回一个 ACK,就会增加很多小包通信 → 影响效率。所以:TCP 引入了 延迟应答(Delayed ACK)和 捎带应答(Piggyback ACK)来优化。

5. 延迟应答

1. 类比场景

  1. A:你吃饭了吗?(B 听到了,但暂时没回——他在想自己是不是也要说点别的)。
  2. 几秒后:B:吃了(这句话既是回答,也是“我听到你说话了”的信号)。
  3. 如果 B 想了半天没要说的,就会单独说一句:B:听到了!

2. 对应 TCP 含义

  1. TCP 收到数据后,不急着立刻回 ACK,而是 稍微等一下,看看自己是否要发送数据回去。
  2. 如果这段时间内有要发的数据 → 就在数据包里“顺便带上 ACK”。
  3. 如果一直没有新数据 → 到了延迟时间(比如 40ms)再单独发 ACK。

3. 作用

  • 减少纯 ACK 包数量,降低网络负担;
  • 提高吞吐率,让数据传输更高效。

6. 捎带应答

1. 类比场景

  1. A:吃饭了吗?
  2. B:吃了。

这里 B 没有说「听到你说话了」,但实际上他回答了问题,自然就说明他听到了。这就是「捎带」:在自己的内容里顺便带上确认。

2. 对应 TCP 含义

当 TCP 双方都在发数据时,接收方就 在自己的数据包中附上 ACK 确认号,告诉对方“上一个包我收到了”,不需要单独发一个 ACK 包。

3. 作用

  • 减少包的数量(因为数据和确认合并成一个包)。
  • 提高通信效率,尤其在双向传输时。

7. 延迟应答 VS 捎带应答

  • 延迟应答:等一等再回,看看能不能顺便带上 ACK。
  • 捎带应答:既然要说话,那就顺便说“我听到了”。
项目延迟应答捎带应答
类比B 等一等,看要不要顺便回B 在自己的回答中顺便确认
触发条件收到数据,但无数据要发收到数据且有数据要发
是否等会延迟一小段时间不延迟,直接发
是否双向通信不一定必须双向都有数据
目的减少无意义的 ACK 包合并数据和 ACK,节省一次发包
典型场景单向传输(如文件下载)双向交互(如 Telnet、HTTP)

8. 面向字节流

1. 面向字节流的本质

TCP 是 「面向字节流」 的协议,这句话的意思是:TCP 只看连续的字节,不关心消息边界。 也就是说,TCP 眼中没有“包”或“消息”的概念,它只负责:把发送方写入的那一串字节完整、有序地交给接收方,确保不丢、不乱、不错。所以无论写几次、读几次,对 TCP 来说都没区别——它只是管「流动的字节」。

2. 从发送到接收的完整过程

应用层调用 write() → 用户态数据拷贝到内核态的 TCP 发送缓冲区 → TCP 协议栈按当前网络状况和拥塞窗口决定什么时候、按多大尺寸分段发送 → 网络传输 → 对端的 TCP 收到后,先把数据放入内核态的 TCP 接收缓冲区 → 对端应用层调用 read() 从内核缓冲区中按需读取任意长度的字节数据 → 由应用层自己根据协议格式解析出完整消息边界。这就是 TCP 面向字节流的本质:提供可靠、有序的字节传输通道,但把“语义”交给上层。

我们用一个简化图来还原整个流程:

PixPin_2025-10-06_22-16-21

现在来讲讲每一步背后的逻辑:

1. 应用层调用 write()

应用层产生数据转成字节拷贝进 内核的发送缓冲区。这时 write() 就可以返回了,并不意味着数据发出去了,只是进入了内核队列。

2. TCP 自动分片与聚合

发送缓冲区中的数据由 TCP 协议控制发送:

  • 如果太大,会拆成多个 TCP 段;
  • 如果太小,TCP 可能暂时不发(比如启用了 Nagle 算法),等待更多数据合并成一个包再发。

这一步是 TCP 的“面向字节流”特性在发送端的体现:它不管我们一次 write 写多少字节,只管按自己的节奏连续发字节。

3. 网络传输与确认

TCP 在传输过程中做三件事:

  1. 维护序号(保证有序)。
  2. 超时重传(保证可靠)。
  3. 滑动窗口(控制流量)。

丢了会重发,乱序会重排。

4. 接收方重组数据流

数据到达接收方内核后,TCP 会根据序号:把乱序的包重新排序,确认收到的字节,把连续的字节流写入 接收缓冲区

5. 应用层调用 read()

应用层从接收缓冲区中 按自己想要的长度 去读数据。读多少、读几次都行。TCP 不关心我们每次 read 的“分界”,它只负责保证我们收到的字节顺序正确。

6. 举个例子

write(fd, "Hello", 5) 写 5 次,每次 1 字节:

1
2
3
4
5
write(fd, "H", 1);
write(fd, "e", 1);
write(fd, "l", 1);
write(fd, "l", 1);
write(fd, "o", 1);

和一次写 5 字节:

1
write(fd, "Hello", 5);

这对 TCP 来说完全一样!它只看到 5 个连续字节:H e l l o

同样,接收方可以:

  • 一次 read(buf, 5) 读完;
  • 或 5 次 read(buf, 1) 逐字节读;
  • 甚至一次 read(buf, 100) 把这 5 字节和其他后续数据一起读进来。

TCP 不保证“写多少次,就读多少次”;也不保证“每次写的边界 = 每次读的边界”。 像水管输水,不管你倒一桶还是倒十桶,水流不断过去。水分段传输,但水的“总量”是靠序号控制的,不需要每次标记“这一桶多大”。

9. 粘包问题

1. 什么是粘包问题?

这就好像小时候家里蒸包子,出现从蒸笼里拿一个包子,结果连带一个或者多个包子一并被拿起一样。

TCP 粘包是因为 TCP 是面向字节流的协议,不保留消息边界,导致多个应用层消息被合并成一个 TCP 段传输。其根本就是 TCP 是面向字节流的协议,它只保证字节的顺序和可靠(不丢不重)传输,不关心一条消息从哪里开始、到哪里结束。举个例子:假设客户端连续发了两条消息:”hello”、”world”,底层 TCP 实际发送过程可能是:

  1. 一起发出去 → 收方一次 read() 得到 “helloworld”
  2. 拆成两次发 → 收方第一次 read() 得到 “hell”,第二次得到 “oworld”

无论哪种情况,对 TCP 来说都没错,它完成了“字节传输”。但对应用层来说,就糊涂了:到底哪 5 个字节是 “hello”,哪 5 个字节是 “world” 呢?这就是 粘包/拆包问题

注意:严格来说,“粘包”是 应用层术语,TCP 本身没有“包”的概念(它是字节流),所以更准确的说法是 “消息边界丢失”“应用层消息粘连”

2. 为什么会发生粘包?

主要有两个原因:

  1. TCP 是流式协议 —— 没有消息边界。
  2. 发送端的优化机制:TCP 可能把多次 write() 的小数据包合并成一个更大的包再发送(比如受 Nagle 算法 影响)。

比喻:连发两封信,TCP 相当于觉得信太小,顺手把它们塞进一个信封寄了出去。收信人收到一封信里两张纸,就要自己判断哪一张属于哪一封。

3. UDP 会不会有粘包问题?

不会。 因为:UDP 是 面向报文 的,每次 sendto() 发出的数据就是一个独立的报文,接收方 recvfrom() 一次只能收到一个完整的报文,如果太大,UDP 直接丢(不会拆分合并)。所以 UDP 可能会 丢包,但 不会粘包

4. 解决粘包的四种典型方案(应用层协议)

方案类型描述优点缺点常见场景
1. 定长报文每个消息的长度是固定的,比如每条 128 字节。实现最简单,无需解析。浪费带宽,不适合变长内容。心跳包、状态同步。
2. 特殊字符分隔每条消息结尾加一个独特分隔符,比如 \n\r\n# 等。简单直观,易调试。若数据本身可能包含该字符,就要转义或转码。文本协议:HTTP(\r\n\r\n)、Redis(\r\n)、FTP。
3. 定长报头 + 描述字段(自描述长度)报头中包含“消息体长度”,先读报头,再按长度读消息体。高通用性,可支持任意变长数据。需要两阶段解析(读头再读体)。二进制协议、RPC 通信、游戏服务器。
4. 自描述字段 + 特殊字符报头带长度字段,报尾再加结束符;双重保险。边界更安全、健壮。报文略复杂。通信要求高可靠性时(如金融系统)。

举个例子(第 3 种最常用),比如我们定义协议格式:| 报头:4字节消息体长度 | 报体:消息数据 |,假设发两条消息:”0005hello” 和 “0005world”,接收方解析逻辑:

  1. 先读 4 字节(0005) → 得知消息长度为 5。
  2. 再读 5 字节 → “hello”。
  3. 重复上述步骤 → “world”。

这样即使 TCP 把两条粘一起了,也能正确拆包。

5. 小结(可直接答面试)

粘包是因为 TCP 是字节流,没有边界概念,所以我们要在 用户层定义应用协议 划分消息。常见方案包括:

  1. 固定长度;
  2. 特殊分隔符;
  3. 定长报头 + 长度字段(最通用);
  4. 长度字段 + 分隔符(更健壮)。

UDP 不会粘包,因为它是面向报文的。

10. TCP 异常情况

1. 进程终止(正常退出或被 kill)

TCP 连接会正常断开(四次挥手),原理:当进程调用 exit()_exit() 或被 kill -9 终止时,内核会自动关闭该进程打开的所有文件描述符,包括 socket,关闭 socket 时,内核 TCP 协议栈会:

  1. 发送 FIN 报文(表示“我不会再发数据了”)。
  2. 进入 FIN-WAIT-1 状态。
  3. 后续完成标准的 四次挥手 流程。

即使进程是被 kill -9 强制杀死,内核仍会清理其资源并发送 FIN(因为 socket 是内核对象,进程只是持有 fd)。

对方表现:对端收到 FIN 后,read() 返回 0(表示对方关闭连接),应用可正常感知连接关闭,做清理工作。注意:

  • 如果进程退出前有未发送完的数据,内核会尝试发送(取决于 SO_LINGER 设置)。
  • 默认情况下,内核会尽力完成挥手,连接是“优雅关闭”的

2. 机器重启(操作系统重启)

所有 TCP 连接会被强制中断,但过程分两步,原理:

  1. 关机阶段

    • 系统 shutdown 时,会向所有进程发送 SIGTERM,然后 SIGKILL。
    • 内核会尝试关闭所有 socket,理想情况下会发送 FIN
    • 但如果关机太快,可能来不及发 FIN。
  2. 重启后

    • 所有旧连接的 socket 已被销毁。
    • 本机 TCP 状态机重置。
    • 对端仍认为连接存在(因为没收到 FIN 或 RST)。

关键问题:对端无法立即感知连接已断!

  • 对端继续发数据 → 本机收到后,发现无对应连接 → 回 RST(复位)
  • 对端收到 RST → 知道连接已失效,write() 会触发 SIGPIPE 或返回 ECONNRESET

TCP 保活机制详解(KeepAlive)| 知乎

但如果对端不发数据,它可能 长时间不知道连接已断(直到保活探测或应用超时)。

解决方案建议:

  • 应用层实现 心跳机制(定期 ping/pong)。
  • 启用 TCP Keep-Alive

TCP 长连接与短连接、心跳机制 | CSDN

0407.TCP 长连接心跳机制的实现 | B 站

3. 机器掉电 / 网线断开(非正常断连)

连接“静默失效”——双方都无法立即感知!,原理:本机突然断电或网线拔掉 → 无法发送任何 TCP 报文(包括 FIN、RST),对端:仍认为连接正常,若继续发数据 → 数据包到达对方(但对方已关机)→ 无 ACK 返回,经过多次重传超时后 → write() 返回 ETIMEDOUT,若一直不发数据 → 永远不知道连接已断!

这就是所谓的 “半开连接” —— 一方已断,另一方不知情。

默认超时时间有多长? Linux 默认 TCP 重传约 15 次,总超时可达 9~13 分钟!在此期间,连接“看似正常”,实则已失效。

1
2
# 查看重传次数和间隔(单位:秒)
cat /proc/sys/net/ipv4/tcp_retries2 # 通常为 15

4. 小结

异常场景本机能否发 FIN/RST?对端能否立即感知?是否会自动断开连接如何关闭建议应对措施
进程终止✅ 能(发 FIN)✅ 能(收到 FIN)✅ 正常断开正常四次挥手无需特殊处理
机器重启⚠️ 可能来不及发❌ 不能(除非对端发数据)❌ 不一定对端收到 RST启用 Keep-Alive 或心跳
掉电/断网❌ 完全不能❌ 不能(静默失效)❌ 不自动断开超时或 Keep-Alive 探测失败必须用心跳或调短 Keep-Alive
  1. 只有进程终止能保证优雅关闭;
  2. 系统级异常(重启、断电)会导致连接“假死”;
  3. TCP 本身无法快速检测物理层断连;
  4. 生产环境必须依赖:
    • 应用层心跳(推荐)
    • 或 调优 TCP Keep-Alive

记住:“TCP 可靠,但不万能;异常检测,靠心跳保命。”

11. TCP 小结

TCP 协议这么复杂就是因为 TCP 既要保证可靠性,同时又尽可能的提高性能。

可靠性:

  • 检验和: 检测数据传输中的错误。

  • 序列号: 确保数据按序到达,解决重复和乱序问题。

  • 确认应答: 接收方确认收到数据,形成闭环(核心)。

  • 超时重传: 发送方未及时收到确认则重发数据。

  • 连接管理: 三次握手建立连接,四次挥手释放连接。

  • 流量控制: 通过滑动窗口机制控制发送速率,避免接收方过载(也属于提高性能)。

  • 拥塞控制: 检测网络拥塞并调整发送速率(也属于提高性能)。

提高性能:

  • 滑动窗口: 允许发送方连续发送多个数据包。

  • 快速重传: 基于重复确认快速检测丢包并重传。

  • 延迟应答: 合并多个确认,减少网络开销。

  • 捎带应答: 在数据报文段中携带确认信息。

其他:TCP 定时器:

  • 重传定时器:为了控制丢失的报文段或丢弃的报文段,也就是对报文段确认的等待时间。
  • 坚持定时器:专门为对方零窗口通知而设立的,也就是向对方发送窗口探测的时间间隔。
  • 保活定时器:为了检查空闲连接的存在状态,也就是向对方发送探查报文的时间间隔。
  • TIME_WAIT 定时器:双方在四次挥手后,主动断开连接的一方需要等待的时长。

12. 基于 TCP 的应用层协议

常见的基于 TCP 的应用层协议如下:

  • HTTP(超文本传输协议)。
  • HTTPS(安全数据传输协议)。
  • SSH(安全外壳协议)。
  • Telnet(远程终端协议)。
  • FTP(文件传输协议)。
  • SMTP(电子邮件传输协议)。

当然,也包括自己写 TCP 程序时自定义的应用层协议。

13. UDP 实现可靠传输的思路 —— 具体场景具体分析,往 TCP 靠

虽然 UDP 本身是不可靠的,但可以在应用层实现类似 TCP 的可靠性机制:

  1. 引入序列号:为每个数据包分配唯一序号,确保数据有序。
  2. 确认应答机制:接收方收到数据后发送确认信息。
  3. 超时重传:设置合理的超时时间,未收到确认则重传。
  4. 滑动窗口:控制发送窗口大小,实现流量控制。
  5. 拥塞控制:根据网络状况动态调整发送速率。

14. 补充:listen 第二个参数(backlog)的准确理解

1. 函数原型

1
int listen(int sockfd, int backlog);

官方定义:backlog 参数指定了 内核为该套接字维护的等待接受的连接队列的最大长度

2. 实际含义

在现代 Linux 系统中,这个参数控制的是 全连接队列(accept queue) 的大小,即:

  • 已经完成三次握手的连接。
  • 等待应用层调用 accept() 函数取走的连接。

3. 关键要点(重要)

1. 队列机制

TCP 连接建立过程中涉及两个队列:

  • 半连接队列(SYN queue):处理三次握手过程中的连接(SYN_RECV 状态)。
  • 全连接队列(accept queue):已完成握手等待应用处理的连接(ESTABLISHED 状态)。

backlog 参数主要影响的是 全连接队列 的大小。

2. 实际队列长度

在 Linux 内核中,实际的全连接队列长度通常是 backlog + 1。这是因为:队列维护时会包含当前正在被 accept() 处理的连接,所以设置 backlog = 5 时,实际可容纳 6 个等待连接。

3. 连接拒绝机制

当全连接队列满时:新的连接请求会被内核拒绝,客户端可能会收到 ECONNREFUSED 错误,或者内核会静默丢弃连接请求(取决于具体配置)。

4. 历史演变

  • 早期实现backlog 表示半连接队列和全连接队列的总和。
  • 现代实现backlog 主要控制全连接队列,半连接队列由其他内核参数控制。

5. 实际应用

  • Web 服务器:通常设置为 128、256 或 512。
  • 高并发服务:可以适当增大,如 1024 或更高。
  • 考虑系统限制:受 /proc/sys/net/core/somaxconn 内核参数限制。
  • 监控队列状态:通过 ss -tlnp 等命令查看队列使用情况。

简单来说,backlog 参数决定了服务器能够同时 “挂起” 多少个已经建立但尚未被应用程序处理的 TCP 连接。