052 传输层 —— TCP(下)

052 传输层 —— TCP(下)
小米里的大麦传输层 —— TCP(下)
1. TCP 流量控制
1. 什么是流量控制?
先想一个比喻:你在和朋友聊天,对方打字太快,你还没看完一句他又发十条,你就“被淹没”了。TCP 里也一样,发送方(Sender)发数据太快,而接收方(Receiver)处理不过来,就会导致:
- 接收缓冲区溢出(数据丢失)
- 重传、拥塞、效率下降
所以 TCP 设计了 流量控制机制(Flow Control),让接收方告诉发送方:“我现在只能接收这么多数据,请你慢一点。”它是一种防止发送方发送数据过快,导致接收方来不及处理而造成数据丢失的机制,其核心目标是 匹配发送速率与接收能力。
2. 实现方式:接收窗口
TCP 报文头部包含一个 16 位的窗口字段(Window Size),表示接收方当前还能接收多少字节的数据(即接收缓冲区剩余空间)。发送方根据这个窗口大小决定最多能发送多少未确认的数据。如果接收方缓冲区快满了,它会通告一个较小的窗口;如果缓冲区空了,就通告一个较大的窗口(甚至为 0)。
注意:窗口大小字段最大为 65535 字节(64 KB),这对早期的低速网络还行,但在高速宽带(比如千兆)环境下,64KB 窗口太小,会严重限制吞吐量。于是 RFC 1323 引入了“窗口缩放选项(Window Scale Option,RFC 1323)”,根据原始 RFC 1323(后来更新到 RFC 7323)在 三次握手阶段,双方可以协商一个窗口缩放因子 S,范围是:[0,14],也就是说,报文里的 16 位窗口字段只是一个“基值”,实际值还要乘上缩放因子。
1 | 实际窗口大小 = 通告窗口大小 × (2^S) |
所以 理论上最大窗口 ≈ 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 | # 查看TCP窗口缩放功能是否启用,输出1表示启用TCP窗口缩放(RFC 1323定义的扩展) |
2. 滑动窗口
1. 什么是滑动窗口?
这里的滑动窗口和在算法中的滑动窗口算法可以认为是一致的,如果滑动窗口算法你有所了解,那么此处很容易理解,因为原理一致!
TCP 滑动窗口 = 流量控制的核心机制,用于让发送方根据接收方的处理能力动态调整发送速率,防止“发太快、收不动”。
滑动窗口是 TCP 实现 流量控制 + 高效传输 的关键机制。它允许发送方在 未收到确认的情况下 连续发送多个数据段,而不是“发送一个、等待一个”。其本质是一个“动态的发送许可”。
- 本质:就是 TCP 为了提高传输 效率 设计的一种机制。
- 目的:不用每发一个包就等确认,而是允许「一批包」在途,从而实现一次可以发送大量 TCP 报文。
可以理解为:“发送方最多能发多少还没被确认的数据(未 ACK 的数据)。”这个“能发的范围”由 接收方 决定, 接收方通过 TCP Header 里的 Window 字段(窗口大小) 通知发送方,举个直观例子(假设):
- 接收方当前窗口大小为
4096 bytes。 - 每个数据包
1024 bytes。
发送方就能 连续发 4 个包(4096 bytes)后,必须停下来等 ACK。 等接收方确认了第 1 个包,它的窗口“右移”,发送方又能继续发新的一个。
2. 滑动窗口的组成(发送方视角)
发送窗口通常分为四部分(按序列号顺序):
| 区域 | 说明 | 是否占用缓冲区 |
|---|---|---|
| 已发送且已确认(收到 ACK) | 可以丢弃,窗口向前滑动 | 不再占用(可释放) |
| 已发送但未确认(未收到 ACK) | 正在等待 ACK | 占用 |
| 未发送但可发送 | 在窗口内,可立即发送 | 占用(待发) |
| 未发送且不可发送(存在感不高,可忽略,而且是窗口之外的空间,严格来说 不属于滑动窗口本身) | 超出窗口,需等待窗口滑动 | 占用(但被“冻结”) |
发送方的 发送窗口 是一个区间,主要分为三块:
- 已发送且已确认的数据(窗口左边,已经安全了)
- 已发送但未确认的数据(窗口中间,在途数据)
- 允许发送但还没发送的数据(窗口右边,可以继续发)
重点:理论上的滑动窗口大小 = 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. 向右移动:
- 当收到 ACK(比如 ACK = x),说明 x 之前的数据都确认了,窗口整体向右滑动。
- 发送窗口的 左边界 就移动到 x,释放已确认的数据空间,这意味着一些数据已经确认,不用再管了。
- 如果接收方缓冲区有空闲,还会在 ACK 中携带新的接收窗口大小,可能让 右边界也右移(窗口变大)。
2. 窗口大小会变化吗?
会!而且经常变!
- 变大:接收方处理了数据,缓冲区空出 → 接收窗口大小增大 → 窗口右边界右移。
- 变小:接收方缓冲区快满了 → 接收窗口大小减小 → 窗口右边界左移(但左边界不能左移!)。
- 变为 0:接收方缓冲区完全满 → 接收窗口大小 = 0 → 窗口大小为 0,发送方暂停发送(进入“零窗口探测”状态)。
注意:窗口左边界只能右移或不动,不能左移(因为已确认的数据不能“反悔”)。
5. 滑动窗口会“越界”发送缓冲区吗?
TCP 采用了类似环状算法,始终保证滑动窗口不会越界!
不会。
- 滑动窗口的右边界(即“可发送的最大序号”)永远不会超过发送缓冲区的容量。
- 如果应用程序写入的数据太多,而窗口太小(比如接收窗口大小 = 0),
send()系统调用会 阻塞(或返回 EAGAIN,如果是非阻塞 socket),直到窗口有空间。 - 因此,滑动窗口始终被限制在发送缓冲区内,不会越界。
6. 流量控制是通过滑动窗口实现的吗?
是的!流量控制的核心就是滑动窗口!
流量控制的本质:防止发送方发得太快,把接收方“撑爆”。实现方式:接收方通过 TCP 报文头中的 Window 字段(即接收窗口大小) 告诉发送方自己还能收多少。发送方据此 动态调整滑动窗口的大小。当 接收窗口大小 = 0 时,发送方停止发送(除零窗口探测包外)。
滑动窗口是流量控制的执行机制,接收窗口大小是流量控制的控制信号。
7. 如果发生丢包,滑动窗口怎么处理?
丢包主要影响 拥塞控制,但也会间接影响滑动窗口:
- 发送方发现丢包(通过超时或重复 ACK):
- 会触发重传(重发未确认的数据)。
- 同时 减小拥塞窗口(比如减半)→ 导致滑动窗口变小。
- 滑动窗口本身不会“回退”:
- 已确认的部分不会撤销。
- 未确认的部分继续等待 ACK 或重传。
- 窗口左边界不变,右边界可能因 拥塞窗口 减小而左移(窗口缩小)。
- 接收方视角:
- 如果中间丢包,接收方会重复发送 最后一个正确 ACK(比如一直 ACK = 1001)。
- 发送方收到 3 个重复 ACK 后,会快速重传序号 1001 开始的数据(快速重传机制)。
重点:滑动窗口只向前滑(左边界不回退),丢包通过重传 + 调整拥塞窗口大小来处理。
3. 拥塞控制
1. 什么是拥塞控制?
拥塞控制 是 TCP 协议中的一种 机制,用于 防止发送方因发送数据过快而导致网络过载(网络中的路由器或链路被过多数据淹没/拥塞),从而避免大量丢包、延迟剧增甚至网络崩溃。它的目标是:既要尽可能高效利用带宽,又要避免让网络“堵车”。
关键点:
- 流量控制 → 保护 接收方(别发太快,我处理不过来),防止「发送方」把「接收方」撑爆。
- 拥塞控制 → 保护 整个网络(别发太多,网络会堵),防止「所有发送方」把「网络」堵死。
2. 为什么要有拥塞控制?
如果所有主机都“无脑狂发数据”,网络会出现:路由器缓存溢出(包被丢弃)、延迟飙升、重传风暴(越丢越发),最终导致整个网络吞吐量下降,也就是所谓的“网络崩溃”。
- 网络资源有限:网络资源本质还是 共享资源,路由器缓存、带宽都是有限的。
- 无控制的后果:
- 多个发送方同时高速发包 → 路由器缓存溢出 → 大量丢包。
- 丢包触发重传 → 更多数据进入网络 → 恶性循环(拥塞崩溃)。
因此,TCP 必须“感知”网络状态,并 自适应调整发送速率。
3. 如何判断网络是否拥塞?
TCP 无法直接看到网络状态,只能通过 间接信号 推断拥塞:
| 拥塞信号 | 说明 |
|---|---|
| 超时(Timeout) | 数据包长时间未收到 ACK → 很可能已丢弃(严重拥塞) |
| 重复 ACK | 接收方收到乱序包,反复确认最后一个正确序号(如连续 3 次 ACK = 1001)→ 中间包可能丢失(轻度拥塞) |
这两个事件是触发拥塞控制算法动作的关键“警报”。
4. 拥塞控制的核心机制与关键概念
在热恋中,人总是“小心试探”——
- 一开始不敢太快靠近(慢启动)。
- 发现对方反应良好,就逐步增加接触(拥塞避免/快增长)。
- 如果对方突然冷淡(丢包/超时),就立刻收敛,变得保守(快恢复/慢启动重启)。
TCP 也是这样:它“试探”网络承受能力,逐渐增加速度,一旦发现不对劲(丢包),马上退回去,避免网络“受伤”。
TCP 拥塞控制通过一个 拥塞窗口 来限制发送速率。
| 阶段/策略 | 触发条件 | 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 的解决思路就是「自我降速 + 逐步恢复」:
- 动态调整 cwnd(拥塞窗口)。
- 根据 ACK 反馈推断网络状态。
- 丢包时快速响应,防止恶化。
- 利用慢启动和线性增长实现自适应调节。
现代系统还在此基础上发展出了许多变种算法,如:
- Reno(经典)
- NewReno(改进快恢复)
- CUBIC(Linux 默认)
- BBR(基于带宽估计,Google 提出)
4. 流量控制 VS 拥塞窗口
| 对比维度 | 流量控制 | 拥塞控制 |
|---|---|---|
| 控制目标 | 防止 接收方 缓冲区溢出 | 防止 网络 整体拥塞 |
| 控制范围 | 端到端(发送方与接收方之间) | 全局(整个网络路径) |
| 触发因素 | 接收方缓冲区容量 | 网络路由器队列溢出、分组丢失 |
| 关键参数 | 接收窗口 (rwnd) | 拥塞窗口 (cwnd)、阈值 (ssthresh) |
| 实现方式 | 接收方反馈窗口大小 | 发送方主动调整发送速率 |
| 典型算法 | 滑动窗口协议 | 慢启动、拥塞避免、快重传、快恢复 |
| 窗口增长 | 根据接收方反馈动态调整 | 指数增长 → 线性增长 → 快速下降 |
| 检测机制 | 零窗口检测 | 超时重传、重复 ACK 检测 |
TCP 是全双工通信(双方都能同时发数据和 ACK)。但发送 ACK(确认号)本身也要占用网络资源,如果对方每发一个小包,我就立刻回一个 ACK,就会增加很多小包通信 → 影响效率。所以:TCP 引入了 延迟应答(Delayed ACK)和 捎带应答(Piggyback ACK)来优化。
5. 延迟应答
1. 类比场景
- A:你吃饭了吗?(B 听到了,但暂时没回——他在想自己是不是也要说点别的)。
- 几秒后:B:吃了(这句话既是回答,也是“我听到你说话了”的信号)。
- 如果 B 想了半天没要说的,就会单独说一句:B:听到了!
2. 对应 TCP 含义
- TCP 收到数据后,不急着立刻回 ACK,而是 稍微等一下,看看自己是否要发送数据回去。
- 如果这段时间内有要发的数据 → 就在数据包里“顺便带上 ACK”。
- 如果一直没有新数据 → 到了延迟时间(比如 40ms)再单独发 ACK。
3. 作用
- 减少纯 ACK 包数量,降低网络负担;
- 提高吞吐率,让数据传输更高效。
6. 捎带应答
1. 类比场景
- A:吃饭了吗?
- 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 面向字节流的本质:提供可靠、有序的字节传输通道,但把“语义”交给上层。
我们用一个简化图来还原整个流程:
现在来讲讲每一步背后的逻辑:
1. 应用层调用 write()
应用层产生数据转成字节拷贝进 内核的发送缓冲区。这时 write() 就可以返回了,并不意味着数据发出去了,只是进入了内核队列。
2. TCP 自动分片与聚合
发送缓冲区中的数据由 TCP 协议控制发送:
- 如果太大,会拆成多个 TCP 段;
- 如果太小,TCP 可能暂时不发(比如启用了 Nagle 算法),等待更多数据合并成一个包再发。
这一步是 TCP 的“面向字节流”特性在发送端的体现:它不管我们一次 write 写多少字节,只管按自己的节奏连续发字节。
3. 网络传输与确认
TCP 在传输过程中做三件事:
- 维护序号(保证有序)。
- 超时重传(保证可靠)。
- 滑动窗口(控制流量)。
丢了会重发,乱序会重排。
4. 接收方重组数据流
数据到达接收方内核后,TCP 会根据序号:把乱序的包重新排序,确认收到的字节,把连续的字节流写入 接收缓冲区。
5. 应用层调用 read()
应用层从接收缓冲区中 按自己想要的长度 去读数据。读多少、读几次都行。TCP 不关心我们每次 read 的“分界”,它只负责保证我们收到的字节顺序正确。
6. 举个例子
用 write(fd, "Hello", 5) 写 5 次,每次 1 字节:
1 | write(fd, "H", 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 实际发送过程可能是:
- 一起发出去 → 收方一次 read() 得到 “helloworld”
- 拆成两次发 → 收方第一次 read() 得到 “hell”,第二次得到 “oworld”
无论哪种情况,对 TCP 来说都没错,它完成了“字节传输”。但对应用层来说,就糊涂了:到底哪 5 个字节是 “hello”,哪 5 个字节是 “world” 呢?这就是 粘包/拆包问题。
注意:严格来说,“粘包”是 应用层术语,TCP 本身没有“包”的概念(它是字节流),所以更准确的说法是 “消息边界丢失” 或 “应用层消息粘连”。
2. 为什么会发生粘包?
主要有两个原因:
- TCP 是流式协议 —— 没有消息边界。
- 发送端的优化机制: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”,接收方解析逻辑:
- 先读 4 字节(0005) → 得知消息长度为 5。
- 再读 5 字节 → “hello”。
- 重复上述步骤 → “world”。
这样即使 TCP 把两条粘一起了,也能正确拆包。
5. 小结(可直接答面试)
粘包是因为 TCP 是字节流,没有边界概念,所以我们要在 用户层定义应用协议 划分消息。常见方案包括:
- 固定长度;
- 特殊分隔符;
- 定长报头 + 长度字段(最通用);
- 长度字段 + 分隔符(更健壮)。
UDP 不会粘包,因为它是面向报文的。
10. TCP 异常情况
1. 进程终止(正常退出或被 kill)
TCP 连接会正常断开(四次挥手),原理:当进程调用 exit()、_exit() 或被 kill -9 终止时,内核会自动关闭该进程打开的所有文件描述符,包括 socket,关闭 socket 时,内核 TCP 协议栈会:
- 发送 FIN 报文(表示“我不会再发数据了”)。
- 进入 FIN-WAIT-1 状态。
- 后续完成标准的 四次挥手 流程。
即使进程是被
kill -9强制杀死,内核仍会清理其资源并发送 FIN(因为 socket 是内核对象,进程只是持有 fd)。
对方表现:对端收到 FIN 后,read() 返回 0(表示对方关闭连接),应用可正常感知连接关闭,做清理工作。注意:
- 如果进程退出前有未发送完的数据,内核会尝试发送(取决于 SO_LINGER 设置)。
- 默认情况下,内核会尽力完成挥手,连接是“优雅关闭”的。
2. 机器重启(操作系统重启)
所有 TCP 连接会被强制中断,但过程分两步,原理:
关机阶段:
- 系统 shutdown 时,会向所有进程发送 SIGTERM,然后 SIGKILL。
- 内核会尝试关闭所有 socket,理想情况下会发送 FIN。
- 但如果关机太快,可能来不及发 FIN。
重启后:
- 所有旧连接的 socket 已被销毁。
- 本机 TCP 状态机重置。
- 对端仍认为连接存在(因为没收到 FIN 或 RST)。
关键问题:对端无法立即感知连接已断!
- 对端继续发数据 → 本机收到后,发现无对应连接 → 回 RST(复位)。
- 对端收到 RST → 知道连接已失效,
write()会触发 SIGPIPE 或返回 ECONNRESET。
但如果对端不发数据,它可能 长时间不知道连接已断(直到保活探测或应用超时)。
解决方案建议:
- 应用层实现 心跳机制(定期 ping/pong)。
- 启用 TCP Keep-Alive。
3. 机器掉电 / 网线断开(非正常断连)
连接“静默失效”——双方都无法立即感知!,原理:本机突然断电或网线拔掉 → 无法发送任何 TCP 报文(包括 FIN、RST),对端:仍认为连接正常,若继续发数据 → 数据包到达对方(但对方已关机)→ 无 ACK 返回,经过多次重传超时后 → write() 返回 ETIMEDOUT,若一直不发数据 → 永远不知道连接已断!
这就是所谓的 “半开连接” —— 一方已断,另一方不知情。
默认超时时间有多长? Linux 默认 TCP 重传约 15 次,总超时可达 9~13 分钟!在此期间,连接“看似正常”,实则已失效。
1 | # 查看重传次数和间隔(单位:秒) |
4. 小结
| 异常场景 | 本机能否发 FIN/RST? | 对端能否立即感知? | 是否会自动断开 | 连接如何关闭 | 建议应对措施 |
|---|---|---|---|---|---|
| 进程终止 | ✅ 能(发 FIN) | ✅ 能(收到 FIN) | ✅ 正常断开 | 正常四次挥手 | 无需特殊处理 |
| 机器重启 | ⚠️ 可能来不及发 | ❌ 不能(除非对端发数据) | ❌ 不一定 | 对端收到 RST | 启用 Keep-Alive 或心跳 |
| 掉电/断网 | ❌ 完全不能 | ❌ 不能(静默失效) | ❌ 不自动断开 | 超时或 Keep-Alive 探测失败 | 必须用心跳或调短 Keep-Alive |
- 只有进程终止能保证优雅关闭;
- 系统级异常(重启、断电)会导致连接“假死”;
- TCP 本身无法快速检测物理层断连;
- 生产环境必须依赖:
- 应用层心跳(推荐)
- 或 调优 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 的可靠性机制:
- 引入序列号:为每个数据包分配唯一序号,确保数据有序。
- 确认应答机制:接收方收到数据后发送确认信息。
- 超时重传:设置合理的超时时间,未收到确认则重传。
- 滑动窗口:控制发送窗口大小,实现流量控制。
- 拥塞控制:根据网络状况动态调整发送速率。
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 连接。

















