3. TCP数据传输与粘包:send/recv原理、阻塞与非阻塞、粘包问题分析与解决方案
聊完了三次握手和四次挥手,咱们终于可以开始传数据了。但别高兴太早——TCP 的数据传输,远没有你想象中那么简单。我见过不少新手,写了个 send/recv 就跑起来,觉得挺顺,结果一上生产环境就崩。嗯,今天咱们就把这块彻底讲透。
3.1 send/recv 到底干了什么?
先问个问题:你调用 send(sockfd, buf, len, 0) 的时候,数据真的发出去了吗?
答案是:不一定。
send 只是把数据从用户态拷贝到内核态的发送缓冲区。至于什么时候真正通过网卡发出去,那是内核的事。同样,recv 是从内核的接收缓冲区里把数据拷贝到你的应用缓冲区。
说白了,这两个函数都是在跟内核缓冲区打交道,而不是直接跟网络打交道。
核心要点:
send返回的是成功写入发送缓冲区的字节数,不是对端收到的字节数recv返回的是从接收缓冲区读到的字节数,可能小于你期望的- 两个函数都可能返回 -1,表示出错或被信号中断
我在项目中遇到过一个问题:客户端调了 send 返回了 4096,就以为数据全发出去了,直接关闭了连接。结果服务端只收到了 2048 字节,剩下的全丢了。这就是典型的「以为发了就等于到了」的误区。
3.2 阻塞模式 vs 非阻塞模式
默认情况下,socket 是阻塞的。什么意思?
- 阻塞 send:如果发送缓冲区满了,
send会一直等,直到有空间可用 - 阻塞 recv:如果接收缓冲区没数据,
recv会一直等,直到有数据到达
听起来挺省心对吧?但实际用起来,坑不少。
| 模式 | send 行为 | recv 行为 | 适用场景 |
|---|---|---|---|
| 阻塞 | 缓冲区满则阻塞 | 无数据则阻塞 | 简单客户端、低并发 |
| 非阻塞 | 缓冲区满返回 EAGAIN | 无数据返回 EAGAIN | 高并发、事件驱动 |
设置非阻塞很简单:
// 方法一:创建时设置
int sock = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
// 方法二:运行时设置
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
我个人习惯用非阻塞 + epoll/poll/select 的组合。为什么呢?因为阻塞模式下,一个线程只能处理一个连接,并发量上不去。非阻塞配合事件驱动,一个线程可以管几千个连接。
注意:非阻塞模式下,send 和 recv 返回 -1 时,要检查 errno 是不是 EAGAIN 或 EWOULDBLOCK。如果是,说明只是暂时不可读写,不是真的出错。我曾经见过有人把 EAGAIN 当错误处理,直接关了连接,结果线上疯狂断连。
3.3 粘包问题:TCP 的「老毛病」
终于到重点了。粘包,几乎是每个 TCP 开发者都会遇到的坑。
什么是粘包?
你发送了两次数据:第一次发 "Hello",第二次发 "World"。结果接收方一次 recv 读到了 "HelloWorld"。这就是粘包。
为什么会这样?因为 TCP 是流式协议,没有消息边界。它只保证字节的顺序,不保证每次 recv 拿到的正好是你一次 send 的数据。
你想想看,如果发送方连续调了两次 send,内核可能把两段数据合并成一个 TCP 报文发出去。接收方一次 recv 就把两段数据全读走了。
小知识:粘包不是 TCP 的 bug,而是特性。TCP 追求的是传输效率,Nagle 算法会尽量合并小包。所以小数据频繁发送,反而更容易粘包。
3.4 粘包解决方案
解决粘包,说白了就是给数据「画边界」。我常用的方案有三种:
方案一:固定长度
每个消息固定长度,比如 1024 字节。不够的补零。接收方每次读满 1024 字节,就是一个完整消息。
#define MSG_LEN 1024
// 发送
char buf[MSG_LEN] = {0};
memcpy(buf, "Hello", 5);
send(sockfd, buf, MSG_LEN, 0);
// 接收
char buf[MSG_LEN];
recv(sockfd, buf, MSG_LEN, 0);
// buf 就是一个完整消息
优点:实现简单。缺点:浪费带宽,小消息也要占满固定长度。
方案二:特殊分隔符
在消息末尾加一个特殊标记,比如 \r\n 或 \0。接收方读到分隔符,就知道一个消息结束了。
// 发送
char *msg = "Hello\n";
send(sockfd, msg, strlen(msg), 0);
// 接收(需要逐字节或逐行处理)
// 读到 '\n' 就截断,处理一个完整消息
优点:灵活,适合文本协议。缺点:消息内容里不能出现分隔符,需要转义。
方案三:长度前缀(我最推荐的)
每个消息前面加一个固定长度的头部,头部里记录消息体的长度。接收方先读头部,拿到长度,再读消息体。
// 发送
uint32_t len = htonl(5); // 网络字节序
char *body = "Hello";
send(sockfd, &len, 4, 0);
send(sockfd, body, 5, 0);
// 接收
uint32_t len;
recv(sockfd, &len, 4, MSG_WAITALL);
len = ntohl(len);
char *buf = malloc(len);
recv(sockfd, buf, len, MSG_WAITALL);
// buf 就是完整消息
注意:上面代码里用了 MSG_WAITALL 标志,它会阻塞直到读满指定字节数。如果不加这个标志,recv 可能只读到部分数据就返回了。
我个人在项目中几乎都用长度前缀方案。它兼顾了效率和灵活性,而且容易扩展——你可以在头部里加更多字段,比如消息类型、序列号等。
3.5 一个完整的粘包处理流程
光说不练假把式。咱们画个图,看看数据从发送到接收,到底经历了什么。
从图里可以清楚看到:发送端两次 send,在内核层被合并成一个 TCP 报文。接收端一次 recv 就把两段数据全拿走了。如果没有边界标记,你根本不知道 "Hello" 和 "World" 是两条消息。
3.6 实战中的避坑指南
最后,分享几个我踩过的坑:
- 不要假设一次 send 对应一次 recv。TCP 是流,不是消息队列。
- recv 返回 0 表示对端关闭了连接,不是没数据。很多人把这个当正常情况处理,结果死循环。
- 非阻塞模式下,一定要处理 EAGAIN。我曾经在写一个网关时忘了处理,结果 CPU 跑到 100%,因为一直在死循环里重试。
- 长度前缀记得用网络字节序。htonl/ntohl 不能省,否则大端小端不一致,解析出来的长度就是天文数字。
- recv 可能分多次返回。即使你用了 MSG_WAITALL,也要检查返回值,因为信号中断可能导致提前返回。
血的教训:我曾经在一个高并发项目里,用固定长度方案处理消息。结果有个业务方发了个超长消息,直接撑爆了缓冲区,整个服务 OOM 了。后来改成长度前缀 + 动态分配,才彻底解决。所以,永远不要假设消息长度是固定的,除非你完全控制协议。
好了,TCP 数据传输这块,核心就是理解缓冲区、阻塞/非阻塞、以及粘包处理。把这些搞明白,你写出来的网络程序才算真正「能打」。