閱讀筆記 - 使用 .NET Async/Await 的常見錯誤
18 |
看到一場 NDC 演講(Norwegian Developers Conference,NDC 是 歐洲最大的 .NET / Agile 開發者研討會),獲益不少,筆記分享之。
摘要重點如下:
- 講師 Brandon Minnick 是微軟開發大使,分享自己開發 Xamarin 手機程式過程曾犯過的 async/await 錯誤。
(不理解原理程式沒寫好,出現許多光怪陸離的奇妙錯誤,從 Stack Trace 根本想不出來程式怎能跑成這副德行。) - 多執行緒執行能充分利用 CPU 運算資源,提升程式運作效能,.NET 開發者很幸福有現成的 Thread Pool 管理機制,開發者只需交付工作,.NET 會自己協調調度執行緒將其完成。
- 編譯器將每個 await 前後拆成三段,例如:
await 之前的程式由 Thread 1 執行,wc.DownloadDataTaskAsync() 交給 Thread 2 執行,此時 Thread 1 被釋放可處理其他事(當 Thread 1 是 UI Thread,這點就格外重要,UI Thread 忙碌時,畫面無法更新介面會凍結),待 DownloadDataTaskAsync() 完成後,繼續由 Thread 1 做完下面的事。async Task ReadDataFromUrl(string url) { var wc = new WebClient(); byte[] result = await wc.DownloadDataTaskAsync(url); string data = Encoding.UTF8.GetString(result); LoadData(data); }
[2019-08-26 更新]感謝讀者 This Wayne 補充,在影片評論中 另一位 MVP Stephen Cleary 提出 DownloadDataTaskAsync() 將不會動用另一條 Thread 的主張。Stephen 有一篇Blog 文章詳細剖析了 I/O 動作底層機制,說明這類低階 IO 處理是用類似「借用原 Thread」的方式執行,並不會觸發 Thread 切換。但 async 讓 UI Thread 不必等待結果防止凍結的理論不變。 - .NET 編譯遇到 async 方法時,會將其轉換一個 IAsyncStateMachine 類別:(執行檔大小增加約 100 Bytes)
而在 MoveNext() 方法裡,程式被拆成前後兩段,switch 不同 case 的程式片段可由不同執行緒執行:
偵錯過程有時會在 Stack Trace 看到原始碼沒寫過的 MoveNext() 方法,就是 .NET 背後加工造成的。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(); }
- MoveNext() 中使用 try ... catch 捕捉例外,在 await 時會拋出,但如果你採取 Fire-and-Forget 策略,呼叫 async 方法卻不等結果,程式將忽略這些錯誤繼續執行, 產生難以預期的結果。
- 錯誤修正範例 1
當使用 UI Thread 呼叫 async 方法時,勿使用 SomeAsyncMethod().Wait(),它會佔用 UI Thread 等待結果,等待期間 UI 將凍結無法操作。
改成 awit SomeAsyncMethod(); 可避免。 - 錯誤修正範例 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() 的一些事情| 微軟開發工具資訊分享- 點部落 - 錯誤範例 3
傳回還在執行中的 Task,你將永遠抓不到 Exception。Task<string> GetData() { try { return SomeAsyncMethod(); //SomeAsyncMethod傳回Task<string> } catch (Exception ex) { Log(ex); throw; } }
- ValueTask 可以改善效能,原理是大量反覆執行時,ValueTask 會儲存在 Stack 記憶體,比 Task 放 Heap 記憶體更有效率。延伸閱讀:C# 7.0 新增 ValueTask 的用意 by kinanson
- async void 在發動後無從掌握,除非是為了符合事件函式簽章回傳值 void 之外,勿用。延伸閱讀:async 與 await by Huanlin學習筆記
- 需要得到結果才繼續執行的場合,請使用 .GetAwaiter().GetResult() 取代 .Result,雖然也會鎖定 Thread,至少會拋回包含明確 StackTrace、程式碼位置的例外物件。(.Wait()/.Result拋回的是 AggrateException)。
- 作者發明了 SafeFireAndForget() 方法,明確表達此處真的不用等結果不是不小心寫錯,並保留處理例外的機制。
- 不要 return await,拿掉 async,改 return Task 就好。(除非是用在 try / catch 或 using 區塊)
例如:
前面提過加 async 後該函式會被轉成 IAsyncStateMachine 類別並增加 100 Bytes,但在此案例卻沒帶來任何好處,徒增檔案大小及無謂 Thread 切換。建議拿掉 async/await:async Task<string> SomeMethod() { //... some logic ... return await AnotherAsyncMethodReturnString(); }
但 return await AnotherAsyncMethodReturnString() 如果有被包在 try/catch 或 using 區塊裡就另當別論。Task<string> SomeMethod() { //... some logic ... return AnotherAsyncMethodReturnString(); }
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。