后台线程操作UI元素未触发跨线程异常的问题排查
问题
我们的代码库实现了类似Prism的事件聚合器,支持订阅返回Task的处理器以避免使用async void。原PublishAsync方法会等待所有处理器执行完成,若存在耗时处理器会导致调用耗时较长。改版后做了以下调整:
- 允许订阅
Action<TEvent>或Func<TEvent, Task>类型的处理器 - 支持指定处理器是否在UI线程执行
- 支持Fire/Forget模式(不await
PublishAsync)
但发现异常现象:
- 当
await PublishAsync时,非UI线程中更新绑定到Label.Text的属性会触发预期的跨线程异常 - 但使用Fire/Forget方式(
_ = _aggregator.PublishAsync(...))时,同样在非UI线程执行该操作却未触发异常
寻求该现象的原因。
原因分析
1. 异常传播的上下文差异
当你await PublishAsync时,当前的同步上下文(比如WPF/WinForms的UI同步上下文)会被捕获,处理器执行时抛出的跨线程异常会沿着await的调用栈向上传播,最终被UI上下文的异常机制捕获并抛出,所以你能看到预期的异常提示。
而Fire/Forget模式下,PublishAsync返回的Task没有被await,抛出的异常会在后台线程池中触发。从.NET 4.5开始,默认会将未被观察到的Task异常标记为“已处理”并吞掉,不会主动将异常冒泡到主线程的UI上下文,所以你看不到异常,但实际上异常已经发生了。
2. 未观察Task异常的验证方式
你可以通过订阅TaskScheduler.UnobservedTaskException事件来验证这一点——当Fire/Forget模式下处理器抛出跨线程异常时,这个事件会被触发,证明异常确实存在,只是没有被传播到你能直接看到的地方。在.NET 4.0及更早版本,这种未观察的异常会直接导致进程崩溃,但后续版本做了优化,避免了无差别崩溃的问题。
3. 事件聚合器内部的异常处理(可能性)
如果你的事件聚合器在Fire/Forget模式下,内部用try/catch包裹了处理器的执行逻辑,也会导致异常被静默捕获吞掉。但这种情况属于代码实现细节,更普遍的原因还是前面提到的未观察Task异常的默认处理机制。
内容的提问来源于stack exchange,提问作者Logan K

