在同步編程中,一旦出現錯誤就會拋出異常,我們可以使用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對象失敗了:則拋出“其中一個”異常。
全部成功的情況自不必說,那麼在失敗的情況下,什麼叫做拋出“其中一個”異常?如果我們要處理所有拋出的異常該怎麼辦?下次我們繼續討論這方面的問題。
摘自 老趙點滴-追求編程之美