async 與 await

本文將透過一個範例的修改過程來示範如何將原本的同步呼叫的程式碼改成非同步的版本。透過這篇文章,你將了解 C# 的 async 與 await 關鍵字的用法以及非同步呼叫的執行流程。

本文摘自《.NET 本事-非同步程式設計》第三章。
2020-03-15 隨電子書更新:潤飾、補充。例如:非同步方法的回傳型別,除了原本的 Task,現在一併提及 ValueTask 和 IAsyncEnumerable。另外還增加了 Async Main 語法。

範例:同步呼叫

先來看一個同步呼叫的範例。程式碼如下:


此 Console 應用程式範例有一個 MyDownloadPage 方法,它會透過  .NET 的 WebClient.DownloadString() 來取得指定 URL 的網頁內容,並傳回呼叫端。

這裡全都採用同步呼叫的寫法,程式的控制流只有一條,很容易理解,就不贅述。

接著要使用 C# 的 async 與 await 關鍵字來把這個範例修改成非同步呼叫的版本。

範例:非同步呼叫

原本的 MyDownloadPage 是同步呼叫的寫法,底下是改成非同步呼叫的版本:

除了引用 System.Threading.Tasks 命名空間,在宣告 MyDownloadPageAsync 方法的地方有三處修改::
  1. 宣告方法時加上關鍵字 async,即告訴 C# 編譯器:這是一個非同步方法,裡面會用到 await  關鍵字。
  2. 原先同步版本的方法是傳回 string ,此處改為 Task<string>。非同步方法若不需要傳回值,則回傳型別應宣告為 Task,而不要寫 void(原因後述)。
  3. 一般而言,非同步方法的名稱會以「Async」結尾,所以方法名稱現在改為 MyDownloadPageAsync

也許你注意到了,.NET 的 WebClient.DownloadStringTaskAsync() 方法名稱是以 "TaskAsync" 結尾。這是因為,在 TAP 出現之前,.NET 已經有提供 EAP(基於事件的非同步模式)的版本:DownloadStringAsync(參見第 2 章)。因此,在增加 TAP 的非同步版本時,只好以 "TaskAsync" 結尾的方式來命名。

接著修改此方法的實作,把這行程式碼:

string content = webClient.DownloadString(url);

改成這樣:

string content = await webClient.DownloadStringTaskAsync(url);

或者也可以拆成兩行來寫:

Task<string> task = webClient.DownloadStringTaskAsync(url); // (1) 
string content = await task; // (2)

說明:
  1. 原本呼叫 WebClient 類別的 DownloadString 方法,現在改呼叫它提供的非同步版本:DownloadStringTaskAsync。與其他非同步 I/O 方法類似,DownloadStringTaskAsync 方法的內部會起始一個非同步 I/O 工作,而且不等該工作完成便立即返回呼叫端;此時傳回的物件是個 Task<string>,代表一個將傳回 string 的非同步 I/O 工作。 
  2. 使用 await 關鍵字來等待非同步工作執行完畢,然後取得其執行結果。這裡的「等待」,是採取「非同步等待」的作法。意思是說,使用了關鍵字 await 的地方會暫且記住 await 敘述所在的程式碼位置,並且令程式控制流程立刻返回呼叫端;等到 await 所等待的那個非同步工作執行完畢,控制流才又會切回來繼續執行剛才保留而未執行的程式碼。 

接下來要修改的是 Main 函式:

static void Main(string[] args)
{
    Task<string> task = MyDownloadPageAsync("https://www.huanlintalk.com/");

    string content = task.Result; // 取得非同步工作的結果。

    Console.WriteLine("網頁內容總共為 {0} 個字元。", content.Length);
    Console.ReadKey();
}

這裡也是先取得非同步方法 MyDownloadPageAsync 所傳回的 Task 物件。但這一次是用 TaskResult 屬性來取得非同步工作的執行結果,而不是寫成 await task。其實這裡不能使用 await 關鍵字,因為有用到 await 的函式都必須在宣告時加上 async 關鍵字,否則無法通過編譯。
也許你已經知道,C# 從 7.1 版開始便加入了 async Main 語法。先別著急,稍後會把上述範例用新語法修改一下。
前面提過,「await 某件工作」的寫法會令控制流立刻返回呼叫端。相較之下,「讀取 Task 物件的 Result 屬性」則是阻斷式(blocking)操作,也就是說,它會令當前的執行緒暫停,直到欲等待的工作執行完畢並傳回結果之後,才繼續往下執行。

至此,原先採用同步呼叫的程式碼已經全部改成非同步的寫法。最後再以下面這張圖來呈現完整程式碼以及程式執行時的控制流:
圖中有綠、紅、藍三種不同顏色的箭頭,綠色箭頭代表一般流程,紅色箭頭代表因為非同步等待而立即返回呼叫端的控制流,藍色箭頭則代表從先前 await 所保留的地方恢復執行的控制流。請先別把這些不同顏色的箭頭想成不同的執行緒,稍後會再修改此範例來顯示這些控制流所在的執行緒。

圖中的控制流箭頭旁邊標有數字,分別說明如下:
  1. Main 中呼叫 MyDownloadPageAsync 方法。
  2. MyDownloadPageAsync 方法中,呼叫 WebClient.DownloadStringTaskAsync 方法時,該方法會在內部起始一個非同步 I/O 工作來取得指定 URL 的網頁內容。
  3. WebClient.DownloadStringTaskAsync 方法一旦起始了內部的非同步 I/O 工作,就會立刻返回呼叫端,並傳回一個代表那件非同步工作的 Task 物件。
  4. 碰到 await 時,立刻返回呼叫端(Main 函式),並傳回一個 Task 物件。
  5. Main 函式中以原先的控制流繼續執行程式。此時碰到了 task.Result,欲取得非同步工作的結果。這是個阻斷式呼叫,必須等待目標工作完成才能繼續往下執行。
  6. 在某個時間點,WebClient.DownloadStringTaskAsync 方法終於完成了它內部的非同步 I/O 工作,並且取得了結果,於是控制流切回來先前 (4) 所在之 await 敘述所保留的程式區塊並繼續執行。
  7. WebClient.DownloadStringTaskAsync 的結果指派給 content 變數,然後返回呼叫端。
  8. 回到 Main 函式,把非同步工作的執行結果輸出至螢幕。
上述說明當中有提到幾個重點,接著用一個小節再詳細整理一遍。

關鍵字 async 與 await 的作用

前面提過,在宣告方法時加上關鍵字 async,即表示它是個非同步方法。其實 async 的作用也真的就只有一個,那就是告訴編譯器:「這是個非同步方法,裡面可以、而且應該要使用關鍵字 await 來等待非同步工作的結果。」

方便閱讀起見,再貼一次剛才的非同步版本的範例:


請注意,程式的控制流一開始進入非同步方法時,仍是以同步的方式執行,而且是執行於呼叫端所在的執行緒;直到碰到 await 敘述,控制流才會一分為二。
錯誤觀念:程式執行時,一旦進入以 async 關鍵字修飾的方法,就會執行在另一條工作執行緒上面。

基本上,我們可以這樣來理解:await 之前的程式碼是一個同步執行的程式區塊,而 await 敘述之後的程式碼則為另一個同步執行的程式區塊(這塊程式碼會在 await 所等待的工作完成之後才執行)。

一個以 async  關鍵字修飾的非同步方法裡面可以有一個或多個 await 敘述。按照剛才的說法,若非同步方法中有兩個 await 陳述句,即可以理解為該方法被切成三個控制流(三個各自同步執行的程式區塊)。若非同步方法中有三個 await 陳述句,則表示該方法被切成四個控制流,依此類推。
👉 碰到 await 時,控制流會立刻返回呼叫端,而 await 之後的程式碼則會暫時保留,直到 await 所等待的非同步工作完成後,才會從剛才暫時保留的地方恢復,繼續執行後面的程式碼(即被 await 切開的後半部分)。

在回傳值的部分,非同步方法比較常見的回傳型別有:
  • Task: 適用於沒有回傳值的場合。
  • Task<T> :適用於有回傳值的場合。回傳值的型別帶入泛型參數 T,例如 Task<String>
  • ValueTask<T>:類似 Task<T>,但它是實質型別(value type),物件實體是存放於堆疊(stack),而不像參考型別 Task<T> 那樣需要從堆積(heap)配置記憶體空間。使用堆疊空間的好處是可以減輕額外配置與回收記憶體的負荷,從而提高程式的執行效能(當然要用對場合才有這個好處)。
  • IAsyncEnumerable<T> 和 IAsyncEnumerator<T>:用於回傳多個物件。
  • void:雖然能夠通過編譯,但此寫法應該只用於事件處理常式的場合,而不該用來宣告沒有回傳值的非同步工作。
ValueTask<T> 是從 .NET Core 2 開始出現。.NET Framework 4.x 沒有這個型別。

此外,雖然非同步方法也可以宣告為 void,但此寫法通常只用於事件處理常式。一般而言,應避免使用 void 來宣告沒有回傳值的非同步方法。

無回傳值的非同步方法不應宣告為 async void 的一個重要原因,是這種寫法會令呼叫端捕捉不到非同步方法所拋出的異常。參考以下範例:

另外要注意的是,以 async 關鍵字修飾的方法,其傳入參數有個規則:不可使用 ref  或 out 修飾詞。若違反此規則,程式將無法通過編譯。稍微想一下,此限制的確合理,畢竟非同步方法返回呼叫端時,可能還有程式碼尚未執行完畢,亦即輸出參數的值不見得已經設定好,故對於呼叫端而言不具意義。

Async Main 方法

先前範例程式的 Main 方法都是宣告為 void,這使得我們無法在 Main 方法中使用 await 語法來獲取非同步工作的執行結果,而得用 Task.Result 屬性。C# 7.1 開始加入的 Async Main 語法便解決了這個問題,讓我們從程式一開始的進入點就能使用 asyncawait 語法。

底下是改用此語法之後的範例程式:


實際修改的地方只有第 3 行的 async Task 宣告,以及第 6 行的 await 陳述句。



到目前為止,不知道你是否覺得腦袋裝了太多東西、一時之間難以消化?由於非同步程式本來就比單一控制流來得複雜,所以無論是語法或程式碼的執行順序,都需要一些時間去熟悉、理解,甚至需要一點想像力。如果碰到疑問,最好的辦法就是寫一點程式碼來觀察執行結果,以驗證自己的理解是否正確。

觀察執行緒切換過程

經過前面幾個範例的說明,加上實際動手練習,相信你已經了解 asyncawait 的語法以及它們對程式控制流程有何影響。也許你會好奇:程式碼被多個 await 關鍵字切成數個片段,令控制流在這些程式片段之間跳來跳去,這當中究竟有沒有建立新的執行緒?或者,哪些片段是以主執行緒來執行,哪些又是以其他工作執行緒來執行?


如果你對這些問題也感興趣,不妨試試底下的範例程式,看看結果如何。

這個版本只是在先前的範例程式中加入幾個 ShowThreadInfo 呼叫,以便觀察程式執行的流程,以及每一個階段的程式碼是執行在哪一條執行緒上面(以執行緒 ID 來識別)。

執行結果如下圖所示:



圖中每一行文字前面的數字代表程式執行的步驟,而 T1 和 T7 分別代表編號為 1 和 7 的執行緒。進一步說明如下:
  • 步驟 (1) 至 (3) 的程式碼都是在同一條執行緒 T1 上面執行,也就是主執行緒。
  • MyDownloadPage() 方法中,步驟 (2) 之後,碰到帶有 await 關鍵字的這行程式碼,就如稍早提過的,可以解讀為程式執行流程由此處切開,先返回呼叫端,等到目前等待的非同步工作(即 DownloadStringTaskAsync 完成後,才切回來接著執行 await 之後的程式碼。因此,在步驟 (2) 之後是回到 Main 函式中輸出步驟 (3) 的文字訊息。
  • DownloadStringTaskAsync 正忙著以非同步 I/O 擷取遠端網頁內容的時候,此時程式流程已經回到 Main,並接著執行原先 Main 函式區塊的下一行程式碼,也就是 string content = task.Result;。於是在此處等待非同步工作完成,然後才能繼續往下執行。
  • 過了一段時間,先前以非同步呼叫的 DownloadStringTaskAsync 已經完成任務,並返回執行結果。此時程式流程會切回來 MyDownloadPageAsync 方法中的 await 敘述的下一行程式碼,並繼續執行剩下的程式碼。於是此時輸出步驟 (4) 的文字訊息,然後傳回執行結果給呼叫端。注意步驟 (4) 輸出的訊息顯示當時的程式碼是執行在執行緒 T7 上,而不是原先的 T1。這是因為 DownloadStringTaskAsync 內部發起的非同步工作完成時,會從執行緒集區裡面取一條執行緒(即 T7)出來負責執行這個「完成」(completion)的動作,然後繼續執行先前因 await 而暫時保留、尚未執行的程式碼,所以在 await 底下的程式碼都是由 T7 這條執行緒來執行。
  • Main 函式取得非同步工作的執行結果,接著輸出步驟 (5) 的訊息,程式結束。

值得一提的是,此實驗的結果僅適用於 console 類型的應用程式,而不適用於有 UI(使用者介面)的應用程式。箇中原因,下回再進一步討論。

👉 本文摘自:《.NET 本事-非同步程式設計》第 3 章
技術提供:Blogger.
回頂端⬆️