TCP 中「发送单位=接收单位」的误区 ── 用字节流思维设计接收端,避免拆包与粘包

· · TCP, Socket, Network, .NET, CSharp, ProtocolDesign, 运维, 现有资产利用

1. 首先要弄清楚的事情

在 TCP 通信的实现中,有一个相当常见的误区。

那就是:

发送端调用 Send / Write 的每一个单位,接收端也能一一对应地通过 Receive / Read 收到

假设发送端是这样发送数据的:

Send("LOGIN\n")
Send("GET /items\n")
Send("QUIT\n")

于是就以为接收端会像下面这样分 3 次收到:

Receive() => "LOGIN\n"
Receive() => "GET /items\n"
Receive() => "QUIT\n"

但 TCP 并不保证会这样。

实际上,下面这些情况都可能发生:

Receive() => "LOGIN\nGET /items\nQUIT\n"
Receive() => "LOG"
Receive() => "IN\nGET /ite"
Receive() => "ms\nQUIT\n"
Receive() => "LOGIN\nGET /items\n"
Receive() => "QUIT"
Receive() => "\n"

以上这些对 TCP 来说都是正常的。

TCP 保证的,粗略来说是「发出的字节序列会按照顺序、不重复、不丢失地送达」。它不保证的是「应用程序调用 Send 的单位,会被保留为接收端 Receive 的单位」。

因此,使用 TCP 的应用程序,接收端需要有一套机制,用来判断「当前收到的字节序列,从哪里到哪里是 1 条消息」。

这套机制,就是应用层协议中的分帧(framing)

本文整理 TCP 的 SendReceive 之间容易产生的误解,以及在 .NET / C# 中正确处理这个问题的方式。

另外,本文出现的代码已经整理成一套可构建、可运行的示例(库、用于本地回环测试的 TCP demo、复现拆包・粘包・中途断连的单元测试),发布在 GitHub 上。

tcp-send-receive-message-framing - komurasoft-blog-samples (GitHub)

2. TCP 传输的不是「消息」,而是「字节序列」

首先,很重要的一点是不要把 TCP 想象成消息队列(message queue)。

TCP 会把应用程序传入的数据,当作连续的字节序列来处理。

举例来说,即使发送端调用了 3 次 Send

Send("ABC")
Send("DEF")
Send("GHI")

从 TCP 的角度来看,最终只是下面这样一段 9 字节的数据流:

ABCDEFGHI

其中并不会保留

ABC | DEF | GHI

这种属于应用程序层面的边界。

接收端会在某个时间点,读取「当前接收缓冲区里的数据」。因此,接收结果可能是下面这样:

发送端的调用 接收端可能看到的结果
Send("ABC"), Send("DEF") 1 次 Receive() 就收到 "ABCDEF"
Send("ABCDEF") 分 2 次 Receive() 收到 "AB""CDEF"
Send("ABC"), Send("DEF"), Send("GHI") 分 3 次 Receive() 收到 "A""BCDEFG""HI"
Send("\u3042") 这样的 UTF-8 字符 也可能在多字节字符的中途被切断

重点是,这里面没有任何「异常」。

「接收数据好像有时会缺一部分」「多条消息粘在一起」「出现乱码」,这些看起来像是异常的现象,多数情况下并不是 TCP 本身出了问题,而是接收端把 TCP 当成消息单位在处理,属于设计上的错误。

3. 为什么看起来好像可以按 Send 单位收到

这个误区之所以难以消除,是因为在本机环境或数据量较小的情况下,经常会「碰巧」看起来符合预期。

在开发环境中,很容易同时满足以下条件:

  • 客户端与服务器在同一台机器上,或处于相近的网络环境
  • 数据量很小
  • 通信对端会立即读取数据
  • CPU 与网络资源都比较充裕
  • 测试是手动进行的,时间上的抖动很小
  • 调用 Send 之后马上调用 Receive

在这种条件下,1 次 Send 对应 1 次 Receive 就能读到完整数据,看起来是理所当然的。

但在生产环境中,条件会发生变化:

  • 操作系统的发送 / 接收缓冲区会累积数据
  • 多次小数据发送可能被合并
  • 大数据发送可能因为 TCP 分段或接收缓冲区的限制而被拆开
  • 接收端线程的调度可能出现延迟
  • 中间会经过 TLS、代理、负载均衡器、VPN 等层
  • 可能出现网络延迟或拥塞
  • 会受到 Nagle 算法或延迟 ACK 的影响

结果就变成「开发环境没问题,但生产环境偶尔会出故障」这种棘手的问题。

在网络处理中,这种「碰巧能跑通」是最危险的状态。

4. 常见的脆弱接收代码

举例来说,下面这样的代码是有风险的:

byte[] buffer = new byte[4096];
int read = await stream.ReadAsync(buffer, cancellationToken);

if (read == 0)
{
    // 对端已正常断开连接
    return;
}

string message = Encoding.UTF8.GetString(buffer, 0, read);
await HandleMessageAsync(message, cancellationToken);

这段代码的前提是「1 次 ReadAsync 就能取得 1 条完整消息」,但这个前提在 TCP 中并不成立。

主要问题有 3 个。

第 1 个问题是,1 条消息可能被拆开(拆包)。

发送: {"command":"login","user":"komura"}\n
接收1: {"command":"login",
接收2: "user":"komura"}\n

这种情况下,如果只把接收1当作 JSON 来解析,就会失败。

第 2 个问题是,多条消息可能被合并(粘包)。

发送1: {"command":"login"}\n
发送2: {"command":"get"}\n
接收: {"command":"login"}\n{"command":"get"}\n

这种情况下,如果把它当成 1 个 JSON 来解析,同样会失败。

第 3 个问题是,可能在字符编码的边界处被切断。

在 UTF-8 中,1 个字符有时会由多个字节组成。ReadAsync 的边界并不保证与字符的边界一致。

因此,如果每次收到字节序列后就立刻调用 Encoding.UTF8.GetString 转成字符串,一旦在多字节字符的中途被切断,就有可能出错。

基本原则不是「收到就马上转成字符串」,而是「先把数据当作字节累积起来,直到确认消息边界为止,等一条消息的内容完整收齐后再解码」。

5. 不能用 DataAvailable 来判断消息的结尾

以下这种代码也很常见:

var ms = new MemoryStream();
byte[] buffer = new byte[4096];

while (stream.DataAvailable)
{
    int read = await stream.ReadAsync(buffer, cancellationToken);
    if (read == 0)
    {
        break;
    }

    ms.Write(buffer, 0, read);
}

byte[] message = ms.ToArray();

这种写法同样有风险。DataAvailable 表示的是「在这一瞬间,本地接收缓冲区里是否还有可读取的数据」,并不代表应用层面的 1 条消息已经接收完毕。

举例来说,假设 1 条消息长度为 100 字节,当只有最前面 40 字节到达的那一瞬间,DataAvailable 可能是 true;读完这 40 字节之后,DataAvailable 可能会暂时变为 false。剩下的 60 字节可能稍后才会到达。

这时候,如果把 DataAvailable == false 解读为「消息已经结束」,就会把还没收完的数据当成 1 条完整消息来处理。

DataAvailable 可以用于读取循环的优化,或做一些非阻塞式的检查,但不建议用它来判断协议层面的消息边界。

6. 正确的思路:把「接收」与「解析」分开

在设计 TCP 的接收处理时,把下面两件事分开考虑,会更容易设计:

接收: 读取从 TCP 送达的字节序列,累积进缓冲区
解析: 从缓冲区中,切分出应用层面的 1 条消息

Receive / Read 终究只是「读取字节」的处理。至于「1 条消息在哪里结束」,需要由应用层协议来决定。

有代表性的方法共有下面 4 种:

方式 内容 适合的场景
固定长度 始终以固定字节数作为 1 条消息 老旧设备、二进制报文、控制系统
分隔符 \n 等特定字节序列作为 1 条消息的结尾 命令、日志、NDJSON、简易协议
长度前缀 在开头放置正文长度,再读取对应字节数的正文 二进制、JSON、MessagePack、Protocol Buffers 等
自描述格式 像 HTTP 的 Content-Length 或 chunked 那样,在格式内部自行表达长度或结束标志 现有协议、需要可扩展性的通信

就个人经验而言,如果要设计自定义协议,会优先考虑长度前缀方式。理由是正文中可以包含换行或任意二进制数据,接收端的实现也很明确,也容易加上最大长度限制。

7. 长度前缀方式的基本原理

长度前缀方式,会把消息设计成下面的格式:

[4 字节的正文长度][正文]

举例来说,假设正文是 UTF-8 编码的 JSON,正文长度为 31 字节,就会像下面这样发送:

00 00 00 1F 7B 22 63 6F 6D 6D 61 6E 64 ...
^---------^ ^------------------------------^
  正文长度                正文

接收端会按照下面的顺序处理:

  1. 先把 4 字节读取完整
  2. 从这 4 字节中取出正文长度
  3. 验证正文长度是否合理
  4. 读取对应长度的正文
  5. 把读取完成的正文当作 1 条消息来处理
  6. 读取下一帧

这里要注意的重点是「即便是 4 字节的头部,也可能被拆开」。

接收1: 00 00
接收2: 00 1F 7B 22 63 ...

因此,即便是头部,也不能保证 1 次 Read 就能取得完整的 4 字节。

正文部分也是一样。Read 的返回值小于所需大小是很正常的事。如果已经确定需要的字节数,就需要写一个「持续读取直到满足所需字节数为止」的循环。

8. .NET 中的接收实现示例:长度前缀方式

以下是在 .NET / C# 中,读取长度前缀方式帧的示例。

这里把最前面的 4 个字节,当作大端(big-endian)的 int 来表示正文长度。

using System.Buffers.Binary;
using System.IO;

public static class LengthPrefixedProtocol
{
    private const int HeaderSize = 4;
    private const int MaxPayloadSize = 1024 * 1024; // 1 MiB,按场景决定

    public static async ValueTask<byte[]?> ReadFrameAsync(
        Stream stream,
        CancellationToken cancellationToken)
    {
        byte[] header = new byte[HeaderSize];

        int headerBytes = await ReadUntilFullOrEndAsync(
            stream,
            header,
            cancellationToken);

        if (headerBytes == 0)
        {
            // 不是在帧的中途,而是在下一帧开始之前,对端就正常结束了
            return null;
        }

        if (headerBytes != HeaderSize)
        {
            throw new EndOfStreamException("Frame header was truncated.");
        }

        int payloadLength = BinaryPrimitives.ReadInt32BigEndian(header);

        if (payloadLength < 0 || payloadLength > MaxPayloadSize)
        {
            throw new InvalidDataException(
                $"Invalid payload length: {payloadLength} bytes.");
        }

        byte[] payload = new byte[payloadLength];

        int payloadBytes = await ReadUntilFullOrEndAsync(
            stream,
            payload,
            cancellationToken);

        if (payloadBytes != payloadLength)
        {
            throw new EndOfStreamException("Frame payload was truncated.");
        }

        return payload;
    }

    private static async ValueTask<int> ReadUntilFullOrEndAsync(
        Stream stream,
        Memory<byte> buffer,
        CancellationToken cancellationToken)
    {
        int totalRead = 0;

        while (totalRead < buffer.Length)
        {
            int read = await stream.ReadAsync(
                buffer[totalRead..],
                cancellationToken);

            if (read == 0)
            {
                break;
            }

            totalRead += read;
        }

        return totalRead;
    }
}

调用方的写法如下:

while (true)
{
    byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
        stream,
        cancellationToken);

    if (payload is null)
    {
        // 对端在帧边界处干净地断开了连接
        break;
    }

    await HandleMessageAsync(payload, cancellationToken);
}

在这个实现中,无论 ReadAsync 每次返回多少字节都没有问题。即使每次只返回 1 个字节,也会持续循环,直到读完头部与正文为止。

反过来说,即使操作系统的接收缓冲区里已经累积了多条消息的数据,这段代码也只会依照正文长度切出最前面那一帧,剩下的部分留到下一次循环中读取下一帧。

另外,在较新版本的 .NET 中,也可以使用 Stream.ReadExactly / ReadExactlyAsync。如果使用这些 API,就可以把「读取直到满足所需字节数」的处理交给标准库。不过,连接结束时的处理方式、帧开始之前的正常结束、以及帧中途断开的异常结束该如何区分,仍然需要由应用程序自行设计。

9. 发送端的实现示例

发送端也需要按照相同的帧格式来发送数据。

using System.Buffers.Binary;
using System.IO;

public static class LengthPrefixedProtocolWriter
{
    private const int HeaderSize = 4;
    private const int MaxPayloadSize = 1024 * 1024;

    public static async ValueTask WriteFrameAsync(
        Stream stream,
        ReadOnlyMemory<byte> payload,
        CancellationToken cancellationToken)
    {
        if (payload.Length > MaxPayloadSize)
        {
            throw new InvalidDataException(
                $"Payload is too large: {payload.Length} bytes.");
        }

        byte[] header = new byte[HeaderSize];
        BinaryPrimitives.WriteInt32BigEndian(header, payload.Length);

        await stream.WriteAsync(header, cancellationToken);
        await stream.WriteAsync(payload, cancellationToken);
    }
}

这段代码把头部与正文分别调用了 WriteAsync。这里又容易产生一个误区:发送端把头部与正文拆成 2 次调用 WriteAsync,并不代表接收端就会分 2 次收到。

接收端看到的结果,可能是下面这样:

Read() => [4 字节头部 + 部分正文]
Read() => [剩余的正文]

也可能是这样:

Read() => [头部的前 2 字节]
Read() => [头部的后 2 字节 + 全部正文 + 下一帧的头部]

正因为如此,接收端要依据的不是「调用了多少次 Read」,而是「按照帧格式,读到了多少字节」。

10. 直接使用 Socket.Send 时,发送端也要检查返回值

如果使用 NetworkStream.Write / WriteAsync,基本上可以把它当作「会写入指定范围」的 API 来处理。

但另一方面,如果直接使用 Socket.Send,就要特别注意它的返回值。

Socket.Send 返回的是「实际成功发送的字节数」。尤其是在非阻塞 socket 等场景下,实际发送的字节数可能会少于请求的字节数。

因此,如果要直接使用 Socket.Send,发送端也需要一段「持续发送直到全部发完为止」的处理。

using System.Net.Sockets;

public static async ValueTask SendAllAsync(
    Socket socket,
    ReadOnlyMemory<byte> buffer,
    CancellationToken cancellationToken)
{
    while (!buffer.IsEmpty)
    {
        int sent = await socket.SendAsync(
            buffer,
            SocketFlags.None,
            cancellationToken);

        if (sent == 0)
        {
            throw new IOException("Socket was closed while sending data.");
        }

        buffer = buffer[sent..];
    }
}

不过,这里所说的「发送成功」,并不代表「对端应用程序已经处理完这条消息」。发送 API 的成功,与应用层协议上的成功响应,是两件不同的事。

举例来说,如果业务上需要确认「订单已受理」「文件已保存」「命令已执行」,就不能只靠 TCP 发送成功来判断,而必须把对端应用程序返回的 ACK 或响应消息,定义为协议的一部分。

11. 使用分隔符方式时的注意事项

在文本协议中,有时会使用换行作为分隔符。

LOGIN komura secret\n
GET item-001\n
QUIT\n

这种方式容易理解,也很适合日志或命令格式。

不过,仍然有几点需要注意:

  • 确定分隔符出现在正文中时的转义规则
  • 确定 \r\n\n 的处理方式
  • 确定单行的最大长度
  • 在遇到分隔符之前,不要无限制地累积内存
  • 确保 UTF-8 的多字节字符即使被拆开也不会出错

尤其要避免下面这样的代码:

int read = await stream.ReadAsync(buffer, cancellationToken);
string text = Encoding.UTF8.GetString(buffer, 0, read);

foreach (string line in text.Split('\n'))
{
    await HandleLineAsync(line, cancellationToken);
}

这段代码没有考虑到:收到的这段数据,末尾可能恰好停在某一行的中途;也没有考虑到 UTF-8 字符可能在中途被拆开的可能性。

如果要使用换行分隔,至少要做到「先把字节累积起来,找出换行字节,等凑齐 1 行之后才解码」,或者使用像 StreamReader.ReadLineAsync 这种可以在流上逐行读取的 API。

不过,即使使用 StreamReader.ReadLineAsync,最大行长度、超时、取消、连接结束时的处理,仍然需要自行设计。

12. 使用固定长度方式时的注意事项

在固定长度报文中,会事先约定「始终以 128 字节作为 1 条消息」之类的规则。这种方式常见于老旧的业务系统、控制系统、设备联动等场景。

固定长度的思路也是一样的。

1 条消息 = 128 字节

如果是这样约定,接收端就需要持续循环,直到读完 128 字节为止。

byte[] message = new byte[128];
int read = await ReadUntilFullOrEndAsync(stream, message, cancellationToken);

if (read != message.Length)
{
    throw new EndOfStreamException("Fixed-length message was truncated.");
}

await HandleMessageAsync(message, cancellationToken);

这里同样要注意,1 次 ReadAsync 并不保证能读到 128 字节。

固定长度方式的优点是边界明确、便于实现;缺点则是难以处理可变长度的数据、后续扩展较为困难、多余空白的处理比较麻烦,而且字符编码转换后字节数可能会发生变化。

13. 禁用 Nagle 算法无法解决消息边界问题

如果想要立即发送小数据,有时会考虑把 Socket.NoDelay 设为 true。这是用来禁用 Nagle 算法的设置。

不过,NoDelay 只是关于「小数据发送如何合并」的发送延迟与效率设置,并不是「把 Send 的单位保留为 Receive 单位」的设置。

也就是说,即使把 NoDelay 设为 true,下面这些问题依然无法解决:

  • 1 次 Send 被拆分成多次 Receive(拆包)
  • 多次 Send 被合并成 1 次 Receive(粘包)
  • 字符在中途被切断
  • 接收端无法判断消息边界

NoDelay 在调整延迟方面确实有意义,但无法替代分帧(framing)。

14. 即使使用 TLS 或 SslStream,思路也是相同的

即使通过 SslStream 进行 TLS 加密,从应用程序的角度来看,基本的处理方式也是一样的。

TLS 内部有所谓的 TLS 记录(TLS record)这个单位,但那并不是应用层消息的边界。

即使是 SslStream.ReadAsync,也不保证应用程序期望的 1 条消息能在 1 次调用中完整取得。

因此,无论是否使用 TLS,都需要在应用层设计下面其中一种方案:

  • 长度前缀
  • 换行等分隔符
  • 固定长度
  • 现有协议格式

TLS 只是加密与认证的层,并不会自动为你划分消息边界。

15. 接收循环中要注意的错误处理

在 TCP 的接收处理中,除了正常流程,明确处理断连或中途结束的情况同样很重要。

Read / Receive 的返回值为 0 时,一般表示对端已经正常结束发送。

不过,在应用层协议的层面,需要区分下面两种情况:

状态 处理方式
在读取下一帧之前,收到 0 字节而结束 有些情况下可以当作正常结束来处理
在帧头部或正文中途结束 属于不完整的报文,应当作异常处理

以长度前缀方式为例,可以这样考虑:

在帧边界处断开连接:
  可以视为正常结束

4 字节头部只收到 2 字节就断开连接:
  属于协议错误

正文长度应为 100 字节却只收到 60 字节就断开连接:
  属于协议错误

事先做好这个区分,之后排查日志时会轻松很多。

比起只留下「对端断开连接了」这样的信息,如果能输出像下面这样的内容:

Frame payload was truncated. expected=100 actual=60

就更容易怀疑是对端异常结束、超时,还是协议不一致所导致的问题。

16. 一定要设置最大长度上限

在长度前缀方式中,开头会放置正文长度。

这里有一个危险之处:如果对端指定了一个异常巨大的长度值。

FF FF FF FF

如果直接拿这个值去分配数组,就会尝试分配大量内存,导致应用程序变得不稳定。

因此,接收端一定要设置最大长度上限。

private const int MaxPayloadSize = 1024 * 1024;

if (payloadLength < 0 || payloadLength > MaxPayloadSize)
{
    throw new InvalidDataException(
        $"Invalid payload length: {payloadLength} bytes.");
}

最大长度上限要按业务需求来决定。如果是命令类的消息,64 KiB 可能就已经足够;如果要传输图片或文件,或许应该考虑其他的传输方式或流式处理。重点在于,不要把系统设计成「理论上可以无限接受」的架构。

17. 字符串协议看的是「字节数」,不是「字符数」

TCP 传输的并不是字符串,而是字节序列。

因此,在长度前缀方式中填入正文长度时,通常应该填入的是「字节数」,而不是「字符数」。

举例来说,假设要把下面这段文本转成 UTF-8:

こんにちは

这段文本有 5 个字符,但转成 UTF-8 之后是 15 个字节。

如果协议上的长度直接用 5 来表示,接收端就只会读取 5 个字节,结果会在字符的中途被切断。

因此,发送端一定要以编码后的字节数组作为长度计算的基准。

string json = "{\"message\":\"こんにちは\"}";
byte[] payload = Encoding.UTF8.GetBytes(json);

await LengthPrefixedProtocolWriter.WriteFrameAsync(
    stream,
    payload,
    cancellationToken);

接收端则要先把帧正文以字节的形式读取完整,之后再转换回字符串。

byte[]? payload = await LengthPrefixedProtocol.ReadFrameAsync(
    stream,
    cancellationToken);

if (payload is not null)
{
    string json = Encoding.UTF8.GetString(payload);
    await HandleJsonAsync(json, cancellationToken);
}

按照这个顺序处理,即使 Read 恰好在 UTF-8 字符的中途被拆开,也不会造成问题。

18. 也要注意并发写入导致的应用层混线

另一个容易被忽略的重点,是并发写入(concurrent write)。

举例来说,假设有多个任务(task)同时对同一个 TCP 连接写入帧:

_ = WriteFrameAsync(stream, messageA, cancellationToken);
_ = WriteFrameAsync(stream, messageB, cancellationToken);

如果不加以控制,就可能在应用层出现下面这种混线:

A 的头部
B 的头部
A 的正文
B 的正文

接收端在读完 A 的头部之后,会预期接下来收到的是 A 的正文。如果这时候插入了 B 的头部,协议就会被破坏。

因此,对于同一个连接的写入,最安全的做法是将其串行化(serialize)。举例来说,可以使用 SemaphoreSlim 或发送队列,避免不同帧的写入相互混杂。

private readonly SemaphoreSlim _sendLock = new(1, 1);

public async ValueTask SendFrameSafelyAsync(
    Stream stream,
    byte[] payload,
    CancellationToken cancellationToken)
{
    await _sendLock.WaitAsync(cancellationToken);

    try
    {
        await LengthPrefixedProtocolWriter.WriteFrameAsync(
            stream,
            payload,
            cancellationToken);
    }
    finally
    {
        _sendLock.Release();
    }
}

TCP 会保证字节的发送顺序,但如果应用程序让多个任务把字节序列混在一起写入,TCP 也只会忠实地把这个「混杂后的顺序」送达对端。

19. 测试时要刻意制造拆包与粘包

如果只按常规方式测试 TCP 的接收处理,很容易忽略掉「碰巧能跑通」的状态。

因此,测试时要刻意制造下面这些模式:

测试角度 示例
每次只送 1 字节 头部和正文都以 1 字节为单位被 Read
头部中途断开 4 字节头部只收到 2 字节就结束
正文中途断开 正文长度 100 却只收到 60 字节就结束
多帧被合并(粘包) 2 帧同时出现在 1 次内部缓冲区中
指定异常巨大的长度 发送超过最大长度限制的正文长度
0 字节正文 确认是否允许正文长度为 0
UTF-8 被拆开 日文或表情符号的字节序列在中途被拆开

在单元测试中,即使不使用真正的 TCP socket,也可以把 Stream 替换成「只能按指定块大小读取的流」,借此验证接收处理。

通过真实 TCP 连接进行的集成测试同样不可或缺,但如果先把接收解析器(parser)拆分成一个针对 Stream 进行纯粹处理的组件,测试起来会容易得多。

网络处理的质量,不应该用「照常规方式发送就能跑通」来判断,而应该用「即使被拆开、被合并、中途断开,也能按预期运行」来判断。

20. 修改现有代码时的检查清单

在检视现有的 TCP 通信代码时,从下面这些角度来看,比较容易发现问题:

检查角度 确认事项
接收单位 是否把 1 次 Read / Receive 当作 1 条消息来处理
返回值 是否确实使用了 Read / Receive 返回的字节数
累积 是否把数据累积到凑齐 1 条消息为止
边界 是否有固定长度、分隔符、长度前缀等明确规则
字符编码 是否在消息完整收齐之前就转成字符串
最大长度 长度或行长是否有上限
断连 是否区分了帧边界处的断连与中途断连
发送 是否忽略了 Socket.Send 的返回值
并发性 同一连接是否可能被多个任务同时写入而混线
日志 是否能输出 expected / actual 的字节数
测试 是否覆盖了拆包、粘包、中途断连的测试

特别危险的代码,通常是下面这种形式:

int read = socket.Receive(buffer);
string message = Encoding.UTF8.GetString(buffer);
Handle(message);

这里的问题有很多:

  • 没有使用 read 这个返回值
  • 把整个缓冲区都转成字符串
  • 把 1 次 Receive 当作 1 条消息来处理
  • 没有消息边界的概念
  • 没有考虑字符被中途拆开的情况

至少,应该改成下面这种思路:

把 Receive 得到的 read 字节追加进接收缓冲区
  ↓
确认能否按协议规则,从接收缓冲区中切出 1 帧
  ↓
如果能切出,就进行处理
  ↓
多余的字节保留下来,作为下一帧的开头
  ↓
如果不够,就等待下一次 Receive

21. 总结

在 TCP 通信中,不能保证按照 Send 的单位就能收到 Receive。这不是例外行为,而是使用 TCP 时的基本前提。

需要掌握的要点如下:

  • TCP 提供的不是消息,而是保持顺序的字节流
  • Send / Write 调用的单位,不会被保留为接收端 Receive / Read 的单位
  • 1 次发送可能被拆分成多次接收(拆包),多次发送也可能被合并成 1 次接收(粘包)
  • 接收端需要以应用层协议的方式,自行决定消息边界
  • 在自定义协议中,长度前缀方式通常更容易处理
  • 设计时要包含:读取直到满足所需字节数为止的循环、最大长度上限、中途断连、字符编码、并发写入
  • NoDelayDataAvailable 都无法替代消息边界的判断

只看正常情况时,网络处理看起来很简单。但实际上,只有先确定好「在哪里分割」「不够时如何等待」「过多时如何保留」「中途断开时如何处理」,通信才会真正变得稳定。

如果要使用 TCP,就要记住:Receive 返回的不是消息,只是字节序列的一部分。把它组装成消息的责任,在于应用层的协议设计。

参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

为什么 TCP 无法保证按 Send 的单位进行 Receive?
因为 TCP 保证的只是「发出的字节序列会按照顺序、不重复、不丢失地送达」,并不保证 Send 调用的单位会被保留为接收端 Receive 的单位。TCP 传输的是连续的字节流,而不是消息,因此一次发送被拆分成多次接收(拆包)、或多次发送被合并成一次接收(粘包),都是正常现象。接收端必须自行建立判断消息边界的机制,也就是所谓的分帧(framing)。
TCP 的分帧(决定消息边界的方式)有哪些做法?
有代表性的方式共 4 种:固定长度方式(始终以固定字节数作为 1 条消息)、分隔符方式(以换行等特定字节序列作为 1 条消息的结尾)、长度前缀方式(在开头放置正文长度)、以及像 HTTP 的 Content-Length 那样、在格式内部自行表达长度或结束标志的自描述格式。如果要设计自定义协议,长度前缀方式通常是首先考虑的方案,因为正文可以包含任意二进制数据,也容易加上最大长度限制。
把 Socket.NoDelay 设为 true,能解决 TCP 的拆包 / 粘包问题吗?
无法解决。NoDelay 是用来禁用 Nagle 算法的设置,调整的是小数据发送的延迟与效率,并不是把 Send 的单位保留为 Receive 单位的设置。即使把 NoDelay 设为 true,一次 Send 被拆分成多次 Receive(拆包)、多次 Send 被合并成一次 Receive(粘包)、字符在中途被切断等问题依然会发生。它无法替代分帧(framing)。
TCP 接收数据时为什么会出现乱码?
因为 UTF-8 中一个字符有时会由多个字节组成,而 Read 的边界并不保证与字符的边界一致。如果每次收到字节序列后就立刻用 Encoding.UTF8.GetString 转成字符串,一旦在多字节字符中途被切断,就会出错。对策是先把数据当作字节累积起来,直到确认消息边界为止,等一条消息完整收齐后才解码。长度前缀中的正文长度,也要按编码后的字节数计算,而不是字符数。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表