溫故知新,CSharp遇見異步編程(Async/Await),聊聊異步編程最佳做法


什么是異步編程(Async/Await)

Async/Await本質上是通過編譯器實現的語法糖,它讓我們能夠輕松的寫出簡潔、易懂、易維護的異步代碼。

Async/Await是C# 5引入的關鍵字,用以提高用戶界面響應能力和對Web資源的訪問能力,同時它使異步代碼的編寫變得更加容易。

image

如果需要I/O綁定(例如從網絡請求數據、訪問數據庫或讀取和寫入到文件系統),則需要利用異步編程。還可以使用CPU綁定代碼(例如執行成本高昂的計算),對編寫異步代碼而言,這是一個不錯的方案。

C#擁有語言級別的異步編程模型,讓你能輕松編寫異步代碼,而無需應付回調或受限於支持異步的庫。

使用異步編程的好處

使用異步編程可幫助應用在完成可能花費較長時間的工作時保持響應。例如,從Internet下載內容的應用等待內容到達可能要花費數秒鍾。如果你已在UI線程中使用同步方法來檢索內容,則應用會在方法返回之前被阻止。應用將不會響應用戶交互,而且因為無響應的原因,用戶可能會感到沮喪。使用異步編程效果更佳。采用此方式時,應用在等待操作完成時繼續運行並響應UI。

異步模型概述

異步編程的核心是TaskTask<T>對象,這兩個對象對異步操作建模。它們受關鍵字async和await的支持。在大多數情況下模型十分簡單:

  • 對於I/O綁定代碼,等待一個在async方法中返回TaskTask<T>的操作。
  • 對於CPU綁定代碼,等待一個使用Task.Run方法在后台線程啟動的操作。

await關鍵字有這奇妙的作用。它控制執行await的方法的調用方,且它最終允許UI具有響應性或服務具有靈活性。雖然有方法可處理async和await以外的異步代碼,但本文重點介紹語言級構造。

I/O綁定示例:從Web服務下載數據

你可能需要在按下按鈕時從Web服務下載某些數據,但不希望阻止UI線程。可執行如下操作來實現:

private readonly HttpClient _httpClient = new HttpClient();

downloadButton.Clicked += async (o, e) =>
{
    // This line will yield control to the UI as the request
    // from the web service is happening.
    //
    // The UI thread is now free to perform other work.
    var stringData = await _httpClient.GetStringAsync(URL);
    DoSomethingWithData(stringData);
};

代碼表示目的(異步下載數據),而不會在與Task對象的交互中停滯。

CPU綁定示例:為游戲執行計算

假設你正在編寫一個移動游戲,在該游戲中,按下某個按鈕將會對屏幕中的許多敵人造成傷害。執行傷害計算的開銷可能極大,而且在UI線程中執行計算有可能使游戲在計算執行過程中暫停!

此問題的最佳解決方法是啟動一個后台線程,它使用Task.Run執行工作,並使用await等待其結果。這可確保在執行工作時UI能流暢運行。

private DamageResult CalculateDamageDone()
{
    // Code omitted:
    //
    // Does an expensive calculation and returns
    // the result of that calculation.
}

calculateButton.Clicked += async (o, e) =>
{
    // This line will yield control to the UI while CalculateDamageDone()
    // performs its work. The UI thread is free to perform other work.
    var damageResult = await Task.Run(() => CalculateDamageDone());
    DisplayDamage(damageResult);
};

此代碼清楚地表達了按鈕的單擊事件的目的,它無需手動管理后台線程,而是通過非阻止性的方式來實現。

內部原理

異步操作涉及許多移動部分。

在C#方面,編譯器將代碼轉換為狀態機,它將跟蹤類似以下內容:到達await時暫停執行以及后台作業完成時繼續執行。

從理論上講,這是異步的承諾模型的實現。

語法糖本質

async/await本質上只是一個語法糖,它並不產生線程,只是在編譯時把語句的執行邏輯改了,相當於過去我們用callback,這里編譯器幫你做了。線程的轉換是通過SynchronizationContext來實現,如果做了Task.ConfigureAwait(false)操作,運行MoveNext時就只是在線程池中拿個空閑線程出來執行;如果Task.ConfigureAwait(true)-(默認),則會在異步操作前Capture當前線程的SynchronizationContext,異步操作之后運行MoveNext時通過SynchronizationContext轉到目標之前的線程。一般是想更新UI則需要用到SynchronizationContext,如果異步操作完成還需要做大量運算,則可以考慮Task.ConfigureAwait(false)把計算放到后台算,防止UI卡死。

另外還有在異步操作前做的ExecutionContext.FastCapture,獲取當前線程的執行上下文,注意,如果Task.ConfigureAwait(false),會有個IgnoreSynctx的標記,表示在ExecutionContext.Capture里不做SynchronizationContext.Capture操作,Capture到的執行上下文用來在awaiter completed后給MoveNext用,使MoveNext可以有和前面線程同樣的上下文。

通過SynchronizationContext.Post操作,可以使異步異常在最開始的try..catch塊中輕松捕獲。

原理

與同步函數相比,CLR在執行異步函數時有幾個不同的特點:

  1. 並非一次完成,而且分多次完成
  2. 並非由同一個線程完成,而是線程池每次動態分配一個線程來處理;

結合這些特點,C#編譯器將異步函數轉換為一個狀態機結構。這種結構能掛起和恢復。它的執行方式是一種工作流的方式。

執行步驟

  1. CLR創建一個狀態機,這個狀態機的操作數默認值為-1。
  2. 開始執行狀態機
  3. 狀態機通過操作數來選定執行路徑
  4. 狀態機調用GetAwaiter方法來獲取一個等待者對象awaiter,它的類型為TaskAwaiter<T>
  5. 狀態機獲取awaiter后,查詢其IsCompleted屬性。
  6. 若IsCompleted為True,則操作已經以同步方式完成,狀態機繼續執行以處理結果。
  7. 若IsCompleted為False,則操作將以異步方式來完成,狀態機調用awaiter的OnCompleted方法並向它傳遞一個委托(引用狀態機的MoveNext來實現工作流狀態的變遷)。這時狀態機允許線程返回原地以執行其它代碼。
  8. 將來某個時候,awaiter會在完成時調用委托以執行MoveNext,這時可根據狀態機中的字段知道如何到達代碼中的正確位置,使方法能夠從它當初離開的位置繼續。
  9. 調用awaiter的GetResult方法獲取結果,並進行處理。
  10. 狀態機執行完畢后,垃圾回收器會回收任何內存。

限制

  1. 應用程序的Main方法不能轉變成異步函數
  2. 構造函數、屬性、事件不能轉變成異步函數
  3. 不能在catch、finally、unsafe塊中使用await操作符
  4. 不能在支持線程鎖中使用await操作符
  5. Linq中,只能在from子句的第一個集合表達式或join子句的集合表達式中使用await操作符。

異步編程模式

.NET提供了執行異步操作的三種模式:

  • 基於任務的異步模式(TAP),該模式使用單一方法表示異步操作的開始和完成。TAP是在.NETFramework4中引入的。這是在.NET中進行異步編程的推薦方法。C#中的async和await關鍵詞以及VisualBasic中的Async和Await運算符為TAP添加了語言支持。

  • 基於事件的異步模式(EAP),是提供異步行為的基於事件的舊模型。這種模式需要后綴為Async的方法,以及一個或多個事件、事件處理程序委托類型和EventArg派生類型。EAP是在.NETFramework2.0中引入的。建議新開發中不再使用這種模式。

  • 異步編程模型(APM)模式(也稱為IAsyncResult模式),這是使用IAsyncResult接口提供異步行為的舊模型。在這種模式下,同步操作需要Begin和End方法(例如,BeginWrite和EndWrite以實現異步寫入操作)。不建議新的開發使用此模式。

模式的比較

為了快速比較這三種模式的異步操作方式,請考慮使用從指定偏移量處起將指定量數據讀取到提供的緩沖區中的Read方法:

public class MyClass  
{  
    public int Read(byte [] buffer, int offset, int count);  
}

此方法對應的TAP將公開以下單個ReadAsync方法:

public class MyClass  
{  
    public Task<int> ReadAsync(byte [] buffer, int offset, int count);  
}

對應的EAP將公開以下類型和成員的集:

public class MyClass  
{  
    public void ReadAsync(byte [] buffer, int offset, int count);  
    public event ReadCompletedEventHandler ReadCompleted;  
}

對應的APM將公開BeginRead和EndRead方法:

public class MyClass  
{  
    public IAsyncResult BeginRead(  
        byte [] buffer, int offset, int count,
        AsyncCallback callback, object state);  
    public int EndRead(IAsyncResult asyncResult);  
}

基於任務的異步模式

基於任務的異步模式(TAP)是基於System.Threading.Tasks命名空間中的System.Threading.Tasks.TaskSystem.Threading.Tasks.Task<TResult>類型,這些類型用於表示任意異步操作。TAP是用於新開發的建議的異步設計模式。

命名、參數和返回類型

TAP使用單個方法表示異步操作的開始和完成。這與異步編程模型(APM或IAsyncResult)模式和基於事件的異步模式(EAP)形成對比。APM需要Begin和End方法。EAP需要后綴為Async的方法,以及一個或多個事件、事件處理程序委托類型和EventArg派生類型。TAP中的異步方法在返回可等待類型(如TaskTask<TResult>ValueTaskValueTask<TResult>)的方法的操作名稱后面添加Async后綴。例如,返回Task<String>的異步Get操作可命名為GetAsync。若要將TAP方法添加到已包含帶Async后綴的EAP方法名稱的類中,請改用后綴TaskAsync。例如,如果類具有GetAsync方法,請使用名稱GetTaskAsync。如果方法啟動異步操作,但不返回可等待類型,它的名稱應以Begin、Start或表明此方法不返回或拋出操作結果的其他某謂詞開頭。

TAP方法返回System.Threading.Tasks.TaskSystem.Threading.Tasks.Task<TResult>,具體取決於相應同步方法返回的是void還是類型TResult。

TAP方法的參數應與其同步對應方法的參數匹配,並應以相同順序提供。但是,out和ref參數不受此規則的限制,並應完全避免。應該將通過out或ref參數返回的所有數據改為作為由TResult返回的Task<TResult>的一部分返回,且應使用元組或自定義數據結構來容納多個值。即使TAP方法的同步對應項沒有提供CancellationToken參數,也應考慮添加此參數。

專用於創建、控制或組合任務的方法無需遵循此命名模式,因為方法名稱或方法所屬類型的名稱已明確指明方法的異步用途;此類方法通常稱為“組合器”。組合器的示例包括WhenAll和WhenAny,使用基於任務的異步模式一文的使用基於任務的內置組合器部分對此進行了介紹。

異步編程模型

通過使用異步編程,你可以避免性能瓶頸並增強應用程序的總體響應能力。但是,編寫異步應用程序的傳統技術可能比較復雜,使它們難以編寫、調試和維護。

C# 5引入了一種簡便方法,即異步編程。此方法利用了.NETFramework 4.5及更高版本、.NETCoreWindows運行時中的異步支持。編譯器可執行開發人員曾進行的高難度工作,且應用程序保留了一個類似於同步代碼的邏輯結構。因此,你只需做一小部分工作就可以獲得異步編程的所有好處。

異步編程提升響應能力

異步對可能會被屏蔽的活動(如Web訪問)至關重要。對Web資源的訪問有時很慢或會延遲。如果此類活動在同步過程中被屏蔽,整個應用必須等待。在異步過程中,應用程序可繼續執行不依賴Web資源的其他工作,直至潛在阻止任務完成。

異步編程提高響應能力的典型區域

應用程序區域 包含異步方法的.NET類型 包含異步方法的Windows運行時類型
Web訪問 HttpClient Windows.Web.Http.HttpClient
SyndicationClient
使用文件 JsonSerializer
StreamReader
StreamWriter
XmlReader
XmlWriter
StorageFile
使用圖像 MediaCapture
BitmapEncoder
BitmapDecoder
WCF編程 同步和異步操作

由於所有與用戶界面相關的活動通常共享一個線程,因此,異步對訪問UI線程的應用程序來說尤為重要。如果任何進程在同步應用程序中受阻,則所有進程都將受阻。你的應用程序停止響應,因此,你可能在其等待過程中認為它已經失敗。

使用異步方法時,應用程序將繼續響應UI。例如,你可以調整窗口的大小或最小化窗口;如果你不希望等待應用程序結束,則可以將其關閉。

當設計異步操作時,該基於異步的方法將自動傳輸的等效對象添加到可從中選擇的選項列表中。開發人員只需要投入較少的工作量即可使你獲取傳統異步編程的所有優點。

異步方法易於編寫

C#中的Async和Await關鍵字是異步編程的核心。通過這兩個關鍵字,可以使用.NETFramework、.NETCore或Windows運行時中的資源,輕松創建異步方法(幾乎與創建同步方法一樣輕松)。使用async關鍵字定義的異步方法簡稱為“異步方法”。

可從C#中使用Async和Await的異步編程中找到可供下載的完整Windows Presentation Foundation(WPF)示例。

public async Task<int> GetUrlContentLengthAsync()
{
    var client = new HttpClient();

    Task<string> getStringTask =
        client.GetStringAsync("https://docs.microsoft.com/dotnet");

    DoIndependentWork();

    string contents = await getStringTask;

    return contents.Length;
}

void DoIndependentWork()
{
    Console.WriteLine("Working...");
}

可以從前面的示例中了解幾種做法。從方法簽名開始。它包含async修飾符。返回類型為Task<int>。方法名稱以Async結尾。在方法的主體中,GetStringAsync返回Task<string>。這意味着在await任務時,將獲得string(contents)。在等待任務之前,可以通過GetStringAsync執行不依賴於string的工作。

密切注意await運算符。它會暫停GetUrlContentLengthAsync:

  • 在getStringTask完成之前,GetUrlContentLengthAsync無法繼續。
  • 同時,控件返回至GetUrlContentLengthAsync的調用方。
  • 當getStringTask完成時,控件將在此處繼續。
  • 然后,await運算符會從getStringTask檢索string結果。

return語句指定整數結果。任何等待GetUrlContentLengthAsync的方法都會檢索長度值。

如果GetUrlContentLengthAsync在調用GetStringAsync和等待其完成期間不能進行任何工作,則你可以通過在下面的單個語句中調用和等待來簡化代碼。

string contents = await client.GetStringAsync("https://docs.microsoft.com/dotnet");

在異步方法中,可使用提供的關鍵字和類型來指示需要完成的操作,且編譯器會完成其余操作,其中包括持續跟蹤控件以掛起方法返回等待點時發生的情況。一些常規流程(例如,循環和異常處理)在傳統異步代碼中處理起來可能很困難。在異步方法中,元素的編寫頻率與同步解決方案相同且此問題得到解決。

異步方法的運行機制

異步編程中最需弄清的是控制流是如何從方法移動到方法的。下圖可引導你完成此過程:

image

關系圖中的數字對應於以下步驟,在調用方法調用異步方法時啟動。

  1. 調用方法調用並等待GetUrlContentLengthAsync異步方法。

  2. GetUrlContentLengthAsync可創建HttpClient實例並調用GetStringAsync異步方法以下載網站內容作為字符串。

  3. GetStringAsync中發生了某種情況,該情況掛起了它的進程。可能必須等待網站下載或一些其他阻止活動。為避免阻止資源,GetStringAsync會將控制權出讓給其調用方GetUrlContentLengthAsync。

    • GetStringAsync返回Task<TResult>,其中TResult為字符串,並且GetUrlContentLengthAsync將任務分配給getStringTask變量。該任務表示調用GetStringAsync的正在進行的進程,其中承諾當工作完成時產生實際字符串值。
  4. 由於尚未等待getStringTask,因此,GetUrlContentLengthAsync可以繼續執行不依賴於GetStringAsync得出的最終結果的其他工作。該任務由對同步方法DoIndependentWork的調用表示。

  5. DoIndependentWork是完成其工作並返回其調用方的同步方法。

  6. GetUrlContentLengthAsync已運行完畢,可以不受getStringTask的結果影響。接下來,GetUrlContentLengthAsync需要計算並返回已下載的字符串的長度,但該方法只有在獲得字符串的情況下才能計算該值。

    • 因此,GetUrlContentLengthAsync使用一個await運算符來掛起其進度,並把控制權交給調用GetUrlContentLengthAsync的方法。GetUrlContentLengthAsync將Task<int>返回給調用方。該任務表示對產生下載字符串長度的整數結果的一個承諾。

    • 如果GetStringAsync(因此getStringTask)在GetUrlContentLengthAsync等待前完成,則控制會保留在GetUrlContentLengthAsync中。如果異步調用過程getStringTask已完成,並且GetUrlContentLengthAsync不必等待最終結果,則掛起然后返回到GetUrlContentLengthAsync將造成成本浪費。

    • 在調用方法中,處理模式會繼續。在等待結果前,調用方可以開展不依賴於GetUrlContentLengthAsync結果的其他工作,否則就需等待片刻。調用方法等待GetUrlContentLengthAsync,而GetUrlContentLengthAsync等待GetStringAsync。

  7. GetStringAsync完成並生成一個字符串結果。字符串結果不是通過按你預期的方式調用GetStringAsync所返回的。(記住,該方法已返回步驟3中的一個任務)。相反,字符串結果存儲在表示getStringTask方法完成的任務中。await運算符從getStringTask中檢索結果。賦值語句將檢索到的結果賦給contents。

  8. 當GetUrlContentLengthAsync具有字符串結果時,該方法可以計算字符串長度。然后,GetUrlContentLengthAsync工作也將完成,並且等待事件處理程序可繼續使用。在此主題結尾處的完整示例中,可確認事件處理程序檢索並打印長度結果的值。如果你不熟悉異步編程,請花1分鍾時間考慮同步行為和異步行為之間的差異。當其工作完成時(第5步)會返回一個同步方法,但當其工作掛起時(第3步和第6步),異步方法會返回一個任務值。在異步方法最終完成其工作時,任務會標記為已完成,而結果(如果有)將存儲在任務中。

API 異步方法

你可能想知道從何處可以找到GetStringAsync等支持異步編程的方法。.NETFramework4.5或更高版本以及.NETCore包含許多可與async和await結合使用的成員。可以通過追加到成員名稱的“Async”后綴和Task或Task<TResult>的返回類型,識別這些成員。例如,System.IO.Stream類包含CopyToAsync、ReadAsync和WriteAsync等方法,以及同步方法CopyTo、Read和Write。

線程

異步方法旨在成為非阻止操作。異步方法中的await表達式在等待的任務正在運行時不會阻止當前線程。相反,表達式在繼續時注冊方法的其余部分並將控件返回到異步方法的調用方。

async和await關鍵字不會創建其他線程。因為異步方法不會在其自身線程上運行,因此它不需要多線程。只有當方法處於活動狀態時,該方法將在當前同步上下文中運行並使用線程上的時間。可以使用Task.Run將占用大量CPU的工作移到后台線程,但是后台線程不會幫助正在等待結果的進程變為可用狀態。

對於異步編程而言,該基於異步的方法優於幾乎每個用例中的現有方法。具體而言,此方法比BackgroundWorker類更適用於I/O綁定操作,因為此代碼更簡單且無需防止爭用條件。結合Task.Run方法使用時,異步編程比BackgroundWorker更適用於CPU綁定操作,因為異步編程將運行代碼的協調細節與Task.Run傳輸至線程池的工作區分開來。

Async 和 Await

如果使用async修飾符將某種方法指定為異步方法,即啟用以下兩種功能。

標記的異步方法可以使用await來指定暫停點。await運算符通知編譯器異步方法:在等待的異步過程完成后才能繼續通過該點。同時,控制返回至異步方法的調用方。

異步方法在await表達式執行時暫停並不構成方法退出,只會導致finally代碼塊不運行。

標記的異步方法本身可以通過調用它的方法等待。

異步方法通常包含await運算符的一個或多個實例,但缺少await表達式也不會導致生成編譯器錯誤。如果異步方法未使用await運算符標記暫停點,則該方法會作為同步方法執行,即使有async修飾符,也不例外。編譯器將為此類方法發布一個警告。

返回類型和參數

異步方法通常返回Task或Task<TResult>。在異步方法中,await運算符應用於通過調用另一個異步方法返回的任務。

如果方法包含指定TResult類型操作數的return語句,將Task<TResult>指定為返回類型。

如果方法不含任何return語句或包含不返回操作數的return語句,將Task用作返回類型。

自C#7.0起,還可以指定任何其他返回類型,前提是類型包含GetAwaiter方法。例如,ValueTask<TResult>就是這種類型。可用於System.Threading.Tasks.ExtensionNuGet包。

async Task<int> GetTaskOfTResultAsync()
{
    int hours = 0;
    await Task.Delay(0);

    return hours;
}


Task<int> returnedTaskTResult = GetTaskOfTResultAsync();
int intResult = await returnedTaskTResult;
// Single line
// int intResult = await GetTaskOfTResultAsync();

async Task GetTaskAsync()
{
    await Task.Delay(0);
    // No return statement needed
}

Task returnedTask = GetTaskAsync();
await returnedTask;
// Single line
await GetTaskAsync();

每個返回的任務表示正在進行的工作。任務可封裝有關異步進程狀態的信息,如果未成功,則最后會封裝來自進程的最終結果或進程引發的異常。

異步方法也可以具有void返回類型。該返回類型主要用於定義需要void返回類型的事件處理程序。異步事件處理程序通常用作異步程序的起始點。

無法等待具有void返回類型的異步方法,並且無效返回方法的調用方捕獲不到異步方法引發的任何異常。

異步方法無法聲明in、ref或out參數,但可以調用包含此類參數的方法。同樣,異步方法無法通過引用返回值,但可以調用包含ref返回值的方法。

有關詳細信息和示例,請參閱異步返回類型(C#)。若要詳細了解如何在異步方法中捕獲異常,請參閱try-catch。

Windows運行時編程中的異步API具有下列返回類型之一(類似於任務):

  • IAsyncOperation<TResult>(對應於Task<TResult>
  • IAsyncAction(對應於Task)
  • IAsyncActionWithProgress<TProgress>
  • IAsyncOperationWithProgress<TResult,TProgress>

命名約定

按照約定,返回常規可等待類型的方法(例如TaskTask<T>ValueTaskValueTask<T>)應具有以“Async”結束的名稱。啟動異步操作但不返回可等待類型的方法不得具有以“Async”結尾的名稱,但其開頭可以為“Begin”、“Start”或其他表明此方法不返回或引發操作結果的動詞。

如果某一約定中的事件、基類或接口協定建議其他名稱,則可以忽略此約定。例如,你不應重命名常用事件處理程序,例如OnButtonClick。

Async關鍵字

使用async修飾符可將方法、lambda表達式或匿名方法指定為異步。如果對方法或表達式使用此修飾符,則其稱為異步方法。如下示例定義了一個名為ExampleMethodAsync的異步方法:

public async Task<int> ExampleMethodAsync()
{
    //...
}

如果不熟悉異步編程,或者不了解異步方法如何在不阻止調用方線程的情況下使用await運算符執行可能需要長時間運行的工作,請參閱使用Async和Await的異步編程中的說明。如下代碼見於一種異步方法中,且調用HttpClient.GetStringAsync方法:

string contents = await httpClient.GetStringAsync(requestUrl);

異步方法同步運行,直至到達其第一個await表達式,此時會將方法掛起,直到等待的任務完成。同時,如下節示例中所示,控件將返回到方法的調用方。

如果async關鍵字修改的方法不包含await表達式或語句,則該方法將同步執行。編譯器警告將通知你不包含await語句的任何異步方法,因為該情況可能表示存在錯誤。

async關鍵字是上下文關鍵字,原因在於只有當它修飾方法、lambda表達式或匿名方法時,它才是關鍵字。在所有其他上下文中,都會將其解釋為標識符。

Await運算符

await運算符暫停對其所屬的async方法的求值,直到其操作數表示的異步操作完成。異步操作完成后,await運算符將返回操作的結果(如果有)。當await運算符應用到表示已完成操作的操作數時,它將立即返回操作的結果,而不會暫停其所屬的方法。await運算符不會阻止計算異步方法的線程。當await運算符暫停其所屬的異步方法時,控件將返回到方法的調用方。

在下面的示例中,HttpClient.GetByteArrayAsync方法返回Task<byte[]>實例,該實例表示在完成時生成字節數組的異步操作。在操作完成之前,await運算符將暫停DownloadDocsMainPageAsync方法。當DownloadDocsMainPageAsync暫停時,控件將返回到Main方法,該方法是DownloadDocsMainPageAsync的調用方。Main方法將執行,直至它需要DownloadDocsMainPageAsync方法執行的異步操作的結果。當GetByteArrayAsync獲取所有字節時,將計算DownloadDocsMainPageAsync方法的其余部分。之后,將計算Main方法的其余部分。

using System;
using System.Net.Http;
using System.Threading.Tasks;

public class AwaitOperator
{
    public static async Task Main()
    {
        Task<int> downloading = DownloadDocsMainPageAsync();
        Console.WriteLine($"{nameof(Main)}: Launched downloading.");

        int bytesLoaded = await downloading;
        Console.WriteLine($"{nameof(Main)}: Downloaded {bytesLoaded} bytes.");
    }

    private static async Task<int> DownloadDocsMainPageAsync()
    {
        Console.WriteLine($"{nameof(DownloadDocsMainPageAsync)}: About to start downloading.");

        var client = new HttpClient();
        byte[] content = await client.GetByteArrayAsync("https://docs.microsoft.com/en-us/");

        Console.WriteLine($"{nameof(DownloadDocsMainPageAsync)}: Finished downloading.");
        return content.Length;
    }
}
// Output similar to:
// DownloadDocsMainPageAsync: About to start downloading.
// Main: Launched downloading.
// DownloadDocsMainPageAsync: Finished downloading.
// Main: Downloaded 27700 bytes.

上一個示例使用異步Main方法,該方法從C#7.1開始可用。

只能在通過async關鍵字修改的方法、lambda表達式或匿名方法中使用await運算符。在異步方法中,不能在同步函數的主體、lock語句塊內以及不安全的上下文中使用await運算符。

await運算符的操作數通常是以下其中一個.NET類型:TaskTask<TResult>ValueTaskValueTask<TResult>。但是,任何可等待表達式都可以是await運算符的操作數。有關詳細信息,請參閱C#語言規范中的可等待表達式部分。

如果表達式t的類型為Task<TResult>ValueTask<TResult>,則表達式awaitt的類型為TResult。如果t的類型為Task或ValueTask,則awaitt的類型為void。在這兩種情況下,如果t引發異常,則awaitt將重新引發異常。

async和await關鍵字在C#5和更高版本中都可用。

Main方法中的await運算符

從C#7.1開始,作為應用程序入口點的Main方法可以返回TaskTask<int>,使其成為異步的,以便在其主體中使用await運算符。在較早的C#版本中,為了確保Main方法等待異步操作完成,可以檢索由相應的異步方法返回的Task<TResult>實例的Task<TResult>.Result屬性值。對於不生成值的異步操作,可以調用Task.Wait方法。

使用Async和Await的異步編程

基於任務的異步編程模型(TAP)提供了異步代碼的抽象化。你只需像往常一樣將代碼編寫為一連串語句即可。就如每條語句在下一句開始之前完成一樣,你可以流暢地閱讀代碼。編譯器將執行許多轉換,因為其中一些語句可能會開始運行並返回表示正在進行的工作的Task。

這就是此語法的目標:支持讀起來像一連串語句的代碼,但會根據外部資源分配和任務完成時間以更復雜的順序執行。這與人們為包含異步任務的流程給予指令的方式類似。在本文中,你將通過做早餐的指令示例來查看如何使用async和await關鍵字更輕松地推斷包含一系列異步指令的代碼。你可能會寫出與以下列表類似的指令來解釋如何做早餐:

  1. 倒一杯咖啡。
  2. 加熱平底鍋,然后煎兩個雞蛋。
  3. 煎三片培根。
  4. 烤兩片面包。
  5. 在烤面包上加黃油和果醬。
  6. 倒一杯橙汁。

如果你有烹飪經驗,便可通過異步方式執行這些指令。你會先開始加熱平底鍋以備煎蛋,接着再從培根着手。你可將面包放進烤面包機,然后再煎雞蛋。在此過程的每一步,你都可以先開始一項任務,然后將注意力轉移到准備進行的其他任務上。

做早餐是非並行異步工作的一個好示例。單人(或單線程)即可處理所有這些任務。繼續講解早餐的類比,一個人可以以異步方式做早餐,即在第一個任務完成之前開始進行下一個任務。不管是否有人在看着,做早餐的過程都在進行。在開始加熱平底鍋准備煎蛋的同時就可以開始煎了培根。在開始煎培根后,你可以將面包放進烤面包機。

對於並行算法而言,你則需要多名廚師(或線程)。一名廚師煎雞蛋,一名廚師煎培根,依次類推。每名廚師將僅專注於一項任務。每名廚師(或線程)都在同步等待需要翻動培根或面包彈出時都將受到阻。

現在,考慮一下編寫為C#語句的相同指令:

using System;
using System.Threading.Tasks;

namespace AsyncBreakfast
{
    class Program
    {
        static void Main(string[] args)
        {
            Coffee cup = PourCoffee();
            Console.WriteLine("coffee is ready");

            Egg eggs = FryEggs(2);
            Console.WriteLine("eggs are ready");

            Bacon bacon = FryBacon(3);
            Console.WriteLine("bacon is ready");

            Toast toast = ToastBread(2);
            ApplyButter(toast);
            ApplyJam(toast);
            Console.WriteLine("toast is ready");

            Juice oj = PourOJ();
            Console.WriteLine("oj is ready");
            Console.WriteLine("Breakfast is ready!");
        }

        private static Juice PourOJ()
        {
            Console.WriteLine("Pouring orange juice");
            return new Juice();
        }

        private static void ApplyJam(Toast toast) => 
            Console.WriteLine("Putting jam on the toast");

        private static void ApplyButter(Toast toast) => 
            Console.WriteLine("Putting butter on the toast");

        private static Toast ToastBread(int slices)
        {
            for (int slice = 0; slice < slices; slice++)
            {
                Console.WriteLine("Putting a slice of bread in the toaster");
            }
            Console.WriteLine("Start toasting...");
            Task.Delay(3000).Wait();
            Console.WriteLine("Remove toast from toaster");

            return new Toast();
        }

        private static Bacon FryBacon(int slices)
        {
            Console.WriteLine($"putting {slices} slices of bacon in the pan");
            Console.WriteLine("cooking first side of bacon...");
            Task.Delay(3000).Wait();
            for (int slice = 0; slice < slices; slice++)
            {
                Console.WriteLine("flipping a slice of bacon");
            }
            Console.WriteLine("cooking the second side of bacon...");
            Task.Delay(3000).Wait();
            Console.WriteLine("Put bacon on plate");

            return new Bacon();
        }

        private static Egg FryEggs(int howMany)
        {
            Console.WriteLine("Warming the egg pan...");
            Task.Delay(3000).Wait();
            Console.WriteLine($"cracking {howMany} eggs");
            Console.WriteLine("cooking the eggs ...");
            Task.Delay(3000).Wait();
            Console.WriteLine("Put eggs on plate");

            return new Egg();
        }

        private static Coffee PourCoffee()
        {
            Console.WriteLine("Pouring coffee");
            return new Coffee();
        }
    }
}

image

同步准備的早餐大約花費了30分鍾,因為總耗時是每個任務耗時的總和。

計算機不會按人類的方式來解釋這些指令。計算機將阻塞每條語句,直到工作完成,然后再繼續運行下一條語句。這將創造出令人不滿意的早餐。后續任務直到早前任務完成后才會啟動。這樣做早餐花費的時間要長得多,有些食物在上桌之前就已經涼了。

如果你希望計算機異步執行上述指令,則必須編寫異步代碼。

這些問題對即將編寫的程序而言至關重要。編寫客戶端程序時,你希望UI能夠響應用戶輸入。從Web下載數據時,你的應用程序不應讓手機出現卡頓。編寫服務器程序時,你不希望線程受到阻塞。這些線程可以用於處理其他請求。存在異步替代項的情況下使用同步代碼會增加你進行擴展的成本。你需要為這些受阻線程付費。

成功的現代應用程序需要異步代碼。在沒有語言支持的情況下,編寫異步代碼需要回調、完成事件,或其他掩蓋代碼原始意圖的方法。同步代碼的優點是,它的分步操作使其易於掃描和理解。傳統的異步模型迫使你側重於代碼的異步性質,而不是代碼的基本操作。

不要阻塞,而要await

上述代碼演示了不正確的實踐:構造同步代碼來執行異步操作。顧名思義,此代碼將阻止執行這段代碼的線程執行任何其他操作。在任何任務進行過程中,此代碼也不會被中斷。就如同你將面包放進烤面包機后盯着此烤面包機一樣。你會無視任何跟你說話的人,直到面包彈出。

我們首先更新此代碼,使線程在任務運行時不會阻塞。await關鍵字提供了一種非阻塞方式來啟動任務,然后在此任務完成時繼續執行。“做早餐”代碼的簡單異步版本類似於以下片段:

static async Task Main(string[] args)
{
    Coffee cup = PourCoffee();
    Console.WriteLine("coffee is ready");

    Egg eggs = await FryEggsAsync(2);
    Console.WriteLine("eggs are ready");

    Bacon bacon = await FryBaconAsync(3);
    Console.WriteLine("bacon is ready");

    Toast toast = await ToastBreadAsync(2);
    ApplyButter(toast);
    ApplyJam(toast);
    Console.WriteLine("toast is ready");

    Juice oj = PourOJ();
    Console.WriteLine("oj is ready");
    Console.WriteLine("Breakfast is ready!");
}

在煎雞蛋或培根時,此代碼不會阻塞。不過,此代碼也不會啟動任何其他任務。你還是會將面包放進烤面包機里,然后盯着烤面包機直到面包彈出。但至少,你會回應任何想引起你注意的人。在接受了多份訂單的一家餐館里,廚師可能會在做第一份早餐的同時開始制作另一份早餐。

現在,在等待任何尚未完成的已啟動任務時,處理早餐的線程將不會被阻塞。對於某些應用程序而言,此更改是必需的。僅憑借此更改,GUI應用程序仍然會響應用戶。然而,對於此方案而言,你需要更多的內容。你不希望每個組件任務都按順序執行。最好首先啟動每個組件任務,然后再等待之前任務的完成。

同時啟動任務

在許多方案中,你希望立即啟動若干獨立的任務。然后,在每個任務完成時,你可以繼續進行已准備的其他工作。在早餐類比中,這就是更快完成做早餐的方法。你也幾乎將在同一時間完成所有工作。你將吃到一頓熱氣騰騰的早餐。

System.Threading.Tasks.Task和相關類型是可以用於推斷正在進行中的任務的類。這使你能夠編寫更類似於實際做早餐方式的代碼。你可以同時開始煎雞蛋、培根和烤面包。由於每個任務都需要操作,所以你會將注意力轉移到那個任務上,進行下一個操作,然后等待其他需要你注意的事情。

啟動一項任務並等待表示運行的Task對象。你將首先await每項任務,然后再處理它的結果。

讓我們對早餐代碼進行這些更改。第一步是存儲任務以便在這些任務啟動時進行操作,而不是等待:

Coffee cup = PourCoffee();
Console.WriteLine("coffee is ready");

Task<Egg> eggsTask = FryEggsAsync(2);
Egg eggs = await eggsTask;
Console.WriteLine("eggs are ready");

Task<Bacon> baconTask = FryBaconAsync(3);
Bacon bacon = await baconTask;
Console.WriteLine("bacon is ready");

Task<Toast> toastTask = ToastBreadAsync(2);
Toast toast = await toastTask;
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("toast is ready");

Juice oj = PourOJ();
Console.WriteLine("oj is ready");
Console.WriteLine("Breakfast is ready!");

接下來,可以在提供早餐之前將用於處理培根和雞蛋的await語句移動到此方法的末尾:

Coffee cup = PourCoffee();
Console.WriteLine("coffee is ready");

Task<Egg> eggsTask = FryEggsAsync(2);
Task<Bacon> baconTask = FryBaconAsync(3);
Task<Toast> toastTask = ToastBreadAsync(2);

Toast toast = await toastTask;
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("toast is ready");
Juice oj = PourOJ();
Console.WriteLine("oj is ready");

Egg eggs = await eggsTask;
Console.WriteLine("eggs are ready");
Bacon bacon = await baconTask;
Console.WriteLine("bacon is ready");

Console.WriteLine("Breakfast is ready!");

image

異步准備的早餐大約花費了20分鍾,由於一些任務並發運行,因此節約了時間。

上述代碼效果更好。你可以一次啟動所有的異步任務。你僅在需要結果時才會等待每項任務。上述代碼可能類似於Web應用程序中請求各種微服務,然后將結果合並到單個頁面中的代碼。你將立即發出所有請求,然后await所有這些任務並組成Web頁面。

與任務組合

除了吐司外,你准備好了做早餐的所有材料。吐司制作由異步操作(烤面包)和同步操作(添加黃油和果醬)組成。更新此代碼說明了一個重要的概念:

異步操作后跟同步操作的這種組合是一個異步操作。換言之,如果操作的任何部分是異步的,整個操作就是異步的。

上述代碼展示了可以使用Task或Task<TResult>對象來保存運行中的任務。你首先需要await每項任務,然后再使用它的結果。下一步是創建表示其他工作組合的方式。在提供早餐之前,你希望等待表示先烤面包再添加黃油和果醬的任務完成。你可以使用以下代碼表示此工作:

static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
{
    var toast = await ToastBreadAsync(number);
    ApplyButter(toast);
    ApplyJam(toast);

    return toast;
}

上述方式的簽名中具有async修飾符。它會向編譯器發出信號,說明此方法包含await語句;也包含異步操作。此方法表示先烤面包,然后再添加黃油和果醬的任務。此方法返回表示這三個操作的組合的Task<TResult>。主要代碼塊現在變成了:

static async Task Main(string[] args)
{
    Coffee cup = PourCoffee();
    Console.WriteLine("coffee is ready");
    
    var eggsTask = FryEggsAsync(2);
    var baconTask = FryBaconAsync(3);
    var toastTask = MakeToastWithButterAndJamAsync(2);

    var eggs = await eggsTask;
    Console.WriteLine("eggs are ready");

    var bacon = await baconTask;
    Console.WriteLine("bacon is ready");

    var toast = await toastTask;
    Console.WriteLine("toast is ready");

    Juice oj = PourOJ();
    Console.WriteLine("oj is ready");
    Console.WriteLine("Breakfast is ready!");
}

上述更改說明了使用異步代碼的一項重要技術。你可以通過將操作分離到一個返回任務的新方法中來組合任務。可以選擇等待此任務的時間。可以同時啟動其他任務。

異步異常

至此,已隱式假定所有這些任務都已成功完成。異步方法會引發異常,就像對應的同步方法一樣。對異常和錯誤處理的異步支持通常與異步支持追求相同的目標:你應該編寫讀起來像一系列同步語句的代碼。當任務無法成功完成時,它們將引發異常。當啟動的任務為awaited時,客戶端代碼可捕獲這些異常。例如,假設烤面包機在烤面包時着火了。可通過修改ToastBreadAsync方法來模擬這種情況,以匹配以下代碼:

private static async Task<Toast> ToastBreadAsync(int slices)
{
    for (int slice = 0; slice < slices; slice++)
    {
        Console.WriteLine("Putting a slice of bread in the toaster");
    }
    Console.WriteLine("Start toasting...");
    await Task.Delay(2000);
    Console.WriteLine("Fire! Toast is ruined!");
    throw new InvalidOperationException("The toaster is on fire");
    await Task.Delay(1000);
    Console.WriteLine("Remove toast from toaster");

    return new Toast();
}

在編譯前面的代碼時,你將收到一個關於無法訪問的代碼的警告。 這是故意的,因為一旦烤面包機着火,操作就不會正常進行。

請注意,從烤面包機着火到發現異常,有相當多的任務要完成。當異步運行的任務引發異常時,該任務出錯。Task對象包含Task.Exception屬性中引發的異常。出錯的任務在等待時引發異常。

需要理解兩個重要機制:異常在出錯的任務中的存儲方式,以及在代碼等待出錯的任務時解包並重新引發異常的方式。

當異步運行的代碼引發異常時,該異常存儲在Task中。Task.Exception屬性為System.AggregateException,因為異步工作期間可能會引發多個異常。引發的任何異常都將添加到AggregateException.InnerExceptions集合中。如果該Exception屬性為NULL,則將創建一個新的AggregateException且引發的異常是該集合中的第一項。

對於出錯的任務,最常見的情況是Exception屬性只包含一個異常。當代碼awaits出錯的任務時,將重新引發AggregateException.InnerExceptions集合中的第一個異常。因此,此示例的輸出顯示InvalidOperationException而不是AggregateException。提取第一個內部異常使得使用異步方法與使用其對應的同步方法盡可能相似。當你的場景可能生成多個異常時,可在代碼中檢查Exception屬性。

繼續之前,在ToastBreadAsync方法中注釋禁止這兩行。你不想再引起火災:

Console.WriteLine("Fire! Toast is ruined!");
throw new InvalidOperationException("The toaster is on fire");

高效地等待任務

可以通過使用Task類的方法改進上述代碼末尾的一系列await語句。其中一個API是WhenAll,它將返回一個其參數列表中的所有任務都已完成時才完成的Task,如以下代碼中所示:

await Task.WhenAll(eggsTask, baconTask, toastTask);
Console.WriteLine("eggs are ready");
Console.WriteLine("bacon is ready");
Console.WriteLine("toast is ready");
Console.WriteLine("Breakfast is ready!");

另一種選擇是使用WhenAny,它將返回一個當其參數完成時才完成的Task<Task>。你可以等待返回的任務,了解它已經完成了。以下代碼展示了可以如何使用WhenAny等待第一個任務完成,然后再處理其結果。處理已完成任務的結果之后,可以從傳遞給WhenAny的任務列表中刪除此已完成的任務。

var breakfastTasks = new List<Task> { eggsTask, baconTask, toastTask };
while (breakfastTasks.Count > 0)
{
    Task finishedTask = await Task.WhenAny(breakfastTasks);
    if (finishedTask == eggsTask)
    {
        Console.WriteLine("eggs are ready");
    }
    else if (finishedTask == baconTask)
    {
        Console.WriteLine("bacon is ready");
    }
    else if (finishedTask == toastTask)
    {
        Console.WriteLine("toast is ready");
    }
    breakfastTasks.Remove(finishedTask);
}

進行所有這些更改之后,代碼的最終版本將如下所示:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace AsyncBreakfast
{
    class Program
    {
        static async Task Main(string[] args)
        {
            Coffee cup = PourCoffee();
            Console.WriteLine("coffee is ready");

            var eggsTask = FryEggsAsync(2);
            var baconTask = FryBaconAsync(3);
            var toastTask = MakeToastWithButterAndJamAsync(2);

            var breakfastTasks = new List<Task> { eggsTask, baconTask, toastTask };
            while (breakfastTasks.Count > 0)
            {
                Task finishedTask = await Task.WhenAny(breakfastTasks);
                if (finishedTask == eggsTask)
                {
                    Console.WriteLine("eggs are ready");
                }
                else if (finishedTask == baconTask)
                {
                    Console.WriteLine("bacon is ready");
                }
                else if (finishedTask == toastTask)
                {
                    Console.WriteLine("toast is ready");
                }
                breakfastTasks.Remove(finishedTask);
            }

            Juice oj = PourOJ();
            Console.WriteLine("oj is ready");
            Console.WriteLine("Breakfast is ready!");
        }

        static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
        {
            var toast = await ToastBreadAsync(number);
            ApplyButter(toast);
            ApplyJam(toast);

            return toast;
        }

        private static Juice PourOJ()
        {
            Console.WriteLine("Pouring orange juice");
            return new Juice();
        }

        private static void ApplyJam(Toast toast) =>
            Console.WriteLine("Putting jam on the toast");

        private static void ApplyButter(Toast toast) =>
            Console.WriteLine("Putting butter on the toast");

        private static async Task<Toast> ToastBreadAsync(int slices)
        {
            for (int slice = 0; slice < slices; slice++)
            {
                Console.WriteLine("Putting a slice of bread in the toaster");
            }
            Console.WriteLine("Start toasting...");
            await Task.Delay(3000);
            Console.WriteLine("Remove toast from toaster");

            return new Toast();
        }

        private static async Task<Bacon> FryBaconAsync(int slices)
        {
            Console.WriteLine($"putting {slices} slices of bacon in the pan");
            Console.WriteLine("cooking first side of bacon...");
            await Task.Delay(3000);
            for (int slice = 0; slice < slices; slice++)
            {
                Console.WriteLine("flipping a slice of bacon");
            }
            Console.WriteLine("cooking the second side of bacon...");
            await Task.Delay(3000);
            Console.WriteLine("Put bacon on plate");

            return new Bacon();
        }

        private static async Task<Egg> FryEggsAsync(int howMany)
        {
            Console.WriteLine("Warming the egg pan...");
            await Task.Delay(3000);
            Console.WriteLine($"cracking {howMany} eggs");
            Console.WriteLine("cooking the eggs ...");
            await Task.Delay(3000);
            Console.WriteLine("Put eggs on plate");
            
            return new Egg();
        }

        private static Coffee PourCoffee()
        {
            Console.WriteLine("Pouring coffee");
            return new Coffee();
        }
    }
}

image

異步准備的早餐的最終版本大約花費了15分鍾,因為一些任務並行運行,並且代碼同時監視多個任務,只在需要時才執行操作。

此最終代碼是異步的。它更為准確地反映了一個人做早餐的流程。將上述代碼與本文中的第一個代碼示例進行比較。閱讀代碼時,核心操作仍然很明確。你可以按照閱讀本文開始時早餐制作說明的相同方式閱讀此代碼。async和await的語言功能支持每個人做出轉變以遵循這些書面指示:盡可能啟動任務,不要在等待任務完成時造成阻塞。

Async/Await異步編程中的最佳做法

異步編程指導原則總結

名稱 說明 異常
避免Async Void 最好使用asyncTask方法而不是async void方法 事件處理程序
始終使用Async 不要混合阻塞式代碼和異步代碼 控制台main方法
配置上下文 盡可能使用ConfigureAwait(false) 需要上下文的方法

避免Async Void

Async方法有三種可能的返回類型:TaskTask<T>void,但是async方法的固有返回類型只有TaskTask<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 Taskasync Task<T>方法的語義略有不同。

Async void方法具有不同的錯誤處理語義。當async Taskasync Task<T>方法引發異常時,會捕獲該異常並將其置於Task對象上。對於async void方法,沒有Task對象,因此async void方法引發的任何異常都會直接在SynchronizationContext(在async void方法啟動時處於活動狀態)上引發。

無法使用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方法具有不同的組合語義。返回TaskTask<T>的async方法可以使用awaitTask.WhenAnyTask.WhenAll等方便地組合而成。返回void的async方法未提供一種簡單方式,用於向調用代碼通知它們已完成。啟動幾個async void方法不難,但是確定它們何時結束卻不易。Async void方法會在啟動和結束時通知SynchronizationContext,但是對於常規應用程序代碼而言,自定義SynchronizationContext是一種復雜的解決方案。

Async void方法難以測試。由於錯誤處理和組合方面的差異,因此調用async void方法的單元測試不易編寫。MSTest異步測試支持僅適用於返回TaskTask<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 voidAsync Task方法更便於實現錯誤處理、可組合性和可測試性。此指導原則的例外情況是異步事件處理程序,這類處理程序必須返回void。此例外情況包括邏輯上是事件處理程序的方法,即使它們字面上不是事件處理程序(例如ICommand.Executeimplementations)。

始終使用Async

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

“始終異步”表示,在未慎重考慮后果的情況下,不應混合使用同步和異步代碼。具體而言,通過調用Task.Wait或Task.Result在異步代碼上進行阻塞通常很糟糕。對於在異步編程方面“淺嘗輒止”的程序員,這是個特別常見的問題,他們僅僅轉換一小部分應用程序,並采用同步API包裝它,以便代碼更改與應用程序的其余部分隔離。不幸的是,他們會遇到與死鎖有關的問題。在MSDN論壇、StackOverflow和電子郵件中回答了許多與異步相關的問題之后,我可以說,迄今為止,這是異步初學者在了解基礎知識之后最常提問的問題:“為何我的部分異步代碼死鎖?”

演示一個簡單示例,其中一個方法發生阻塞,等待async方法的結果。此代碼僅在控制台應用程序中工作良好,但是在從GUI或ASP.NET上下文調用時會死鎖。此行為可能會令人困惑,尤其是通過調試程序單步執行時,這意味着沒完沒了的等待。在調用Task.Wait時,導致死鎖的實際原因在調用堆棧中上移。

在異步代碼上阻塞時的常見死鎖問題

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,則可能會在完成之前返回,從而導致程序結束。演示了指導原則的這一例外情況:控制台應用程序的Main方法是代碼可以在異步方法上阻塞為數不多的幾種情況之一。

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是將同步操作替換為異步替換的速查表。

執行操作的“異步方式”

執行以下操作… 替換以下方式… 使用以下方式
檢索后台任務的結果 Task.Wait或Task.Result await
等待任何任務完成 Task.WaitAny awaitTask.WhenAny
檢索多個任務的結果 Task.WaitAll awaitTask.WhenAll
等待一段時間 Thread.Sleep awaitTask.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顯示了一個修改后的示例。

處理在等待之前完成的返回任務

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,然后在處理程序結束時重新啟用其控制;因為這一點,事件處理程序不能放棄其上下文。

讓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進行了少量改動。

每個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中,建議將事件處理程序的所有核心邏輯都置於一個可測試且無上下文的asyncTask方法中,僅在上下文相關事件處理程序中保留最少量的代碼。即使是編寫ASP.NET應用程序,如果存在一個可能與桌面應用程序共享的核心庫,請考慮在庫代碼中使用ConfigureAwait。

總結這第三個指導原則便是,應盡可能使用ConfigureAwait。無上下文的代碼對於GUI應用程序具有最佳性能,是一種可在使用部分async基本代碼時避免死鎖的方法。此指導原則的例外情況是需要上下文的方法。

了解您的工具

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

常見異步問題的解決方案

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

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

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();
    }
}

異步代碼通常用於初始化隨后會緩存並共享的資源。沒有用於此用途的內置類型,但是StephenToub開發了AsyncLazy<T>,其行為相當於Task<T>Lazy<T>合二為一。該原始類型在其博客(bit.ly/dEN178)上進行了介紹,並且在我的AsyncEx庫(nitoasyncex.codeplex.com)中提供了更新版本。

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

使用線程池的最佳做法

應做事項

  • 使用線程池在應用中執行並行工作。

  • 使用工作項實現擴展任務,而不阻止UI線程。

  • 創建生存時間較短的獨立工作項。工作項異步運行,可以從隊列中以任何順序將它們提交到池中。

  • 使用Windows.UI.Core.CoreDispatcher調度對UI線程的更新。

  • 使用ThreadPoolTimer.CreateTimer而不是Sleep函數。

  • 使用線程池,而不是創建自己的線程管理系統。線程池運行在具有高級功能的操作系統級別,並且優化為根據進程和整個系統內的設備資源和活動來動態縮放。

  • 在C++中,確保工作項代理使用敏捷線程模型(默認情況下,C++代理是敏捷的)。

  • 如果無法忍受資源分配在使用時失敗,請使用預分配的工作項。

禁止事項

  • 不要創建period值 < 1毫秒(包括0)的定期計時器。這樣將使工作項像單次計時器一樣操作。

  • 不要提交需要花費比period參數指定的時間量更長的時間才能完成的定期工作項。

  • 不要嘗試從后台任務調度的工作項發送UI更新(非Toast和通知)。相反,使用后台任務進度和完成處理程序(例如IBackgroundTaskInstance.Progress)。

  • 當使用的工作項處理程序使用async關鍵字時,請注意,在執行處理程序中的所有代碼之前,線程池工作項可能會設置為完成狀態。在工作項已設置為完成狀態后,可能會執行處理程序中await關鍵字之后的代碼。

  • 不要在未重新初始化的情況下嘗試運行預分配的工作項多次。創建定期工作項

參考


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM