Hello World
Spiga

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

2012-04-11 09:15 by 老赵, 17538 visits

在同步编程中,一旦出现错误就会抛出异常,我们可以使用try…catch来捕捉异常,而未被捕获的异常则会不断向上传递,形成一个简单而统一的错误处理机制。不过对于异步编程来说,异常处理一直是件麻烦的事情,这也是C#中async/await或是Jscex等异步编程模型的优势之一。但是,同步的错误处理机制,并不能完全避免异步形式的错误处理方式,这需要一定实践规范来保证,至少我们需要了解async/await到底是如何捕获和分发异常的。在开发Jscex的过程中,我也在C#内部邮件邮件列表中了解了很多关于TPL和C#异步特性的问题,错误处理也是其中之一。在此记录一下吧。

使用try…catch捕获异常

首先我们来看下这段代码:

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方法进行一个简单的改造。

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方法上场的时候了:

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语句仅仅“抛出”了“其中一个”异常而已。

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

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

全部成功的情况自不必说,那么在失败的情况下,什么叫做抛出“其中一个”异常?如果我们要处理所有抛出的异常该怎么办?下次我们继续讨论这方面的问题。

相关文章

Creative Commons License

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

Add your comment

10 条回复

  1. chenkai
    119.254.225.*
    链接

    chenkai 2012-04-11 09:59:44

    .net 4.0 和4.5 环境是都做了具体的Demo,在这篇文章中基本和遇到情况一致,都是在一旦强制做GC操作时,Task中的未觉察状态的异常会在垃圾回收时终结器执行线程中被抛出,像其他未处理异常一样。这个异常会使整个进程崩溃,在4.0照成程序退出,.4.5 Beta中依然可以重现4.0中崩溃情况,需要配置RunTime运行时。

    具体的配置如下:https://gist.github.com/2356180

  2. waynebaby
    116.231.123.*
    链接

    waynebaby 2012-04-11 10:38:03

    群众表示 WhenAll 会导致本来的一秒返回变成两秒返回了吧。

    这个状况是不是用取消 token 好一些呢? 我暂时还没测试。

  3. 老赵
    admin
    链接

    老赵 2012-04-11 12:06:05

    @waynebaby

    一秒变两秒是什么意思?WhenAll的定义就是等待所有人都完成咯。如果你有别的需求,完全可以写一个自己的辅助方法,比如WaynebabyWhenAll,它的表现就是……就是你需要的样子。

  4. waynebaby
    116.231.123.*
    链接

    waynebaby 2012-04-11 13:30:06

    oh~~~yeah

  5. leeolevis
    124.126.50.*
    链接

    leeolevis 2012-04-11 15:00:53

    oh~~~good

  6. cnhzlt
    122.224.140.*
    链接

    cnhzlt 2012-04-11 17:18:18

    这篇blog也可参考 http://blogs.msdn.com/b/pfxteam/archive/2011/09/28/10217876.aspx

  7. 老赵
    admin
    链接

    老赵 2012-04-11 22:19:22

    @cnhzlt

    这篇其实还没说清,当时讨论的时候也是基于这篇继续展开的。

  8. zhuisha
    219.142.54.*
    链接

    zhuisha 2012-05-22 18:27:41

    不过对于异步编程来说,异常处理一直是件麻烦的事情,这也是C#中async/await或是Jscex等异步编程模型的优势之一。

    老赵,应该是劣势吧...

  9. 老赵
    admin
    链接

    老赵 2012-05-22 19:31:28

    @zhuisha

    async/await帮你更方便地处理异常,例如一个try...catch就能捕获多个异步操作发生的错误,怎么不是优势?

  10. 链接

    airwolf2026 2012-10-08 16:56:46

    呃.老赵的域名也被那个了? 测试下我的agent的

发表回复

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

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

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

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

使用Live Messenger联系我