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是主流方案。它让你用一个线程同时管理成千上万个连接哪个连接有数据可读、可写内核就通知你。高并发服务器的标配。代价是代码复杂度上升需要维护连接状态机。我的建议很直接如果你在写服务端且预期连接数超过几百直接上 epollLinux或 kqueueBSD/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出错。要看errnoEAGAIN/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 这东西入门容易精通难但只要你理解了字节流这个本质剩下的都是工程问题。把协议设计好把边界处理好把错误分支写全你的收发模块就稳了。