本文章是由機器翻譯。

Async/Await

非同步程式設計中的最佳做法

Stephen Cleary

 

近日來,湧現了許多關於 Microsoft .NET Framework 4.5 中新增了對 async 和 await 支援的資訊。 本文旨在作為學習非同步程式設計的「第二步」;我假設您已閱讀過有關這一方面的至少一篇介紹性文章。 本文不提供任何新內容,Stack Overflow、MSDN 論壇和 async/await FAQ 這類線上資源提供了同樣的建議。 本文只重點介紹一些淹沒在文檔海洋中的最佳做法。

本文中的最佳做法更大程度上是「指導原則」,而不是實際規則。 其中每個指導原則都有一些例外情況。 我將解釋每個指導原則背後的原因,以便可以清楚地瞭解何時適用以及何時不適用。 圖 1 中總結了這些指導原則;我將在以下各節中逐一討論。

圖 1 非同步程式設計指導原則總結

「名稱」 說明 異常
Avoid async void Prefer async Task methods over async void methods 事件處理常式
始終使用 Async 不要混合阻塞式代碼和非同步代碼 主控台 main 方法
Configure context 盡可能使用 ConfigureAwait(false) Methods that require con­text

Avoid 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-returning async methods have a specific purpose:用於支援非同步事件處理常式。 It is possible to have an event handler that returns some actual type, but that doesn't work well with the language; invoking an event handler that returns a type is very awkward, and the notion of an event handler actually returning something doesn't make much sense. Event handlers naturally return void, so async methods return void so that you can have an asynchronous event handler. 但是,async void 方法的一些語義與 async Task 或 async Task<T> 方法的語義略有不同。

Async void 方法具有不同的錯誤處理語義。 當 async Task 或 async Task<T> 方法引發異常時,會捕獲該異常並將其置於 Task 物件上。 With async void methods, there is no Task object, so any exceptions thrown out of an async void method will be raised directly on the SynchronizationContext that was active when the async void method started. 圖 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

非同步代碼讓我想起了一個故事,有個人提出世界是懸浮在太空中的,但是一個老婦人立即提出質疑,她聲稱世界位於一個巨大烏龜的背上。 When the man enquired what the turtle was standing on, the lady replied, “You’re very clever, young man, but it’s turtles all the way down!” As you convert synchronous code to asynchronous code, you’ll find that it works best if asynchronous code calls and is called by other asynchronous code—all the way down (or “up,” if you prefer). 其他人已注意到非同步程式設計的傳播行為,並將其稱為「傳染」或將其與僵屍病毒進行比較。 無論是烏龜還是僵屍,無可置疑的是,非同步代碼趨向于推動周圍的代碼也成為非同步代碼。 此行為是所有類型的非同步程式設計中所固有的,而不僅僅是新 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 執行操作的「非同步方式」

執行以下操作... 替換以下方式... Use This
檢索背景工作的結果 Task.Wait or Task.Result await
等待任何任務完成 Task.WaitAny await Task.WhenAny
檢索多個任務的結果 Task.WaitAll await Task.WhenAll
等待一段時間 Thread.Sleep await Task.Delay

總結這第二個指導原則便是,應避免混合使用非同步代碼和阻塞代碼。 混合非同步代碼和阻塞代碼可能會導致鎖死、更複雜的錯誤處理及上下文執行緒的意外阻塞。 此指導原則的例外情況是主控台應用程式的 Main 方法,或是(如果是高級使用者)管理部分非同步基本代碼。

Configure Context

在本文前面,我簡要說明了當等待未完成 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 or TaskCompletionSource<T>
支援取消 CancellationTokenSource and CancellationToken
報告進度 IProgress<T> 和 Progress<T>
處理資料流程 TPL 資料流程或被動擴展
同步對共用資源的訪問 SemaphoreSlim
非同步初始化資源 AsyncLazy<T>
非同步就緒生產者/消費者結構 TPL 資料流程或 AsyncCollection<T>

第一個問題是任務創建。 顯然,async 方法可以創建任務,這是最簡單的選項。 如果需要線上程池上運行代碼,請使用 Task.Run。 如果要為現有非同步作業或事件創建任務包裝,請使用 TaskCompletionSource<T>。 下一個常見問題是如何處理取消和進度報告。 基類庫 (BCL) 包括專門用於解決這些問題的類型: CancellationTokenSource/CancellationToken and 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>。

我希望本文中的指導原則和指示能有所説明。 非同步真的是非常棒的語言功能,現在正是開始使用它的好時機!

Stephen Cleary 生活在密歇根州北部,他是一位丈夫、父親和程式師。他已從事了 16 年的多執行緒和非同步程式設計工作,自第一個 CTP 以來便在使用 Microsoft .NET Framework 中的非同步支援。他的主頁(包括博客)位於 stephencleary.com

衷心感謝以下技術專家對本文的審閱: Stephen Toub
Stephen Toub 在 Microsoft 的 Visual Studio 團隊中工作。 他專門從事與並行性和非同步相關的領域。