C语言TCP socket连续收发字符串丢数据问题及正确实现方法
你对TCP传输特性和send()/recv()的工作机制存在本质误解:
TCP是面向字节流的协议,本身不维护消息边界,不存在“调用一次send()发的内容,就刚好被一次recv()全部读完”的一一对应关系。
你本地同机测试时,环回网卡延迟极低、缓冲区充足,内核调度刚好让每次recv()读到1024字节的块,所以看起来逻辑正常;跨公网传输时,受链路延迟、拥塞控制、Nagle算法、内核缓冲区调度策略影响,多次send()写入的数据可能被合并成一个TCP段传输(粘包),单次send()写入的大块数据也可能被拆成多个段传输(拆包),接收端调用recv()读到的字节数完全不可控——你以为的“第三条消息丢失”不是数据真的丢了,是接收端读的字节边界和你发送的消息边界错位了,比如第一次recv()可能读完了第一条消息加第二条的前半段,第二次读了第二条剩下的加第三条的前半段,第三次自然读不到完整的第三条消息。
你现在用的每次发完sleep(1)的方案完全是投机取巧:靠时间差强行让内核把当前缓冲区的数据发出去,只是降低了粘包的概率,根本不可靠。一旦网络抖动导致延迟超过1秒,问题照样复现,而且每秒发一条消息的效率极低,生产环境绝对不能用。
另外你的代码还有一个明显问题:不管fill_message()实际写入了多少有效内容,你都固定发送1024字节,等于把数组里填充的零值也一起发了,平白浪费带宽,也会增加接收端解析有效内容的成本。
不需要逐字节发送,这种做法效率极低。TCP编程的核心是自己在应用层定义消息边界,工业界常用两种成熟方案:
1. 固定长度前缀法(二进制协议首选,实现简单效率高)
这是最通用的方案,逻辑非常清晰:
- 发送端处理逻辑:
- 每次填充完消息后,先计算消息的实际有效长度,不要固定传1024
- 先把消息长度转换成固定字节数的整数(统一用网络字节序,避免跨机器大小端问题,通常用4字节无符号整数即可),先发这个长度字段
- 再发送对应长度的有效消息内容
- 所有
send()调用都要做循环处理:send()的返回值是实际成功写入内核发送缓冲区的字节数,可能一次写不完所有待发数据,必须循环直到所有字节都发送成功,不能假设一次调用就能发完全部内容。
- 接收端处理逻辑:
- 先固定读取4字节的长度字段,解析出当前消息的总长度
- 循环读取,直到累计读够对应长度的字节,这就是完整的一条消息,再交给业务逻辑处理
- 处理完一条消息后重复上述流程,即可连续读取所有消息,完全不需要靠时间差判断边界。
给个通用的可靠发送函数示例:
// 可靠发送len字节的缓冲区数据,返回-1表示出错/连接断开 ssize_t send_all(int sockfd, const void* buf, size_t len, int flags) { size_t total_sent = 0; const char* ptr = (const char*)buf; while (total_sent < len) { ssize_t cur_sent = send(sockfd, ptr + total_sent, len - total_sent, flags); if (cur_sent <= 0) return cur_sent; // 出错或对端关闭连接 total_sent += cur_sent; } return total_sent; }
接收端同理可以实现对应的recv_all函数,逻辑完全一致,把send替换为recv即可。
2. 特殊分隔符法(文本协议适用)
如果传输的是纯文本内容,可以约定一个不会出现在正文里的特殊字符作为消息结束标记,比如换行符\n,接收端持续读取字节,直到读到约定的分隔符,就认为一条消息结束。HTTP、Redis文本协议都用了类似思路。这种方案不需要提前计算消息长度,但如果正文可能出现分隔符需要做转义处理,解析效率比长度前缀法稍低。
- 永远不要忽略
send()和recv()的返回值:返回值小于0代表出错,返回0代表对端已经正常关闭连接,必须处理这些分支,不能默认函数调用一定成功。 - 跨平台开发注意兼容
winsock2.h和sys/socket.h的差异:Windows下套接字类型是SOCKET,错误码需要通过WSAGetLastError()获取,类Unix系统下套接字是文件描述符(int类型),错误码存放在errno中,这些差异最好做一层统一封装。 - 不要随意开启
TCP_NODELAY禁用Nagle算法,除非你明确知道业务场景需要低延迟小包传输,多数场景下默认配置配合正确的应用层边界处理,就能达到很好的传输效率。
内容的提问来源于stack exchange,提问作者R_marche

