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

範例:同步呼叫

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

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 是同步呼叫的寫法,底下是改成非同步呼叫的版本:

static async Task<string> MyDownloadPageAsync(string url)
{
    var webClient = new WebClient(); 
    Task<string> task = webClient.DownloadStringTaskAsync(url);
    string content = await task;
    return content;
}

光是方法簽名(signature)就有三處修改:
  1. 在宣告方法時,前面加上關鍵字 async,表示這是一個非同步方法,裡面會用到 await 關鍵字。
  2. 以 async 關鍵字宣告的方法若有回傳值,回傳的型別須以泛型 Task 表示。原先同步版本的方法是傳回 string,故此處改為 Task。非同步方法若不需要傳回值,則回傳型別應寫成 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.DownloadStringAsync(url);

而這行程式碼也可以拆成兩行來寫:

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


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

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

static void Main(string[] args)
{
    Task<string> task = MyDownloadPageAsync("http://huan-lin.blogspot.com");

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

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

這裡也是先取得非同步方法 MyDownloadPageAsync 所傳回的 Task 物件。但這一次是用 Task 的 Result 屬性來取得非同步工作的執行結果,而不是寫成 await task。其實這裡不能使用 await 關鍵字,因為有用到 await 的函式都必須在宣告時加上 async 關鍵字,否則無法通過編譯。

前面提過,「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 來等待非同步工作的結果。」

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






1 static async Task<string> MyDownloadPageAsync(string url)
2 {
3 var webClient = new WebClient();
4 string content = await webClient.DownloadStringTaskAsync(url);
5 return content;
6 }

錯誤觀念:程式執行時,一旦進入以 async 關鍵字修飾的方法,就會執行在另一條工作執行緒上面。

請注意,程式的控制流一開始進入非同步方法時,仍是以同步的方式執行,而且是執行於呼叫端所在的執行緒;直到碰到 await 敘述,控制流才會一分為二。基本上,await 之前的程式碼是一個同步執行的程式區塊,而 await 敘述之後的程式碼則為另一個同步執行的程式區塊;兩者分屬不同的控制流。前者即為本章開頭提到的先導工作,後者則是延續的工作——它會在 await 所等待的工作完成之後接著執行。
錯誤觀念:await 會阻斷當前所在的程式碼,直到欲等待的工作完成時才會繼續執行。
一個以 async 關鍵字修飾的非同步方法裡面可以有一個或多個 await 敘述。按照先前的講法,若非同步方法中有兩個 await 敘述,即可以理解為該方法被切成三個控制流(三個各自同步執行的程式區塊)。若非同步方法中三個 await 敘述,則表示該方法被切成四個控制流。依此類推。







在回傳值的部分,非同步方法的回傳型別可以是下列三種寫法:
  • Task: 適用於非同步工作沒有傳回值的場合。
  • Task<T> :適用於非同步工作有傳回值的場合。回傳值的型別帶入泛型參數 T,例如 Task<String>
  • void:雖然能夠通過編譯,但此寫法應該只用於事件處理常式的場合,而不該用來宣告沒有回傳值的非同步工作。






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

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


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


    using System;
    using System.Net;
    using System.Threading;
    using System.Threading.Tasks;
    
    namespace Ex02_Async
    {
        class Program
        {
            leanpub-start-insert
            static void ShowThreadInfo(int num, string msg)
            {
                Console.WriteLine("({0}) T{1}: {2}", 
                                  num, Thread.CurrentThread.ManagedThreadId, msg);
            }
            leanpub-end-insert
    
            static void Main(string[] args)
            {
                ShowThreadInfo(1, "正要起始非同步工作 MyDownloadPageAsync()。");
    
                Task<string> task = MyDownloadPageAsync("http://huan-lin.blogspot.com");
    
                ShowThreadInfo(3, "已經起始非同步工作 MyDownloadPageAsync(),但尚未取得工作結果。");
    
                string content = task.Result;
    
                ShowThreadInfo(5, "已經取得 MyDownloadPageAsync() 的結果。");
    
                Console.WriteLine("網頁內容總共為 {0} 個字元。", content.Length);
                Console.ReadKey();
            }
    
            static async Task<string> MyDownloadPageAsync(string url)
            {
                ShowThreadInfo(2, "正要呼叫 WebClient.DownloadStringTaskAsync()。");
    
                var webClient = new WebClient();  
                Task<string> task = webClient.DownloadStringTaskAsync(url);
                string content = await task;
    
                ShowThreadInfo(4, "已經取得 WebClient.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 章