问题背景

应用程序调用 sendrecv 时,很容易把 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 返回的是“当前可以交付的字节”,而不是“对端的一条完整业务消息”。

发送成功不等于业务成功

sendWriteAsync 成功,通常只表示数据已经被本机 Socket 接受。它不证明:

  1. 数据已经到达对端应用;
  2. 对端已经解析;
  3. 业务动作已经执行;
  4. 响应一定能返回。

因此业务协议仍然需要请求 ID、响应、超时与幂等性设计。

排查过程

查看监听与连接状态

ss -lntp
ss -ntp state established

ss 可以确认进程是否监听预期地址、连接是否建立,以及发送与接收队列是否持续堆积。大量 SYN-SENT 更像连接建立问题,持续增长的发送队列则可能表示对端不再读取或网络路径异常。

检查系统调用

strace -f -e trace=network -p <pid>

这能回答应用是否真的调用了 connectsendtorecvfrom,以及内核返回了什么错误。它比只看业务日志更接近 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、系统调用和抓包逐层验证。