☰
数据采集的多线程模型:每通道一线程还是线程池
2026/10/1 6:48:35 网站建设 项目流程

数据采集的多线程模型:每通道一线程还是线程池

上一篇聊了配置放哪。这篇聊另一半麻烦——采集线程怎么开。

第一篇讲分层时提过一句:采集层用「每通道一个接口线程 + 每个协议一个解析线程」,也说了不用线程池。当时只是一句话带过,这篇把它展开:为什么这么选、什么情况下该换、以及线程之间到底怎么交换数据。

一、先看清楚采集线程在循环什么

选线程模型之前,得先看清采集线程实际在干什么。骨架就这几行:

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 + 计算池
  • 采集线程绝不动界面;线程之间不直连,所有数据经实时库中转
  • 队列必须有界,满了的三种策略(阻塞 / 丢弃 / 覆盖)后果完全不同,必须提前定
  • 四个坑:无界队列、退出丢数据、采样漂移、持锁时间过长

下一篇讲实时画面引擎:配置文件里的画面怎么在运行时变成屏幕上的控件,数据绑定怎么做,以及刷新频率的取舍。


需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询