C#中async/await中的異常處理


在同步編程中,一旦出現錯誤就會拋出異常,我們可以使用try…catch來捕捉異常,而未被捕獲的異常則會不斷向上傳遞,形成一個簡單而統一的錯誤處理機制。不過對於異步編程來說,異常處理一直是件麻煩的事情,這也是C#中async/await或是Jscex等異步編程模型的優勢之一。但是,同步的錯誤處理機制,並不能完全避免異步形式的錯誤處理方式,這需要一定實踐規范來保證,至少我們需要了解async/await到底是如何捕獲和分發異常的。在開發Jscex的過程中,我深入了解了很多關於TPL和C#異步特性方面的問題,錯誤處理自然也是其中之一。

使用try…catch捕獲異常

首先我們來看下這段代碼:

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方法進行一個簡單的改造。

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語句僅僅“拋出”了“其中一個”異常而已。

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)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM