需求是这样。开发一个类库,该类库核心功能是能够读取、写入、订阅PLC的数据,当用户订阅了某写数据时,如果这些数据发生变化,可以通过事件形式,将变化后的数据发给谁用户。
现在做了一个版本。大概思路如下:
1.定义一个接口(比如叫IDevice),该接口包含,链接,断开,读取,写入,订阅plc数据,以及当订阅的数据发生变化后执行的DataChanged事件。
2.实现IDevice接口,比如叫ModbusTcpDevice,该类实现了IDevice。并且ModbusTcpDevice中,缓存用户订阅的数据地址和值,再开一个后台线程,一直去读取 用户订阅的数据,把读取到的数据和缓存的数据比较,同一个地址,缓存数据和当前读取的数据不一致,就把新数据记录一下(比如叫 changedValues ),然后再把缓存值更新成当前值。 最后 执行 DataChanged 事件,传入刚才记录的变化值 (changedValues )。
由此联想的问题:
当一个程序链接较少的PLC 时,应该没什么问题。 如果要链接上百个甚至更多PLC时,每一个IDevice对象都有一个后台线程去轮询,会不会导致线程池爆炸呢?或者引发其他问题。
换成Task就可以 ,20万Task,只要不涉及到IO,没问题。
感谢您的回复。会涉及到I/O,从PLC 读取数据会有网络的I/O
@愤怒的小辣椒: 网络 IO没问题的,只要不涉及到磁盘IO就没问题的,我公司的IOT系统12万的设备都没问题的,Task是几万的
@需要格局: 我现在就是task的。相当于一个设备一个后台的tasi在一直循环读数据,然后发现订阅的数据变了,就触发datachange时间。如果同时几百或者更多设备,也就意味着几百或者更多task ,没问题吗?
@愤怒的小辣椒: 没问题的。只要Task中没有 磁盘IO,没问题。
几百个也不是问题呀. 可以考虑改成 一个主管理(timer)线程, 定时批量触发PLC读取.
IDeviceManager
_devices
--> add_device(IDevice device)
--> read_callback(fuction (device, data)[] frame)
--> _trigger_read() --> for _devices
--> read_callback
大致表达意思.
那如果其中一个 device 掉线 或者网络延迟很长,其他的是不是有受到影响了呢 ?
队列行不行?
你的判断方向是对的——这个设计在几十台以内没问题,上百台就会撞上瓶颈,但"线程池爆炸"这个说法需要修正一下,真正的痛点在三个不同层面。我先把诊断说清楚,再给改造建议。
| 实现方式 | 真实后果 |
|---|---|
每个设备 new Thread() 独立后台线程 |
不是线程池爆炸,而是纯浪费:每线程约 1MB 栈 + 内核对象,100 台 ≈ 上百 MB 空耗内存;线程 99.9% 时间阻塞在 Socket.Receive 上,纯 I/O 等待,白占资源 |
用 Task.Run / ThreadPool 跑 while(true){ 阻塞读 } |
这才是真正的线程池爆炸:长驻阻塞循环吃光池内 worker,池饥饿后 CLR 大约每 0.5~1s 注入一个新线程,高并发下能滚到几千个线程,还会拖累进程内其他异步任务 |
所以结论是:"每个设备一个轮询线程"是经典反模式,但不是线程数量这一个问题。
1. 协议本身不支持订阅,Modbus 只能轮询,这是根因
你的思路没错——Modbus TCP 没有服务端推送机制,变化检测只能靠"定时读 + 比对缓存"。但轮询可以做得极省:按地址逐个读是浪费,应该按连续寄存器块一次读回(比如一次帧读 64~128 个寄存器),再和缓存 diff。这一条就能把你的请求量降一个数量级。
2. PLC/网关的连接数上限(比线程更早卡死你)
很多 Modbus TCP 从站/网关只允许少量并发连接(常见 1~4 个 socket)。上百台设备各开一个 socket 通常没问题,但如果一个网关下挂多个从站、或者设备本身连接数限制很严,你遇到的第一个问题不是线程,而是连接被拒。这个要在架构里考虑连接池/连接复用。
3. 线程模型本身不scale
线程数=设备数,意味着无法集中做:请求合并、轮询频率自适应、故障隔离、超时背压。
| 方案 | 线程模型 | 扩展性 | 复杂度 | 适用 |
|---|---|---|---|---|
| A. 中央轮询引擎(推荐) | 每协议一个调度线程(定时轮/Timer),内部 async I/O | 好,单线程驱动数百 socket | 中 | Modbus 这种只能轮询的协议 |
| B. 纯异步订阅引擎 | 零常驻线程,全 async/await + CancellationToken | 最好,可上千 | 高 | 你库的长期形态 |
| C. 固定大小工作池 | min(设备数, 小N) 个 worker 复用 |
中 | 低 | 某些 PLC 库只能同步 I/O 时的兜底 |
| D. 协议原生订阅 | 事件驱动 | 最优 | 取决于协议 | OPC UA / S7 / ADS 有推送能力,但Modbus 没有 |
你的 IDevice 接口本身(连接/断开/读写/订阅 + DataChanged 事件)设计得没问题,别动它。要改的是实现层。
IDevice(公共API,保持不变)
│ 订阅/退订/读写
▼
SubscriptionEngine(每协议一个,不是每设备一个)
├─ 定时轮:Timer Wheel / 调度队列,管理 (设备, 寄存器块, 间隔)
├─ async I/O 读块 → 和缓存 diff → 收集 changedValues
└─ 触发 IDevice.DataChanged(注意线程安全和事件封送)
│
▼
DevicePool / 连接管理(连接复用、断线重连、超时隔离)
几个关键点:
PollScheduler 单线程 + 设备队列,把现在每个设备内部的 while 循环改成"向调度器注册轮询任务",改动量可控。上百个 PLC:会不会炸? —— 用你现在"每设备一线程+逐地址读"的方案,大概率会遇到(线程内存浪费、ThreadPool 饥饿、请求量过大);改不改? —— 改,但不用推翻重来:保留 IDevice 接口,把轮询收拢成每协议一个 async 调度引擎 + 寄存器块批量读,线程数从"设备数"降到"协议数(个位数)",顺带解决连接数限制和请求量问题。这在工业通信库里是成熟套路(HslCommunication 等就是单后台线程驱动多设备)。
如果你愿意,我可以直接帮你把这版改造落地——比如写一个 ModbusSubscriptionEngine 的核心骨架(定时轮 + async 块读 + diff + 事件触发),你拿回去替换现在的后台线程逻辑就行。要做吗?
一看就是用AI写的