【轉】C# Async/Await 異步編程中的最佳作法

Async/Await

異步編程中的最佳作法

Stephen Cleary程序員

 

近日來,涌現了許多關於 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 Void

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 方法引起的異常。app

圖 2 沒法使用 Catch 捕獲來自 Async Void 方法的異常dom

複製代碼
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

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

異步代碼讓我想起了一個故事,有我的提出世界是懸浮在太空中的,可是一個老婦人當即提出質疑,她聲稱世界位於一個巨大烏龜的背上。 當這我的問烏龜站在哪裏時,老夫人回答:「很聰明,年輕人,下面是一連串的烏龜!」在將同步代碼轉換爲異步代碼時,您會發現,若是異步代碼調用其餘異步代碼而且被其餘異步代碼所調用,則效果最好 — 一路向下(或者也能夠說「向上」)。 其餘人已注意到異步編程的傳播行爲,並將其稱爲「傳染」或將其與殭屍病毒進行比較。 不管是烏龜仍是殭屍,不容置疑的是,異步代碼趨向於推進周圍的代碼也成爲異步代碼。 此行爲是全部類型的異步編程中所固有的,而不只僅是新 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

總結這第二個指導原則即是,應避免混合使用異步代碼和阻塞代碼。 混合異步代碼和阻塞代碼可能會致使死鎖、更復雜的錯誤處理及上下文線程的意外阻塞。 此指導原則的例外狀況是控制檯應用程序的 Main 方法,或是(若是是高級用戶)管理部分異步的基本代碼。

配置上下文

在本文前面,我簡要說明了當等待未完成 Task 時默認狀況下如何捕獲「上下文」,以及此捕獲的上下文用於恢復 async 方法的執行。 圖 3 中的示例演示在上下文上的恢復執行如何與同步阻塞發生衝突從而致使死鎖。此上下文行爲還可能會致使另外一個問題 — 性能問題。 隨着異步 GUI 應用程序在不斷增加,可能會發現 async 方法的許多小部件都在使用 GUI 線程做爲其上下文。 這可能會造成遲滯,由於會因爲「成千上萬的剪紙」而下降響應性。

若要緩解此問題,請儘量等待 ConfigureAwait 的結果。 下面的代碼段說明了默認上下文行爲和 ConfigureAwait 的用法:

複製代碼
async Task MyMethodAsync()
{
  // Code here runs in the original context.
await Task.Delay(1000);
  // Code here runs in the original context.
await Task.Delay(1000).ConfigureAwait(
    continueOnCapturedContext: false);
  // Code here runs without the original
  // context (in this case, on the thread pool).
}
複製代碼

經過使用 ConfigureAwait,能夠實現少許並行性: 某些異步代碼能夠與 GUI 線程並行運行,而不是不斷塞入零碎的工做。

除了性能以外,ConfigureAwait 還具備另外一個重要方面: 它能夠避免死鎖。 再次考慮圖 3;若是向 DelayAsync 中的代碼行添加「ConfigureAwait(false)」,則可避免死鎖。 此時,當等待完成時,它會嘗試在線程池上下文中執行 async 方法的剩餘部分。 該方法可以完成,並完成其返回任務,所以不存在死鎖。 若是須要逐漸將應用程序從同步轉換爲異步,則此方法會特別有用。

若是能夠在方法中的某處使用 ConfigureAwait,則建議對該方法中此後的每一個 await 都使用它。 前面曾提到,若是等待未完成的 Task,則會捕獲上下文;若是 Task 已完成,則不會捕獲上下文。 在不一樣硬件和網絡狀況下,某些任務的完成速度可能比預期速度更快,須要謹慎處理在等待以前完成的返回任務。 圖 6 顯示了一個修改後的示例。

圖 6 處理在等待以前完成的返回任務

複製代碼
async Task MyMethodAsync()
{
  // Code here runs in the original context.
await Task.FromResult(1);
  // Code here runs in the original context.
await Task.FromResult(1).ConfigureAwait(continueOnCapturedContext: false);
  // Code here runs in the original context.
var random = new Random();
  int delay = random.Next(2); // Delay is either 0 or 1
  await Task.Delay(delay).ConfigureAwait(continueOnCapturedContext: false);
  // Code here might or might not run in the original context.
// The same is true when you await any Task
  // that might complete very quickly.
}
複製代碼

若是方法中在 await 以後具備須要上下文的代碼,則不該使用 ConfigureAwait。 對於 GUI 應用程序,包括任何操做 GUI 元素、編寫數據綁定屬性或取決於特定於 GUI 的類型(如 Dispatcher/CoreDispatcher)的代碼。 對於 ASP.NET 應用程序,這包括任何使用 HttpContext.Current 或構建 ASP.NET 響應的代碼(包括控制器操做中的返回語句)。 圖 7 演示 GUI 應用程序中的一個常見模式:讓 async 事件處理程序在方法開始時禁用其控制,執行某些 await,而後在處理程序結束時從新啓用其控制;由於這一點,事件處理程序不能放棄其上下文。

圖 7 讓 async 事件處理程序禁用並從新啓用其控制

複製代碼
private async void button1_Click(object sender, EventArgs e)
{
  button1.Enabled = false;
  try
  {
    // Can't use ConfigureAwait here ...
await Task.Delay(1000);
  }
  finally
  {
    // Because we need the context here.
button1.Enabled = true;
  }
}
複製代碼

每一個 async 方法都具備本身的上下文,所以若是一個 async 方法調用另外一個 async 方法,則其上下文是獨立的。 圖 8 演示的代碼對圖 7 進行了少許改動。

圖 8 每一個 async 方法都具備本身的上下文

複製代碼
private async Task HandleClickAsync()
{
  // Can use ConfigureAwait here.
await Task.Delay(1000).ConfigureAwait(continueOnCapturedContext: false);
}
private async void button1_Click(object sender, EventArgs e)
{
  button1.Enabled = false;
  try
  {
    // Can't use ConfigureAwait here.
await HandleClickAsync();
  }
  finally
  {
    // We are back on the original context for this method.
button1.Enabled = true;
  }
}
複製代碼

無上下文的代碼可重用性更高。嘗試在代碼中隔離上下文相關代碼與無上下文的代碼,並儘量減小上下文相關代碼。圖 8 中,建議將事件處理程序的全部核心邏輯都置於一個可測試且無上下文的 async Task 方法中,僅在上下文相關事件處理程序中保留最少許的代碼。即便是編寫 ASP.NET 應用程序,若是存在一個可能與桌面應用程序共享的核心庫,請考慮在庫代碼中使用 ConfigureAwait。

總結這第三個指導原則即是,應儘量使用 Configure­Await。無上下文的代碼對於 GUI 應用程序具備最佳性能,是一種可在使用部分 async 基本代碼時避免死鎖的方法。此指導原則的例外狀況是須要上下文的方法。

瞭解您的工具

關於 async 和 await 有許多須要瞭解的內容,這天然會有點迷失方向。圖 9 是常見問題的解決方案的快速參考。

圖 9 常見異步問題的解決方案

問題 解決方案
建立任務以執行代碼 Task.Run 或 TaskFactory.StartNew(不是 Task 構造函數或 Task.Start)
爲操做或事件建立任務包裝 TaskFactory.FromAsync 或 TaskCompletionSource<T>
支持取消 CancellationTokenSource 和 CancellationToken
報告進度 IProgress<T> 和 Progress<T>
處理數據流 TPL 數據流或被動擴展
同步對共享資源的訪問 SemaphoreSlim
異步初始化資源 AsyncLazy<T>
異步就緒生產者/使用者結構 TPL 數據流或 AsyncCollection<T>

第一個問題是任務建立。顯然,async 方法能夠建立任務,這是最簡單的選項。若是須要在線程池上運行代碼,請使用 Task.Run。若是要爲現有異步操做或事件建立任務包裝,請使用 TaskCompletionSource<T>。下一個常見問題是如何處理取消和進度報告。基類庫 (BCL) 包括專門用於解決這些問題的類型: CancellationTokenSource/CancellationToken 和 IProgress<T>/Progress<T>。異步代碼應使用基於任務的異步模式(或稱爲 TAP,msdn.microsoft.com/library/hh873175),該模式詳細說明了任務建立、取消和進度報告。

出現的另外一個問題是如何處理異步數據流。任務很棒,可是隻能返回一個對象而且只能完成一次。對於異步流,可使用 TPL 數據流或被動擴展 (Rx)。TPL 數據流會建立相似於主角的「網格」。Rx 更增強大和高效,不過也更加難以學習。TPL 數據流和 Rx 都具備異步就緒方法,十分適用於異步代碼。

僅僅由於代碼是異步的,並不意味着就安全。共享資源仍須要受到保護,因爲沒法在鎖中等待,所以這比較複雜。下面是一個異步代碼示例,該代碼若是執行兩次,則可能會破壞共享狀態,即便始終在同一個線程上運行也是如此:

int value;
Task<int> GetNextValueAsync(int current);
async Task UpdateValueAsync()
{
  value = await GetNextValueAsync(value);
}

問題在於,方法讀取值並在等待時掛起本身,當方法恢復執行時,它假設值未更改。爲了解決此問題,使用異步就緒 WaitAsync 重載擴展了 SemaphoreSlim 類。圖 10 演示 SemaphoreSlim.WaitAsync。

圖 10 SemaphoreSlim 容許異步同步

複製代碼
SemaphoreSlim mutex = new SemaphoreSlim(1);
int value;
Task<int> GetNextValueAsync(int current);
async Task UpdateValueAsync()
{
  await mutex.WaitAsync().ConfigureAwait(false);
  try
  {
    value = await GetNextValueAsync(value);
  }
  finally
  {
    mutex.Release();
  }
}
複製代碼

異步代碼一般用於初始化隨後會緩存並共享的資源。沒有用於此用途的內置類型,可是 Stephen Toub 開發了 AsyncLazy<T>,其行爲至關於 Task<T> 和 Lazy<T> 合二爲一。該原始類型在其博客 (bit.ly/dEN178) 上進行了介紹,而且在個人 AsyncEx 庫 (nitoasyncex.codeplex.com) 中提供了更新版本。

最後,有時須要某些異步就緒數據結構。TPL 數據流提供了 BufferBlock<T>,其行爲如同異步就緒生產者/使用者隊列。而 AsyncEx 提供了 AsyncCollection<T>,這是異步版本的 BlockingCollection<T>。

我但願本文中的指導原則和指示能有所幫助。異步真的是很是棒的語言功能,如今正是開始使用它的好時機!

相關文章
相關標籤/搜索