這篇文章主要內容來自於文章C# Async Tips and Tricks Part 2 : Async Void,我本想直接翻譯的,無奈由於水平有限,因此這里給的是參考原文結合自己的理解的一篇隨筆。
一、創建Async函數
Async是C# 5.0中新增的關鍵字,通過語法糖的形式簡化異步編程,它有如下三種方式:
- async Task<T> MyReturningMethod { return default(T); }
- async Task MyMethod() { }
-
async void MyFireAndForgetMethod() { }
從功能上來看方式2和方式3非常類似,都是無返回值的,區別僅僅是方式3無法等待。既然有功能更加強大的async Task的形式,為什么還要支持一個async void呢?
二、async void函數
async void函數存在的唯一目的就是和就是用於兼容現有的事件分發函數,MS在BCL庫中提供了大量void類型的事件,基本形式如下:
private void Button1_Click(object sender, EventArgs args) { }
這個和方式2中async Task的方法簽名是不兼容的,因此,就增加了async void來實現對現有BCL庫中的事件或委托兼容。
private async void Button1_Click(object sender, EventArgs args) { }
更進一步,通過ILSpy反編譯異步函數可以發現,async void和async Task類型的函數的實現是不一樣的,前者是AsyncVoidMethodBuilder類,而后者是 AsyncTaskMethodBuilder類。不過,它們的功能和處理方式都差不多,唯一的區別就是異常處理。
三、TPL中的未處理異常
由於async函數和TPL存在非常大的關聯,在分析async函數異常處理方式前,首先來復習下TPL中對於異常是如何處理的,以如下代碼為例:
static void Main(string[] args)
{
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
Test();
Console.ReadLine(); //等待Test任務執行完成
GC.Collect();
GC.WaitForPendingFinalizers();
Console.ReadLine();
}
static void TaskScheduler_UnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
{
Console.WriteLine(e.Exception);
}
public static Task Test()
{
return Task.Run(() => { throw new Exception(); });
}
執行上面代碼后,我們可以發現:
-
Test任務拋異常后,程序能仍然能正常運行
-
GC執行時,可以通過 TaskScheduler.UnobservedTaskException捕獲 到Test任務拋異常的信息。
從上面代碼執行結果,我們可以大致了解Tpl對未處理異常的處理方式:
-
Task未處理異常不會繼續往上拋導致程序異常終止
-
Task中未處理異常可以通過 TaskScheduler.UnobservedTaskException事件 捕獲
-
TaskScheduler.UnobservedTaskException事件 並不是在拋異常時立即的,而是GC時從Finalizer線程里觸發並執行的。
簡單的說,TaskScheduler中處理了Task中拋出的異常,不會導致程序異常終止。老趙的Blog關於C#中async/await中的異常處理中詳細描述了這一過程,感興趣的朋友可以看看。
不過,Task中的未處理異常不會導致程序異常終止在另一方面也掩蓋了代碼中存在bug的隱患,因此建議注冊TaskScheduler.UnobservedTaskException事件,對未處理異常記錄日志,方便后續跟蹤分析。
四、async Task函數中的未處理異常
復習完TPL的處理過程后,我們再來看看async Task中對異常處理的方式,還是前面的那個代碼,只不過這次把Test函數替換成如下形式:
public static async Task Test()
{
throw new Exception();
}
編譯這段代碼的時候,會發現如下告警:
warning CS4014: 由於不等待此調用,因此會在此調用完成前繼續執行當前方法。請考慮向此調用的結果應用"await"運算符。
這個告警確實很有幫助,可以有效的提示忘記等待異步函數的執行完成,不過不知道為什么沒有async標記的異步函數不提示這個告警。
當執行這段代碼時,執行結果和前面TPL中一致:Test函數中的異常並不終止程序,異常信息在TaskScheduler.UnobservedTaskException事件中可以獲取。由於AsyncTaskMethodBuilder內部就是調用Task來處理的,這個也就不難理解了(兩段代碼並不等價,async標記了的函數是對UI線程是特殊處理了的)。
五、async void函數中的未處理異常
下面我們再來看看async void函數未處理異常,這次我們把Test函數替換為如下形式:
public static async void Test()
{
throw new Exception();
}
這次和上面有幾點不同:
-
編譯的時候沒有CS4014告警
-
Test函數執行時拋異常直接終止了程序
-
在 TaskScheduler.UnobservedTaskException中 查看不到異常信息
這幾點主要的不同在於AsyncVoidMethodBuilder內部並沒有使用TaskScheduler,線程池中的未處理異常便一直向上拋,導致程序異常終止(CLR中的處理方式可以參看Exceptions in Managed Threads這篇文章。)。
這個異常信息在桌面程序中可以通過AppDomain.UnhandledException事件查看,但該回調是沒有處理異常的功能,因此一旦出現該異常,程序仍然將終止,不過可以記錄個出錯的原因,方便錯誤定位。
但是,對於WinRT程序來說就悲催了,由於不支持AppDomain,並且程序是直接crash的,都不拋個對話框掛調試器,對於那些不必現的問題連定位都不容易。在我以前的文章WinRT中的UnhandledException不能捕獲異步函數的異常中就描述過這一問題。
我最初寫WinRT程序的時候,為了消除CS4014告警,對於無需等待的函數,就直接寫成了async void的形式,導致后續定位時欲哭不能。
因此,強烈建議嚴格限制async void的使用范圍,盡量使用async Task來替換;對於CS4014告警,也不要無視,無需等待的任務通過下列擴展函數來清除告警。
static class AsyncExtension
{
public static void IgnorCompletion(this Task task)
{
}
}
六、Async lambda表達式
綜合前面的分析,async void函數拋的異常非常難以定位,因此要嚴格限制使用。不過仍有一個非常隱蔽的async void類型函數非常容易被忽略,那就是async lambda表達式。
首先看一下如下代碼:
Enumerable.Range(0, 3).ToList().ForEach(async (i) => { throw new Exception(); });
這段代碼就隱式生成了async void函數,直接導致了程序的crash。
另外,編譯器是優先生成async Task形式的匿名函數的,這一點比較好。例如對於如下兩個重載函數:
public void ForEach(Action<T> action);
public void ForEach(Func<T, Task> action);
對於如下代碼:
ForEach(async i => { });
編譯器是使用ForEach(Func<T, Task> action);生成匿名函數的,而不是async void類型,但對於那些沒有Func<T, Task>的重載的函數(例如前面的List.Foreach),仍然會生成async void匿名函數,需要注意。
七、編程建議:
使用async異步編程時,請注意如下事項:
-
async void 函數只能在UI Event回調中使用。
-
async void 函數中一定要用try-catch捕獲所有異常,否則會很容易導致程序崩潰。
-
async void 類型的lambda表達式非常隱蔽,並且容易在無意中編寫出來,尤其需要注意。
-
不要忽視CS4014告警,更不要為了消除CS4014告警而改用 async void 函數。
確實無需等待的 async Task 函數用我前面寫的擴展函數 IgnorCompletion 消除 這個告警。 -
注冊 TaskScheduler.UnobservedTaskException事件 ,記錄Task中未處理異常信息,方便分析及錯誤定位。(注意,這個回調里面不能進行耗時操作,具體原因參看前面的老趙的那篇Blog)
總結起來一句話:async void函數能不用就不用,用的時候也要捕獲所有異常再用。
不過,這個做起來還是有些難度的,有的時候會在不經意間寫了async void函數(例如在lambda表達式中),並且不容易發現。最好還是需要一個工具來分析下程序集,檢查函數是否只用於UI Event回調。原文作者說會提供一個基於這個原則的fxcop的靜態分析規則,但目前還並沒有給出,不過感覺並不難,有空的話我寫一個試試。
最后,該作者的這系列文章一共有三篇,寫的都非常不錯,這里強烈推薦下:
我這篇隨筆主要是基於第二篇文章寫的,如果有空的話剩下兩篇放在一篇隨筆中一並介紹下。