Hello World
Spiga

关于C#中async/await中的异常处理(下)

2012-04-12 09:26 by 老赵, 12159 visits

上一篇文章里我们讨论了某些async/await的用法中出现遗漏异常的情况,并且谈到该如何使用WhenAll辅助方法来避免这种情况。WhenAll辅助方法将会汇总一系列的任务对象,一旦其中某个出错,则会抛出“其中一个”异常。那么究竟是哪个异常?如果我们要处理所有的异常怎么办?我们这次就来详细讨论await操作在异常分派时的相关行为。

await抛出异常时的行为

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

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

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

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

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

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

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

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对象。

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

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。那么请问:

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

结果如下:

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

这里我们也顺便可以得知,如果您不想捕获AggregateException集合中的“其中一个”异常,而是想处理所有异常的话,也可以写这样的代码:

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方法中“捕获”异常的行为,假如异常没有成功捕获,直接对外抛出的时候,对任务本身的有什么影响呢?且看这个示例:

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对于外部就不可见了。

如果您想在外部处理所有的异常,则可以这样:

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了。

相关文章

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

11 条回复

  1. cnhzlt
    122.224.140.*
    链接

    cnhzlt 2012-04-12 09:33:52

    看了两篇后,清楚多了,赞

  2. Dozer
    180.169.28.*
    链接

    Dozer 2012-04-12 09:50:17

    看上去这块还有待微软来完善~

  3. 老赵
    admin
    链接

    老赵 2012-04-12 10:00:11

    @Dozer

    还行吧,哪里还需要完善?现在似乎每一点都是By Design的,这意味着不是“还没搞好下次再搞”的地方。

  4. JustAProgrammer
    124.93.222.*
    链接

    JustAProgrammer 2012-04-12 11:17:06

    而它不会被cache到 是不是 "而它不会被catch到"

  5. lin
    221.224.92.*
    链接

    lin 2012-04-12 12:57:01

    学习了, 谢谢分享 :)

  6. 老赵
    admin
    链接

    老赵 2012-04-12 14:12:21

    @JustAProgrammer

    完了,被你发现代笔和抄袭的证据了……

  7. 银光小子
    219.135.147.*
    链接

    银光小子 2012-04-28 16:12:31

    最近在Silverlight很多次用到了async/await 最烦的就是 所有用到 await的地方居然都要异常处理。不然的话一旦有异常程序就不知不觉的给你退出了.... 唉....

  8. 老赵
    admin
    链接

    老赵 2012-04-29 20:16:39

    @银光小子

    估计你没用对,async/await就是方便你作异常处理,或者说只需要的地方(如最外层)做异常处理的。

  9. 银光小子
    219.137.247.*
    链接

    银光小子 2012-05-01 22:58:47

    这.....可能是我表达的意思不够清楚把。

    这么来说把,我程序的目的是从服务端下载一堆的数据,构成一颗树。传统的写法是WebClient.Downed+=方法A; 方法A里头写逻辑。之前的写法在方法A里因为某个东西没做异常处理,程序蹦了.... 于是乎我按F5键,伟大的VS给我定位到了出错的那行代码..... 我瞬间即知道程序错在哪了....

    而用async/await的话,我按F5键,程序不会定位到那行出错的代码.... 界面突然间一片空白.... 然后... 就没有然后了.... 。

    是这么个事...

  10. 老赵
    admin
    链接

    老赵 2012-05-01 23:41:15

    @银光小子

    懂了……不过这就是使用TaskScheduler.UnobservedTaskException的场景,不是么。

  11. ahdung
    106.61.6.*
    链接

    ahdung 2019-07-27 12:13:16

    很有收获,感谢分享

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我