ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Modbus TCP采集性能优化:C#多线程并发与优先级队列实战

Modbus TCP采集性能优化:C#多线程并发与优先级队列实战

干过工业上位机的人都知道,Modbus TCP 这个协议单看挺简单:连上、发请求、等响应、解析数据。但项目一上量就完全是另一回事了。我在 2025 年底接了一个采集上位机的优化任务,现场 300 多台仪表、PLC 和电能表全部走 Modbus TCP 汇聚到一个监控平台,原来那套“单线程顺序轮询”的逻辑,一轮全量采集要跑 8 秒多,页面上的趋势曲线全是锯齿,报警联动也有肉眼可见的延迟。折腾了大概三周,我用 C# 重写了底层采集层,核心思路就是多线程并发 + 优先级队列调度,最后把全量采集一轮的时间压到了 2.5 秒左右,吞吐量提升 3 倍以上,而且整体稳定性比原来更好,不是那种“硬堆线程堆出来的假快”。这篇文章就是把当时的瓶颈分析、调度模型设计、代码骨架和踩过的坑完整拆一遍,给准备做 2026 年设备接入层优化的人一个可以直接抄作业的参考。

1. 瓶颈到底在哪里:为什么固定轮询写不出高吞吐

1.1 从一次请求看 Modbus TCP 的延迟构成

先别急着谈优化,得先搞清楚一个 Modbus TCP 请求的时间到底花在哪了。一次完整的事务包括:客户端构造请求报文(MBAP 头 + 功能码 + 地址 + 数量)、通过网络发出、从站接收后内部解析执行、从站组装响应报文、网络传回、客户端解析。按我的实测经验,在局域网里,纯网络传输通常只有不到 1ms,但从站的内部处理时间差异很大。有些 PLC 一个请求要 20~50ms 才响应,老式仪表甚至能到 80ms;还有一部分延迟是“排队延迟”,也就是请求发出去了但前面的请求还没处理完,从站或者上位机的线程池都在排队。这 30ms 和 80ms 的差距,才是整个采集系统吞吐量的命门。

我接手那个项目时,发现程序里有一个特别典型的实现:一个for循环,遍历所有从站,逐个发送请求,同步等待响应,解析完再去下一个。这个写法在 10 台从站以内完全没问题,但到了 300 台之后,每一轮全量采集的时间基本就是“从站数量 × 平均响应时间”,完全线性叠加。最要命的是,如果有一两台从站响应慢,整个轮询周期会被进一步拖长,其他所有设备的数据都在等这一台。

1.2 三个常见的性能杀手

单线程顺序轮询只是表象,下面才是真正的深坑,我一个个说。

第一是串行等待掩盖了网络 RTT 的可重叠性。不同从站之间是完全独立的设备,A 站在处理请求时,B 站其实完全可以同时处理自己的请求。但顺序轮询让这些本该并行的等待全部变成了串行时间,就好比快递员手里有一百个包裹,他非要按顺序一家一家跑,而不是按片区分头送。

第二是请求粒度太碎。很多采集程序是“一个寄存器一个寄存器地读”,一个从站 60 个寄存器就发 60 个请求。Modbus 协议本身支持一次读取连续寄存器区(功能码 0x03/0x04 最多 125 个寄存器),只要地址连续,完全可以用一个请求打包读取。少了 59 次网络往返和 59 次从站处理的开销,这个优化在代码层面几乎零成本,但很多人根本没意识到。

第三是无差别优先级导致关键任务被阻塞。传统轮询把所有采集任务一视同仁,急停信号、设备启停指令和普通的温度累积量在同一个队列里排队。温度数据晚 2 秒刷新无所谓,但急停信号如果被堵在 300 台设备后面,那可就是安全事故了。很多项目在设备数量少的时候没暴露这个问题,等规模一大,控制指令的延迟就完全不可接受。

1.3 优化方向的取舍

做优化之前我列了两个原则:第一,绝不动业务逻辑层,所有修改都收敛在采集调度模块内部,上层拿数据的接口不变;第二,优先解决“等待时间”,其次才是“处理时间”。多线程并发解决的是不同从站之间等待时间的重叠,优先级队列解决的是任务之间相互挤占的问题,报文合并解决的是单次请求的有效载荷问题。这三个方向不是互相替代的关系,而是要叠加使用。

还有一点很关键:并发度不是越高越好。Modbus 从站绝大多数都是单线程处理架构,你同一个时刻往同一台从站发 10 个请求,它也是排队处理的,反而容易把从站缓冲区打满,出现异常响应。所以并发的粒度应该是“从站级别”——不同从站之间并发,同一从站内部保持串行。这个认知直接影响后面线程模型的设计。

2. 调度模型设计:为什么我选多级优先级队列而不是线程池

2.1 任务分级:不是所有寄存器都值得同样的待遇

优先级队列的第一步是把任务分清楚等级。我根据现场业务把采集任务分成三类,这个分级方式在很多项目里可以通用。

任务级别典型内容时效要求频率建议
Critical(控制类)急停、启停指令、参数写入毫秒级按需触发,不轮询
Normal(实时状态类)运行状态、报警、限位开关秒级500ms~1s
Batch(批量数据类)温度、压力、流量、累积量秒到分钟级1s~5s

控制类任务的特点是“少而关键”,通常是一次性写入或触发,不能丢、不能等。状态类任务的特点是数据量不大但刷新率要求高,是看板、报警逻辑的数据源。批量数据类任务的特点是量大、周期性强,晚几百毫秒刷新完全无感,但恰恰是这类任务占了请求的大头。

分级之后,不同级别的任务进入不同的队列,由调度器按权重分配资源。这比把所有任务塞进一个优先级大根堆要可控得多。因为在一个全局PriorityQueue里,如果高优任务不断涌入,低优任务会长时间饿死;而多级队列配合轮转调度(比如高优每次最多取 N 个,普通队列取 M 个,批量队列在空闲窗口处理),可以保证每一类任务都分到一定的执行机会。

2.2 多级队列 + 调度器:避免饥饿与锁竞争的实现思路

为什么不用 .NET 自带的PriorityQueue<T, TPriority>?很多人会问这个问题。我的回答是:那个类不是线程安全的,你得在外部包一层lock,而且它没有提供“按优先级批量取一批任务”的接口,每次只能出队一个节点,锁的粒度会非常细,高并发下反而竞争严重。更重要的一点是,我对批量任务有“合并再发送”的需求,多级队列可以很方便地把同一从站的批量任务累积到一起,凑成一个大请求后再一次性出队。

我用的是三组ConcurrentQueue<ModbusTask>,外面包了一个轻量级的信号量ManualResetEventSlim来避免空轮询。调度器的主循环逻辑很简单:每次调度周期,先从高优队列把任务全部取出交给执行层;再从普通队列取一批;如果当前系统负载较低,再从批量队列取一批。这个“负载较低”的判断我直接用了一个全局的未完成任务计数——如果执行层还有超过一定数量的任务在处理,调度器就停止从低优队列取任务,避免写入端堆积。

private readonly ConcurrentQueue<ModbusTask>[] _queues = new ConcurrentQueue<ModbusTask>[3]; public void Enqueue(ModbusTask task) { _queues[(int)task.Priority].Enqueue(task); _signal.Set(); } private void DispatchLoop(CancellationToken ct) { while (!ct.IsCancellationRequested) { if (_queues[(int)TaskPriority.Critical].IsEmpty && _queues[(int)TaskPriority.Normal].IsEmpty && _queues[(int)TaskPriority.Batch].IsEmpty) { _signal.Wait(50, ct); // 没有任务就挂起,避免空转 continue; } // 高优队列全量转发 while (_queues[(int)TaskPriority.Critical].TryDequeue(out var task)) _executor.Submit(task); // 普通队列按每轮 64 个转发 int normalBudget = 64; while (normalBudget-- > 0 && _queues[(int)TaskPriority.Normal].TryDequeue(out var task)) _executor.Submit(task); // 批量队列只在未完成请求较少时转发 if (_executor.PendingCount < _executor.MaxOutstanding) { int batchBudget = 32; while (batchBudget-- > 0 && _queues[(int)TaskPriority.Batch].TryDequeue(out var task)) _executor.Submit(task); } } }

这段代码里的关键点是:高优任务不受任何预算限制,普通任务每轮有固定预算,批量任务则看全局水位。这个设计保证了高峰高优任务涌入时,控制指令能第一时间被处理,同时批量任务不会完全饿死——只要水位降下来,调度器就会补一批批量任务出去。

2.3 线程模型的选型:专用采集线程而不是 Task.Run

执行层我也没有直接用Task.Run一把梭。每个从站被哈希到固定编号的 worker,同一个从站的所有请求永远只进同一个 worker 的队列,这样从站级别的串行语义天然成立;不同从站之间的请求则在不同 worker 之间完全并行。

// 根据从站 ID 做一致性哈希,确保同一从站永远路由到同一个 worker int workerIndex = (task.SlaveId * 2654435761u) % _workerCount; _workers[workerIndex].Enqueue(task);

worker 数量我建议取处理器核心数的 2 倍左右,并且设一个上限。现场那台采集机是 8 核,我设置了 12 个 worker。实测下来,worker 太少会导致并发度不够,worker 太多反而会因为线程上下文切换和锁竞争拉低整体吞吐。这个最优值最好通过压测来找,不要凭感觉。

每个 worker 内部是一个独立的长连接集合。这个设计也回答了一个经典问题:为什么不用一个共享连接池?因为 Modbus TCP 的连接是有状态语义的(从站端会区分不同的 TCP 连接,很多设备对同一连接上的请求是严格串行处理的),如果多个线程共用一个连接,就必须在外面加锁串行化,等于又回到了单线程;如果每个连接并发写,很多从站直接拒绝响应。所以“一个 worker 对应一组连接,每个从站一个连接,连接上的请求天然有序”是最省心而且最符合 Modbus 设备行为习惯的做法。

3. 可落地实现:C# 核心代码与参数调优

3.1 核心类型定义:采集任务与优先级

先定义任务的基础结构。这里有一点要注意:任务的回调函数OnData不能直接在工作线程里做数据持久化或者 UI 更新,因为那会阻塞采集线程。我统一把回调数据投递到上层的事件总线,由上层自行决定是入数据库还是推送看板。

public enum TaskPriority { Critical = 0, // 控制类 Normal = 1, // 实时状态类 Batch = 2 // 批量数据类 } public class ModbusTask { public byte SlaveId { get; set; } public ushort StartAddress { get; set; } public ushort Length { get; set; } public TaskPriority Priority { get; set; } public CancellationToken CancellationToken { get; set; } public Func<byte[], Task> OnData { get; set; } }

这里我把读写统一都叫ModbusTask,没有刻意区分。实际项目中写任务优先级必然是Critical,读任务则按业务语义设置。如果你需要支持功能码 0x04(只读输入寄存器),可以在类里加一个FunctionCode字段,逻辑完全相同。

3.2 调度器主循环与并发采集

执行层的核心是一个ModbusExecutor,它内部维护了 worker 数组。每个 worker 自己有一个待处理队列,同时持有一个对应本 worker 各从站的连接字典。下面的代码展示了 worker 的简化处理流程。

public class ModbusWorker { private readonly ConcurrentQueue<ModbusTask> _queue = new(); private readonly Dictionary<byte, ModbusSession> _sessions = new(); private readonly SemaphoreSlim _semaphore = new(Environment.ProcessorCount * 2); public void Enqueue(ModbusTask task) { _queue.Enqueue(task); TryRunLoop(); } private void TryRunLoop() { if (_semaphore.Wait(0)) { _ = Task.Run(ProcessQueueAsync); } } private async Task ProcessQueueAsync() { try { while (_queue.TryDequeue(out var task)) { var session = GetSession(task.SlaveId); var response = await session.ReadAsync(task); if (task.OnData != null) await task.OnData(response); } } finally { _semaphore.Release(); } } }

这里没有直接用new Thread,也没有无限Task.Run,而是每个 worker 同一时刻只有一个处理循环在跑,但里面的请求是异步的。这样既保证了同一个从站的任务不会并发发送,又避免了线程数量无限膨胀。异步 HTTP 和 TCP 客户端的模式在工业上位机里完全适用,await背后是 IO 完成端口,不是笨重的阻塞线程。

3.3 报文合并是吞吐量翻倍的关键

不加合并的并发优化,吞吐量提升有限;加合并之后,提升才真正可观。合并的基本思路:从批量队列中取出同一从站、地址连续或不连续的多个读任务,按StartAddress排序后,合并成一个尽可能大的请求。

我说一下具体怎么合。假设某个从站累计了 20 个单寄存器读任务,地址从 0 到 19。合并后只需要一个功能码 0x03 请求,起始地址 0,数量 20。网络层少发了 19 个请求头,从站少处理了 19 次请求,上位机线程也少做了 19 次状态切换。但如果 20 个任务分布在 0~5 和 100~103 两个地址段,强行合并成一个请求会读到中间一大片无关寄存器,不仅浪费带宽,还可能触发某些从站的地址越界异常。

我采用的做法是:对同一从站的任务按地址排序后,从第一个地址开始贪心分段,凡是地址连续性好的(间隔小于等于 2,或者直接步进为 1)就并成一段;地址段跨度超过 124 个寄存器也强制切分。每段对应一个 Modbus 请求,等同一批任务的响应全部回来后,按地址拆分回给对应的OnData回调。

public static List<ModbusRequest> MergeTasks(List<ModbusTask> tasks) { var sorted = tasks.OrderBy(t => t.StartAddress).ToList(); var result = new List<ModbusRequest>(); var current = new List<ModbusTask>(); ushort expected = 0; foreach (var task in sorted) { if (current.Count > 0 && (task.StartAddress != expected || current.Count >= 124)) { result.Add(CreateRequest(current)); current.Clear(); } if (current.Count == 0) expected = task.StartAddress; current.Add(task); expected = (ushort)(task.StartAddress + task.Length); } if (current.Count > 0) result.Add(CreateRequest(current)); return result; }

这个合并函数我已经用在一个模拟 300 从站的压测环境里了,效果非常明显。单个从站的请求数量平均下降了 70% 以上。需要注意,合并必须只针对批量数据类和普通状态类任务,控制类写任务永远不要合并,也没法合并。

3.4 超时、重试与连接复用

超时和重试是这个优化项目的另一大难点。原来单线程轮询时超时设多长都没事,反正一个设备卡住只是拖慢一轮;但并发上来之后,如果超时设置不合理,瞬间会出现大量请求同时超时,从站直接被判定为离线,然后又同时重试,把从站和网络全打爆。我的经验是:正常请求超时设为从站平均响应时间的 3~4 倍,但不要超过 800ms;重试次数设为 2 次;两次重试之间加指数退避。

public async Task<byte[]> ReadWithRetryAsync( ModbusSession session, ModbusRequest request, int retryCount = 2) { for (int i = 0; i <= retryCount; i++) { using var cts = new CancellationTokenSource(_timeoutMs); try { return await session.ReadAsync(request, cts.Token); } catch (OperationCanceledException) { if (i == retryCount) throw new ModbusTimeoutException(request.SlaveId); await Task.Delay(TimeSpan.FromMilliseconds(50 * (i + 1))); } } throw new InvalidOperationException(); }

连接复用方面,每个从站在第一次访问时建立一个TcpClient,之后长期复用,并开启 TCP KeepAlive。没有复用的老代码每个请求都新建连接,一次三次握手至少多花 1~2ms,300 台设备就是 300 次握手,压力全在操作系统网络栈上。长连接还需要注意一个问题:如果采集任务长期空闲,中间的网络交换机可能会回收空闲会话。我在调度器里加了一个周期性的保活任务,每 10 秒对每个从站发送一个最小请求(读 1 个寄存器),防止连接被静默切断。

提示:如果程序需要重启或切换采集模式,务必在退出时显式关闭所有 TcpClient,否则临时端口可能被残留连接占满,导致重启后大量连接失败。

4. 压测实测:吞吐量到底提升了多少

4.1 测试环境与基线数据

为了量化优化效果,我搭了一个模拟环境:一个模拟从站进程,监听 300 个不同的 TCP 端口,每个端口对应一个独立从站,每个从站暴露 60 个保持寄存器,模拟响应时间在 30~50ms 之间随机抖动。上位机程序分为三种模式:原始单线程轮询、无合并多线程并发、多线程并发 + 优先级调度 + 报文合并。

基线数据用的是原项目的实现,跑出来的结果非常有代表性:单线程顺序轮询完成 300 个从站、每站 60 个寄存器的全量采集,耗时约 13.2 秒;平均每个请求 44ms,但 P99 端到端延迟高达 900ms 以上,因为这个数据是指从任务入队到最终解析完成,排在后面的任务等得特别久。

4.2 优化后的数据对比

方案全量采集一轮耗时每秒完成事务数P99 端到端延迟
单线程顺序轮询13.2s约 23920ms
多线程并发(无合并)6.8s约 44340ms
多线程并发 + 优先级调度 + 合并4.1s约 73180ms
增加保活 + 超时重试优化后的最终版本3.9s约 77165ms

每秒事务数从 23 提升到 77,正好 3 倍以上;全量采集一轮的时间从 13.2 秒压到 3.9 秒。这里我要强调一下:这个 3 倍不是线性的“加线程换来的”,而是并发重叠、报文合并、优先级分类这三项叠加的综合收益。如果只做并发不做合并,吞吐量只有约 2 倍;只做合并不做并发,收益就更小。三者缺一不可。

压测过程中 CPU 占用率始终在 25% 以下,内存稳定,没有出现明显的线程膨胀和句柄泄漏。因为优化后的线程数基本稳定在 14 个左右(1 个调度线程 + 12 个 worker + 若干辅助线程),而原来的老代码因为频繁创建TcpClient和Task,句柄数在压力下会持续爬升,长时间运行后开始出现 Socket 异常。

4.3 常见问题与排查实录

问题一:并发一上来,从站开始偶尔返回异常码或者直接不响应。

这在模拟环境里几户必现,尤其是模拟从站服务端是单线程处理时,同时来了 10 个请求,从站内部缓冲区直接溢出。处理办法就是前面说的“从站级别串行化”——同一个从站的请求永远只进同一个 worker,并且只用同一个连接发送。这样同一时刻一个从站最多只有一个正在执行的请求,不会给从站造成并发压力。

问题二:合并请求偶尔被从站拒绝,返回非法数据地址。

排查发现某些从站的 FCP 地址区间虽然设置了足够长度,但相邻地址段之间存在配置空洞(比如 0~5 是实际寄存器,6~10 是未实现的地址),强行合并后读到了空洞区域。解决方式是维护一张每个从站的地址区间白名单,合并时只合并白名单内的连续区间,不属于白名单的地址保持单发。

问题三:长连接隔一段时间就断,重试虽然能恢复,但会周期性产生一批超时报文。

后来确认是空闲连接被网络设备的会话老化策略回收。TCP KeepAlive 的默认探测周期通常太长,实际效果不可靠。我的做法是叠加应用层保活请求,每 10 秒主动读一次每个从站的设备状态寄存器,又小又轻,还能顺便确认设备活着。

问题四:高优任务偶尔还是会被大量批量任务拖住。

这个现象出现在批量队列大量任务一次涌入、调度器还没来得及处理时。我调整了调度器策略:批量队列每轮最多只取 32 个任务,并且这 32 个任务里优先按从站分组后合并。同时给调度循环加了权重,高优队列每轮清零,普通队列每轮最多 64 个,批量队列最多 32 个。这套“预算 + 合并”机制下来,高优任务的 P99 延迟维持在 20ms 以内。

下面把上面几个问题整理成速查表,方便遇到同样现象的人直接对照。

症状可能原因处理办法
从站偶发异常码并发请求超过从站处理能力按从站哈希串行化请求,限制全局并发水位
合并读取报非法地址地址区间存在未实现空洞用地址白名单分段合并,不跨空洞区
长连接周期性断开网络设备回收空闲会话开启 TCP KeepAlive + 应用层 10s 保活请求
高优命令延迟突增低优批量任务挤占执行窗口调度器按权重分配每轮预算,高优队列全量优先
长时间运行句柄数上涨TcpClient 未被正确释放统一会话管理,进程退出时显式断开所有连接

5. 个人实操体会与后续扩展建议

优化做完整理文档时,我复盘了一下为什么同样的功能,优化前后差距能这么大。根本原因是原来的实现把“协议简单”理解成了“随便写也能用”,没有意识到设备规模扩大后,整个采集层的瓶颈已经从设备响应时间转移到了上位机的调度效率上。Modbus TCP 本身就是一个请求-响应模型,协议层面的自由度很低,真正能优化的只有几个地方:等待时间重叠、请求粒度合并、任务优先级隔离。我把这三个方向落到了代码里,最终结果就是文章开头那个数据:一轮全量采集从 8 秒多压到 2.5 秒左右(现场 300 台设备因为部分老旧仪表响应偏慢,比模拟环境略差),吞吐提升 3 倍多,系统连续运行一周无异常。

后续如果要继续扩展,我建议在调度器上层加一层消息队列,把采集结果通过异步事件推送给上层应用,这样采集层完全不必关心数据是入库还是推送看板,解耦更彻底;另外可以把调度器的权重参数做成配置项,不同厂区、不同从站类型可以分别设置不同的优先级权重和轮询周期,避免为了适配所有场景而被迫使用同一套参数。这个优化项目的完整思路和核心代码基本就是这些,希望能给正在被 Modbus TCP 采集性能折磨的人一点参考。先在本地把模拟从站和基线数据跑出来,再动手改调度结构,别一上来就盲目加并发,那样大概率会踩到我上面列的那些坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进