1. 从一个"看起来很简单"的需求说起
TCP 发送和接收数据,这六个字放在任何一本网络编程教材的目录里,都显得平平无奇。但如果你真的动手写过、调过、压过、排查过线上问题,就会知道这块内容几乎是所有网络应用的"地基中的地基"。无论是后端服务之间的 RPC 调用、物联网设备上报数据、游戏服务器的状态同步,还是你随手写的一个聊天小工具,底层跑的都是同一套东西:一条 TCP 连接上,一端把字节流写进去,另一端把字节流读出来。
问题在于,"写进去"和"读出来"这六个字背后,藏着太多新手踩不完的坑。我见过太多人第一次写 TCP 程序时,客户端send一次,服务端recv一次,本地测试完美通过,一上真实网络就出现粘包、半包、数据截断、连接莫名断开。也见过有人把send的返回值当成"对方已经收到",结果在弱网环境下丢了一大段数据还浑然不觉。
这篇内容就是围绕"TCP 协议发送和接收数据"这个核心主题,把我在实际项目里反复验证过的东西整理出来。它适合三类人:刚学网络编程、想搞明白send/recv到底怎么回事的新手;写过一些代码但对粘包、缓冲区、阻塞非阻塞还是一知半解的进阶者;以及需要排查线上 TCP 传输问题的运维和开发。我会从整体设计思路讲到具体代码实现,从参数计算讲到问题排查,尽量做到你读完就能直接抄作业。
需要先明确一个前提:TCP 是面向字节流的协议,它不保留消息边界。这句话是理解后面所有内容的钥匙。你send了 100 字节,对方可能一次recv到 100 字节,也可能分三次收到 30、40、30,甚至可能和下一次send的数据粘在一起变成 200 字节。这不是 bug,这是 TCP 的设计。搞不清这一点,后面全是坑。
2. 整体设计思路:为什么 TCP 收发要这么写
2.1 先搞清楚 TCP 到底给了你什么
很多人写 TCP 代码时的思维模型是错的。他们脑子里想的是"我发一个消息,对方收一个消息",这其实是消息队列或者 UDP 数据报的模型。TCP 给你的只有两样东西:一条可靠的、有序的、双向的字节流通道。可靠性体现在它保证字节不丢、不重、不乱序;但它不保证你写入的"块"和对方读出的"块"一一对应。
打个生活化的比方。TCP 连接就像一根水管,你往里面倒水(send),水会连续地流到另一头。你在这一头是分三次倒的,但另一头拿桶接的时候,可能一次接满,也可能接半桶。水管本身不记录"这是第几次倒的水"。所以如果你想让对方知道"这是一条完整消息",就必须自己在数据里做标记,这就是后面要讲的应用层协议设计。
理解了这一点,你就明白为什么 TCP 收发代码的核心不是send和recv这两个函数本身,而是围绕它们构建的缓冲区管理和消息边界处理逻辑。
2.2 阻塞、非阻塞、IO 多路复用的选型逻辑
写 TCP 收发,第一个要做的决策是:用阻塞 IO、非阻塞 IO,还是 IO 多路复用?这个选择直接决定了你代码的整体结构。
阻塞 IO是最直观的。recv调用后如果没数据,线程就卡在那里等,直到有数据或者连接关闭。优点是代码简单,逻辑线性,适合连接数少、每连接一个线程的场景,比如内部工具、简单的客户端。缺点是每个连接占一个线程,连接数一上来线程开销就爆炸,而且一个线程卡在recv上时没法干别的事。
非阻塞 IO把 socket 设成非阻塞后,recv没数据会立刻返回一个错误(EAGAIN/EWOULDBLOCK),你需要自己轮询。裸轮询会空耗 CPU,所以实际很少单独用,通常配合 IO 多路复用。
IO 多路复用(select/poll/epoll/kqueue)是主流方案。它让你用一个线程同时管理成千上万个连接,哪个连接有数据可读、可写,内核就通知你。高并发服务器的标配。代价是代码复杂度上升,需要维护连接状态机。
我的建议很直接:如果你在写服务端且预期连接数超过几百,直接上 epoll(Linux)或 kqueue(BSD/macOS);如果是客户端或者连接数很少的内部服务,阻塞 IO 加线程池就够了,别过度设计。我见过太多项目一上来就搞复杂的 Reactor 模型,结果业务逻辑没几行,全在跟状态机较劲,维护成本极高。
2.3 应用层协议:解决粘包绕不开的一步
既然 TCP 不保留边界,那"一条完整消息"就得你自己定义。常见做法有三种,我按推荐程度排:
第一种,长度前缀(Length-Prefixed)。每条消息前面加固定字节数的长度字段,比如 4 字节大端整数表示后续 body 长度。接收方先读 4 字节拿到长度 N,再读 N 字节就是完整消息。这是我最推荐的方案,实现简单、解析高效、二进制友好。绝大多数 RPC 框架(比如 gRPC 的底层帧、很多私有协议)都用这个。
第二种,分隔符(Delimiter)。用特殊字符比如\n或\r\n标记消息结束。HTTP 的头部、Redis 的 RESP 协议部分场景都用类似思路。优点是文本协议可读性好,缺点是如果数据本身包含分隔符就要转义,二进制数据不友好。
第三种,定长消息。每条消息固定长度。简单但浪费带宽,只适合极少数场景。
选哪种取决于你的数据特征。传二进制、传结构化数据,选长度前缀;传文本命令、追求可读性,选分隔符。千万别选"我不管,对方一次 recv 就是一条消息"这种方案,它在本地能跑,上线必炸。
3. 核心细节解析:send 和 recv 到底怎么用
3.1 send 的返回值不是"对方收到了"
这是新手最容易误解的地方。send返回的是成功拷贝到内核发送缓冲区的字节数,不是对方应用层收到的字节数。数据从你的应用缓冲区拷到内核缓冲区,send就返回了,剩下的什么时候发、发多少、对方收没收到,都是内核的事。
这意味着两件事。第一,send返回的值可能小于你请求发送的长度。比如你send(buf, 1000),内核缓冲区只剩 400 字节空间,它可能只拷 400 字节就返回 400。你必须循环发送直到全部发完。第二,send成功返回不代表数据已经到达对方,只代表交给了内核。真正的确认要靠 TCP 的 ACK 机制,那是内核层面的事,应用层感知不到。
正确的发送逻辑应该是这样的:
ssize_t send_all(int fd, const void *buf, size_t len) { const char *p = (const char *)buf; size_t sent = 0; while (sent < len) { ssize_t n = send(fd, p + sent, len - sent, 0); if (n < 0) { if (errno == EINTR) continue; // 被信号打断,重试 if (errno == EAGAIN || errno == EWOULDBLOCK) { // 非阻塞模式下缓冲区满,需要等待可写事件 // 这里简化处理,实际应交给事件循环 continue; } return -1; // 真正的错误 } if (n == 0) return -1; // 理论上 send 不会返回 0 sent += n; } return sent; }这段代码里有个细节值得说:EINTR的处理。当进程收到信号时,慢速系统调用可能被中断并返回EINTR,这时候必须重试,否则会误判为错误。很多线上偶发的"发送失败"就是这个原因,加上EINTR判断就消失了。
3.2 recv 返回 0 意味着连接关闭
recv的返回值有三种情况,必须分清楚:
- 大于 0:读到了这么多字节。
- 等于 0:对端正常关闭了连接(发了 FIN)。这是正常的关闭信号,不是错误。
- 小于 0:出错。要看
errno,EAGAIN/EWOULDBLOCK表示非阻塞下暂时没数据,EINTR要重试,其他才是真错误。
新手常犯的错是把recv返回 0 当成"暂时没数据",然后继续循环,结果死循环空转。记住:返回 0 就是对方关了,你也该关了。
3.3 缓冲区大小的选择有讲究
recv时你提供的缓冲区多大合适?太小会导致频繁系统调用,性能差;太大浪费内存。经验值是4KB 到 64KB之间。为什么?因为 TCP 的 MSS(最大段大小)通常在 1460 字节左右,内核接收缓冲区默认几十 KB 到几 MB。你一次读 4KB 以上,基本能一次读走一个或多个完整的 TCP 段,减少系统调用次数。
但这里有个陷阱:你不能假设一次 recv 就能读完整条消息。哪怕你的缓冲区有 64KB,对方发来的消息可能只有 10 字节,也可能有 100KB。所以接收逻辑必须是"累积 + 解析"的模式:把读到的数据追加到一个应用层缓冲区,然后尝试从缓冲区里解析出完整消息,解析出一条就处理一条,剩下的留着等下次数据。
3.4 关于 TCP_NODELAY 的取舍
默认情况下,TCP 有 Nagle 算法,会把小的发送数据攒一攒再发,减少网络上的小包数量。这对吞吐量友好,但对延迟不友好。如果你在做交互式应用(比如游戏、实时通信),每个小消息都希望立刻发出去,就要设置TCP_NODELAY关闭 Nagle。
int flag = 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));但别盲目开。如果你的应用本来就是批量发送大量小数据,关了 Nagle 反而会增加网络包数量,降低有效吞吐。判断标准:延迟敏感就开,吞吐敏感就关。我一般建议 RPC、游戏、即时通讯开,文件传输、批量同步关。
4. 实操过程:从零实现一个可靠的收发模块
4.1 服务端骨架:监听、接受、收发
先看服务端的主流程。这里用阻塞 IO 加每连接一线程的模型,逻辑最清晰,适合理解原理。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <arpa/inet.h> #include <sys/socket.h> #include <pthread.h> #define PORT 9000 #define BACKLOG 128 #define RECV_BUF_SIZE 8192 // 从缓冲区解析长度前缀消息,返回已消费字节数,-1 表示数据不完整 static int parse_message(const char *buf, size_t len, char *out, size_t out_size) { if (len < 4) return -1; // 长度字段都不够 uint32_t msg_len; memcpy(&msg_len, buf, 4); msg_len = ntohl(msg_len); // 网络字节序转主机序 if (msg_len > out_size) return -2; // 消息过大,协议错误 if (len < 4 + msg_len) return -1; // body 还没收全 memcpy(out, buf + 4, msg_len); return 4 + msg_len; // 返回这条消息占用的总字节 } void *handle_client(void *arg) { int fd = *(int *)arg; free(arg); char *acc = malloc(RECV_BUF_SIZE * 4); // 应用层累积缓冲区 size_t acc_len = 0; char tmp[RECV_BUF_SIZE]; char msg[RECV_BUF_SIZE]; while (1) { ssize_t n = recv(fd, tmp, sizeof(tmp), 0); if (n < 0) { if (errno == EINTR) continue; perror("recv"); break; } if (n == 0) { printf("client closed\n"); break; } // 追加到累积缓冲区 memcpy(acc + acc_len, tmp, n); acc_len += n; // 循环解析,可能一次收到多条消息 size_t offset = 0; while (1) { int consumed = parse_message(acc + offset, acc_len - offset, msg, sizeof(msg)); if (consumed < 0) break; // 数据不完整,等下次 msg[consumed - 4] = '\0'; // 假设是文本,加结束符 printf("recv msg: %s\n", msg); offset += consumed; } // 把已消费的移出缓冲区 if (offset > 0) { memmove(acc, acc + offset, acc_len - offset); acc_len -= offset; } } free(acc); close(fd); return NULL; } int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(PORT); if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; } listen(listen_fd, BACKLOG); printf("server listening on %d\n", PORT); while (1) { struct sockaddr_in cli; socklen_t cli_len = sizeof(cli); int conn_fd = accept(listen_fd, (struct sockaddr *)&cli, &cli_len); if (conn_fd < 0) { if (errno == EINTR) continue; perror("accept"); continue; } int *pfd = malloc(sizeof(int)); *pfd = conn_fd; pthread_t tid; pthread_create(&tid, NULL, handle_client, pfd); pthread_detach(tid); } return 0; }这段代码有几个关键点值得展开。SO_REUSEADDR一定要设,否则服务重启时端口处于 TIME_WAIT 状态会 bind 失败,这是开发期最烦人的问题之一。累积缓冲区我开了RECV_BUF_SIZE * 4,是为了应对一次收到多条消息的情况,实际项目里应该用动态扩容的缓冲区,避免消息超过预设大小。
4.2 客户端:连接、发送、优雅关闭
客户端相对简单,但发送逻辑必须用前面说的send_all。
int connect_to(const char *ip, int port) { int fd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(port); inet_pton(AF_INET, ip, &addr.sin_addr); if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("connect"); close(fd); return -1; } return fd; } int send_message(int fd, const char *body, uint32_t body_len) { uint32_t net_len = htonl(body_len); // 先发长度,再发 body,用 send_all 保证发完 if (send_all(fd, &net_len, 4) < 0) return -1; if (send_all(fd, body, body_len) < 0) return -1; return 0; }发送时先发 4 字节长度再发 body,这是长度前缀协议的标准写法。注意长度字段要用htonl转成网络字节序,接收方用ntohl转回来。字节序问题在跨平台通信时是重灾区,x86 是小端,网络字节序是大端,不转换就会解析出天文数字的长度。
4.3 优雅关闭:别让数据丢在缓冲区里
关闭连接不是简单close就完事。close会立即关闭 socket,如果发送缓冲区里还有数据没发出去,可能被丢弃。正确做法是先shutdown(fd, SHUT_WR)关闭写方向,告诉对方"我没数据要发了",然后继续recv直到对方也关闭,最后close。
shutdown(fd, SHUT_WR); // 发送 FIN,不再发送数据 char buf[1024]; while (recv(fd, buf, sizeof(buf), 0) > 0) { // 把对方剩余的数据读完 } close(fd);这个流程叫"半关闭",是 TCP 优雅关闭的标准姿势。我见过线上因为直接close导致最后一条响应丢失的案例,排查了半天才发现是关闭姿势不对。
5. 常见问题与排查技巧实录
5.1 粘包和半包:最经典的问题
粘包是"多条消息粘在一起被一次读到",半包是"一条消息被拆成多次读到"。两者本质是同一个原因:TCP 是字节流。解决方案就是前面说的应用层协议。这里补充一个排查技巧:如果你怀疑是粘包问题,在接收端打印每次 recv 的字节数和内容,对比发送端,一眼就能看出来。我调试这类问题时,习惯在收发两端都加十六进制 dump,把原始字节打出来,比看字符串直观得多。
5.2 连接被重置(RST)的几种原因
recv返回ECONNRESET表示收到了 RST 包。常见原因有:对端进程崩溃或被杀,内核会发 RST;对端close后你又往这个连接发数据,会触发 RST;中间有防火墙或负载均衡超时踢连接。排查时先确认对端进程是否还活着,再看是不是长时间空闲被中间设备清理了。如果是空闲问题,加心跳保活。
5.3 发送大文件时卡住
发送大文件时send阻塞,通常是因为对端接收慢,内核发送缓冲区满了。这是正常的背压机制,不是 bug。但如果你在单线程里同步发送,就会卡住整个线程。解决办法是用非阻塞 IO 加事件循环,或者把发送放到独立线程。另外检查一下是不是对端recv的缓冲区太小或者处理太慢。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 数据错乱、消息串位 | 粘包/半包 | 打印每次 recv 字节数 | 加长度前缀或分隔符 |
| recv 返回 0 后死循环 | 误判连接关闭 | 检查返回值处理 | 返回 0 立即关闭连接 |
| send 返回小于请求长度 | 内核缓冲区满 | 检查是否循环发送 | 用 send_all 循环发完 |
| 连接偶发失败 | EINTR 未处理 | 检查 errno 分支 | 加 EINTR 重试 |
| 服务重启 bind 失败 | TIME_WAIT 占用端口 | netstat 看端口状态 | 设 SO_REUSEADDR |
| 弱网下数据丢失 | 误以为 send 成功即送达 | 检查是否有应用层确认 | 加 ACK 或重传机制 |
| 小包延迟高 | Nagle 算法攒包 | 抓包看发送时机 | 设 TCP_NODELAY |
| 大文件传输卡顿 | 背压导致阻塞 | 看发送缓冲区水位 | 非阻塞 + 事件循环 |
5.5 几个我踩过的坑
第一个坑:以为send成功对方就收到了。早期做的一个同步工具,客户端send完就认为任务完成,结果服务端处理失败时客户端完全不知道。后来加了应用层 ACK 才解决。TCP 的可靠性是传输层的,端到端的业务确认得自己做。
第二个坑:缓冲区大小拍脑袋定。有次接收端缓冲区设了 1KB,结果对方发 10KB 的消息,被拆成十几次 recv,解析逻辑没处理好,直接崩了。后来统一改成动态缓冲区,按需扩容。
第三个坑:忘了处理部分写。send返回小于请求长度时没循环,导致大消息只发出去一部分,对端解析长度字段对不上,一直等数据。这个 bug 在本地几乎复现不了,因为本地缓冲区大,一次就发完了,只有压测或弱网才暴露。
第四个坑:多线程同时写同一个 socket。TCP socket 不是线程安全的,多个线程同时send会导致数据交错。要么加锁,要么每个连接固定一个写线程。我倾向于后者,用发送队列加单写线程,既安全又能做流量控制。
6. 性能优化与进阶方向
6.1 减少系统调用和内存拷贝
每次recv/send都是系统调用,有用户态内核态切换开销。高并发下这是瓶颈。优化手段包括:用recvmsg/sendmsg一次处理多个缓冲区(scatter-gather IO);用readv/writev减少调用次数;在 Linux 上用sendfile或splice实现零拷贝传输文件。这些在文件服务器、代理服务器里收益明显。
6.2 缓冲区大小的调优
内核的发送和接收缓冲区可以通过setsockopt调整:
int size = 256 * 1024; setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size)); setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size));调大的好处是能容纳更多在途数据,提升高延迟高带宽场景(比如跨地域传输)的吞吐。但要注意,缓冲区不是越大越好,太大会增加内存占用和延迟。一般按"带宽 × 往返延迟"(BDP)来估算,比如 100Mbps 带宽、100ms 延迟,BDP 约 1.25MB,缓冲区设到 2MB 左右比较合适。
6.3 心跳与保活
长连接如果长时间没数据,中间的网络设备(NAT、防火墙、负载均衡)可能悄悄把连接清理掉,而两端都不知道。解决办法是定期发心跳包。TCP 本身有SO_KEEPALIVE,但默认间隔两小时,太长了,通常自己实现应用层心跳,比如每 30 秒发一个空消息,对方回一个,连续几次没回应就判定连接失效并重连。
心跳间隔怎么定?看你的网络环境。内网可以长一点,60 秒;公网、移动网络短一点,15 到 30 秒。太频繁浪费流量和电量,太稀疏又起不到保活作用。我一般从 30 秒起步,根据实际断线情况调整。
6.4 从阻塞到事件驱动的演进路径
如果你的服务连接数从几十涨到几千几万,阻塞加线程的模型会撑不住。演进路径通常是:阻塞 IO → 线程池 + 阻塞 IO → IO 多路复用 + 非阻塞 IO → Reactor/Proactor 模式。每一步都是被实际压力逼出来的,不要提前跳级。我见过连接数才几百就上复杂异步框架的项目,代码复杂度翻了几倍,性能提升却微乎其微,得不偿失。
判断是否需要升级的信号很明确:线程数成为瓶颈(比如超过几百个线程)、上下文切换开销明显、内存被线程栈吃光。到了这个点再重构也不迟。
7. 一些收尾的实操心得
写 TCP 收发代码这些年,我最大的体会是:把简单的事情做对,比把复杂的事情做炫更重要。长度前缀协议、循环发送、累积缓冲区、优雅关闭,这四件事做扎实了,90% 的 TCP 问题都不会找上门。剩下的 10%,靠抓包和日志。
抓包工具是排查 TCP 问题的终极武器。Wireshark 或者 tcpdump 能看到每一个包的序号、确认号、窗口大小、标志位,粘包、重传、RST 在抓包结果里一目了然。我建议每个做网络编程的人都花点时间学会看 TCP 流,比读十篇文章都管用。
最后分享一个小技巧:在开发阶段,故意把接收缓冲区设小(比如 16 字节),强制触发半包场景。这样能在本地就把边界处理逻辑测出来,而不是等上线后在真实网络里踩雷。这个习惯帮我提前发现过好几个隐藏的解析 bug。
TCP 这东西,入门容易精通难,但只要你理解了"字节流"这个本质,剩下的都是工程问题。把协议设计好,把边界处理好,把错误分支写全,你的收发模块就稳了。