ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零搭建灌装监控系统(七):轮询循环与线程安全

从零搭建灌装监控系统(七):轮询循环与线程安全 轮询循环与线程安全这是「从零搭建灌装监控系统」系列第7篇。上一篇封装了 PlcService 的连接和读写但数据怎么持续读上来这篇实现 PollDataLoop 后台轮询循环——200ms 定时读 PLC、SemaphoreSlim 互斥、Task.WhenAny 超时保护、跨线程事件通知。解决工业上位机最经典的跨线程问题。数据不动的界面我朋友把 PlcService 的连接和读写写完之后兴冲冲地跑起来。连上 PLC点读数据按钮——数据上来了。但界面不动。产量一直是 100温度一直是 25.3液位一直是 60。他懵了——PLC 在跑数据在变为什么界面不刷新因为他只读了一次。点按钮读一次数据上来一次。要持续刷新得不停点按钮。这显然不行。他试着加了个 Timer// 错误示范privateDispatcherTimer_timernew();_timer.IntervalTimeSpan.FromMilliseconds(200);_timer.Tickasync(s,e){varregsawaitPlcService.ReadHoldingRegistersAsync(0,10);ActualCountregs[0];CurrentTempBitConverter.ToSingle(...);};_timer.Start();跑起来界面是动了但两个问题偶发卡顿— Tick 是 UI 线程的await 期间 UI 线程被阻塞界面偶尔卡 1-2 秒跨线程崩溃— 有时候抛InvalidOperationException: 调用线程无法访问此对象问题出在哪用 DispatcherTimer 做轮询是错的。DispatcherTimer 在 UI 线程跑await 期间阻塞 UI而且 PLC 读取在后台线程完成回调里给绑定属性赋值会触发 UI 线程检查。正确做法是——后台 Task 循环 事件通知 Dispatcher 切回 UI 线程。PollDataLoop后台轮询的正确姿势privatestaticasyncTaskPollDataLoop(CancellationTokentoken){while(!token.IsCancellationRequested){try{// 1. 检查连接if(!IsConnected||(_tcpClient!null!IsTcpReallyConnected())){if(IsConnected)ConnectionChanged?.Invoke(null,false);// 尝试重连if(_currentModeConnectionMode.ModbusTcp)awaitTryReconnectTcpAsync();elseif(_currentModeConnectionMode.Serial)awaitTryReconnectSerialAsync();awaitTask.Delay(3000,token);// 重连间隔 3 秒continue;}// 2. 读数据带超时保护varreadTaskReadAllRegistersAsync();vartimeoutTaskTask.Delay(500,token);if(awaitTask.WhenAny(readTask,timeoutTask)!readTask){// 读超时可能断线LogService.Warn(PLC 读取超时);continue;}varstatesawaitreadTask;// 3. 通过事件发布数据DataReceived?.Invoke(null,states);}catch(OperationCanceledException){break;// 正常取消退出循环}catch(Exceptionex){LogService.Error($轮询异常:{ex.Message});awaitTask.Delay(1000,token);// 异常后等 1 秒再试}// 4. 200ms 轮询间隔awaitTask.Delay(200,token);}}四个关键点① 后台 Task 循环Task.Run(() PollDataLoop(cts.Token))在后台线程跑不占 UI 线程。while (!token.IsCancellationRequested)持续循环DisConnectAsync时 cancel token 退出。② Task.WhenAny 超时保护PLC 读取可能卡住线断了但 TCP 连接状态还是 true。用Task.WhenAny(readTask, Task.Delay(500))实现 500ms 超时——如果读 500ms 没返回就当超时处理不让循环卡死。varreadTaskReadAllRegistersAsync();vartimeoutTaskTask.Delay(500,token);if(awaitTask.WhenAny(readTask,timeoutTask)!readTask){// 500ms 超时LogService.Warn(PLC 读取超时);continue;}varstatesawaitreadTask;// 此时 readTask 已完成注意Task.WhenAny返回的是先完成的 Task不是结果。要再 await 一次readTask拿结果此时它已完成立即返回。③ 事件通知不直接更新 UIDataReceived?.Invoke(null, states)在后台线程触发事件。ViewModel 订阅这个事件在事件处理里更新绑定属性。但这里有个跨线程问题——事件在后台线程触发ViewModel 的事件处理也在后台线程执行。直接给[ObservableProperty]赋值会触发 UI 线程检查抛异常。④ 200ms 轮询间隔await Task.Delay(200, token)控制轮询频率。200ms 是灌装线的合理值——太快增加 PLC 负担太慢数据不实时。token传入 Delay取消时立即抛OperationCanceledException退出循环。跨线程更新 UIDispatcherViewModel 订阅 DataReceived 事件后更新绑定属性要切回 UI 线程publicpartialclassDashboardViewModel:ObservableObject{publicDashboardViewModel(){PlcService.DataReceivedPlcService_DataReceived;}privatevoidPlcService_DataReceived(object?sender,DeviceStatese){// 事件在后台线程触发切回 UI 线程更新绑定属性Application.Current.Dispatcher.Invoke((){ActualCounte.ActualCount;CurrentTempe.CurrentTemp;LiquidLevele.LiquidLevel;// ... 其他属性});}}Application.Current.Dispatcher.Invoke(() { ... })把操作切回 UI 线程。Invoke是同步的等 UI 线程处理完才返回BeginInvoke是异步的不等。Invoke vs BeginInvoke方法行为用在哪Dispatcher.Invoke同步等 UI 线程处理完需要确保属性已更新才继续Dispatcher.BeginInvoke异步不等只管更新不关心何时完成轮询场景用Invoke更稳妥——确保这次数据更新完才进入下一轮。但Invoke会阻塞后台线程等 UI 线程如果 UI 线程忙比如正在渲染大图表后台线程会卡住下一轮轮询延迟。实际项目里BeginInvoke更常用——数据丢给 UI 线程就不管了后台继续轮询。如果 UI 线程忙数据更新延迟一点没关系不阻塞轮询。更优雅的写法检查是否需要切换privatevoidPlcService_DataReceived(object?sender,DeviceStatese){if(Application.Current.Dispatcher.CheckAccess()){// 已经在 UI 线程直接更新UpdateProperties(e);}else{// 在后台线程切回 UI 线程Application.Current.Dispatcher.BeginInvoke(()UpdateProperties(e));}}privatevoidUpdateProperties(DeviceStatese){ActualCounte.ActualCount;CurrentTempe.CurrentTemp;LiquidLevele.LiquidLevel;}CheckAccess()检查当前线程是否是 UI 线程。已经是就直接更新不是才切。避免不必要的 Dispatcher 调度开销。SemaphoreSlim并发读写互斥上一篇提过_ioLock这里展开讲为什么需要它。场景轮询循环正在读 10 个寄存器耗时 50ms这时用户点了启动生产按钮要写运行位寄存器。如果不互斥读和写同时操作串口报文会撞车PLC 收到乱码。// 读操作publicstaticasyncTaskushort[]ReadHoldingRegistersAsync(ushortaddr,ushortcount){await_ioLock.WaitAsync();// 等待获取锁try{varmasterGetCurrentMaster()??thrownewInvalidOperationException(未连接);returnawaitmaster.ReadHoldingRegistersAsync(SlaveID,addr,count);}finally{_ioLock.Release();// 释放锁}}// 写操作publicstaticasyncTaskWriteSingleCoilAsync(ushortaddr,boolvalue){await_ioLock.WaitAsync();try{varmasterGetCurrentMaster()??thrownewInvalidOperationException(未连接);awaitmaster.WriteSingleCoilAsync(SlaveID,addr,value);}finally{_ioLock.Release();}}SemaphoreSlim(1, 1)就是互斥锁初始计数 1同时只允许 1 个进入。WaitAsync获取锁Release释放。try/finally保证异常时也释放。为什么不用 lock 关键字lock是同步阻塞的——等锁时线程挂起不释放。在 async 方法里用lock会阻塞线程池线程高并发时线程池耗尽。SemaphoreSlim.WaitAsync是异步的——等锁时不阻塞线程线程回池子干别的。async 方法必须用异步锁。// ❌ 错误lock 不能用在 async 方法里publicasyncTaskReadAsync(){lock(_lock)// 编译报错{awaitmaster.ReadAsync();}}// ✅ 正确SemaphoreSlimpublicasyncTaskReadAsync(){await_ioLock.WaitAsync();try{awaitmaster.ReadAsync();}finally{_ioLock.Release();}}IsTcpReallyConnectedTCP 假连接检测TCP 有个坑——TcpClient.Connected属性不可靠。网线拔了Connected还是 true它只反映上次操作的状态。privatestaticboolIsTcpReallyConnected(){if(_tcpClient?.Clientnull)returnfalse;try{// Poll 检测 socket 是否真的可读/可写return!_tcpClient.Client.Poll(1,SelectMode.SelectRead)||_tcpClient.Client.Available0;}catch{returnfalse;}}Socket.Poll(1, SelectMode.SelectRead)检测 1 微秒内 socket 是否可读。如果可读且没有数据Available0说明对端关闭了连接。这是检测 TCP 假连接的标准方法。串口没这个问题——SerialPort.IsOpen直接反映串口状态拔线会变 false。踩坑记录坑现象原因解决跨线程崩溃InvalidOperationException后台线程给绑定属性赋值Dispatcher.Invoke/BeginInvoke 切回 UI 线程轮询卡死界面完全冻结DispatcherTimer 在 UI 线程跑await 阻塞用 Task.Run 后台循环不用 DispatcherTimer读写撞车数据错乱/报文乱码读和写同时操作串口SemaphoreSlim 互斥TCP 假连接断网后还显示在线Connected 属性不可靠Socket.Poll 检测真实状态轮询不退出程序关了后台还在跑CancellationToken 没传到 Delay所有 await 都传 token第五个坑很隐蔽。Task.Delay(200)不传 token程序关闭时 cancel token但 Delay 不知道200ms 后继续循环。必须Task.Delay(200, token)cancel 时立即抛OperationCanceledException退出。// ❌ 错误Delay 不响应取消awaitTask.Delay(200);// cancel 后还要等 200ms// ✅ 正确Delay 响应取消awaitTask.Delay(200,token);// cancel 立即抛异常退出本篇小结知识点关键代码后台轮询Task.Run(() PollDataLoop(cts.Token))超时保护Task.WhenAny(readTask, Task.Delay(500))跨线程更新 UIDispatcher.Invoke/BeginInvoke检查线程Dispatcher.CheckAccess()并发互斥SemaphoreSlim(1, 1)WaitAsync/ReleaseTCP 假连接检测Socket.Poll(1, SelectMode.SelectRead)响应取消Task.Delay(200, token)传 token从 DispatcherTimer 卡界面到后台 Task 循环 事件通知 Dispatcher 切线程——轮询在后台跑数据通过事件传到 ViewModelViewModel 切回 UI 线程更新属性。200ms 刷新界面不卡数据实时。但有个问题还没解决——PLC 断线了怎么办轮询循环里只是检测到断线但不会自动重连。下一篇讲断线检测与自动重连。下期预告第8篇断线检测与自动重连我们将实现断线后的自动重连机制——3次超时阈值判定断线、串口和TCP分别重连策略、重连间隔递增、重连成功后恢复轮询。让系统在 PLC 重启或网络抖动后自动恢复不需要人工干预。
返回列表