程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> .NET網頁編程 >> C# >> C#入門知識 >> 異步編程中的最佳做法,異步編程做法

異步編程中的最佳做法,異步編程做法

編輯:C#入門知識

異步編程中的最佳做法,異步編程做法


原文鏈接

近日來,湧現了許多關於 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 方法引發的異常。

圖 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

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

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

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved