不要寫「假的」非同步方法

前文提過一個撰寫非同步程式的通用建議:從頭到尾都採用非同步呼叫。可是,有時候就是沒辦法做到這點。

比如說,當我們想要在 console 應用程式的 Main 方法當中呼叫非同步方法,像底下這樣寫,是無法通過編譯的:

static void Main()
{
    var client = new HttpClient();
    string content = await client.GetStringAsync("http://www.google.com"); // 編譯失敗! 
}

讀過前面的內容,現在你應該很清楚第 4 行無法通過編譯的原因了:只要函式裡面有用到 await,則該函式在宣告時必須加上 async 關鍵字。

好,那就試試加上 async

static async void Main()
{
    // 略
}

這樣會變成宣告 Main 方法的那行無法通過編譯,因為 console 應用程式的進入點不能宣告為 async 方法。

在實際開發應用程式時,可能也會碰到類似情形,亦即底層函式庫提供的是 async 方法,可是呼叫端本身無法宣告成 async 方法。

改成以下的寫法則沒有問題:

static void Main()
{
    var t = MyDownloadPageAsync("http://www.google.com");
    t.Wait();
}

static async Task MyDownloadPageAsync(string url)
{
    var client = new HttpClient();
    string content = await client.GetStringAsync(url);
    Console.WriteLine(content.Length);
}

這裡採用的解決辦法是把非同步呼叫的部分包在另一個 async 方法裡面,也就是 MyDownloadPageAsync。然後,在呼叫端接收該方法所返回的 Task 物件,並呼叫它的 Wait 方法來等待非同步工作執行完畢。這是在不得已的情況下使用 TaskWait 方法。

現在考慮相反的情況:假設你正要使用一個現成的函式庫,那個函式庫沒有提供非同步版本的 API。於是,為了讓自己寫程式的時候可以從頭到尾都採用非同步呼叫,你打算另外寫一個 async 方法來包裝那個同步呼叫的 API。也許會像這樣:

public async Task MyDownloadPageFakedAsync(string url)
{
    var client = new WebClient();
    var task = Task.Run(() =>
    {
        string content = client.DownloadString(url);  
        Console.WriteLine("網頁長度: " + content.Length);
    });
    await task;
}
註:我們當然知道 WebClient 有提供 DownloadString 的非同步版本,這裡只是為了方便說明而刻意使用同步呼叫的版本。
使用此函式的人,很可能無法看到函式內部的實作,所以光從函式的宣告來看:有 async 關鍵字、返回 Task 物件,而且函式名稱以 “Async” 結尾,自然會認為那是個非同步方法。既然是非同步方法,那就不見得會動用執行緒。結果,卻完全不是那麼回事。怎麼說呢?

請注意這裡使用了 Task.Run() 方法來建立一個非同步工作,以便在此函式中使用 await,以及在宣告時加上 async。換句話說,這裡使用了 Task.Run() 來把同步執行的工作偽裝成非同步方法。然而,當你使用 Task 類別來建立非同步工作時,預設的工作排程器會向執行緒集區(thread pool)調動一個工作執行緒來執行任務。如此一來,使用這個函式的人會以為它跟其他 async 函式一樣,卻不知道它背後其實使用了執行緒集區——如果大量用於 ASP.NET 應用程式中,可能導致效能或者延展性(scalability)不佳的問題(因為跟 ASP.NET 爭搶使用執行緒集區可能會導致集區耗盡)。

小結

如果沒辦法撰寫「真正的」非同步方法,最好別假裝它是。這樣的話,至少別人在使用你的函式庫時,一眼就能判斷那是個同步方法,不至於因為誤用而導致捉摸不定的效能問題。

參考資料

沒有留言:

技術提供:Blogger.
回頂端⬆️