Handle Task Exceptions Gracefully

优雅地处理Task中的异常

Posted by eagleboost on March 14, 2021

1. The Problem

When using Tasks on .net platform, exception handling is one of most common problems for developers to deal with. In this blog, however, instead of talking about topics like how to properly propagate exceptions in a task chain, we’ll only focus on how to gracefully handle exceptions (assume all exceptions are properly propagated).

The most common way is to call Task.ContinueWith to create a continuation for the task, then check for any exceptions when the task is completed and notify users in some way like displaying a MessageBox.

1
2
3
4
5
6
7
CallSomethingAsync().ContinueWith(t =>
{
  if (t.Exception != null)
  {
    ////Handle exceptions here
  }
});

This is perfectly fine for small applications or some POC, but it would likely cause maintenance headaches for large scale applications:

  1. To display a MessageBox when exceptions happen in a Task, extra dependencies are required, MessageBox service for instance.
  2. When async calls are many, the same piece of exception handling code need to be repeated as well as the dependencies for MessageBox.

We need some simpler way to handle the Task exceptions gracefully.

Note that the Task here refers to the fire and forget tasks like async calls invoked from event handlers of Button.Click.

2. Analysis

The TaskScheduler.UnobservedTaskException event is one of the solutions for lazy developers. This event is used to receive task exceptions thrown but not observed, e.g. the Task.Exception property has never been accessed by external codes. It works, but should not be the top one choice of responsible developers. On one hand, it leave the responsibilities of developers to CLR, on the other hand, this event is only triggered when GC happens, so it might cause miscommunications in production because the exceptions are actually delayed to show to the user. The Unobserved button in the sample code demonstrate this behavior。

In order to show the exceptions to the user with codes and dependencies as less as possible, the easiest way would be adding an extension method, the method in turn capture the exceptions and send them to someone so they can be displayed.

1
CallSomethingAsync().WhenFaultedAsync("Failed to xxx");

And the someone here is the subscribers of the Dispatcher.DispatcherUnhandledException event. Not like the TaskScheduler.UnobservedTaskException event, the former would trigger as long as the Dispatcher flushes its priority queue.

3. Implementations

Talk is cheap, let’s show the code first. The WhenFaultedAsync method accepts two parameters, a Dispatcher and a string used to tell the context of the exception. Given that most of the applications only have one Dispatcher, the second overload of WhenFaultedAsync uses Application.Dispatcher as default. For applications with more than one Dispatchers, the first overload can be used to pass the current Dispatcher if different from Application.Dispatcher.

The core part of the code is to marshal to the Dispatcher thread and throw an AggregateException that contains the context so that is can be captured by the subscribers of the Dispatcher.DispatcherUnhandledException event.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const TaskContinuationOptions FaultedFlag = TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously;

public static Task WhenFaultedAsync(this Task task, Dispatcher dispatcher, string context)
{
  task.ContinueWith(t =>
  {
    if (t.Exception != null)
    {
      dispatcher.BeginInvoke(() => throw new AggregateException(context, t.Exception.InnerExceptions));
    }
  }, FaultedFlag);
  
  return task;
}

public static Task WhenFaultedAsync(this Task task, string context)
{
  return task.WhenFaultedAsync(Application.Current.Dispatcher, context);
}

Below is a sample to display a MessageBox in the event handler. For cases of multiple exceptions being thrown in a short time, here are two possible strategies, details won’t be covered in this blog:

  • One way is to show the first exception in a MessageBox and only log the rest of the exceptions before the MessageBox is closed by the user. This is to avoid the famous annoying case of user seeing another MessageBox after he closed one.
  • Alternatively, we can send all exceptions to a centralized place, e.g. an always live modeless window, and show them in a list/grid. So all of the exceptions can be presented to the user without interrupting the user’s current focus.
1
2
3
4
5
Application.Current.DispatcherUnhandledException += (_, e) =>
{
  ShowException(e.Exception.Message, e.Exception);
  e.Handled = true;
};

In certain cases we can even save the call to extension method like WhenFaultedAsync. For example, in an application that uses Unity Container, Interception can be used to intercept all of the async calls of a class (usually a Service impalements some interfaces) and automatically inject calls to the WhenFaultedAsync extension method. This can help reduce some maintenance efforts for applications with clear architectures

Please visit github for more details.

1. 问题

异常处理是在.net平台上使用Task时最常遇到的问题之一。异常处理本身是个不小的话题,本文不打算把焦点放在如何在一连串的Task Chain中正确Propagate异常之类的话题,只讨论如何优雅地处理一个异步调用(可能包含一连串的Task)中可能发生的异常(假设所有异常都会被正确地送回来)。

原生的做法是调用ContinueWith来创建一个Continuation,当任务结束后在其中判断是否有Exception发生并处理之——比如显示一个MessageBox告诉用户有错误发生。

1
2
3
4
5
6
7
CallSomethingAsync().ContinueWith(t =>
{
  if (t.Exception != null)
  {
    ////在这里处理异常
  }
});

这样做在小型应用程序或某个POC中完全正常,但是对于颇具规模的中大型应用程序中却会导致维护上的麻烦:

  1. 异常发生的时候如果要显示MessageBox则需要引入额外的依赖,比如某个MessageBox service
  2. 异步调用数量很多的时候在每个地方都需要重复同样的代码以及引入额外的依赖。

我们需要一种简洁而优雅的方式来处理Task的异常。

注:此处Task特指fire and forget的那种任务,比如在一个按钮的Click事件里发起一个异步调用,上下文本身已有对Task异常有处理的情况不在讨论之列。

2. 分析

一种偷懒的方式是使用TaskScheduler.UnobservedTaskException事件——该事件用于接收任何在Task中发生但未被观察到的异常,即Task.Exception属性从未被外部代码显示访问过。这种方法可行但属于不负责任的做法,一方面把原本该由开发者捕获并做出相应处理的异常丢给CLR,另一方面该事件只有等GC发生的时候才会触发,实时性差,在生产环境中容易引起误解。本文例子中的Unobserved按钮演示了这一行为。

针对我们的需求:用尽量简洁以及依赖最少的代码来把异常显示出来,能想到最简单的方式莫过于添加一个扩展方法,并让该方法把异常传播到合适的地方以便显示出来。

1
CallSomethingAsync().WhenFaultedAsync("xxx调用失败。");

而这个合适的地方,则是Dispatcher.DispatcherUnhandledException事件的订阅者。跟TaskScheduler.UnobservedTaskException事件不同的是,前者在Dispatcher空闲的时候即会触发。

3. 实现

废话少说,先上代码。WhenFaultedAsync接受两个参数,一是适当的Dispatcher,二是一个用于区分异常来源的字符串。考虑到绝大多数应用程序实际上只有一个Dispatcher,另一个重载则默认使用Application.Dispatcher。有多个Dispatcher的情形可以选择性调用第一个方法把Dispatcher显示传递过去。

代码的重点在于异常发生时切换到Dispatcher所在线程抛出一个包含上下文信息的AggregateException,以便被Dispatcher.DispatcherUnhandledException捕捉到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const TaskContinuationOptions FaultedFlag = TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously;

public static Task WhenFaultedAsync(this Task task, Dispatcher dispatcher, string context)
{
  task.ContinueWith(t =>
  {
    if (t.Exception != null)
    {
      dispatcher.BeginInvoke(() => throw new AggregateException(context, t.Exception.InnerExceptions));
    }
  }, FaultedFlag);
  
  return task;
}

public static Task WhenFaultedAsync(this Task task, string context)
{
  return task.WhenFaultedAsync(Application.Current.Dispatcher, context);
}

用于显示异常的代码可以是个简单的事件处理方法。当然这里有所简化,对于多个异常连续发生的情形有两个可用的策略,具体实现不再赘述:

  • 其一,显示第一个异常为MessageBox,在MessageBox被用户关掉之前,后续的异常只写入日志。避免出现用户关掉一个MessageBox又来一个的尴尬场景。
  • 其二,把异常全部发送到某个一直存在于内存中会自动弹出的非模态窗口,显示为列表或表格,一来所有异常一目了然,二来避免打断用户的当前操作。
1
2
3
4
5
Application.Current.DispatcherUnhandledException += (_, e) =>
{
  ShowException(e.Exception.Message, e.Exception);
  e.Handled = true;
};

在某些情况下下我们甚至可以省去WhenFaultedAsync这样的扩展方法。比如一个使用了Unity Container的系统可以通过Interception截获某个类(通常是实现了某些接口的Service)的所有异步调用,并动态生成代码来隐式调用WhenFaultedAsync,这对于有清晰层级划分的系统来说可以一定程度简化应用层代码的维护。

完整实现请移步github