问题背景
应用程序调用 send 和 recv 时,很容易把 Socket 想象成一根负责“发送消息”的管道。但在 Linux 与 TCP 的语义里,Socket 更接近一个由内核维护的通信端点:应用通过文件描述符访问它,内核负责连接状态、缓冲区、分段、重传和流量控制。
理解这个边界,才能解释许多看似偶发的问题:一次发送为什么要多次接收、连接断开后为什么仍可能读到数据、超时为什么不等于对端没有执行。
现象
下面这些现象通常不是网络“随机出错”,而是应用把 TCP 当成消息队列:
- 客户端发送 100 字节,服务端第一次只读到 40 字节;
- 两次连续发送,在对端一次读取中合并出现;
send返回成功,但业务请求最终没有得到响应;- 对端关闭连接后,本地仍能从接收缓冲区读出剩余数据;
- 连接超时后重试,设备却执行了两次同一命令。
最小复现
服务端故意使用一个很小的缓冲区读取:
using System.Net;
using System.Net.Sockets;
var listener = new TcpListener(IPAddress.Loopback, 9000);
listener.Start();
using TcpClient client = await listener.AcceptTcpClientAsync();
await using NetworkStream stream = client.GetStream();
var buffer = new byte[8];
int count;
while ((count = await stream.ReadAsync(buffer)) > 0)
{
Console.WriteLine($"read={count}, data={Convert.ToHexString(buffer.AsSpan(0, count))}");
}
客户端即使一次调用 WriteAsync 写入更大的数组,服务端也只保证按顺序读到字节,不保证一次 ReadAsync 恰好对应一次写入。
原理
Socket 是内核对象的入口
socket() 返回一个文件描述符。这个整数只是进程文件描述符表中的索引,背后指向内核的 Socket 状态。应用关闭描述符时,是放弃对该对象的引用;TCP 状态机仍可能因为挥手或超时继续存在一段时间。
一个 TCP 连接通常由四元组区分:
source IP + source port + destination IP + destination port
因此同一个监听端口可以同时接受许多客户端。监听 Socket 负责接收新连接,accept() 为每个已建立连接返回新的文件描述符。
TCP 提供有序字节流
TCP 保证的是:在连接未失败的前提下,接收方按顺序获得发送方写入的字节。它不保留应用层写入边界。
应用的一次写入可能被拆成多个 TCP 段,也可能与后续写入一起出现在接收缓冲区。ReadAsync 返回的是“当前可以交付的字节”,而不是“对端的一条完整业务消息”。
发送成功不等于业务成功
send 或 WriteAsync 成功,通常只表示数据已经被本机 Socket 接受。它不证明:
- 数据已经到达对端应用;
- 对端已经解析;
- 业务动作已经执行;
- 响应一定能返回。
因此业务协议仍然需要请求 ID、响应、超时与幂等性设计。
排查过程
查看监听与连接状态
ss -lntp
ss -ntp state established
ss 可以确认进程是否监听预期地址、连接是否建立,以及发送与接收队列是否持续堆积。大量 SYN-SENT 更像连接建立问题,持续增长的发送队列则可能表示对端不再读取或网络路径异常。
检查系统调用
strace -f -e trace=network -p <pid>
这能回答应用是否真的调用了 connect、sendto、recvfrom,以及内核返回了什么错误。它比只看业务日志更接近 Socket 边界。
对照抓包
sudo tcpdump -i any -nn 'tcp port 9000'
抓包用于判断报文是否离开主机、是否收到 ACK、对端是否复位连接。抓包中的 TCP 段边界同样不是业务消息边界,分析时应根据应用协议重新组装。
根因
常见根因是协议没有定义帧边界,却假设一次读取就是一条消息。例如把 JSON 直接连续写入 TCP 连接,然后期待对端每次 ReadAsync 都返回一个完整 JSON。这个假设在低负载本机测试中可能长期成立,一旦经过真实网络就会暴露。
解决方案
应用层必须自己定义 framing。常见选择包括:
- 固定长度帧;
- 长度前缀,例如先发送 4 字节长度,再发送正文;
- 明确分隔符,同时处理转义和最大长度;
- 使用已经定义消息边界的上层协议。
无论使用哪种方式,都要限制最大帧长度,防止错误或恶意长度导致内存分配失控。
下面的辅助方法会一直读取,直到填满目标缓冲区或连接提前关闭:
static async Task ReadExactlyAsync(
NetworkStream stream,
Memory<byte> destination,
CancellationToken cancellationToken)
{
var offset = 0;
while (offset < destination.Length)
{
var count = await stream.ReadAsync(destination[offset..], cancellationToken);
if (count == 0)
throw new EndOfStreamException("连接在完整帧到达前关闭");
offset += count;
}
}
读取长度前缀时,先读取固定的 4 字节,再校验长度并读取正文。超时应通过 CancellationToken 控制;取消之后是否重试,要由业务命令的幂等性决定。
为什么这个方案有效
它把 TCP 的职责和业务协议的职责分开:TCP 只交付有序字节,应用层负责识别完整帧。读取次数、分段方式和网络路径变化不再影响消息解析。
同时,请求 ID 和响应让调用方能够区分“没有收到结果”与“设备没有执行”。如果设备可以缓存最近请求的结果,重复请求也能安全返回原结果,而不是再次动作。
最终结果
完成帧协议后,测试不再依赖每次读取的字节数。可以主动把数据拆成任意大小写入、延迟发送剩余部分,或把多个帧合并写入,解析器都应得到一致结果。
总结
Socket 是进程访问内核通信状态的接口,TCP 是有序字节流,不是消息队列。可靠的网络程序需要自行定义消息边界、最大长度、超时、取消与幂等语义,并通过 ss、系统调用和抓包逐层验证。