)
前面讲完基础连接、批量读写、订阅回调、死锁定位本篇聚焦工控项目线上运行会遇到的高阶问题长生命周期后台任务、会话异常后的异步重连状态机、SemaphoreSlim并发限流落地、未观察异常捕获、以及WPF上位机异步最佳实践。七、工控后台长任务处理上位机里经常存在周期采集任务后台持续运行界面可启停程序退出时需要优雅终止。很多人直接写Task.Runwhile循环会出现几个典型问题任务被线程池回收、无法真正停止、异常悄无声息消失。错误示例// 不推荐存在诸多隐患 public void StartCollect() { Task.Run(async () { while (true) { await ReadAllTags(); await Task.Delay(1000); } }); }问题点返回的Task没有保存引用一旦内部抛出异常成为未观察异常没有取消令牌想要停止循环只能暴力置标记位遇到阻塞IO时循环不能立刻退出长时间运行任务默认交给线程池线程池可能会回收空闲线程造成调度抖动。标准长任务实现private CancellationTokenSource _collectCts; private Task _collectTask; /// summary启动周期采集/summary public void StartCycleCollect() { if (_collectTask ! null !_collectTask.IsCompleted) return; _collectCts new CancellationTokenSource(); // LongRunning标记提示调度器尽量不使用线程池线程适合长时间后台循环 _collectTask Task.Factory.StartNew(async () { var token _collectCts.Token; while (!token.IsCancellationRequested) { try { await ReadAllDeviceTagsAsync(token); } catch (Exception ex) { // 单轮采集失败记录日志不直接退出整个循环 // 设备短暂离线等待下一轮重试 _logger.LogError(ex, 一轮标签采集发生异常); } await Task.Delay(1000, token); } }, TaskCreationOptions.LongRunning).Unwrap(); } /// summary停止采集优雅退出/summary public async Task StopCycleCollectAsync() { if (_collectCts ! null) { _collectCts.Cancel(); try { if(_collectTask ! null) await _collectTask; } catch (OperationCanceledException) { // 正常取消直接忽略 } _collectCts.Dispose(); _collectTask null; } }Unwrap()StartNew返回TaskTaskUnwrap把外层任务解包方便直接await内层异步逻辑。注意LongRunning不要大批量创建适合数量少的常驻后台任务不适合大量短周期并发请求。八、OPC UA会话断开异步重连状态机现场环境网线抖动、PLC断电重启是常态单纯捕获一次异常重试很容易出现状态错乱多个重连并发执行、旧会话没有释放、订阅重复创建。需要一套简单状态机控制连接生命周期。简单定义会话状态枚举public enum SessionState { Disconnected, Connecting, Connected, Reconnecting }核心重连逻辑使用SemaphoreSlim保证同一时刻只有一次连接尝试防止并发重连private SessionState _sessionState SessionState.Disconnected; private readonly SemaphoreSlim _connectLock new SemaphoreSlim(1,1); private Session _currentSession; public async Taskbool TryReconnectAsync(string endpoint, CancellationToken token default) { // 锁保证同一时间只有一个连接流程在跑 if (!await _connectLock.WaitAsync(0, token)) return false; try { if (_sessionState is SessionState.Connecting or SessionState.Reconnecting) return false; _sessionState SessionState.Reconnecting; // 清理旧会话资源 if (_currentSession ! null) { if(_currentSession.Connected) await _currentSession.CloseAsync(); _currentSession.Dispose(); _currentSession null; } // 重新建立会话 _currentSession await ConnectAsync(endpoint, token); // 重建订阅恢复监控项 RebuildSubscription(_currentSession); _sessionState SessionState.Connected; return true; } catch (Exception ex) { _logger.LogError(ex,重连OPC UA服务失败); _sessionState SessionState.Disconnected; return false; } finally { _connectLock.Release(); } }配合会话保活事件检测断连触发重连触发入口// 会话创建完成后挂载事件 _currentSession.KeepAlive (sender, args) { if (args.Status ! StatusCodes.Good) { // 保活失败后台启动重连不要阻塞事件回调 _ Task.Run(async () { await TryReconnectAsync(opc.tcp://192.168.1.10:4840); }); } };回调内部不要await直接丢弃任务会带来未观察异常风险生产环境建议内部增加try‑catch包裹。九、未观察异常处理工控程序中丢弃的Task、后台任务内部抛出异常在.NET Core/.NET8不会直接崩溃进程但异常会被静默吞噬问题很难复现定位。全局订阅Task未观察异常事件捕获这类隐蔽错误便于日志记录排查// 在程序入口处注册 TaskScheduler.UnobservedTaskException (sender, e) { _logger.LogError(e.Exception, 捕获到未观察Task异常); e.SetObserved(); //标记该异常已经被处理阻止进一步传播 };这只是兜底日志手段不能替代业务层try‑catch不能把业务异常全部交给这个事件处理。十、SemaphoreSlim 实战混合场景限流上位机场景有时候既有OPC UA读写又有485串口、CAN总线报文收发IO操作资源有限不能无限制并发。SemaphoreSlim支持异步等待比lock更加适配async/awaitlock关键字不能用于异步方法内部。封装通用限流工具方法/// summary并发限流包装器/summary public async TaskT RunWithLimitT(FuncTaskT workFunc, SemaphoreSlim slim, CancellationToken ct) { await slim.WaitAsync(ct); try { return await workFunc.Invoke(); } finally { slim.Release(); } }调用示例限制最大8个并发IO操作private readonly SemaphoreSlim _ioSem new SemaphoreSlim(8,8); //批量读取标签时对每一个读取任务做限流 var tagTasks tagList.Select(tag RunWithLimit(async () await ReadNodeAsync(_currentSession, tag), _ioSem, ct)); var results await Task.WhenAll(tagTasks);⚠️重要坑SemaphoreSlim一定要保证每一次WaitAsync最终都会走到Release使用try‑finally是标准写法。不要在Task.Run内部new新的SemaphoreSlim实例需要全局共享。取消令牌触发抛出异常依然会进入finally执行释放不会出现锁泄露。十一、WPF上位机异步开发规范汇总UI事件允许async void其余所有业务方法统一返回Task / TaskT。底层采集、工具类方法全部追加.ConfigureAwait(false)ViewModel、UI层代码不添加。严禁业务代码使用.Result、.Wait()这是工控UI死锁最高发来源。OPC UA、串口回调属于非UI线程收到数据禁止直接操作控件两种处理方案方案ADispatcher.CurrentDispatcher.InvokeAsync((){ 更新UI })方案B使用IProgressT回调自动切回原始上下文//IProgress示例 IProgressDataPoint progress new ProgressDataPoint(point { //这里自动回到UI上下文可以直接给ViewModel属性赋值 UpdateUiValue(point); });后台周期任务必须绑定CancellationTokenSource窗口关闭、程序退出时必须等待任务完成再释放资源防止对象已释放异常。大批量标签采集优先批量接口拒绝循环里频繁调用单次读写。十二、调试排障工具建议并行堆栈窗口VS调试暂停程序查看所有线程快速定位线程卡死、阻塞位置。ThreadPool.GetMaxThreads()、ThreadPool.GetAvailableThreads()打印线程池状态判断是否线程池耗尽。日志打印Task.Id、任务状态观察任务是否正常完成、是否大量处于Running。生产环境建议记录聚合异常AggregateException的全部InnerExceptions不要只打印第一个异常。