C#中async/await中的异常处理

在同步编程中,一旦出现错误就会抛出异常,咱们可使用try…catch来捕捉异常,而未被捕获的异常则会不断向上传递,造成一个简单而统一的错误处理机制。不过对于异步编程来讲,异常处理一直是件麻烦的事情,这也是C#中async/await或是Jscex等异步编程模型的优点之一。可是,同步的错误处理机制,并不能彻底避免异步形式的错误处理方式,这须要必定实践规范来保证,至少咱们须要了解async/await究竟是如何捕获和分发异常的。在开发Jscex的过程当中,我深刻了解了不少关于TPL和C#异步特性方面的问题,错误处理天然也是其中之一。git

使用try…catch捕获异常

首先咱们来看下这段代码:github

C#
static async Task ThrowAfter(int timeout, Exception ex)
{
    await Task.Delay(timeout);
    throw ex;
}

static void PrintException(Exception ex)
{
    Console.WriteLine("Time: {0}\n{1}\n============", _watch.Elapsed, ex);
}

static Stopwatch _watch = new Stopwatch();

static async Task MissHandling()
{
    var t1 = ThrowAfter(1000, new NotSupportedException("Error 1"));
    var t2 = ThrowAfter(2000, new NotImplementedException("Error 2"));

    try
    {
        await t1;
    }
    catch (NotSupportedException ex)
    {
        PrintException(ex);
    }
}

static void Main(string[] args)
{
    _watch.Start();

    MissHandling();

    Console.ReadLine();
}

 

这段代码的输出以下:编程

 
Time: 00:00:01.2058970
System.NotSupportedException: Error 1
   at AsyncErrorHandling.Program.d__0.MoveNext() in ...\Program.cs:line 16
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
   at AsyncErrorHandling.Program.d__3.MoveNext() in ...\Program.cs:line 33
============

 

在MissingHandling方法中,咱们首先使用ThrowAfter方法开启两个任务,它们会分别在一秒及两秒后抛出两个不一样的异常。可是在接下来的try中,咱们只对t1进行await操做。很容易理解,t1抛出的NotSupportedException将被catch捕获,耗时大约为1秒左右——固然,从上面的数据能够看出,其实t1在被“捕获”时已经耗费了1.2时间,偏差较大。这是由于程序刚启动,TPL内部正处于“热身”状态,在调度上会有较大开销。这里反却是另外一个问题倒更值得关注:t2在两秒后抛出的NotImplementedException到哪里去了?数组

未捕获的异常

C#的async/await功能基于TPL的Task对象,每一个await操做符都是“等待”一个Task完成。在以前(或者说现在)的TPL中,Task对象的析构函数会查看它的Exception对象有没有被“访问”过,若是没有,且Task对象出现了异常,则会抛出这个异常,最终致使的结果每每即是进程退出。所以,咱们必须当心翼翼地处理每个Task对象的错误,不得遗漏。在.NET 4.5中这个行为被改变了,对于任何没有被检查过的异常,便会触发TaskSchedular.UnobservedTaskException事件——若是您不监听这个事件,未捕获的异常也就这么无影无踪了。异步

为此,咱们对Main方法进行一个简单的改造。async

C#
 
static void Main(string[] args)
{
    TaskScheduler.UnobservedTaskException += (_, ev) => PrintException(ev.Exception);

    _watch.Start();

    MissHandling();

    while (true)
    {
        Thread.Sleep(1000);
        GC.Collect();
    }
}

 

改造有两点,一是响应TaskScheduler.UnobservedTaskException,这天然没必要多说。还有一点即是不断地触发垃圾回收,以便Finalizer线程调用析构函数。现在这段代码除了打印出以前的信息以外,还会输出如下内容:异步编程

 
Time: 00:00:03.0984560
System.AggregateException: A Task's exception(s) were not observed either by Waiting on the Task or accessing its Exception property. As a result, the unobserved exception was rethrown by the finalizer thread. ---> System.NotImplementedException: Error 2
   at AsyncErrorHandling.Program.d__0.MoveNext() in ...\Program.cs:line 16
   --- End of inner exception stack trace ---
---> (Inner Exception #0) System.NotImplementedException: Error 2
   at AsyncErrorHandling.Program.d__0.MoveNext() in ...\Program.cs:line 16<---
============
 

从上面的信息中能够看出,UnobservedTaskException事件并不是在“抛出”异常后便当即触发,而是在某次垃圾收集过程,从Finalizer线程里触发并执行。从中也不可贵出这样的结论:即是该事件的响应方法不能过于耗时,更加不能阻塞,不然便会对程序性能形成灾难性的影响。函数

那么假如咱们要同时处理t1和t2中抛出的异常该怎么作呢?此时即是Task.WhenAll方法上场的时候了:性能

C#
 
static async Task BothHandled()
{
    var t1 = ThrowAfter(1000, new NotSupportedException("Error 1"));
    var t2 = ThrowAfter(2000, new NotImplementedException("Error 2"));
    
    try
    {
        await Task.WhenAll(t1, t2);
    }
    catch (NotSupportedException ex)
    {
        PrintException(ex);
    }
}

 

若是您执行这段代码,会发现其输出与第一段代码相同,但其实不一样的是,第一段代码中t2的异常被“遗漏”了,而目前这段代码t1和t2的异常都被捕获了,只不过await语句仅仅“抛出”了“其中一个”异常而已。spa

WhenAll是一个辅助方法,它的输入是n个Task对象,输出则是个返回它们的结果数组的Task对象。新的Task对象会在全部输入所有“结束”后才完成。在这里“结束”的意思包括成功和失败(取消也是失败的一种,即抛出了OperationCanceledException)。换句话说,假如这n个输入中的某个Task对象很快便失败了,也必须等待其余全部输入对象成功或是失败以后,新的Task对象才算完成。而新的Task对象完成后又可能会有两种表现:

  • 全部输入Task对象都成功了:则返回它们的结果数组。
  • 至少一个输入Task对象失败了:则抛出“其中一个”异常。

所有成功的状况自没必要说,那么在失败的状况下,什么叫作抛出“其中一个”异常?若是咱们要处理全部抛出的异常该怎么办?接着咱们来详细讨论await操做在异常分派时的相关行为。

await抛出异常时的行为

要理解await的行为,仍是从理解Task对象的异常表现开始。Task对象有一个Exception属性,类型为AggregateException,在执行成功的状况下该属性返回null,不然便包含了“全部”出错的对象。既然是AggregateException,则意为着可能包含多个子异常,这种状况每每会在任务的父子关系中出现,具体状况能够参考MSDN中的相关说明。在许多状况下一个Task内部只会出现一个异常,此时这个AggregateException的InnerExceptions属性天然也就只一个元素。

Task对象自己还有一个Wait方法,它会阻塞当前执行代码,直到任务完成。在出现异常的时候,它会将自身的AggregateException抛出:

C#
 
try
{
    t.Wait();
}
catch (AggregateException ex)
{
    ...
}
 

Wait方法是“真阻塞”,而await操做则是使用阻塞语义的代码实现非阻塞的效果,这个区别必定要分清。与Wait方法不一样的是,await操做符效果并不是是“抛出”Task对象上的Exception属性,而只是抛出这个AggregateException对象上的“其中一个”元素。我向C#开发组询问这么作的设计考虑,他们回答道,这个决策在内部也经历了激烈的争论,最终的选择这种方式而不是直接抛出Task对象上的AggregateException是为了不编写出冗余的代码,并让代码与传统同步编程习惯更为接近。

他们举了一个简单的示例,假如一个Task对象t可能抛出两种异常,如今的错误捕获方式为:

C#
 
try
{
    await t1;
}
catch (NotSupportedException ex)
{
    ...
}
catch (NotImplementedException ex)
{
    ...
}
catch (Exception ex)
{
    ...
}
 

假如await操做抛出的是AggregateException,那么代码就必须写为:

C#
 
try
{
    await t1;
}
catch (AggregateException ex)
{
    var innerEx = ex.InnerExceptions[0];

    if (innerEx is NotSupportedException)
    {
        ...
    }
    else if (innerEx is NotImplementedException)
    {
        ...
    }
    else
    {
        ...
    }
}
 

显然前者更贴近传统的同步编程习惯。可是问题在于,若是这个Task中包含了多个异常怎么办?以前的描述是抛出“其中一个”异常,对于开发者来讲,“其中一个”这种模糊的说法天然没法使人满意,但事实的确如此。从内部邮件列表中的讨论来看,C#开发团队提到他们“故意”不提供文档说明究竟会抛出哪一个异常,由于他们并不想作出这方面的约束,由于这部分行为一旦写入文档,便成为一个规定和限制,为了类库的兼容性从此也没法对此作出修改。

他们也提到,若是单论目前的实现,await操做会从Task.Exception.InnerExceptions集合中挑出第一个异常,并对外“抛出”,这是System.Runtime.CompilerServices.TaskAwaiter类中定义的行为。可是既然这并不是是“文档化”的固定行为,开发人员也尽可能不要依赖这点。

WhenAll的异常汇总方式

其实这个话题跟async/await的行为没有任何联系,WhenAll返回的是普通的Task对象,TaskAwaiter也丝绝不关心当前等待的Task对象是否来自于WhenAll,不过既然WhenAll是最经常使用的辅助方法之一,也顺便将其讲清楚吧。

WhenAll获得Task对象,其结果是用数组存放的全部子Task的结果,而在出现异常时,其Exception属性返回的AggregateException集合会包含全部子Task中抛出的异常。请注意,每一个子Task中抛出的异常将会存放在它自身的AggregateException集合中,WhenAll返回的Task对象将会“按顺序”收集各个AggregateException集合中的元素,而并不是收集每一个AggregateException对象。

咱们使用一个简单的例子来理解这点:

C#
 
Task all = null;
try
{
    await (all = Task.WhenAll(
        Task.WhenAll(
            ThrowAfter(3000, new Exception("Ex3")),
            ThrowAfter(1000, new Exception("Ex1"))),
        ThrowAfter(2000, new Exception("Ex2"))));
}
catch (Exception ex)
{
    ...
}
 

这段代码使用了嵌套的WhenAll方法,总共会出现三个异常,按其抛出的时机排序,其顺序为Ex1,Ex2及Ex3。那么请问:

  • catch语句捕获的异常是哪一个?
  • all.Exception这个AggregateException集合中异常按顺序是哪些?

结果以下:

  • catch语句捕获的异常是Ex3,由于它是all.Exception这个AggregateException集合中的第一个元素,但仍是请牢记这点,这只是当前TaskAwaiter所实现的行为,而并不是是由文档规定的结果。
  • all.Exception这个AggregateException集合中异常有三个,按顺序是Ex3,Ex1和Ex2。WhenAll获得的Task对象,是根据输入的Task对象顺序来决定自身AggreagteException集合中异常对象的存放顺序。这个顺序跟异常的抛出时机没有任何关系。

这里咱们也顺即可以得知,若是您不想捕获AggregateException集合中的“其中一个”异常,而是想处理全部异常的话,也能够写这样的代码:

C#
 
Task all = null;
try
{
    await (all = Task.WhenAll(
        ThrowAfter(1000, new Exception("Ex1")),
        ThrowAfter(2000, new Exception("Ex2"))));
}
catch
{
    foreach (var ex in all.Exception.InnerExceptions)
    {
        ...
    }
}
 

固然,这里使用Task.WhenAll做为示例,是由于这个Task对象能够明确包含多个异常,但并不是只有Task.WhenAll返回的Task对象才可能包含多个异常,例如Task对象在建立时指定了父子关系,也会让父任务里包含各个子任务里出现的异常。

假如异常未被捕获

最后再来看一个简单的问题,咱们一直在关注一个async方法中“捕获”异常的行为,假如异常没有成功捕获,直接对外抛出的时候,对任务自己的有什么影响呢?且看这个示例:

C#
 
static async Task SomeTask()
{
    try
    {
        await Task.WhenAll(
            ThrowAfter(2000, new NotSupportedException("Ex1")),
            ThrowAfter(1000, new NotImplementedException("Ex2")));
    }
    catch (NotImplementedException) { }
}

static void Main(string[] args)
{
    _watch.Start();

    SomeTask().ContinueWith(t => PrintException(t.Exception));

    Console.ReadLine();
}
 

这段代码的输出结果是:

 
 
System.AggregateException: One or more errors occurred. ---> System.NotSupportedException: Ex1
   at AsyncErrorHandling.Program.d__0.MoveNext() in ...\Program.cs:line 16
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
   at AsyncErrorHandling.Program.d__3.MoveNext() in ...\Program.cs:line 30
   --- End of inner exception stack trace ---
---> (Inner Exception #0) System.NotSupportedException: Ex1
   at AsyncErrorHandling.Program.d__0.MoveNext() in ...\Program.cs:line 16
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at System.Runtime.CompilerServices.TaskAwaiter.GetResult()
   at AsyncErrorHandling.Program.d__3.MoveNext() in ...\Program.cs:line 30<---

AggregateException的打印内容不那么容易读,咱们能够关注它Inner Exception #0这样的信息。从时间上说,Ex2先于Ex1抛出,而catch的目标是NotImplementedException。但从以前的描述咱们能够知道,WhenAll返回的Task内部的异常集合,与各异常抛出的时机没有关系,所以await操做符抛出的是Ex1,是NotSupportedException,而它不会被catch到,所以SomeTask返回的Task对象也会包含这个异常——也仅仅是抛出这个异常,而Ex2对于外部就不可见了。

若是您想在外部处理全部的异常,则能够这样:

C#
 
Task all = null;
try
{
    await (all = Task.WhenAll(
        ThrowAfter(2000, new NotSupportedException("Ex1")),
        ThrowAfter(1000, new NotImplementedException("Ex2"))));
}
catch
{
    throw all.Exception;
}
 

此时打印的结果即是一个AggregateException包含着另外一个AggregateException,其中包含了Ex1和Ex2。为了“解开”这种嵌套关系,AggregateException也提供了一个Flatten方法,能够将这种嵌套彻底“铺平”,例如:

 
SomeTask().ContinueWith(t => PrintException(t.Exception.Flatten()));
 

此时打印的结果便直接是一个AggregateException包含着Ex1与Ex2了。

原文: https://msdn.microsoft.com/zh-cn/library/jj619227.aspx

参考: 异步编程中的最佳作法(Async/Await)

相关文章
相关标签/搜索