犯了 async/await 低級錯誤,鬼打牆近半小時,PO 文留念。

.NET 4.5/C# 5.0 開始引進 Asynchronous Function 概念及 async/await 保留字,非同步化函式漸漸成為 .NET 的主流寫法,以取代 WebClient/HttpWebRequest 的 HttpClient 類別為例,提供的幾乎都是 ***Async() 非同步方法:

之前遇到 ***Async() 方法,我常用的技倆是 .Result/.GetAwaiter().GetResult()/.Wait() 後回歸熟悉的同步化寫法。隨著 .NET 6 程式愈寫愈多、async/await 在官方範例到處都是,想想自己也該與時俱進,不能再鴕鳥下去,因此最近新開專案,我學著大量改用 async/await 寫程式。

【補充】還不熟悉 async/await 的同學,建議建立基本觀念再繼續看下去。

今天在寫一段測試程式,由於涉及 HttpClient GetAsync(),我將用到它的測試方法由 void 改成 async Task,而測試方法有段類似取回 List<string> urls 所指網頁內容的邏輯,我很直覺寫成 LINQ ForEach 加 Statement Lambda (o) => { ... },配合非同步就改寫成 async (o) => { ... await httpClient.GetAsync(...) ... },心中想像這樣會逐筆循序跑迴圈,每次等待非同步呼叫結果再繼續。不料,ForEach 迴圈像是沒執行一樣,測試失敗,鬼打牆快半小時,才發現自己犯了低級錯誤。

用以下程式重現問題:(我在 Console 專案參照 MSTest.TestFramework 套件,借 Assert.AreEqual() 來用)

using Microsoft.VisualStudio.TestTools.UnitTesting;

var results = new Dictionary<string, string>();

await FillResults(results);
try
{
    Assert.AreEqual(2, results.Count);
    Console.WriteLine("Success");
}
catch (Exception ex)
{
    Console.WriteLine("ERROR:" + ex.Message);
}

async Task FillResults(Dictionary<string, string> results)
{
    var httpClient = new HttpClient();
    //var res = await httpClient.GetAsync(url_to_download_list);    
    //var urls = (await res.Content.ReadAsStringAsync())
    //    .Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries);
    // 用固定 URL 清單模擬下載結果
    var urls = new List<string> {
        "https://www.google.com",
        "https://www.microsoft.com"
    };
    urls.ForEach(async url =>
    {
        var response = await httpClient.GetAsync(url);
        results.Add(url, await response.Content.ReadAsStringAsync());
    });
}

執行結果會得到 ERROR:Assert.AreEqual failed. Expected:<2>. Actual:<0>.,逐行偵錯時 ForEach 內容像是沒執行一樣。

撞牆一陣子,仔細一想才驚覺這裡犯了兩個錯:

  1. 改成 async url => { ... } 意義上類似 Task downloadUrl(string url) ,除非搭配 await 或轉同步化,ForEach 會逐一執行但不等待其結束
  2. 由於 ForEach 陸續啟動各筆 url 下載同時執行,results.Add() 被多執行緒呼叫,這段有 Thread-Safe 問題。

如果要回歸程式原意採循序執行並等待結果,最簡單的改法是改用 foreach (var url in urls) :

async Task FillResults(Dictionary<string, string> results)
{
    var httpClient = new HttpClient();
    // ...   
    var urls = new List<string> {
        "https://www.google.com",
        "https://www.microsoft.com"
    };
    foreach (var url in urls)
    {
        var response = await httpClient.GetAsync(url);
        results.Add(url, await response.Content.ReadAsStringAsync());
    }
}

若要走平行處理同時下載,做法有很多種,例如用 .NET 6 推出的 Parallel.ForEachAsync 寫入結果時再加 lock 解決多執行緒問題。

async Task FillResults(Dictionary<string, string> results)
{
    var httpClient = new HttpClient();
    // ... 略 ....
    var urls = new List<string> {
        "https://www.google.com",
        "https://www.microsoft.com"
    };

    await Parallel.ForEachAsync(urls, new ParallelOptions {
        MaxDegreeOfParallelism = 2
    }, async (url, token) =>
    {
        var response = await httpClient.GetAsync(url);
        // await 不能包在 lock 中
        // REF: https://blog.darkthread.net/blog/cs-in-depth-notes-6/
        var html = await response.Content.ReadAsStringAsync();
        lock (results) 
        {
            results.Add(url, html);
        }
    });
}

就醬,再增加一些實戰經驗。

【補充】許多讀者提到 ConcurrentDictionary,改用它可以省去 lock,也是可行方案。參考:集合物件的多執行緒存取注意事項

Bad example for using async in LINQ ForEach() and how to execute async in loop correctly.


Comments

# by 伊果

對一條 List 循序發出非同步任務時,也可以先用 `Select` 來取得所有的 Task,再使用 `Task.WhenAll()` 等待所有結果返回,寫起來也相當簡潔,推薦給大大 另外 `result.Add()` 這邊的 Thread-Safe 問題,除了加入 lock 以外,也許可以使用 ConcurrentDictionary?

# by Jeffrey

to 尹果,感謝分享。如文章所提,解法蠻多的,你說的 Select + Task.WhenAll() 及 CurrentDictionary 都可行,我最後選擇 lock 是因為跟原邏輯最接近。

# by Der Chien

最初 ``` urls.ForEach(async url => { var response = await httpClient.GetAsync(url); results.Add(url, await response.Content.ReadAsStringAsync()); }); ``` 的兩個問題 (a.k.a., Fire-and-Forget & Thread-Safe) 其實都可以更簡單地處理 Fire-and-Forget 的部分,把 ForEach 換成 Select 後便能以 .Net 4.5 就內建的 Task.WhenAll() 來等待結果 ``` await Task.WhenAll( urls.Select(async url => { var response = await httpClient.GetAsync(url); results.Add(url, await response.Content.ReadAsStringAsync()); }) ); ``` 至於 Thread-Safe 的問題,比起在異步編程中使用 Lock,也許更合適將 Task<Tuple<string, string>> 作為 Async 方法的回傳值,再由主執行緒統一蒐集或複製到 Dictionary ``` var tuples = await Task.WhenAll( urls.Select(async url => { var response = await httpClient.GetAsync(url); return Tuple.Create(url, await response.Content.ReadAsStringAsync()); }) ); foreach (var kvp in tuples.ToDictionary(x => x.Item1, x => x.Item2)) { results.Add(kvp.Key, kvp.Value); } // or simply results = tuples.ToDictionary(x => x.Item1, x => x.Item2); ``` 獲益良多,忍不住嘮叨一些淺見XD

# by Jeffrey

to Der Chien, 感謝分享,再學到一種解法。

# by aladdin

如果決定要用 ConcurrentDictionary: var tasks=urls.select(x=>concurrentDictionary.GetOrAdd(x, x=>doHttpGet(x))); await Tasks.WhenAll(tasks); 但上面這段程式碼不是直接使用 ConcurrentDictionary 做成的,而是我寫的一個 AsyncCache。裡面使用 ConcurrentDictionary 儲存資料,用 Dicitonary<string, SemaphoreSlim> 做 thread-safe 的 lock,然後同時露出 GetOrAdd 的 sync 與 async method,才能寫成這樣。AsyncCache 的實作其實還蠻複雜的...

Post a comment