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;
}
一个小细节: 我故意把 BUFFER_SIZE 设成 1024,远大于每条消息的实际长度。这样 TCP 协议栈会把多个小包合并成一个大数据块交上来——粘包就这么来的。

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 字节数据
核心结论: 粘包和拆包不是 bug,是 TCP 流式传输的固有特性。你无法禁止它发生,只能通过应用层协议来识别消息边界。

3.6 一张图看懂粘包拆包

下面这张 SVG 图展示了数据从客户端到服务端的完整路径,以及粘包和拆包分别在哪个环节发生:

粘包与拆包发生过程 粘包与拆包发生过程 客户端应用层 send("Hello") 发送缓冲区 数据合并 ⬇ 粘包发生 TCP 网络传输 流式传输 接收缓冲区 数据堆积 ⬇ 拆包发生 服务端应用层 read() 读取 说明: • 粘包:多个小包在发送缓冲区合并,或接收缓冲区堆积,导致一次 read() 读到多条消息 • 拆包:单个大包超过 MSS 或接收缓冲区大小,导致一条消息被多次 read() 读取 解决方案:应用层协议定义消息边界(下一章详解)

3.7 避坑指南:我踩过的坑

我曾经在一个物联网项目里犯过这个错。设备端每 100 毫秒上报一次传感器数据,每条数据就几十个字节。服务端用 read() 循环读取,结果发现收到的数据经常是好几条拼在一起的。当时我以为是设备端发错了,查了半天硬件日志……最后才发现是 TCP 粘包。

嗯,这里要注意几个点:

  • 不要依赖 send() 和 recv() 的一一对应关系。 一次 send() 可能对应零次、一次或多次 recv(),反过来也一样。
  • 不要用 sleep() 来避免粘包。 我见过有人加 usleep(1000) 来让每次发送间隔开,这完全不可靠。网络延迟、系统调度都会破坏你的"时间窗口"。
  • 调试时先确认是粘包还是丢包。 如果收到的数据长度总和小于发送的总长度,那可能是丢包或连接断开,不一定是粘包。
一个小技巧: 调试粘包问题时,可以在服务端打印每次 read() 返回的字节数。如果一次读到的字节数大于单条消息的长度,那就是粘包了。这个习惯我一直保留着,排查问题特别快。

3.8 本章小结