看到一場 NDC 演講(Norwegian Developers Conference,NDC 是 歐洲最大的 .NET / Agile 開發者研討會),獲益不少,筆記分享之。

摘要重點如下:

  1. 講師 Brandon Minnick 是微軟開發大使,分享自己開發 Xamarin 手機程式過程曾犯過的 async/await 錯誤。
    (不理解原理程式沒寫好,出現許多光怪陸離的奇妙錯誤,從 Stack Trace 根本想不出來程式怎能跑成這副德行。)
  2. 多執行緒執行能充分利用 CPU 運算資源,提升程式運作效能,.NET 開發者很幸福有現成的 Thread Pool 管理機制,開發者只需交付工作,.NET 會自己協調調度執行緒將其完成。
  3. 編譯器將每個 await 前後拆成三段,例如:
    async Task ReadDataFromUrl(string url)
    {
        var wc = new WebClient();
        byte[] result = await wc.DownloadDataTaskAsync(url);
        string data = Encoding.UTF8.GetString(result);
        LoadData(data);
    }
    
    await 之前的程式由 Thread 1 執行,wc.DownloadDataTaskAsync() 交給 Thread 2 執行,此時 Thread 1 被釋放可處理其他事(當 Thread 1 是 UI Thread,這點就格外重要,UI Thread 忙碌時,畫面無法更新介面會凍結),待 DownloadDataTaskAsync() 完成後,繼續由 Thread 1 做完下面的事。
    [2019-08-26 更新]感謝讀者 This Wayne 補充,在影片評論中 另一位 MVP Stephen Cleary 提出 DownloadDataTaskAsync() 將不會動用另一條 Thread 的主張。Stephen 有一篇Blog 文章詳細剖析了 I/O 動作底層機制,說明這類低階 IO 處理是用類似「借用原 Thread」的方式執行,並不會觸發 Thread 切換。但 async 讓 UI Thread 不必等待結果防止凍結的理論不變。
  4. .NET 編譯遇到 async 方法時,會將其轉換一個 IAsyncStateMachine 類別:(執行檔大小增加約 100 Bytes)

    而在 MoveNext() 方法裡,程式被拆成前後兩段,switch 不同 case 的程式片段可由不同執行緒執行:
    public void MoveNext() 
    { 
     uint num = (uint)this.$PC;
     this.$PC = -1;
     try 
     {
         switch (num) 
         { 
             case 0: 
                 this.<wc>__0 = new WebClient();
                 this.$awaiter0 = this.<wc>__0.DownloadDataTaskAsync(this.url).GetAwaiter();
                 this.$PC = 1;
                 //...
                 return;
                 break;
             case 1: 
                 this.<result>__1 = this.$awaiter0.GetResult();
                 this.<data>__2 = Encoding.ASCII.GetString(this.<result>__1);
                 this.$this.LoadData(this.<data>__2); 
                 break; 
             default:
                 return;
         } 
    
     } 
     catch (Exception exception) 
     { 
         //... 
    
     } 
     this.$PC = -1; 
     this.$builder.SetResult();
    }
    
    偵錯過程有時會在 Stack Trace 看到原始碼沒寫過的 MoveNext() 方法,就是 .NET 背後加工造成的。
  5. MoveNext() 中使用 try ... catch 捕捉例外,在 await 時會拋出,但如果你採取 Fire-and-Forget 策略,呼叫 async 方法卻不等結果,程式將忽略這些錯誤繼續執行, 產生難以預期的結果。
  6. 錯誤修正範例 1
    當使用 UI Thread 呼叫 async 方法時,勿使用 SomeAsyncMethod().Wait(),它會佔用 UI Thread 等待結果,等待期間 UI 將凍結無法操作。
    改成 awit SomeAsyncMethod(); 可避免。
  7. 錯誤修正範例 2
    若 await 後的程式操作不必限定 await 前的 Thread 接手處理(例如:與 UI Thread 無關),建議將 await SomeAsyncMethod() 改成 await SomeAsyncMethod().ConfigureAwait(false),允許 .NET 調撥閒置的 Thread 處理,不必等待與佔用寶貴的 UI Thread。
    補充:此招僅適用 WinForm/WPF/ASP.NET 4.x。Console 程式與 ASP.NET Core 的等待器不記錄 SynchronizationContext,故 ConfigureAwait() 無影響。延伸閱讀:有關Task ConfigureAwait() 的一些事情| 微軟開發工具資訊分享- 點部落
  8. 錯誤範例 3
    Task<string> GetData() 
    {
         try 
         {
             return SomeAsyncMethod(); //SomeAsyncMethod傳回Task<string>
         }
         catch (Exception ex)
         {
             Log(ex);
             throw;
         }
    }
    
    傳回還在執行中的 Task,你將永遠抓不到 Exception。
  9. ValueTask 可以改善效能,原理是大量反覆執行時,ValueTask 會儲存在 Stack 記憶體,比 Task 放 Heap 記憶體更有效率。延伸閱讀:C# 7.0 新增 ValueTask 的用意 by kinanson
  10. async void 在發動後無從掌握,除非是為了符合事件函式簽章回傳值 void 之外,勿用。延伸閱讀:async 與 await by Huanlin學習筆記
  11. 需要得到結果才繼續執行的場合,請使用 .GetAwaiter().GetResult() 取代 .Result,雖然也會鎖定 Thread,至少會拋回包含明確 StackTrace、程式碼位置的例外物件。(.Wait()/.Result拋回的是 AggrateException)。
  12. 作者發明了 SafeFireAndForget() 方法,明確表達此處真的不用等結果不是不小心寫錯,並保留處理例外的機制。
  13. 不要 return await,拿掉 async,改 return Task 就好。(除非是用在 try / catch 或 using 區塊)
    例如:
    async Task<string> SomeMethod() 
    {
        //... some logic ...
        return await AnotherAsyncMethodReturnString();
    }
    
    前面提過加 async 後該函式會被轉成 IAsyncStateMachine 類別並增加 100 Bytes,但在此案例卻沒帶來任何好處,徒增檔案大小及無謂 Thread 切換。建議拿掉 async/await:
    Task<string> SomeMethod() 
    {
        //... some logic ...
        return AnotherAsyncMethodReturnString();
    }
    
    但 return await AnotherAsyncMethodReturnString() 如果有被包在 try/catch 或 using 區塊裡就另當別論。

Digest of Brandon Minnick's speech on NDC talking about experience of fixing common mistakes of async/await.


Comments

# by 佳駿

第七點我記得是ConfigureAwait(false)才是避免鎖死的設定(.net core無此問題)

# by Mickey

ConfigureAwait(false)才是避免鎖死沒錯,理由是不做同步化這件事情,但在ASP.NET 本身是非同步環境的狀態下,如果使用了ConfigureAwait(false) 接下來後面無法在取得HttpContext,而樓上講到.net core無此問題是因為 .net core 本身沒有幫你做同步化,所以就算 await .Result 也不會Block住

# by

問題在原文寫的是 如果"不需"原本的Thread接手 明顯是筆誤

# by Jeffrey

to 佳駿, Mickey, 羽,第7點是筆誤沒錯,應為 ConfigureAwait(false),謝謝大家指正。

# by yanhua

您好: 我寫了一個 windows form 程式測試了一下,DownloadDataTaskAsync() 完成後,確實由 Thread1 所接手之後的工作。 但改以 Console 程式撰寫,發現 DownloadDataTaskAsync() 完成後非由 Thread1 接手, 不確定是原有的 Thread2 還是另一個新的 Thread3 所致。 請問這是什麼原因呢,是否跟 UI 前景執行緒有關係,謝謝。

# by Jeffrey

to yunhua, 請參考第7點的補充說明及連結文章,Console 及 ASP.NET Core 的行為與 WinForm/WPF/ASP.NET 4.x 不同。

# by demo

請問 >>不要 return await,拿掉 async,改 return Task 就好。(除非是用在 try / catch 或 using 區塊) 這句的意思是什麼? 我理解成 當一個非同步方法A呼叫另一個非同步方法B的時候 B 只要回傳 return Task<T> 是這樣的意思嗎?

# by Ares

同樓上, 不要 return await..... 這個的意思是? 我測試的結果是在實際執行非同步方法的Method可以不加async及return await, 執行效果跟有加async及return await一樣, 但是在呼叫非同步方法的外部程式一定要加上await 那不加async及return await的原因是?

# by Ares

我後來有再觀察一下, 差異是在執行緒產生等待器的位置不同, 如果是return await xxxAsunc(), 程式會在這邊產生等待器, 不過還是一樣回傳Task, 呼叫端還是要再加上await, 整個呼叫流程等於是產生兩次等待器, 那麼, "不要 return await....." 的意思是減少多餘等待器的產生嗎?

# by Jeffrey

to demo, Ares, 關於 return Task 我補上範例了,希望這樣有更清楚一點。

# by demo

清楚多了,感謝

# by yanhua

To Jeffrey: 謝謝您的建議,我再看一下 To Ares 看了一下影片,不加 async 及 return await 的好處有 1. Thread1 不用被 Context Switch,減少了一些負擔。 2. 減少建構等待器(100 bytes)的記憶體空間.

# by This Wayne

第三點「wc.DownloadDataTaskAsync() 交給 Thread 2 執行」 DownloadDataTaskAsync()我想應該還是會由thread 1來執行? youtube影片下面有一則Stephen Cleary的留言: 3) @5:00, DownloadDataTaskAsync doesn't require a thread blocking on the download. There is no "Thread 2". https://blog.stephencleary.com/2013/11/there-is-no-thread.html 感謝分享

# by Jeffrey

to This Wayne, 謝謝你的寶貴回饋,已更新本文。

# by PringlesPo

剛剛找到這篇文,看完直接解開了我卡3個小時的BUG。 謝謝作者的分享,De掉錯誤真的很感動!! 我打算開始認真專研Async的寫法了 :)

# by David

Hi Jeffrey, 關於第 3 點 Thread 切換的部分,我實際測試結果會不一定,下面是我測試的結果,情境一和情境二都有可能會發生。 我測試的程式版本是: ASP.Net Core 3.1 === 情境一 === 1. await 之前的程式 -> Thread 1 2. wc.DownloadDataTaskAsync() 未完成 -> Thread 1 3. wc.DownloadDataTaskAsync() 完成後 -> Thread 1 === 情境二 === 1. await 之前的程式 -> Thread 1 2. wc.DownloadDataTaskAsync() 未完成 -> Thread 2 3. wc.DownloadDataTaskAsync() 完成後 -> Thread 2

# by David

上面 === 情境二 === 的 2. wc.DownloadDataTaskAsync() 未完成 -> Thread 2 寫錯了,應該是 Thread 1 才對

# by David

哈~我好像想通了。 以上的情境應該是 ASP.NET Core 的等待器不記錄 SynchronizationContext, 所以 .Net 的演算法會自已選擇延用原本的 Thread 或 新的 Thread。

Post a comment