数据采集的多线程模型:每通道一线程还是线程池
上一篇聊了配置放哪。这篇聊另一半麻烦——采集线程怎么开。
第一篇讲分层时提过一句:采集层用「每通道一个接口线程 + 每个协议一个解析线程」,也说了不用线程池。当时只是一句话带过,这篇把它展开:为什么这么选、什么情况下该换、以及线程之间到底怎么交换数据。
一、先看清楚采集线程在循环什么
选线程模型之前,得先看清采集线程实际在干什么。骨架就这几行:
while(m_running){intn=m_link.read(buf,sizeof(buf));// ← 阻塞在这里if(n>0){m_parser.feed(buf,n);// 组帧、解帧m_store.write(m_tag,m_value);// 写实时库,内部加锁}else{m_link.reconnect();// 断线重连}}关键在read那一行:它会把线程挂住,直到有数据或者超时。
这个"阻塞"特性决定了后面所有选型。因为对线程池来说,一个任务应该"领了活、干完、交回去"。而上面这个循环永远不会干完——它跟一条长连接的生命周期一样长。把这种任务丢进线程池,只会把池子里的线程一个个占死。
所以选线程模型,本质上是回答一个问题:你面对的是"一堆短任务",还是"若干条长期存在的连接"?
二、三种模型摆在一起看
模型一:单线程 + IO 多路复用。
一个线程管所有通道,用select/epoll(Windows 上是 IOCP)等事件通知。没有数据时线程去处理别的通道,不阻塞。
优点是线程数恒定,通道涨到几百个也不怕。代价是任何一处解析耗时都会拖住全部通道——你在一个通道上花 50 毫秒解一个复杂帧,其他通道的数据就只能排队等着。
模型二:每通道一线程。
一个通道一个线程,线程里就是上面那个阻塞循环。通道之间天然隔离。
优点是简单:每个线程只关心自己那条连接,读写顺序天然串行,不用考虑状态机被打断。一个通道卡死或断开,不影响其他通道。
代价是线程数随通道数线性增长。但工业场景里通道数通常只有几个到几十个,这个代价可以接受。
模型三:线程池。
固定数量的工作线程 + 一个任务队列。适合短任务、高并发的场景——比如 Web 服务处理请求,一个请求几十毫秒就结束。
放在采集上就不合适了:长连接阻塞读会把池里的线程占满,队列越堆越长,最后既没并发也没实时性。
| 线程数 | 隔离性 | 适合 | 不适合 | |
|---|---|---|---|---|
| 单线程 + 多路复用 | 1 | 差(互相拖累) | 通道多、单包小、协议简单 | 解析耗时长的协议 |
| 每通道一线程 | 随通道数 | 好 | 通道数十个以内、协议解析是同步的 | 通道数上百 |
| 线程池 | 固定 | 中 | 短任务、高并发请求 | 长连接阻塞读 |
三、工业现场为什么多数落在「每通道一线程」
把上面这张表放到真实的工业现场,答案其实很快就收敛了。
第一,通道数有限。一套装置监控系统,连几个到几十个设备是常态,上百个的很少见。这不是互联网的高并发场景,线程数从来不是瓶颈。
第二,隔离性是刚需。现场最怕的是"一个设备通讯异常,整个系统数据全停了"。每通道一线程天然做到这一点——2 号通道的线缆断了,1 号、3 号照常跑。用单线程多路复用也能做到逻辑隔离,但要求每个通道的解析都是非阻塞状态机,写起来复杂得多。
第三,现成的协议库大多是同步阻塞的。Modbus、IEC104 这类规约的开源实现,接口基本长成read() / write() / 等响应的样子。要在这种库上套异步框架,得自己包一层状态机,付出的复杂度换不回对应的收益。
第四,排查成本低。出了问题,把线程名取成通道名(Ch1、Ch2……),调试器里一眼能看出是哪个通道卡住了。线程池方案里任务在哪个线程上执行是不确定的,定位问题要难得多。
这四条里,第二条和第四条是现场真正在意的,第一条和第三条只是让这个选择不那么贵。
四、那什么时候该换模型
两种情况下值得考虑换:
通道数确实上百了。比如数据网关类产品要同时接几百个 TCP 客户端。这时候线程的内存开销(每线程默认栈就不小)和切换开销开始变得可观,单线程多路复用更划算。但这时候协议解析通常也简单(往往只是透传或轻量解析),正好符合"解析不耗时"的前提。
更常见的折中是拆开做。不必二选一:
- IO 线程只负责收发字节,读写完就交给队列,不做任何解析
- 计算线程池从队列取原始数据,做组帧、解帧、写库
这样 IO 侧仍然是"每通道一线程"的简单模型,而耗时不定的解析被挪到了池子里,不会拖住收发。代价是数据要过一道队列——这道队列的边界条件(满了怎么办)反而成了新的风险点,见下一节。
五、线程之间怎么交换数据
先说一条铁律:采集线程绝不动界面。
控件不是线程安全的,从工作线程直接调setText()之类的接口,轻则界面错乱,重则随机崩溃——而且这类崩溃往往"在开发机上不复现",到了现场才出。
第二件事:线程之间不要互相直连。
A 线程直接调 B 线程的对象,等于把两个线程的生命周期绑在一起:B 还没启动、或者已经退出了,A 就出问题。正确做法是让所有线程只跟一个共享的数据中心打交道。
数据流是单向的:设备 → 通道线程 → 队列 → 解析 → 实时库。界面按自己的节奏从实时库取值,采集侧完全不知道界面存在。
关键是队列必须有界。无界队列在正常情况下看不出问题,一旦下游卡住(数据库慢、磁盘满),队列就会一路涨到把内存吃光。满了怎么办,三种策略后果完全不同:
// 队列必须有界。满了怎么办,三种策略后果完全不同:// 阻塞等待 —— 不丢帧,但采集线程会被下游拖住,实时性没了// 丢弃新帧 —— 采集不受影响,但会丢数据// 覆盖最旧 —— 保留最新状态,适合"只关心当前值"的场景boolpush(constFrame&frame){QMutexLockerlock(&m_mutex);if(m_queue.size()>=m_capacity){++m_dropped;// 丢弃也必须计数,否则现场查不出数据为什么少了returnfalse;}m_queue.enqueue(frame);returntrue;}选哪种要看数据性质:历史存储通常选"丢弃 + 计数",因为丢几帧不影响趋势;控制回执类的数据一帧都不能丢,那就得选阻塞等待,同时把队列容量和超时时间设计好。
界面这一侧则是定时取样:
// 界面侧:定时器里主动去实时库取值,而不是被动等采集线程推过来voidonRefreshTimer(){for(auto&item:m_boundItems){doublev=m_store.read(item.deviceId,item.pointId);// 内部加锁,调用方无感item.widget->setValue(v);}}界面刷新的节奏由界面自己决定(通常 500 毫秒到 1 秒一次),跟采集周期解耦。采集多快是采集的事,画面多久刷新一次是画面的事——这两件事混在一起,后面想单独调哪一个都动不了。
六、几个真实的坑
坑一:队列无界。上面说过了,再强调一次是因为它太常见——开发阶段数据量小,无界队列跑得好好的,上线几个月后某次数据库卡顿,进程直接被内存撑死。
坑二:线程退出时队列里还剩数据。停止采集时,队列里可能还有几百帧没处理完。直接杀掉线程,这些数据就静默丢了。正确做法是给一个退出标志,让线程把队列消费完再退出,并设一个超时上限防止卡死。
坑三:采样周期漂移。这个坑很隐蔽:
// 错的写法:每轮的实际周期 = 1000ms + 本轮处理耗时,越跑越慢while(m_running){doSample();sleep(1000);}// 对的写法:按绝对时间对齐,处理耗时被自动扣掉qint64 next=0;while(m_running){doSample();next+=1000;qint64 wait=next-m_clock.elapsed();if(wait>0)sleep(wait);}处理耗时如果是几十毫秒,一天下来就会累积出几十秒的偏差——对需要严格等间隔采样的场景(比如算功率积分)是实打实的误差。
坑四:锁的粒度。第一篇讲过用"每装置一把锁"而不是全局一把大锁。这里补一句:锁粒度选对了,但持锁时间还得短。常见错误是在持锁期间做耗时操作(拼 SQL、写日志、发网络包),结果锁竞争比全局锁还严重。原则是:锁里只做内存读写,出锁之后再干别的。
小结
- 选线程模型先看任务形态:长连接阻塞读和短任务高并发是两种东西
- 工业现场多数落在每通道一线程:通道数有限、隔离性是刚需、排查成本低
- 通道数上百,或想把解析与收发拆开时,才考虑多路复用或 IO + 计算池
- 采集线程绝不动界面;线程之间不直连,所有数据经实时库中转
- 队列必须有界,满了的三种策略(阻塞 / 丢弃 / 覆盖)后果完全不同,必须提前定
- 四个坑:无界队列、退出丢数据、采样漂移、持锁时间过长
下一篇讲实时画面引擎:配置文件里的画面怎么在运行时变成屏幕上的控件,数据绑定怎么做,以及刷新频率的取舍。