3. 粘包现象演示:写个简单程序看看它怎么发生的
理论讲完了,咱们直接动手。我个人习惯是——先看现象,再找原因,最后想解法。这一章我们就写一个最朴素的 TCP 客户端和服务端,故意让它产生粘包,然后你亲眼看看数据到底是怎么粘在一起的。
3.1 先搭个服务端:收数据但不拆包
服务端逻辑很简单:监听端口,接受连接,然后循环读取。我不做任何分包处理,收到啥就打印啥。你想想看,这其实就是很多新手写网络程序时的第一版代码。
// server.c — 粘包演示服务端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define PORT 8888
#define BUFFER_SIZE 1024
int main() {
int server_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t addr_len = sizeof(client_addr);
char buffer[BUFFER_SIZE];
int bytes_read;
// 1. 创建 socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
exit(1);
}
// 2. 绑定地址
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind");
close(server_fd);
exit(1);
}
// 3. 监听
if (listen(server_fd, 5) < 0) {
perror("listen");
close(server_fd);
exit(1);
}
printf("服务端已启动,监听端口 %d...\n", PORT);
// 4. 接受连接
client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &addr_len);
if (client_fd < 0) {
perror("accept");
close(server_fd);
exit(1);
}
printf("客户端已连接: %s:%d\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
// 5. 循环读取 — 这里就是粘包发生的地方
while ((bytes_read = read(client_fd, buffer, BUFFER_SIZE)) > 0) {
buffer[bytes_read] = '\0';
printf("收到数据 [长度=%d]: %s\n", bytes_read, buffer);
}
close(client_fd);
close(server_fd);
return 0;
}
3.2 再写客户端:连续发三条消息
客户端更简单。连接成功后,连续调用三次 send(),每次发一条短消息。中间不加任何延迟,也不做任何特殊处理。
// client.c — 粘包演示客户端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define SERVER_IP "127.0.0.1"
#define PORT 8888
int main() {
int sock_fd;
struct sockaddr_in server_addr;
// 1. 创建 socket
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
if (sock_fd < 0) {
perror("socket");
exit(1);
}
// 2. 连接服务端
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr);
if (connect(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect");
close(sock_fd);
exit(1);
}
printf("已连接到服务端\n");
// 3. 连续发送三条消息 — 粘包就在这里产生
const char *msg1 = "Hello";
const char *msg2 = "World";
const char *msg3 = "TCP";
send(sock_fd, msg1, strlen(msg1), 0);
send(sock_fd, msg2, strlen(msg2), 0);
send(sock_fd, msg3, strlen(msg3), 0);
printf("已发送三条消息: %s, %s, %s\n", msg1, msg2, msg3);
// 4. 等待一会再关闭
sleep(2);
close(sock_fd);
return 0;
}
sleep(2) 才关闭连接,目的是让服务端有足够时间把数据读完。如果你不加这个延迟,客户端可能还没等数据发完就关闭了,服务端收到的数据会不完整。
3.3 运行看看:粘包现象出现了
先启动服务端,再启动客户端。服务端打印的结果会让你大吃一惊——
服务端已启动,监听端口 8888...
客户端已连接: 127.0.0.1:54321
收到数据 [长度=13]: HelloWorldTCP
看到了吗?客户端明明发了三次 send(),服务端却只收到一次 read(),而且三条消息被拼成了一整串 "HelloWorldTCP"。这就是典型的粘包。
为什么会这样?说白了,TCP 是流式协议。它不关心你应用层怎么切分数据,只负责把字节流从 A 端搬到 B 端。三次 send() 的数据在 TCP 缓冲区里连成了一片,一次 read() 就全读走了。
3.4 换个场景:拆包也会发生
粘包的反面是拆包。如果客户端一次发一个很大的数据块,而服务端每次只读一小部分,那一条消息就会被拆成多次读取。我们来改一下客户端:
// 客户端发送一个大包
char big_msg[2000];
memset(big_msg, 'A', sizeof(big_msg));
send(sock_fd, big_msg, sizeof(big_msg), 0);
服务端输出会变成:
收到数据 [长度=1024]: AAAA...(1024个A)
收到数据 [长度=976]: AAAA...(976个A)
一条 2000 字节的消息,被拆成了两次读取。第一次读满 1024 字节(缓冲区大小),第二次读剩下的 976 字节。这就是拆包。
3.5 粘包与拆包的核心原因
我把常见原因整理成了一张表,方便你对照排查:
| 现象 | 触发条件 | 典型场景 |
|---|---|---|
| 粘包 | 多个小包在发送端缓冲区合并 | 连续发送短消息,间隔时间短 |
| 粘包 | 接收端来不及读取,数据在接收缓冲区堆积 | 服务端处理慢,客户端发得快 |
| 拆包 | 单个大包超过发送端 MSS | 发送超过 1460 字节的数据 |
| 拆包 | 接收端缓冲区小于消息长度 | 服务端用 1024 缓冲区收 2000 字节数据 |
3.6 一张图看懂粘包拆包
下面这张 SVG 图展示了数据从客户端到服务端的完整路径,以及粘包和拆包分别在哪个环节发生:
3.7 避坑指南:我踩过的坑
我曾经在一个物联网项目里犯过这个错。设备端每 100 毫秒上报一次传感器数据,每条数据就几十个字节。服务端用 read() 循环读取,结果发现收到的数据经常是好几条拼在一起的。当时我以为是设备端发错了,查了半天硬件日志……最后才发现是 TCP 粘包。
嗯,这里要注意几个点:
- 不要依赖
send()和recv()的一一对应关系。 一次send()可能对应零次、一次或多次recv(),反过来也一样。 - 不要用
sleep()来避免粘包。 我见过有人加usleep(1000)来让每次发送间隔开,这完全不可靠。网络延迟、系统调度都会破坏你的"时间窗口"。 - 调试时先确认是粘包还是丢包。 如果收到的数据长度总和小于发送的总长度,那可能是丢包或连接断开,不一定是粘包。
read() 返回的字节数。如果一次读到的字节数大于单条消息的长度,那就是粘包了。这个习惯我一直保留着,排查问题特别快。