本文將透過一個範例的修改過程來示範如何將原本的同步呼叫的程式碼改成非同步的版本。透過這篇文章,你將了解 C# 的
此 Console 應用程式範例有一個
這裡全都採用同步呼叫的寫法,程式的控制流只有一條,很容易理解,就不贅述。
接著要使用 C# 的
除了引用
接著修改此方法的實作,把這行程式碼:
改成這樣:
或者也可以拆成兩行來寫:
說明:
接下來要修改的是
這裡也是先取得非同步方法
至此,原先採用同步呼叫的程式碼已經全部改成非同步的寫法。最後再以下面這張圖來呈現完整程式碼以及程式執行時的控制流:
圖中有綠、紅、藍三種不同顏色的箭頭,綠色箭頭代表一般流程,紅色箭頭代表因為非同步等待而立即返回呼叫端的控制流,藍色箭頭則代表從先前
圖中的控制流箭頭旁邊標有數字,分別說明如下:
關鍵字 async 與 await 的作用
前面提過,在宣告方法時加上關鍵字
方便閱讀起見,再貼一次剛才的非同步版本的範例:
請注意,程式的控制流一開始進入非同步方法時,仍是以同步的方式執行,而且是執行於呼叫端所在的執行緒;直到碰到
基本上,我們可以這樣來理解:
一個以
在回傳值的部分,非同步方法比較常見的回傳型別有:
此外,雖然非同步方法也可以宣告為
無回傳值的非同步方法不應宣告為
另外要注意的是,以
底下是改用此語法之後的範例程式:
實際修改的地方只有第 3 行的
到目前為止,不知道你是否覺得腦袋裝了太多東西、一時之間難以消化?由於非同步程式本來就比單一控制流來得複雜,所以無論是語法或程式碼的執行順序,都需要一些時間去熟悉、理解,甚至需要一點想像力。如果碰到疑問,最好的辦法就是寫一點程式碼來觀察執行結果,以驗證自己的理解是否正確。
如果你對這些問題也感興趣,不妨試試底下的範例程式,看看結果如何。
這個版本只是在先前的範例程式中加入幾個
執行結果如下圖所示:
圖中每一行文字前面的數字代表程式執行的步驟,而 T1 和 T7 分別代表編號為 1 和 7 的執行緒。進一步說明如下:
值得一提的是,此實驗的結果僅適用於 console 類型的應用程式,而不適用於有 UI(使用者介面)的應用程式。箇中原因,下回再進一步討論。
👉 本文摘自:《.NET 本事-非同步程式設計》第 3 章
async
與 await
關鍵字的用法以及非同步呼叫的執行流程。本文摘自《.NET 本事-非同步程式設計》第三章。2020-03-15 隨電子書更新:潤飾、補充。例如:非同步方法的回傳型別,除了原本的
Task
,現在一併提及 ValueTask 和
IAsyncEnumerable
。另外還增加了 Async Main 語法。範例:同步呼叫
先來看一個同步呼叫的範例。程式碼如下:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Net; | |
namespace Ex01_Sync | |
{ | |
class Program | |
{ | |
static void Main(string[] args) | |
{ | |
string content = MyDownloadPage("http://huan-lin.blogspot.com"); | |
Console.WriteLine("網頁內容總共為 {0} 個字元。", content.Length); | |
Console.ReadKey(); | |
} | |
static string MyDownloadPage(string url) | |
{ | |
var webClient = new WebClient(); // 須引用 System.Net 命名空間。 | |
string content = webClient.DownloadString(url); | |
return content; | |
} | |
} | |
} |
此 Console 應用程式範例有一個
MyDownloadPage
方法,它會透過 .NET 的 WebClient.DownloadString()
來取得指定 URL 的網頁內容,並傳回呼叫端。這裡全都採用同步呼叫的寫法,程式的控制流只有一條,很容易理解,就不贅述。
接著要使用 C# 的
async
與 await
關鍵字來把這個範例修改成非同步呼叫的版本。範例:非同步呼叫
原本的MyDownloadPage
是同步呼叫的寫法,底下是改成非同步呼叫的版本:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Net; | |
using System.Threading.Tasks; | |
...... | |
static async Task<string> MyDownloadPageAsync(string url) | |
{ | |
using (var webClient = new WebClient()) | |
{ | |
Task<string> task = webClient.DownloadStringTaskAsync(url); | |
string content = await task; | |
return content; | |
} | |
} |
System.Threading.Tasks
命名空間,在宣告 MyDownloadPageAsync
方法的地方有三處修改::- 宣告方法時加上關鍵字
async
,即告訴 C# 編譯器:這是一個非同步方法,裡面會用到await
關鍵字。 - 原先同步版本的方法是傳回
string
,此處改為Task<string>
。非同步方法若不需要傳回值,則回傳型別應宣告為Task
,而不要寫void
(原因後述)。 - 一般而言,非同步方法的名稱會以「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)
說明:
- 原本呼叫
WebClient
類別的DownloadString
方法,現在改呼叫它提供的非同步版本:DownloadStringTaskAsync
。與其他非同步 I/O 方法類似,DownloadStringTaskAsync
方法的內部會起始一個非同步 I/O 工作,而且不等該工作完成便立即返回呼叫端;此時傳回的物件是個Task<string>
,代表一個將傳回string
的非同步 I/O 工作。 - 使用
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 物件。但這一次是用 Task
的 Result
屬性來取得非同步工作的執行結果,而不是寫成 await task
。其實這裡不能使用 await
關鍵字,因為有用到 await
的函式都必須在宣告時加上 async
關鍵字,否則無法通過編譯。也許你已經知道,C# 從 7.1 版開始便加入了 async Main 語法。先別著急,稍後會把上述範例用新語法修改一下。前面提過,「
await
某件工作」的寫法會令控制流立刻返回呼叫端。相較之下,「讀取 Task
物件的 Result
屬性」則是阻斷式(blocking)操作,也就是說,它會令當前的執行緒暫停,直到欲等待的工作執行完畢並傳回結果之後,才繼續往下執行。至此,原先採用同步呼叫的程式碼已經全部改成非同步的寫法。最後再以下面這張圖來呈現完整程式碼以及程式執行時的控制流:
圖中有綠、紅、藍三種不同顏色的箭頭,綠色箭頭代表一般流程,紅色箭頭代表因為非同步等待而立即返回呼叫端的控制流,藍色箭頭則代表從先前
await
所保留的地方恢復執行的控制流。請先別把這些不同顏色的箭頭想成不同的執行緒,稍後會再修改此範例來顯示這些控制流所在的執行緒。圖中的控制流箭頭旁邊標有數字,分別說明如下:
- 在
Main
中呼叫MyDownloadPageAsync
方法。 - 在
MyDownloadPageAsync
方法中,呼叫WebClient.DownloadStringTaskAsync
方法時,該方法會在內部起始一個非同步 I/O 工作來取得指定 URL 的網頁內容。 -
WebClient.DownloadStringTaskAsync
方法一旦起始了內部的非同步 I/O 工作,就會立刻返回呼叫端,並傳回一個代表那件非同步工作的Task
物件。 - 碰到
await
時,立刻返回呼叫端(Main
函式),並傳回一個Task
物件。 - 在
Main
函式中以原先的控制流繼續執行程式。此時碰到了task.Result
,欲取得非同步工作的結果。這是個阻斷式呼叫,必須等待目標工作完成才能繼續往下執行。 - 在某個時間點,
WebClient.DownloadStringTaskAsync
方法終於完成了它內部的非同步 I/O 工作,並且取得了結果,於是控制流切回來先前 (4) 所在之await
敘述所保留的程式區塊並繼續執行。 - 把
WebClient.DownloadStringTaskAsync
的結果指派給content
變數,然後返回呼叫端。 - 回到
Main
函式,把非同步工作的執行結果輸出至螢幕。
關鍵字 async 與 await 的作用
前面提過,在宣告方法時加上關鍵字
async
,即表示它是個非同步方法。其實 async
的作用也真的就只有一個,那就是告訴編譯器:「這是個非同步方法,裡面可以、而且應該要使用關鍵字 await
來等待非同步工作的結果。」方便閱讀起見,再貼一次剛才的非同步版本的範例:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Net; | |
using System.Threading.Tasks; | |
...... | |
static async Task<string> MyDownloadPageAsync(string url) | |
{ | |
using (var webClient = new WebClient()) | |
{ | |
Task<string> task = webClient.DownloadStringTaskAsync(url); | |
string content = await task; | |
return content; | |
} | |
} |
請注意,程式的控制流一開始進入非同步方法時,仍是以同步的方式執行,而且是執行於呼叫端所在的執行緒;直到碰到
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
的一個重要原因,是這種寫法會令呼叫端捕捉不到非同步方法所拋出的異常。參考以下範例:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
static void Main(string[] args) | |
{ | |
try | |
{ | |
TestVoidAsync(); | |
} | |
catch (Exception ex) // 捕捉不到 TestVoidAsync 方法所拋出的異常! | |
{ | |
Console.WriteLine(ex); | |
} | |
} | |
static async void TestVoidAsync() | |
{ | |
using (var webClient = new WebClient()) | |
{ | |
string content = await webClient.DownloadStringTaskAsync(url); | |
throw new Exception("error"); | |
} | |
} |
async
關鍵字修飾的方法,其傳入參數有個規則:不可使用 ref
或 out
修飾詞。若違反此規則,程式將無法通過編譯。稍微想一下,此限制的確合理,畢竟非同步方法返回呼叫端時,可能還有程式碼尚未執行完畢,亦即輸出參數的值不見得已經設定好,故對於呼叫端而言不具意義。Async Main 方法
先前範例程式的Main
方法都是宣告為 void
,這使得我們無法在 Main
方法中使用 await
語法來獲取非同步工作的執行結果,而得用 Task.Result
屬性。C# 7.1 開始加入的 Async Main 語法便解決了這個問題,讓我們從程式一開始的進入點就能使用 async
和 await
語法。底下是改用此語法之後的範例程式:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
static class Program | |
{ | |
static async Task Main() | |
{ | |
var url = "https://www.huanlintalk.com"; | |
var content = await MyDownloadPageAsync(url); | |
Console.WriteLine("網頁內容總共為 {0} 個字元。", content.Length); | |
} | |
static async Task<string> MyDownloadPageAsync(string url) | |
{ | |
using (var wc = new WebClient()) | |
{ | |
return await wc.DownloadStringTaskAsync(url); | |
} | |
} | |
} |
實際修改的地方只有第 3 行的
async Task
宣告,以及第 6 行的 await
陳述句。到目前為止,不知道你是否覺得腦袋裝了太多東西、一時之間難以消化?由於非同步程式本來就比單一控制流來得複雜,所以無論是語法或程式碼的執行順序,都需要一些時間去熟悉、理解,甚至需要一點想像力。如果碰到疑問,最好的辦法就是寫一點程式碼來觀察執行結果,以驗證自己的理解是否正確。
觀察執行緒切換過程
經過前面幾個範例的說明,加上實際動手練習,相信你已經了解async
和 await
的語法以及它們對程式控制流程有何影響。也許你會好奇:程式碼被多個 await
關鍵字切成數個片段,令控制流在這些程式片段之間跳來跳去,這當中究竟有沒有建立新的執行緒?或者,哪些片段是以主執行緒來執行,哪些又是以其他工作執行緒來執行?如果你對這些問題也感興趣,不妨試試底下的範例程式,看看結果如何。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
static class Program | |
{ | |
static void Log(int num, string msg) | |
{ | |
Console.WriteLine("({0}) T{1}: {2}", | |
num, Thread.CurrentThread.ManagedThreadId, msg); | |
} | |
static async Task Main() | |
{ | |
Log(1, "正要起始非同步工作 MyDownloadPageAsync()。"); | |
var task = MyDownloadPageAsync("https://www.huanlintalk.com"); | |
Log(4, "已從 MyDownloadPageAsync() 返回,但尚未取得工作結果。"); | |
string content = await task; | |
Log(6, "已經取得 MyDownloadPageAsync() 的結果。"); | |
Console.WriteLine("網頁內容總共為 {0} 個字元。", content.Length); | |
} | |
static async Task<string> MyDownloadPageAsync(string url) | |
{ | |
Log(2, "正要呼叫 WebClient.DownloadStringTaskAsync()。"); | |
using (var webClient = new WebClient()) | |
{ | |
var task = webClient.DownloadStringTaskAsync(url); | |
Log(3, "已起始非同步工作 DownloadStringTaskAsync()。"); | |
string content = await task; | |
Log(5, "已經取得 DownloadStringTaskAsync() 的結果。"); | |
return content; | |
} | |
} | |
} |
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 章