TCP 协议相关知识 2
本节继续总结 TCP 协议,首先从字节流特性出发分析粘包问题,并整理分隔符、固定长度、长度字段和短连接等常见消息边界处理方法。
随后介绍心跳机制、TCP KeepAlive、Nagle 算法和拥塞控制,并对 TCP、UDP 及基于 UDP 构建可靠传输的思路进行综合比较。
一、TCP 粘包问题
1. 什么是粘包
TCP 是一种面向字节流的传输协议。
发送端多次调用 send 时,TCP 只把这些数据看成一串连续的字节,并不记录应用程序每一次 send 的边界。接收端调用 recv 时,读取到的是当前接收缓冲区中能够取得的一段字节。
因此可能出现:
- 发送端连续发送的多个数据包,被接收端一次
recv读出; - 发送端一次发送的数据,被接收端分多次
recv读出; - 一次
recv读到前一个数据包的结尾和后一个数据包的开头。
所谓“粘包”,本质上不是 TCP 把数据传错了,而是应用层没有办法从连续字节流中识别每一条消息的边界。
2. 为什么会出现粘包
常见原因包括:
- TCP 只负责可靠地传输字节流,不保留消息边界;
- 发送端和接收端都存在缓冲区;
send与recv并不是一一对应的;- 操作系统可能合并较小的数据后再发送;
- 网络状况和接收方读取速度会影响每次
recv得到的数据量。
粘包的形成位置分成两类:
- 发送端粘包:多个较小数据在发送缓冲区中被合并后发送,Nagle 算法也可能促成这种合并;
- 接收端粘包:接收程序没有及时读取,或一次读取了缓冲区中的多条消息,导致多条应用层数据连在一起。
需要牢记:
一次 send ≠ 对方的一次 recvTCP 能保证字节的顺序和可靠性,但应用程序必须自己设计“如何拆包”。
二、解决粘包问题的四种方法
方法一:设置开始或结束标志
在每条消息的开始或结束位置放置特殊标志。接收端不断读取数据,遇到该标志后,就认为一条完整消息结束。
例如:
你好\n吃饭了吗\n其中 \n 可以作为结束标志。
优点:
- 实现思路简单;
- 消息长度可以变化。
缺点:
- 正文中也可能出现相同标志;
- 如果正文允许出现标志,需要设计转义规则;
- 接收端要在缓存中查找分隔符。
方法二:固定数据包大小
规定每条消息都占固定数量的字节,例如每个包固定为 100 字节。接收端每收满 100 字节,就得到一条完整消息。
优点:
- 接收端容易判断消息边界;
- 拆包逻辑简单。
缺点:
- 数据不足固定长度时会浪费空间;
- 数据超过固定长度时仍要额外拆分;
- 不适合消息长度变化很大的场景。
方法三:先发送数据长度,再发送数据
发送端先发送一个固定长度的“包头”,在包头中说明后续数据的大小;接收端先取得长度,再按该长度读取正文。
数据格式可以表示为:
┌──────────────┬────────────────────────┐│ 数据长度 │ 数据内容 ││ 固定大小 │ 长度由前面的字段决定 │└──────────────┴────────────────────────┘接收过程:
先接收长度字段 ↓根据长度申请空间 ↓再接收指定长度的数据 ↓还原并处理完整消息优点:
- 适合可变长度消息;
- 接收端能够准确知道还需要读取多少字节;
- 是实际项目中常用的应用层封包方式。
缺点:
- 需要分别处理包头和包体;
- 必须校验长度,防止错误或恶意长度导致内存问题。
1. 示例结构体
struct Node{ int a; char b[10]; long c;};2. 发送端代码
struct Node n1;int nSize = sizeof(n1);
// 先发送数据包大小send(sockClient, (char*)&nSize, sizeof(int), 0);
// 再发送数据包send(sockClient, (char*)&n1, sizeof(n1), 0);这段代码先让接收端知道结构体数据有多大,然后再发送结构体本身。
3. 接收端代码
int nPackSize = 0;
// 先接收数据包大小recv(sockClient, (char*)&nPackSize, sizeof(int), 0);
// 根据收到的大小申请空间char* buf = new char[nPackSize];
// 再接收数据包recv(sockClient, buf, nPackSize, 0);
delete[] buf;4. 将接收缓冲区解释为结构体
方式一:使用结构体指针。
struct Node* p = (struct Node*)buf;cout << p->a << endl;方式二:复制出一个结构体对象。
struct Node p1 = *(struct Node*)buf;cout << p1.a << endl;区别:
p指向buf中的原始数据,不会另外复制一个结构体;p1会把buf中的内容复制到新的结构体对象中。
方法四:使用短连接
每发送一条或一组数据就建立一次连接,发送结束后关闭连接。连接关闭可作为本次数据结束的标志。
优点:
- 消息边界容易确定。
缺点:
- 频繁进行三次握手和四次挥手;
- 连接建立与释放的开销较大;
- 不适合需要频繁通信的场景。
四种方法对比
| 方法 | 消息长度 | 优点 | 主要问题 |
|---|---|---|---|
| 开始/结束标志 | 可变 | 直观、容易理解 | 标志冲突,需要转义 |
| 固定包大小 | 固定 | 拆包简单 | 浪费空间,灵活性差 |
| 长度字段 + 数据 | 可变 | 通用、边界明确 | 要处理不完整读写并校验长度 |
| 短连接 | 可变 | 可用断开表示结束 | 建连、断连开销大 |
三、心跳机制
1. 为什么需要心跳
TCP 长连接建立后,如果双方长时间没有业务数据,仅靠“当前没有收到消息”不能判断:
- 对方仍然在线,只是暂时没有发送数据;
- 对方程序已经崩溃;
- 对方主机断电;
- 中间网络已经断开;
- 连接实际上已经失效。
因此,可以定期发送很小的固定消息,用来检查对端是否仍然存活,这就是心跳机制。
2. 基本工作过程
客户端 服务端 │────── 心跳请求 ──────────>│ │<───── 心跳响应 ───────────│ │ │ │ 等待一个固定时间间隔 │ │────── 心跳请求 ──────────>│ │<───── 心跳响应 ───────────│如果在规定时间内连续多次没有得到响应,程序可以认为连接已经失效,并执行:
- 关闭当前连接;
- 清理连接资源;
- 通知上层业务;
- 必要时尝试重新连接。
3. 两种实现方式
应用层自定义心跳
通信双方自行规定心跳包和响应包。
特点:
- 可以根据具体业务设置时间间隔和失败次数;
- 可以携带业务状态;
- 应用程序可以明确知道对端业务是否仍在正常运行;
- 需要自己编写定时、超时和重连逻辑。
TCP KeepAlive
通过套接字选项开启 TCP 自带的保活检测:
int keepAlive = 1;setsockopt(sock, SOL_SOCKET, SO_KEEPALIVE, (char*)&keepAlive, sizeof(keepAlive));特点:
- 由操作系统的 TCP 协议栈完成;
- 主要判断 TCP 连接是否仍然可达;
- 默认探测时间通常比较长,具体参数与操作系统有关;
- 它不能完全替代业务层心跳,因为 TCP 连接正常不代表应用业务一定正常。
四、Nagle 算法
1. 作用
如果应用程序频繁发送很小的数据包,大量 TCP/IP 首部会增加网络开销。
Nagle 算法的目标是:
尽量把多个小数据合并成较大的数据段再发送,减少“小包”数量,提高网络利用率。
Nagle 算法通常默认开启。
2. 发送条件
满足下列条件之一时,数据可以被发送:
- 数据长度达到 MSS;
- 数据中包含
FIN; - 设置了
TCP_NODELAY,即关闭 Nagle 算法; - 未设置
TCP_CORK选项,并且此连接上先前发送的小数据已经全部收到确认; - 前面条件都不满足,但等待达到超时时间,通常按约 200 ms 理解。
3. MSS
MSS 是 Maximum Segment Size,即最大报文段数据长度。
它表示一个 TCP 报文段中能够承载的最大应用数据量,不包含 TCP 首部和 IP 首部。
4. Nagle 算法的优缺点
优点:
- 减少小数据包的数量;
- 减少协议首部开销;
- 提高带宽利用率。
缺点:
- 为了等待更多数据,可能增加发送延迟;
- 对交互性强、要求低延迟的程序不一定合适。
例如终端输入、实时操作等场景,可能更关注响应速度,因此会考虑关闭 Nagle 算法。
5. 使用 TCP_NODELAY 关闭 Nagle 算法
int value = 1;setsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, (char*)&value, sizeof(int));含义:
TCP_NODELAY = 1:关闭 Nagle 算法,小数据可以更及时地发送;TCP_NODELAY = 0:允许使用 Nagle 算法。
关闭 Nagle 并不等于程序一定更快,它是在“更低延迟”和“更少网络开销”之间作选择。
TCP_CORK 会让发送端尽量先积累数据、再组成较大的报文段发送;它与 TCP_NODELAY 的关注点不同,而且主要见于 Linux。当前阶段先记住它会影响“小数据是否立即发送”即可。
五、TCP 拥塞控制
1. 什么是网络拥塞
网络中的路由器和交换机,其处理速度与缓存空间都是有限的。
当发送方送入网络的数据过多、过快时,可能出现:
- 中间设备排队加剧;
- 网络时延升高;
- 缓存被占满;
- 数据包丢失;
- 发送方超时后再次重传;
- 重传又进一步增加网络负担。
如果不进行控制,可能形成“越丢越重传,越重传越拥塞”的恶性循环。
拥塞控制的目标是根据网络状态调整发送速度,避免向网络中一次注入过多数据。
拥塞控制还可以让共享网络资源的连接获得相对更公平的使用机会。但不同连接的网络路径、RTT 和丢包情况并不相同,因此这种公平是相对的,不是理论上的绝对平均。
2. 流量控制与拥塞控制的区别
| 对比项 | 流量控制 | 拥塞控制 |
|---|---|---|
| 保护对象 | 接收方 | 整个传输网络 |
| 主要问题 | 接收方来不及处理 | 网络设备和链路负担过重 |
| 关键窗口 | 接收窗口 rwnd | 拥塞窗口 cwnd |
| 信息来源 | 接收端通告的可用缓冲区 | ACK、丢包、超时等网络反馈 |
发送方真正能够发送而未确认的数据量,通常受两者共同限制:
实际发送窗口 = min(rwnd, cwnd)3. 两个重要变量
拥塞窗口 cwnd
由发送方维护,用于限制当前可以向网络中发送但尚未确认的数据量。
- 网络状况良好时,逐步增大;
- 检测到拥塞时,减小。
慢开始门限 ssthresh
用于决定使用慢开始还是拥塞避免:
cwnd < ssthresh:执行慢开始;cwnd > ssthresh:执行拥塞避免;cwnd == ssthresh:两种算法都可以进入,具体以实现为准。
4. 四种拥塞控制算法
4.1 慢开始
“慢开始”是指一开始的拥塞窗口较小,并不是窗口增长得慢。
基本过程:
- 连接刚开始时,把
cwnd设置得较小; - 收到确认后逐步增加
cwnd; - 在理想化模型中,每经过一个 RTT,窗口大约扩大为原来的两倍;
- 当窗口达到
ssthresh后,转入拥塞避免。
示意:
RTT 次数: 0 1 2 3 4cwnd: 1 2 4 8 16 MSS它采用近似指数增长,目的是快速探测当前网络能够承受的发送量。
4.2 拥塞避免
当 cwnd 达到慢开始门限后,如果仍然按指数增长,网络很容易迅速拥塞。因此转入较平缓的增长阶段。
- 慢开始:指数增长;
- 拥塞避免:线性增长;
- 每经过一个 RTT,
cwnd大约增加 1 MSS。
示意:
慢开始: 1 → 2 → 4 → 8 → 16拥塞避免: 16 → 17 → 18 → 19 → 204.3 超时后的处理
重传定时器超时通常说明网络中出现了较严重的拥塞。
调整过程:
ssthresh = 当前 cwnd / 2cwnd = 1 MSS重新进入慢开始这是一种较强的降速方式。
4.4 快重传
正常情况下,接收方收到失序报文段后,会重复确认自己下一步仍然希望得到的序号。
当发送方连续收到 3 个重复 ACK 时,可以推断某个报文段很可能已经丢失,不必等待重传定时器超时,立即重传缺失的报文段。
这就是快重传。
作用:
- 更早发现丢包;
- 减少等待超时造成的停顿;
- 提高恢复速度。
4.5 快恢复
连续收到重复 ACK,说明虽然有个别报文段丢失,但网络仍然能够继续传输后面的报文段,因此拥塞程度通常没有“超时”那么严重。
基本过程:
收到 3 个重复 ACK ↓立即重传丢失的报文段 ↓ssthresh = 当前 cwnd / 2 ↓cwnd = ssthresh + 3 MSS ↓每多收到一个重复 ACK,cwnd 再增加 1 MSS ↓收到新的 ACK 后退出快恢复,进入拥塞避免4.6 两种丢包信号的处理对比
| 发现方式 | 对拥塞程度的判断 | 典型处理 |
|---|---|---|
| 重传超时 | 拥塞较严重 | ssthresh 减半,cwnd 降至较小值,重新慢开始 |
| 3 个重复 ACK | 仍有数据能够到达,拥塞相对较轻 | 快重传并进入快恢复 |
4.7 四种算法之间的关系
连接开始 ↓慢开始(指数增长) ↓ 达到 ssthresh拥塞避免(线性增长) ├─ 发生超时 → 大幅减小 cwnd → 慢开始 └─ 3 个重复 ACK → 快重传 → 快恢复 → 拥塞避免六、TCP 协议总结
1. TCP 的三个核心特点
TCP 是:
- 面向连接的协议;
- 可靠传输的协议;
- 基于字节流的传输协议。
2. 面向连接
通信前,客户端和服务端需要先建立连接。
- 建立连接:三次握手;
- 释放连接:四次挥手;
- 一条 TCP 连接的两个端点之间进行点对点通信。
3. 可靠传输
TCP 通过多种机制实现可靠传输,例如:
- 序列号
seq; - 确认号
ack; - 确认机制;
- 超时重传;
- 快重传;
- 校验;
- 失序数据重排;
- 合理分段。
4. 字节流与粘包
TCP 不保留应用层消息边界,因此应用程序必须自行制定消息格式。
四种拆包方法:
- 先发送包大小,再发送数据;
- 添加开始或结束标志;
- 固定包大小;
- 使用短连接。
5. TCP 的控制机制
- 流量控制:通过滑动窗口避免接收方来不及处理;
- 拥塞控制:通过慢开始、拥塞避免、快重传、快恢复调节网络中的数据量;
- 心跳或保活:发现长连接是否已经失效;
- Nagle 算法:减少网络中的小数据包。
6. TCP 是否支持广播
TCP 面向连接,一条连接的通信端点是确定的,因此不能像 UDP 那样直接进行广播或组播。
七、扩展知识:协议选择与“可靠 UDP”
1. 一个应用不一定只使用一种传输协议
协议的选择应该服从具体业务,而不是简单地认为“整个软件只能使用 TCP”或“整个软件只能使用 UDP”。
同一个功能较复杂的应用,可以在不同场景中组合使用 TCP 和 UDP。例如:
- 游戏中的实时状态更新更看重及时性,可以考虑 UDP;
- 游戏中的文字聊天、购买或赠送物品更看重可靠性,可以考虑 TCP;
- 即时通信中的文字、文件传输更看重完整可靠,可以考虑 TCP;
- 实时音视频更看重低延迟,可以考虑 UDP;
- 以文件存取为核心的应用,可能主要使用 TCP。
FTP 是应用层的文件传输协议,其底层使用 TCP 提供可靠传输。
结论:
不要脱离业务直接问“TCP 和 UDP 哪个更好”,而要先判断该功能更重视可靠性、实时性,还是可控性。
2. 在 UDP 之上增加可靠机制
UDP 本身不提供 TCP 那样的可靠传输,但应用层可以选择性实现部分可靠机制:
- 为每个应用数据包增加序号
SEQ; - 接收方收到数据后返回确认
ACK; - 发送方启动定时器;
- 在超时时间内没有收到确认,就重新发送;
- 使用发送队列保存尚未确认的数据;
- 引入滑动窗口,一次发送多个包,提高传输效率。
示意:
应用层发送队列:1 2 3 4 5 6 ...当前窗口: [1 2 3] │ └── 收到 ACK=4 后,窗口向后移动下一窗口: [4 5 6]这些机制的思想与 TCP 中的 SEQ、ACK、滑动窗口和超时重传相似,但实现位置从传输层变成了应用层。
3. 为什么不一定直接使用 TCP
应用层在 UDP 之上实现可靠性,会比原始 UDP 增加开销,但可以只选取业务真正需要的能力。例如:
- 保留 UDP 的报文边界,不产生 TCP 字节流的拆包问题;
- 自行决定确认、重传和窗口策略;
- 不必照搬 TCP 的所有控制机制;
- 可以针对实时业务在可靠性与延迟之间作更细的取舍。
相应地,应用程序也必须自行承担更多工作,例如:
- 超时与重传;
- 顺序和去重;
- 发送窗口;
- 心跳与连接状态管理;
- 异常数据处理。
4. QUIC
QUIC 是一种基于 UDP 的新型网络传输协议,由 Google 发起,并由互联网工程任务组(IETF)标准化为 RFC 9000。
它的核心目标是比 TCP 更快、更安全,目前已成为 HTTP/3 的底层传输协议。
简单来说,它改善了传统网络在弱网环境下速度慢、切换网络易断连等问题,已被多种互联网服务采用。
说明:可以以 UDP 为基础,在其上层增加可靠传输等能力,形成更符合特定需求的新协议。
八、UDP 与 TCP 对比
| 性能 | UDP | TCP |
|---|---|---|
| 是否连接 | 无连接 | 面向连接 |
| 是否可靠 | 不保证可靠传输,不使用 TCP 式流量控制和拥塞控制 | 可靠传输,具有流量控制和拥塞控制 |
| 通信对象 | 支持一对一、一对多、多对一和多对多 | 单条连接是点对点、一对一通信 |
| 传输方式 | 面向报文,保留报文边界 | 面向字节流,不保留应用消息边界 |
| 首部开销 | 首部固定 8 字节 | 首部最小 20 字节,最大 60 字节 |
| 适用场景 | 更重视实时性、可以容忍少量丢包的应用,如 IP 电话、视频会议、直播等 | 更重视可靠性的应用,如文件传输 |
补充理解:
- “UDP 不可靠”表示协议本身不保证送达、顺序和去重,并不代表 UDP 完全不能用于可靠业务;应用层可以自行增加确认、重传等机制。
- “TCP 一对一”是针对一条 TCP 连接而言。服务器可以监听一个端口并同时维护多条客户端连接。
- UDP 每次发送的是一个报文,接收方能够识别报文边界;TCP 只提供连续字节流,因此需要应用层协议解决分包问题。
如果这篇文章对你有帮助,欢迎分享给更多人!





