在同步編程中,一旦出現錯誤就會拋出異常,咱們可使用try…catch來捕捉異常,而未被捕獲的異常則會不斷向上傳遞,造成一個簡單而統一的錯誤處理機制。不過對於異步編程來講,異常處理一直是件麻煩的事情,這也是C#中async/await或是Jscex等異步編程模型的優點之一。可是,同步的錯誤處理機制,並不能徹底避免異步形式的錯誤處理方式,這須要必定實踐規範來保證,至少咱們須要瞭解async/await究竟是如何捕獲和分發異常的。在開發Jscex的過程當中,我深刻了解了不少關於TPL和C#異步特性方面的問題,錯誤處理天然也是其中之一。git
首先咱們來看下這段代碼:github
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
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語句僅僅「拋出」了「其中一個」異常而已。spa
WhenAll是一個輔助方法,它的輸入是n個Task對象,輸出則是個返回它們的結果數組的Task對象。新的Task對象會在全部輸入所有「結束」後才完成。在這裏「結束」的意思包括成功和失敗(取消也是失敗的一種,即拋出了OperationCanceledException)。換句話說,假如這n個輸入中的某個Task對象很快便失敗了,也必須等待其餘全部輸入對象成功或是失敗以後,新的Task對象纔算完成。而新的Task對象完成後又可能會有兩種表現:
所有成功的狀況自沒必要說,那麼在失敗的狀況下,什麼叫作拋出「其中一個」異常?若是咱們要處理全部拋出的異常該怎麼辦?接着咱們來詳細討論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類中定義的行爲。可是既然這並不是是「文檔化」的固定行爲,開發人員也儘可能不要依賴這點。
其實這個話題跟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。那麼請問:
結果以下:
這裏咱們也順即可以得知,若是您不想捕獲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了。
原文: https://msdn.microsoft.com/zh-cn/library/jj619227.aspx