4. 复现粘包问题
好,咱们现在进入实战环节。前面讲了那么多理论,说白了都是纸上谈兵。这一节,我带你亲手把粘包问题“炸”出来。
我个人习惯,遇到网络问题先写个最小复现Demo。这样能快速锁定问题边界,避免被业务逻辑干扰。你想想看,如果在一个几千行的项目里排查粘包,那得多痛苦?
4.1 准备一个“会犯错”的Demo
我特意写了一个有问题的Demo。它的问题很典型——发送端连续发送多条消息,但接收端没有做任何边界处理。嗯,这就是粘包和拆包的温床。
先看服务端代码:
// server.c - 有问题的服务端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8888
#define BUFFER_SIZE 1024
int main() {
int server_fd, client_fd;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
// 创建socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 绑定端口
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
bind(server_fd, (struct sockaddr *)&address, sizeof(address));
// 监听
listen(server_fd, 3);
printf("服务端已启动,监听端口 %d\n", PORT);
// 接受连接
client_fd = accept(server_fd, (struct sockaddr *)&address,
(socklen_t*)&addrlen);
// 连续发送3条消息 —— 问题就出在这里!
char *msg1 = "Hello, this is message 1.";
char *msg2 = "Hello, this is message 2.";
char *msg3 = "Hello, this is message 3.";
send(client_fd, msg1, strlen(msg1), 0);
sleep(1); // 故意加延迟,让粘包更明显
send(client_fd, msg2, strlen(msg2), 0);
sleep(1);
send(client_fd, msg3, strlen(msg3), 0);
printf("三条消息已发送完毕\n");
close(client_fd);
close(server_fd);
return 0;
}
再看客户端代码:
// client.c - 有问题的客户端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8888
#define BUFFER_SIZE 1024
int main() {
int sock = 0;
struct sockaddr_in serv_addr;
char buffer[BUFFER_SIZE] = {0};
// 创建socket
sock = socket(AF_INET, SOCK_STREAM, 0);
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);
// 连接服务端
connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
// 只调用一次recv —— 这就是问题所在!
int valread = recv(sock, buffer, BUFFER_SIZE, 0);
buffer[valread] = '\0';
printf("客户端收到数据: %s\n", buffer);
printf("实际接收字节数: %d\n", valread);
close(sock);
return 0;
}
4.2 编译与运行
编译很简单,两条命令搞定:
gcc -o server server.c
gcc -o client client.c
运行顺序要注意:先启动服务端,再启动客户端。
# 终端1:启动服务端
./server
# 终端2:启动客户端
./client
4.3 观察异常现象
运行后,你会看到客户端输出类似这样的内容:
客户端收到数据: Hello, this is message 1.Hello, this is message 2.Hello, this is message 3.
实际接收字节数: 72
看到了吗?三条消息粘在一起了! 客户端只调用了一次recv,就把三条消息全收进来了。这就是典型的粘包。
为什么会这样?
TCP是流式协议,它不关心你发送了几次数据。它只负责把字节流从A端搬到B端。服务端连续调用了三次send,但TCP可能把这三次的数据合并成一个TCP报文发送出去。客户端这边只调用了一次recv,自然就把所有数据一次性读走了。
核心结论: 粘包的本质是发送端多次send与接收端单次recv之间的不匹配。TCP不保证每次send对应一次recv。
4.4 用GDB验证粘包过程
光看现象还不够。我建议你用GDB跟一下,看看底层到底发生了什么。
先启动服务端,然后用GDB attach到服务端进程:
# 先启动服务端
./server
# 在另一个终端找到服务端PID
ps aux | grep server
# 用GDB attach
gdb -p <服务端PID>
在GDB里设置断点:
(gdb) b send
(gdb) c
每次send被调用时,GDB都会停下来。你可以观察send的返回值:
(gdb) finish
(gdb) p $rax # x86_64架构下,返回值在rax寄存器
你会发现,三次send的返回值分别是:
- 第一次:28("Hello, this is message 1."的长度)
- 第二次:28
- 第三次:28
服务端确实发送了三次,每次28字节。但客户端一次recv就收走了全部84字节(28*3)。
💡 小技巧: 用GDB的watch命令监视客户端的buffer变量,可以看到数据是如何被一步步填充的。我个人习惯在recv调用前后各设一个断点,对比buffer的变化。
4.5 拆包问题怎么复现?
粘包的反面是拆包。拆包怎么复现?很简单,把客户端改成多次recv,但每次只读一点点:
// 修改客户端,每次只读10字节
for (int i = 0; i < 3; i++) {
int valread = recv(sock, buffer, 10, 0);
buffer[valread] = '\0';
printf("第%d次收到: %s\n", i+1, buffer);
}
运行结果:
第1次收到: Hello, thi
第2次收到: s is messag
第3次收到: e 1.Hello,
看到了吗?一条完整的消息被拆成了好几段! 这就是拆包。客户端每次只读10字节,但消息长度是28字节,所以一条消息被拆成了3次接收。
4.6 粘包与拆包的对比
| 现象 | 触发条件 | 表现 | 根本原因 |
|---|---|---|---|
| 粘包 | 多次send + 单次recv | 多条消息合并成一条 | TCP流式特性 + 接收端未做边界处理 |
| 拆包 | 单次send + 多次recv | 一条消息被拆成多段 | TCP流式特性 + 接收缓冲区大小限制 |
其实说白了,粘包和拆包是同一个问题的两面。问题的根源就是:TCP不维护消息边界。它只保证字节流的顺序和完整性,但不保证你发送的每次数据都能被完整地、独立地接收。
4.7 我踩过的坑
我曾经在一个即时通讯项目里遇到过类似问题。客户端发了一条很长的消息(大概4KB),服务端用recv只读了1024字节,结果消息被截断了。用户收到的是半条消息,直接显示乱码。当时排查了好久,最后发现是recv的缓冲区设得太小了。
还有一次,一个同事写了个文件上传功能。他连续调用了10次send发送文件块,结果客户端一次recv就把10个块全收走了。文件解析直接崩溃。嗯,从那以后,我们团队定了个规矩:所有TCP通信必须做消息边界处理。
4.8 本章小结
这一节我们亲手复现了粘包和拆包问题。你看到了异常现象,也用GDB验证了底层行为。记住两个关键点:
- 粘包:多条消息合并,客户端一次收走
- 拆包:一条消息被拆散,客户端多次接收
这两个问题不解决,你的网络程序就是一颗定时炸弹。下一节,我会教你如何用GDB精准定位这些问题的根因,并给出解决方案。
课后练习: 修改Demo,让服务端发送10条消息,客户端只调用一次recv。观察粘包现象是否更严重?再用GDB跟踪一下send和recv的调用次数。