上一篇我们解决了跨线程 UI 更新的问题——数据从串口线程切出来,通过Dispatcher.BeginInvoke切回 UI 线程,界面终于不崩了。
但你有没有想过一个问题:串口线程自己,其实一直在偷偷"堵车"。
我们先从一个日常场景说起,
开车等红绿灯的时候,有些司机会抽空看手机——虽然危险,但背后的逻辑是:"等的时候干别的,能省时间。"
在程序里,这个"等"的过程叫阻塞。它的特点是:当前线程被卡住,什么都干不了,只能等。
而串口通信里最怕的,就是串口线程被"等"卡住。
我们称呼这个”停车“的过程为阻塞。
一、串口线程在偷偷干慢活
我们已经完成了数据的接收、解析和 UI 更新。接着该做的事:把数据存进数据库。
存盘的代码不复杂,看个大概即可:
public void Insert(SensorData sensorData) // 数据库 加入数据 { string baseDir = AppDomain.CurrentDomain.BaseDirectory; string connection = $"Data Source={System.IO.Path.Combine(baseDir, "SensorData.db")}"; string sql = "INSERT INTO SensorData (Temp,Humidity,RecordTime) VALUES(@temp,@humidity,@recordTime);"; using (var con = new SqliteConnection(connection)) { con.Open(); using (var cmd = new SqliteCommand(sql,con)) { cmd.Parameters.Add("@temp",SqliteType.Real).Value = sensorData.Temp; cmd.Parameters.Add("@humidity",SqliteType.Real).Value =sensorData.Humidity; cmd.Parameters.Add("@recordTime", SqliteType.Text).Value = DateTime.Now.ToString("yyyy-MM-dd HH-mm-ss"); cmd.ExecuteNonQuery(); } } }然后像上一篇那样订阅事件:
dataPond.DisplaySensorData += Insert;但是由于DisplaySensorData是按顺序执行,它一定会执行完全程序才结束,而Insert(SensorData sensorData)录入数据库这一过程时间相对漫长,
Insert里有con.Open()和ExecuteNonQuery()——这是磁盘 I/O,毫秒级。
也就是说:串口线程每次收到数据,都要等磁盘写完,才能回去读下一帧。
如果这时候下一帧已经到达,它只能在缓冲区里排队——串口线程被卡在Insert里出不来,这就是阻塞。
虽然因为项目小,这点效率无关痛痒。但如果换成高频传感器,或者同一程序收多路串口——每秒几千上万次 Insert,串口线程会被拖死,进而丢帧。
所以我决定优化。但优化不是拍脑袋——我踩了一个坑。
二、生产者-消费者:如何让数据乖乖“排队”
在介绍我踩的坑之前,先讲清楚两个工具:队列和BlockingCollection。
队列和BlockingCollection:
队列:在生产者(产生数据)和消费者(使用数据)之间做一个中转——数据先排队,再慢慢处理。
BlockingCollection:.NET 提供的线程安全 + 阻塞式队列。
(1)线程安全:
多个线程同时Add/Take不会乱,不用自己加锁。
(2)阻塞式:队列空时,消费者自动"睡"在这等;队列满时(如果设了上限),生产者自动等着。
new BlockingCollection<T>(容量) | 构造 | 创建队列,可选设上限 |
.Add(item) | 生产者 | 入队。队列满时会阻塞(不设上限则不阻塞) |
.GetConsumingEnumerable() | 消费者 | 返回可遍历对象。队列空时自动等;CompleteAdding后自动结束 |
.CompleteAdding() | 关闭时 | 声明"不再入队"。此时GetConsumingEnumerable会在消费完剩余数据后退出 |
你大概已经猜到了:用BlockingCollection做中转,生产者只.Add(),消费者只GetConsumingEnumerable()取——两边互不感知对方速度。
优化后代码:
BlockingCollection<SensorData> blockSQList = new BlockingCollection<SensorData>(1000); Task _consumerTask; DataPond dataPond; public SqliteRepository(DataPond dp) { Init(); dataPond = dp; dataPond.DisplaySensorData += QueueAdd; // 订阅:只入队 QueueTake(); // 启动消费者线程 } // 生产者:串口线程调用,纳秒级返回 public void QueueAdd(SensorData sensorData) { if (!blockSQList.IsAddingCompleted) blockSQList.Add(sensorData); } // 消费者:后台线程运行,专职写库 public void QueueTake() { _consumerTask = Task.Run(() => //上一篇讲到的线程 ,Task.Run 就是创建一条新的线程运行 { foreach (var sensor in blockSQList.GetConsumingEnumerable()) { try { Insert(sensor); } catch (Exception ex) { Debug.WriteLine(ex); } } }); }三.一次"想当然"的后果
看下面这段代码,先别看答案——自己找找问题在哪。:
public void QueueAdd(SensorData sensorData) { Task.Run(() => { while (true) { blockSQList.Add(sensorData); } }); QueueTake(); }当时我的想法是:入队是个"持续过程",那就用while循环一直加。
但完全错了。QueueAdd是事件订阅者——DisplaySensorData每触发一次,才调一次QueueAdd。它不需要循环,因为事件本身就是"一次次通知"的。
结果:每来一帧数据 → 创建一个死循环 Task → 无限Add同一条数据。
10 帧 = 10 个死循环线程。
由于作者我呀,电脑并不好
我多点几下按钮连接下差点没给我cpu干冒烟了。
修法:QueueAdd只留一行——不加Task.Run、不加while:
public void QueueAdd(SensorData sensorData) { if (!blockSQList.IsAddingCompleted) blockSQList.Add(sensorData); }教训:优化不能拍脑袋。写while之前先问自己一句:"这段代码会被调多少次?"
四.别让数据"死在半路"
队列方案让数据从"串口线程直接写库"变成"入队 → 消费者慢慢写"。但却也产出了一个新问题:
程序关闭时,队列里可能还有数据没被消费完。
比如用户点关闭按钮时,消费者正在写第 8 条,队列里还有 9、10、11 三条等着。如果直接关窗口,进程被杀 → 三条数据丢失 → 数据库还可能留下.db-wal临时文件。
public void ShutDown() { dataPond.DisplaySensorData -= QueueAdd; // ① 退订:没有新数据再进来 blockSQList.CompleteAdding(); // ② 声明"不再入队" if (!_consumerTask.IsCompleted) _consumerTask.Wait(2000); // ③ 等消费者把剩余数据写完 }| ① 退订事件 | -= QueueAdd | 关闭过程中,串口可能还会收到数据——不退订的话,队列永远清不完 |
②CompleteAdding() | 告诉队列"生产者收工了" | 消费者的foreach会在消费完剩余数据后自动退出,不会死循环 |
③Wait(2000) | 主线程等消费者退出 | 不等的后果:进程杀了消费者线程,剩余数据丢失 |
Wait(2000)及留给了没正常完成消费的消费者时间 ,也防止了异常消费导致的页面卡死(两秒后即刻关闭)。
然后在 XAML 里绑定窗口关闭事件:
<Window ... Closing="Window_Close">public void Window_Close(object sender, CancelEventArgs e) { sqlist.ShutDown(); // 先关数据链路 _comPortService.Dispose(); // 再释放串口资源 }绑定以后 当我们点右上角结束运行的X的时候 会优先执行Window_Close里面的代码。
也就是先让数据链路收工(ShutDown),再释放串口。
如此我们便优雅的关闭了页面。
五、完整代码
反抗者z/SimpleHMI-TempHumidityhttps://gitee.com/rebel-z/simple-hmi
六、总结
问题:串口线程直接调Insert写数据库 → 磁盘 I/O 是毫秒级 → 串口线程被阻塞 → 高频场景下丢帧。
队列:在生产者(串口线程)和消费者(写库线程)之间做中转,两边通过队列通信,互不感知对方速度。
BlockingCollection:.NET 提供的线程安全 + 阻塞式队列。核心 API 四个:Add(入队)、GetConsumingEnumerable(消费循环)、CompleteAdding(收工)、IsAddingCompleted(状态判断)。
生产者-消费者模型:生产者只Add(纳秒级),消费者只foreach取(专职慢操作)。串口线程不再被磁盘 I/O 拖住。
顺序保证:队列是 FIFO,消费者只有一条线程——写入顺序 = 到达顺序。这是单消费者模式的价值。
踩坑:给事件订阅者加while(true)—— 事件本身就是"一次次通知",不需要循环。误加while会导致每来一帧就创建一个死循环线程,CPU 直接飙满。
优雅关闭:退订事件 →CompleteAdding()→Wait(2000)。三步顺序不能乱。Wait的超时是兜底——防止消费者卡死导致窗口关不掉。
一个容易忽略的点:try-catch要包在消费者循环内部,不是循环外面。包在外面,任何一条数据出错都会退出循环,整条消费者线程死掉;包在里面,单次失败跳过,继续处理下一条。
我是反抗者Z,一个正在往 C# 上位机方向走的开发者。如果这篇对你有帮助,欢迎给我点赞鼓励作者继续创作。
系列文章:C# 上位机开发(一):串口数据总是「半截」或「粘一坨」?——粘包/半包彻底解决-CSDN博客https://blog.csdn.net/YYDS1683/article/details/167038364?spm=1001.2014.3001.5501
C# 上位机开发(二):为什么你的 WPF 程序一改 UI 就崩?——线程亲和性与 Dispatcher-CSDN博客https://blog.csdn.net/YYDS1683/article/details/167076907?spm=1001.2014.3001.5501
C# 上位机开发(三):串口丢帧、CPU 冒烟、连接竞态——一个队列全搞定-CSDN博客https://blog.csdn.net/YYDS1683/article/details/167084164?sharetype=blogdetail&sharerId=167084164&sharerefer=PC&sharesource=YYDS1683&spm=1011.2480.3001.8118