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 的 Send 与 Receive 之间容易产生的误解,以及在 .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 ...
^---------^ ^------------------------------^
正文长度 正文
接收端会按照下面的顺序处理:
- 先把 4 字节读取完整
- 从这 4 字节中取出正文长度
- 验证正文长度是否合理
- 读取对应长度的正文
- 把读取完成的正文当作 1 条消息来处理
- 读取下一帧
这里要注意的重点是「即便是 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 次接收(粘包)
- 接收端需要以应用层协议的方式,自行决定消息边界
- 在自定义协议中,长度前缀方式通常更容易处理
- 设计时要包含:读取直到满足所需字节数为止的循环、最大长度上限、中途断连、字符编码、并发写入
NoDelay与DataAvailable都无法替代消息边界的判断
只看正常情况时,网络处理看起来很简单。但实际上,只有先确定好「在哪里分割」「不够时如何等待」「过多时如何保留」「中途断开时如何处理」,通信才会真正变得稳定。
如果要使用 TCP,就要记住:Receive 返回的不是消息,只是字节序列的一部分。把它组装成消息的责任,在于应用层的协议设计。
参考资料
- 本文的示例代码一式(库、demo、单元测试) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/tcp-send-receive-message-framing
- RFC 9293: Transmission Control Protocol (TCP) https://www.rfc-editor.org/rfc/rfc9293.html
- Microsoft Learn:
Socket.Receivehttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.receive?view=net-10.0 - Microsoft Learn:
NetworkStream.Readhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream.read?view=net-10.0 - Microsoft Learn:
Socket.Sendhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.send?view=net-10.0 - Microsoft Learn:
NetworkStream.Writehttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.networkstream.write?view=net-10.0 - Microsoft Learn:
Stream.ReadExactly/ReadExactlyAsynchttps://learn.microsoft.com/en-us/dotnet/api/system.io.stream.readexactly?view=net-10.0 https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.readexactlyasync?view=net-10.0 - Microsoft Learn:
Socket.NoDelayhttps://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.socket.nodelay?view=net-10.0
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
PDB(程序数据库)是什么 ── 理解调试信息、符号与 Source Link
整理 PDB(Program Database / 程序数据库)到底是什么、里面有什么、没有什么,以及它与 Debug / Release、Portable PDB、Source Link、符号服务器、Dump 分析之间的关系,供实务参考。
在 .NET 中区分 GC 等待与内存泄漏 —— 观测、比较、证明内存增长的实务步骤
本文整理在 .NET 应用程序中,使用 dotnet-counters、dotnet-gcdump、dotnet-dump 区分内存增长的原因究竟是垃圾回收(GC)等待,还是真正的内存泄漏的实务步骤。
把OSI参考模型彻底看懂 ── 拆解一个HTTP请求里的七层结构
本文不靠死记硬背来理解OSI参考模型,而是从实物出发。用C#组装出一个承载HTTP GET请求的Ethernet帧并进行解剖,通过十六进制转储和Wireshark确认L2〜L7如何以字节序列的形式层层物理嵌套在一起。文章从实务角度梳理各层与.NET API(HttpClie...
正确处理 Windows 的模拟令牌 ── 线程级权限借用与安全的还原方式
本文围绕 Windows 的模拟令牌,梳理访问令牌、主令牌、线程令牌、模拟级别、RevertToSelf,直至 .NET 的 WindowsIdentity.RunImpersonated,整理在实务中安全处理它们的思路。
用 PerfView 与 dotnet-trace 定位“变慢”的原因 ── .NET 性能排查实务入门
当业务应用出现“变慢”“CPU 占满”“偶尔卡死”时,该用哪个工具看什么?本文梳理 PerfView 与 dotnet-trace 的分工、CPU 采样的解读方法(inclusive/exclusive)、用 ThreadTime 排查阻塞时间,以及与 EventSourc...
常见问题
汇总了咨询这一主题时常见的问题。
- 为什么 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。