原文鏈接
近日來,涌現了許多關于 Microsoft .NET Framework 4.5 中新增了對 async 和 await 支持的信息。本文旨在作為學習異步編程的“第二步”;我假設您已閱讀過有關這一方面的至少一篇介紹性文章。本文不提供任何新內容,Stack Overflow、MSDN 論壇和 async/await FAQ 這類在線資源提供了同樣的建議。本文只重點介紹一些淹沒在文檔海洋中的最佳做法。
本文中的最佳做法更大程度上是“指導原則”,而不是實際規則。其中每個指導原則都有一些例外情況。我將解釋每個指導原則背后的原因,以便可以清楚地了解何時適用以及何時不適用。圖 1中總結了這些指導原則;我將在以下各節中逐一討論。
圖 1 異步編程指導原則總結
“名稱” | 說明 | 異常 |
避免 Async Void | 最好使用 async Task 方法而不是 async void 方法 | 事件處理程序 |
始終使用 Async | 不要混合阻塞式代碼和異步代碼 | 控制臺 main 方法 |
配置上下文 | 盡可能使用 ConfigureAwait(false) | 需要上下文的方法 |
Async 方法有三種可能的返回類型: Task、Task<T> 和 void,但是 async 方法的固有返回類型只有 Task 和 Task<T>。當從同步轉換為異步代碼時,任何返回類型 T 的方法都會成為返回 Task<T> 的 async 方法,任何返回 void 的方法都會成為返回 Task 的 async 方法。下面的代碼段演示了一個返回 void 的同步方法及其等效的異步方法:
void MyMethod(){ // Do synchronous work.Thread.Sleep(1000);}async Task MyMethodAsync(){ // Do asynchronous work.await Task.Delay(1000);}
返回 void 的 async 方法具有特定用途: 用于支持異步事件處理程序。事件處理程序可以返回某些實際類型,但無法以相關語言正常工作;調用返回類型的事件處理程序非常困難,事件處理程序實際返回某些內容這一概念也沒有太大意義。事件處理程序本質上返回 void,因此 async 方法返回 void,以便可以使用異步事件處理程序。但是,async void 方法的一些語義與 async Task 或 async Task<T> 方法的語義略有不同。
Async void 方法具有不同的錯誤處理語義。當 async Task 或 async Task<T> 方法引發異常時,會捕獲該異常并將其置于 Task 對象上。對于 async void 方法,沒有 Task 對象,因此 async void 方法引發的任何異常都會直接在 SynchronizationContext(在 async void 方法啟動時處于活動狀態)上引發。圖 2演示本質上無法捕獲從 async void 方法引發的異常。
圖 2 無法使用 Catch 捕獲來自 Async Void 方法的異常
PRivate async void ThrowExceptionAsync(){ throw new InvalidOperationException();}public void AsyncVoidExceptions_CannotBeCaughtByCatch(){ try { ThrowExceptionAsync(); } catch (Exception) { // The exception is never caught here!throw; }}
可以通過對 GUI/asp.net 應用程序使用 AppDomain.UnhandledException 或類似的全部捕獲事件觀察到這些異常,但是使用這些事件進行常規異常處理會導致無法維護。
Async void 方法具有不同的組合語義。返回 Task 或 Task<T> 的 async 方法可以使用 await、Task.WhenAny、Task.WhenAll 等方便地組合而成。返回 void 的 async 方法未提供一種簡單方式,用于向調用代碼通知它們已完成。啟動幾個 async void 方法不難,但是確定它們何時結束卻不易。Async void 方法會在啟動和結束時通知 SynchronizationContext,但是對于常規應用程序代碼而言,自定義 SynchronizationContext 是一種復雜的解決方案。
Async void 方法難以測試。由于錯誤處理和組合方面的差異,因此調用 async void 方法的單元測試不易編寫。MSTest 異步測試支持僅適用于返回 Task 或 Task<T> 的 async 方法。可以安裝 SynchronizationContext 來檢測所有 async void 方法都已完成的時間并收集所有異常,不過只需使 async void 方法改為返回 Task,這會簡單得多。
顯然,async void 方法與 async Task 方法相比具有幾個缺點,但是這些方法在一種特定情況下十分有用: 異步事件處理程序。語義方面的差異對于異步事件處理程序十分有意義。它們會直接在 SynchronizationContext 上引發異常,這類似于同步事件處理程序的行為方式。同步事件處理程序通常是私有的,因此無法組合或直接測試。我喜歡采用的一個方法是盡量減少異步事件處理程序中的代碼(例如,讓它等待包含實際邏輯的 async Task 方法)。下面的代碼演示了這一方法,該方法通過將 async void 方法用于事件處理程序而不犧牲可測試性:
private async void button1_Click(object sender, EventArgs e){ await Button1ClickAsync();}public async Task Button1ClickAsync(){ // Do asynchronous work.await Task.Delay(1000);}
如果調用方不希望 async void 方法是異步的,則這些方法可能會造成嚴重影響。當返回類型是 Task 時,調用方知道它在處理將來的操作;當返回類型是 void 時,調用方可能假設方法在返回時完成。此問題可能會以許多意外方式出現。在接口(或基類)上提供返回 void 的方法的 async 實現(或重寫)通常是錯誤的。某些事件也假設其處理程序在返回時完成。一個不易察覺的陷阱是將 async lambda 傳遞到采用 Action 參數的方法;在這種情況下,async lambda 返回 void 并繼承 async void 方法的所有問題。一般而言,僅當 async lambda 轉換為返回 Task 的委托類型(例如,Func<Task>)時,才應使用 async lambda。
總結這第一個指導原則便是,應首選 async Task 而不是 async void。Async Task 方法更便于實現錯誤處理、可組合性和可測試性。此指導原則的例外情況是異步事件處理程序,這類處理程序必須返回 void。此例外情況包括邏輯上是事件處理程序的方法,即使它們字面上不是事件處理程序(例如 ICommand.Execute implementations)。
異步代碼讓我想起了一個故事,有個人提出世界是懸浮在太空中的,但是一個老婦人立即提出質疑,她聲稱世界位于一個巨大烏龜的背上。當這個人問烏龜站在哪里時,老夫人回答:“很聰明,年輕人,下面是一連串的烏龜!”在將同步代碼轉換為異步代碼時,您會發現,如果異步代碼調用其他異步代碼并且被其他異步代碼所調用,則效果最好 — 一路向下(或者也可以說“向上”)。其他人已注意到異步編程的傳播行為,并將其稱為“傳染”或將其與僵尸病毒進行比較。無論是烏龜還是僵尸,無可置疑的是,異步代碼趨向于推動周圍的代碼也成為異步代碼。此行為是所有類型的異步編程中所固有的,而不僅僅是新 async/await 關鍵字。
“始終異步”表示,在未慎重考慮后果的情況下,不應混合使用同步和異步代碼。具體而言,通過調用 Task.Wait 或 Task.Result 在異步代碼上進行阻塞通常很糟糕。對于在異步編程方面“淺嘗輒止”的程序員,這是個特別常見的問題,他們僅僅轉換一小部分應用程序,并采用同步 API 包裝它,以便代碼更改與應用程序的其余部分隔離。不幸的是,他們會遇到與死鎖有關的問題。在 MSDN 論壇、Stack Overflow 和電子郵件中回答了許多與異步相關的問題之后,我可以說,迄今為止,這是異步初學者在了解基礎知識之后最常提問的問題: “為何我的部分異步代碼死鎖?”
圖 3演示一個簡單示例,其中一個方法發生阻塞,等待 async 方法的結果。此代碼僅在控制臺應用程序中工作良好,但是在從 GUI 或 ASP.NET 上下文調用時會死鎖。此行為可能會令人困惑,尤其是通過調試程序單步執行時,這意味著沒完沒了的等待。在調用 Task.Wait 時,導致死鎖的實際原因在調用堆棧中上移。
圖 3 在異步代碼上阻塞時的常見死鎖問題
public static class DeadlockDemo{ private static async Task DelayAsync() { await Task.Delay(1000); } // This method causes a deadlock when called in a GUI or ASP.NET context.public static void Test() { // Start the delay.var delayTask = DelayAsync(); // Wait for the delay to complete.delayTask.Wait(); }}
這種死鎖的根本原因是 await 處理上下文的方式。默認情況下,當等待未完成的 Task 時,會捕獲當前“上下文”,在 Task 完成時使用該上下文恢復方法的執行。此“上下文”是當前 SynchronizationContext(除非它是 null,這種情況下則為當前 TaskScheduler)。GUI 和 ASP.NET 應用程序具有 SynchronizationContext,它每次僅允許一個代碼區塊運行。當 await 完成時,它會嘗試在捕獲的上下文中執行 async 方法的剩余部分。但是該上下文已含有一個線程,該線程在(同步)等待 async 方法完成。它們相互等待對方,從而導致死鎖。
請注意,控制臺應用程序不會形成這種死鎖。它們具有線程池 SynchronizationContext 而不是每次執行一個區塊的 SynchronizationContext,因此當 await 完成時,它會在線程池線程上安排 async 方法的剩余部分。該方法能夠完成,并完成其返回任務,因此不存在死鎖。當程序員編寫測試控制臺程序,觀察到部分異步代碼按預期方式工作,然后將相同代碼移動到 GUI 或 ASP.NET 應用程序中會發生死鎖,此行為差異可能會令人困惑。
此問題的最佳解決方案是允許異步代碼通過基本代碼自然擴展。如果采用此解決方案,則會看到異步代碼擴展到其入口點(通常是事件處理程序或控制器操作)??刂婆_應用程序不能完全采用此解決方案,因為 Main 方法不能是 async。如果 Main 方法是 async,則可能會在完成之前返回,從而導致程序結束。圖 4演示了指導原則的這一例外情況: 控制臺應用程序的 Main 方法是代碼可以在異步方法上阻塞為數不多的幾種情況之一。
圖 4 Main 方法可以調用 Task.Wait 或 Task.Result
class Program{ static void Main() { MainAsync().Wait(); } static async Task MainAsync() { try { // Asynchronous implementation.await Task.Delay(1000); } catch (Exception ex) { // Handle exceptions.} }}
允許異步代碼通過基本代碼擴展是最佳解決方案,但是這意味著需進行許多初始工作,該應用程序才能體現出異步代碼的實際好處??赏ㄟ^幾種方法逐漸將大量基本代碼轉換為異步代碼,但是這超出了本文的范圍。在某些情況下,使用 Task.Wait 或 Task.Result 可能有助于進行部分轉換,但是需要了解死鎖問題以及錯誤處理問題。我現在說明錯誤處理問題,并在本文后面演示如何避免死鎖問題。
每個 Task 都會存儲一個異常列表。等待 Task 時,會重新引發第一個異常,因此可以捕獲特定異常類型(如 InvalidOperationException)。但是,在 Task 上使用 Task.Wait 或 Task.Result 同步阻塞時,所有異常都會用 AggregateException 包裝后引發。請再次參閱圖 4。MainAsync 中的 try/catch 會捕獲特定異常類型,但是如果將 try/catch 置于 Main 中,則它會始終捕獲 AggregateException。當沒有 AggregateException 時,錯誤處理要容易處理得多,因此我將“全局”try/catch 置于 MainAsync 中。
至此,我演示了兩個與異步代碼上阻塞有關的問題: 可能的死鎖和更復雜的錯誤處理。對于在 async 方法中使用阻塞代碼,也有一個問題。請考慮此簡單示例:
public static class NotFullyAsynchronousDemo{ // This method synchronously blocks a thread.public static async Task TestNotFullyAsync() { await Task.Yield(); Thread.Sleep(5000); }}
此方法不是完全異步的。它會立即放棄,返回未完成的任務,但是當它恢復執行時,會同步阻塞線程正在運行的任何內容。如果此方法是從 GUI 上下文調用,則它會阻塞 GUI 線程;如果是從 ASP.NET 請求上下文調用,則會阻塞當前 ASP.NET 請求線程。如果異步代碼不同步阻塞,則其工作效果最佳。圖 5是將同步操作替換為異步替換的速查表。
圖 5 執行操作的“異步方式”
執行以下操作… | 替換以下方式… | 使用以下方式 |
檢索后臺任務的結果 | Task.Wait 或 Task.Result | await |
等待任何任務完成 | Task.WaitAny | await Task.WhenAny |
檢索多個任務的結果 | Task.WaitAll | await Task.WhenAll |
等待一段時間 | Thread.Sleep | await Task.Delay |
總結這第二個指導原則便是,應避免混合使用異步代碼和阻塞代碼?;旌袭惒酱a和阻塞代碼可能會導致死鎖、更復雜的錯誤處理及上下文線程的意外阻塞。此指導原則的例外情況是控制臺應用程序的 Main 方法,或是(如果是高級用戶)管理部分異步的基本代碼。
在本文前面,我簡要說明了當等待未完成 Task 時默認情況下如何捕獲“上下文”,以及此捕獲的上下文用于恢復 async 方法的執行。圖 3中的示例演示在上下文上的恢復執行如何與同步阻塞發生沖突從而導致死鎖。此
新聞熱點
疑難解答