前面讲完基础连接、批量读写、订阅回调、死锁定位,本篇聚焦工控项目线上运行会遇到的高阶问题:长生命周期后台任务、会话异常后的异步重连状态机、SemaphoreSlim并发限流落地、未观察异常捕获、以及WPF上位机异步最佳实践。
七、工控后台长任务处理
上位机里经常存在周期采集任务,后台持续运行,界面可启停,程序退出时需要优雅终止。很多人直接写Task.Run+while循环,会出现几个典型问题:任务被线程池回收、无法真正停止、异常悄无声息消失。
错误示例
// 不推荐,存在诸多隐患 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返回Task<Task>,Unwrap把外层任务解包,方便直接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 Task<bool> 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/await,lock关键字不能用于异步方法内部。
封装通用限流工具方法:
/// <summary>并发限流包装器</summary> public async Task<T> RunWithLimit<T>(Func<Task<T>> 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 / Task<T>。 - 底层采集、工具类方法,全部追加
.ConfigureAwait(false);ViewModel、UI层代码不添加。 - 严禁业务代码使用
.Result、.Wait(),这是工控UI死锁最高发来源。 - OPC UA、串口回调属于非UI线程,收到数据禁止直接操作控件,两种处理方案:
- 方案A:
Dispatcher.CurrentDispatcher.InvokeAsync(()=>{ 更新UI }) - 方案B:使用
IProgress<T>,回调自动切回原始上下文
- 方案A:
//IProgress示例 IProgress<DataPoint> progress = new Progress<DataPoint>(point => { //这里自动回到UI上下文,可以直接给ViewModel属性赋值 UpdateUiValue(point); });- 后台周期任务,必须绑定
CancellationTokenSource,窗口关闭、程序退出时必须等待任务完成再释放资源,防止对象已释放异常。 - 大批量标签采集,优先批量接口,拒绝循环里频繁调用单次读写。
十二、调试排障工具建议
- 并行堆栈窗口:VS调试,暂停程序,查看所有线程,快速定位线程卡死、阻塞位置。
ThreadPool.GetMaxThreads()、ThreadPool.GetAvailableThreads(),打印线程池状态,判断是否线程池耗尽。- 日志打印Task.Id、任务状态,观察任务是否正常完成、是否大量处于Running。
- 生产环境建议记录聚合异常
AggregateException的全部InnerExceptions,不要只打印第一个异常。