说到 C# 高性能 Modbus TCP,很多人第一反应是“协议这么简单,性能还能有什么可挖的?”我在一个动辄几百台设备、几万点位的采集项目里也是这么想的,直到压测时发现 GC 每秒飙升几十次、响应延迟忽高忽低,才意识到问题不在协议,而在数据路径上的无数次拷贝和堆分配。后来我花了三周重写了一套基于零拷贝 + 内存池 + 管道化架构的实现,才算真正把 Modbus TCP 的吞吐和延迟打到了理想水平。这篇文章就是那套方案的完整复盘,适合做上位机、边缘网关或者 SCADA 前置采集的开发者参考。
1. 先从“慢”说起:传统Modbus TCP实现到底把时间花在哪了
1.1 被忽略的拷贝税
Modbus TCP 的报文很小,读保持寄存器请求通常只有 12 字节,响应也不过 20 字节出头。小报文容易让人掉以轻心,觉得“反正没多少数据,拷贝几下也无所谓”。但如果把时间维度拉长:每秒几千次甚至上万次请求,每次请求都要经过网卡到托管堆的一次搬运、再从 byte[] 里截取响应段做二次拷贝,那累积起来的数据搬移量就非常可观。
旧代码里我见过最常见的写法:Socket.Receive 到一个预分配数组,拿到原始字节后要么 BitConverter 处理,要么干脆把整包丢给一个解析类,内部再 new 一个对象、把需要的字段复制出来。一次请求下来,数据在内存里至少被动过四次。而 CPU 的 L1 Cache 才几十 KB,数据没等在缓存里被复用,就已经被下一批垃圾回收清走,局部性天然就不好。
更要命的是系统调用本身有成本。传统同步收发的线程在等待网络数据时是挂起的,靠操作系统的排队机制唤醒,这个上下文的翻转开销在小报文高频场景下会被无限放大。这也是为什么很多老代码表面上看 CPU 占用不高,但实际每秒能处理的请求数上不去——时间大量消耗在调度等待上,而不是计算上。
1.2 GC 才是最隐蔽的敌人
比拷贝更隐蔽的是垃圾回收。一个传统的读寄存器请求,从发起到底层返回,堆上会产生不少临时对象:SocketAsyncEventArgs、读缓冲数组、解析出的响应对象、存放寄存器值的数组、有时候还有 Task 状态机相关对象。单个对象不大,但高频小对象的堆积会冲击 GC 的代数预算,导致频繁的 Gen0 回收。
Gen0 回收本身不慢,可怕的是它在高负载下反复触发,而且一旦某个大对象被误升代,整个托管堆的扫描压力都会上来。压测现场最直观的表现就是延迟毛刺:平时 P99 在几毫秒,某一瞬间突然跳到几百毫秒。用 profiler 一看,那段时间 GC 标记-清除占了几十毫秒的 CPU 时间。
所以高性能 Modbus TCP 的核心矛盾,从来不是“解析协议要花多少 CPU”,而是“为了解析协议而制造的堆垃圾有多少”。明确了这一点,下面三板斧就有了方向:零拷贝减少数据搬移,内存池消灭重复分配,管道化打散线程阻塞。
2. 零拷贝不是C++专属:C#里如何把报文停留在缓冲区
2.1 告别 ToArray 与 byte[] 劫持
C# 的零拷贝和 C++ 意义上的免拷贝还不完全一样,托管内存的移动、边界检查是客观存在的。工程里讲零拷贝,更多是指让数据在缓冲区里“只被读、不被复制”。要做到这一点,最顺手的基础设施是 System.IO.Pipelines,也就是 Kestrel 内部那套管道模型。
Pipelines 的核心价值在于:Socket 接收到的数据直接落到一个由底层内存池管理的连续段(Segment)上,读侧拿到的不是 byte[],而是 ReadOnlySequence 。解析器可以基于 Span 去切片、取值,完全不用把报文从管道 buffer 再复制到一个全新的数组里。
很多人第一次接触 Pipelines 时会下意识调用 buffer.ToArray(),这就又绕回了老路。正确的写法应该是直接用 buffer.Slice(start, length),用 Span 读取字段。比如 Modbus TCP 的 MBAP 头,TransactionId 是前两个字节,ProtocolId 是第三四个字节,Length 在第五六个字节,读取时全都用 BinaryPrimitives.ReadUInt16BigEndian 配合 slice 后的 Span,零额外分配。
2.2 组装与解析全程 Span 化
发送方向的零拷贝同样重要。传统代码往往是先构造一个 byte[],再把数据拷进去,交给 Socket.Send。更合理的做法是:向内存池租一块 buffer,直接在目标 Span 上填写 MBAP 头和 PDU,最后用 WriteOnlySequence 或直接 span 交给发送层。
private int BuildReadHoldingRequest( ushort transactionId, byte unitId, ushort startAddress, ushort quantity, Span<byte> target) { // MBAP: TransactionId(2) + ProtocolId(2) + Length(2) + UnitId(1) // PDU: FunctionCode(1) + StartAddress(2) + Quantity(2) BinaryPrimitives.WriteUInt16BigEndian(target, transactionId); BinaryPrimitives.WriteUInt16BigEndian(target.Slice(2), 0); BinaryPrimitives.WriteUInt16BigEndian(target.Slice(4), 6); target[6] = unitId; target[7] = 0x03; BinaryPrimitives.WriteUInt16BigEndian(target.Slice(8), startAddress); BinaryPrimitives.WriteUInt16BigEndian(target.Slice(10), quantity); return 12; }请求体从这里开始就是一块 Span,不经过任何中间对象。如果担心栈上分配太大的报文,可以用 ArrayPool 租一块 260 字节的缓冲区,因为 Modbus TCP 最大 ADU 也就 260 字节左右,池化租借非常划算。
解析方向同理。收到响应后先看 buffer 长度够不够 7 字节 MBAP 头,再读 Length 字段确认完整帧长度,然后用 ReadOnlySequence 的切片直接提取 UnitId、FunctionCode、寄存器数据。数据自始至终没有离开管道缓冲区,这就是零拷贝在 C# 里的落地姿势。
3. 内存池:不只是省内存,是稳住GC的定时炸弹
3.1 ArrayPool 的正确打开方式
System.Buffers.ArrayPool 是一个人人都会用、但很少人用对的类。它本质上是一个按幂次分桶的空闲数组缓存,租借和归还都很快,设计目标就是对高频小数组做复用,正好对上 Modbus TCP 这类请求密集、报文短小的场景。
我最终的实现里,Socket 接收缓冲区、发送组装缓冲区、解析临时 buffer 全部来自 ArrayPool .Shared。和以往“每次 receive 都 new byte[1024]”相比,高负载下的托管堆分配几乎被清零。你可能会说一段报文才几十字节,就算 new 也没多少钱,但高频场景下数量才是最致命的,不是单次体积。
MemoryPool 也可以考虑,它和 Pipelines 的关系更紧密。但我个人经验是:在自定义协议层,直接用 ArrayPool 配合 Memory 包装更可控。Pipelines 底层的段管理在某些情况下会一次性给你一大段空间,如果报文超小,返还精度反而不如自己租用 256 或 512 字节的桶。
3.2 高并发下的租借纪律
内存池看似简单,用不好会带来两个非常隐蔽的问题。第一个是忘了归还,典型症状是长期运行后内存不涨、但出现了大块连续空闲无法被复用,因为租出去的 buffer 被某处引用着。第二个是跨 await 归还,租来的数组经过几次 await 后被另一个线程归还到池里,同一个 buffer 同时被两处使用,数据就串了。
我定的纪律很简单:租借和归还在同一个方法栈里完成,谁租谁还;如果实在要跨方法传递,就把整个租借上下文封装成一个 Dispose 处理器,确保异常路径也会归还。
byte[] buffer = ArrayPool<byte>.Shared.Rent(256); try { int len = BuildReadHoldingRequest( nextTransactionId, unitId, startAddress, quantity, buffer); await SendAsync(buffer.AsMemory(0, len)); } finally { ArrayPool<byte>.Shared.Return(buffer); }这里还有一个很多人忽略的细节:Rent 返回的数组长度不一定是你请求的长度,可能是 256、512 甚至更大。千万不要把整个 buffer 发出去,一定要用 AsMemory(0, len) 精确限定。类似地,Return 之前也不用清空数据,因为池化复用场景里清理数据也是一种成本,只要保证每次读取时按长度边界访问就行。
4. 管道化架构:把Modbus的同步请求拆成流水线
4.1 一条请求在管线中的完整旅程
Modbus 是典型的同步问答协议,一个请求必须等响应回来才能发下一条同连接请求。这是协议层面对并发度的天然限制,但并不意味着整个程序只能串行。管道化架构的思路是:把网卡读写、报文解析、事务匹配、业务回调拆成若干个独立阶段,每一阶段只管自己那部分,阶段之间用队列衔接。
我最终落地的是这样一条流水线:WriteLoop 从请求队列里取出待发请求,把号段写到 socket;ReadPipeLoop 从 socket 读取数据,拆出完整帧;帧解析完成后,用 TransactionId 到事务表里查找等待者,找到后把响应数据填给对应的 TaskCompletionSource,业务侧立刻恢复。
private Channel<ModbusTransaction> _requestQueue; private ConcurrentDictionary<ushort, ModbusTransaction> _pending; private async Task WriteLoopAsync(CancellationToken ct) { await foreach (var tx in _requestQueue.Reader.ReadAllAsync(ct)) { await SendFrameAsync(tx.Buffer, ct); } }这里的关键点在于:发送循环和接收循环完全独立。发送循环可以一边等网卡写完,接收循环一边已经在解析下一帧的头部,两者不再互相等待。对同一个 socket 来说是串行的读写,但在程序内部,发送和接收的执行时机被解耦了,整体吞吐自然上升。
4.2 为什么事务表不能只用一个普通字典
TransactionId 是 Modbus TCP 里做事务匹配的关键。每个请求发出时带一个递增的 ID,响应里会原样带回。传统写法用一个 Dictionary<int, TaskCompletionSource> 就够,但高并发下这个字典会被接收线程和发送线程同时触碰,锁竞争就成了新的性能瓶颈。
我最终用 ConcurrentDictionary,并用 ushort 作为 key,因为 Modbus TCP 的 TransactionId 只有 16 位。除此之外,给每个请求挂一个独立的超时时钟,事务从入表开始计时,到点没响应就主动取消并清理表项,避免字典无限膨胀。
事务表最大容量是 65535,看起来不小,但实际轮询模型中通常不会同时挂这么多在途请求。需要注意的是 TransactionId 回绕后的 ABA 问题:如果 ID 复用了,旧响应还没回来,新请求又发出去了,就会错误匹配。解决方法是:每次取新 ID 时先查表,如果当前 ID 还没释放就跳过继续递增。
ushort nextTid; do { nextTid = _nextTid++; if (_nextTid == 0) _nextTid = 1; } while (!_pending.TryAdd(nextTid, tx));这段代码看起来简单,但缺了 TryAdd 结果判断就是埋雷。回绕之后一旦把还在等待的 ID 重复赋值,可能出现 A 请求发出、B 请求得到 A 的响应这种诡异现象。
5. 核心代码骨架:一份可以直接跑的优化版本
5.1 主类骨架
这里给一份浓缩后的核心代码骨架,不包含完整的协议细节,但足以展示上面三套机制是怎么组合在一起的。我用了 .NET 8,这几个 API 在 .NET 6 上也都可用。
public sealed class HighPerformanceModbusClient : IAsyncDisposable { private readonly Socket _socket; private readonly IDuplexPipe _transport; private readonly Pipe _pipe; private readonly Channel<ModbusTransaction> _requestQueue; private readonly ConcurrentDictionary<ushort, ModbusTransaction> _pending; private ushort _nextTid = 1; public HighPerformanceModbusClient(Socket socket) { _socket = socket; _pipe = new Pipe(new PipeOptions( pool: MemoryPool<byte>.Shared, minimumSegmentSize: 512, pauseWriterThreshold: 4096, resumeWriterThreshold: 1024 )); _transport = _pipe; _requestQueue = Channel.CreateBounded<ModbusTransaction>( new BoundedChannelOptions(256) { SingleReader = true, FullMode = BoundedChannelFullMode.Wait }); _pending = new ConcurrentDictionary<ushort, ModbusTransaction>(); } public async Task RunAsync(CancellationToken ct) { var writerLoop = WriteLoopAsync(ct); var readerLoop = ReadPipeLoopAsync(ct); await Task.WhenAll(writerLoop, readerLoop).ConfigureAwait(false); } public ValueTask<ushort[]> ReadHoldingRegistersAsync( byte unitId, ushort startAddress, ushort quantity, CancellationToken ct) { var tid = NextTransactionId(); var tx = new ModbusTransaction(tid, unitId, ct); _pending.TryAdd(tid, tx); _requestQueue.Writer.TryWrite(tx); return tx.WaitAsync(); } private ushort NextTransactionId() { ushort tid; do { tid = _nextTid++; if (_nextTid == 0) _nextTid = 1; } while (_pending.ContainsKey(tid)); return tid; } private async Task WriteLoopAsync(CancellationToken ct) { await foreach (var tx in _requestQueue.Reader.ReadAllAsync(ct)) { byte[] buffer = ArrayPool<byte>.Shared.Rent(260); try { int len = ComposeRequest(tx, buffer); await _socket.SendAsync(buffer.AsMemory(0, len), SocketFlags.None, ct); } finally { ArrayPool<byte>.Shared.Return(buffer); } } } private async Task ReadPipeLoopAsync(CancellationToken ct) { var reader = _pipe.Reader; while (true) { var result = await reader.ReadAsync(ct); var buffer = result.Buffer; var consumed = buffer.Start; while (TryParseFrame(ref buffer, out var frame)) { consumed = buffer.Start; ResolveTransaction(frame); } reader.AdvanceTo(consumed, buffer.End); if (result.IsCompleted) break; } } private bool TryParseFrame(ref ReadOnlySequence<byte> buffer, out ModbusFrame frame) { // 这里按 MBAP 头判断长度,处理粘包与半包 // 至少 7 字节头才能读 Length // 完整帧 = 6 + 2 + Length return default; } }5.2 关键逻辑逐行拆解
先看 Pipe 的构造参数。minimumSegmentSize 设成 512,因为 Modbus 报文最大 260 字节,单段塞一个完整报文绰绰有余,还避免了细碎段的拼接。pauseWriterThreshold 设成 4096,是在告诉底层:当未消费数据超过 4KB 时就停止从 socket 读取,这个机制天然提供了背压,防止接收侧内存无限膨胀。
然后把请求队列设成有界 Channel。有界意味着当业务侧的请求生成速度超过网络发送速度时,写入方会异步等待而不是无限堆积。这一点在突发流量下极其重要,否则队列长度不可控,延迟和内存都很容易爆掉。FullMode 用 Wait,是让生产者感知背压,而不是丢掉请求。
再看 ReadPipeLoopAsync。每次 ReadAsync 返回的都是一个 ReadOnlySequence,可能包含多个完整帧,也可能只包含半个帧。TryParseFrame 内部用 ref 不断把 buffer 的 Start 向后推,直到把本次拿到所有完整帧都处理完。处理完一批后调用 AdvanceTo(consumed, buffer.End),其中 consumed 是当前已经消费到的位置,buffer.End 表示尚未检查的数据末尾。
这里最容易犯的错是 AdvanceTo(consumed, consumed),也就是把未处理的半包尾巴也标记为已消费。这样会导致那半个帧直接丢失,下一轮无论如何都拼不出完整帧。
ResolveTransaction 做的事就简单了:从 frame 里解出 TransactionId,去 _pending 查找,找到就 SetResult,并从字典移除。
private void ResolveTransaction(ModbusFrame frame) { if (_pending.TryRemove(frame.TransactionId, out var tx)) { tx.TrySetResult(frame.Payload); } }这段代码展示了一个关键设计:响应匹配和请求发送是完全解耦的。发送侧只管发,接收侧只管收和匹配,二者之间唯一的共享就是 _pending 字典,而它本身是并发安全的。
6. 实测数字与踩坑记录:优化前的我 vs 优化后的我
6.1 一个公平的基准该怎么做
我大概花了整整一周做基准测试,踩的坑比写核心代码还多。首要教训是:千万不要用 localhost 做基准。localhost 的回环网络延迟极低、带宽极高,会掩盖掉很多调度和锁竞争问题,也会让你错误地认为方案已经足够好。我在局域网里放了三台物理测试机,一台跑模拟从站、一台跑客户端基准、一台做流量旁路观察。
测试场景模拟 50 台从站设备,每台设备模拟 100 个寄存器,客户端以 10ms 间隔轮询全部寄存器地址。这个场景比常见的“单连接单点轮询”要严格得多,也更能暴露高频小报文下的问题。
对比结果大致如下,数字基于我当时的测试环境,不代表所有机器:
| 指标 | 传统实现 | 本方案 |
|---|---|---|
| 每请求托管堆分配 | 约 900 字节 | 32 字节以内 |
| 吞吐量 | 约 3000 请求/秒 | 25000+ 请求/秒 |
| P99 延迟 | 8-20ms 抖动 | 稳定在 2-3ms |
| GC Gen0 次数 | 每秒 40-60 次 | 几乎没有 |
| CPU 占用 | 单核 60% | 单核 15% |
最让我印象深刻的其实是 GC 次数。优化前,Gen0 一秒钟触发几十次,每次虽然耗时不高,但频率太高以至于延迟分布图上全是毛刺。优化后,托管堆分配趋近于零,GC 静默了,延迟曲线几乎是平的一条线。
6.2 绕不开的五道坎
这套方案看起来美好,实际操作中每一步都有坑,我一个个说。
第一道坎是 Pipelines 和 Socket 的联动。Socket.ReceiveAsync 直接拿到 byte[],Pipelines 需要的是 PipeWriter。正确姿势是先用 Socket 接收池化 buffer,再把该段 buffer 作为 ReadOnlyMemory 写入 PipeWriter,或者用 Socket 的 ReceiveAsync(Memory ) 配合 MemoryPool 实现。后者更贴合管道模型,但对实现细节要求更高。
第二道坎是半包和粘包。一句话点透:Modbus TCP 是流式协议,没有帧边界。必须靠 MBAP 头里的 Length 字段判断当前包是否完整。如果 Length 告诉我们需要 21 字节,而 buffer 里只有 14 字节,就必须把当前位置留在原地,等待下一轮 ReadAsync。处理得好不好,直接决定可靠性。
第三道坎是超时管理。高并发下不能简单用 Task.WhenAny + Delay 的方式做超时,那样会让每个挂起的请求都额外产生一个定时器对象。我改用了一个后台线程,每 50ms 扫描一次事务表,将超时的事务踢出并 SetException。虽然 50ms 粒度的扫描不如精确超时平滑,但在工控场景够用,而且几乎零开销。
第四道坎是背压调度。一开始我把 Channel 容量设成 65536,以为越大越稳,结果突发几万个请求时,发送根本跟不上,队列越积越长,等网络恢复后发送的是早已过期的请求。后来把容量调回到 256,配合 FullMode.Wait,让业务侧在生成请求时就开始感知拥堵,整体稳定性反而上来了。
第五道坎是日志。调试时为了看报文内容,我一度把整个 buffer 都转成 hex 字符串打日志,结果优化后的吞吐直接被日志科大锤砸烂。生产环境一定要用结构化日志,只记录必要字段,而且最好走异步通道,否则日志 IO 会瞬间把性能优势吞掉。
最后再分享一点个人体会
这套方案做下来,我最大的收获不是学会了怎么用 Pipelines,而是真正理解了那句话:性能优化不是让代码跑得更快,而是让不必要的工作消失。Modbus TCP 本身协议极其简单,真正的开销全在数据搬移、堆分配和线程等待上。把这三件事理顺了,代码跑的吞吐自然就上去了。
如果你也想动手试,我的建议是不要一上来就全部重写。先把现有实现接上 BenchmarkDotNet,压一轮测出真实的吞吐和 GC 数据,再针对得分最低的环节引入池化和管道化。改一处、测一处、回滚一处。直接照搬整套架构,遇到问题会很难定位是哪一环出了问题。
最后分享一个小技巧:在本地起一个虚拟串口转网络的从站模拟器,用真实报文做离线回放测试。这套环境比单纯压测更能暴露出 TCP 粘包、超时重试、事务表回绕这些边界问题。我把这套代码跑了两周,才敢说它真正达到了标题里“高性能”三个字的标准。