有關 Task ConfigureAwait() 的一些事情

.Net Task 已經上市好幾年了,相信許多 .Net 開發者已經將它用在你的工作上.上星期我和另一個同事為了解決一些工作上有關效能的問題,我們便對一些 C# 程式碼開始用力地找一些能讓效率提升的地方,即便是一點點也好.我們採用的第一步方法是重新檢視這部份的程式碼.在檢視的過程中,其他的同事也參與並貢獻了一些他們所知的事情.其中有一個有趣的討論是有關 Task 的 ConfigureAwait().因此,這篇文章的內容將記錄與 ConfigureAwait() 有關的重點.
 

await DoSomethingAsync();

當你寫一行如上的程式碼時,相信大家一看到這一行時都明白背後代表的動機,就是希望這一行程式能以非同步的方式進行.這一行程式碼背後所代表的動作是被 C# compiler 偷偷做掉而大家看不到的.當 C# compiler 遇到這一行時,它會產生一個 “等待器”,這個等待器就是等 DoSomethingAsync() 的結果,此時會有兩種情況,第一種情況是 DoSomethingAsync() 尚未完成,則此等待器將繼續等待,因此下一行的程式碼不會被執行,要一直等到 DoSomethingAsync() 完成之後,才會執行下一行執式碼.另一種情況是 DoSoemthingAsync() 已經完成了,此時等待器便不需要等待,它將會繼續往下一行執行.

相信以上的描述是廣為人知的,接下來將會是有趣的內容.把執行此程式是不是 UI thread 考慮進來的話,將會有一些有趣的討論.先看以下的程式碼:

int a = 1;
await DoSomethingAsync();
int b = 2; 

假設 UI thread 來執行並且 DoSometingAsync() 在等待器還沒開始等待時它本身也完成了,那 int b = 2 會是誰來執行呢 ? 很直覺地想,就是 UI thread.這邊有一個值得討論之處,等待器還沒準備好,為何 DoSomethingAsync() 能完成 ? 是誰去做的 ? 這是 compiler 幫你做的一件隱形工作,它做了 Task Yield,所以有一個來自 ThreadPool 的 thread 去執行了 DoSomethingAsync().先聲明,這樣子的解釋不太準確,因為還得看 DoSometingAsync() 的內容,但我這邊先假設 DoSomethingAsync() 真的去做了一些 I/O 動作.

假設 UI thread 來執行並且 DoSomethingAsync() 需要好幾秒甚至更久的時間才能完成,此時等待器將等待,而此時 Task 已 Yield 了,所以 UI thread 能去做別的事情.當 DoSomethingAsync() 完成之後,等待器將會決定該如何進行下一步.以預設的動作來說,它會讓 UI thread 過來繼續往下執行,所以 int b = 2 是 UI thread 所執行的.

不論是那一種情況,都是值得討論的.首先,你需要明白的是在 await DoSomethingAsync() 之後由那一個 thread 來執行對你的程式有差嗎 ? 如果你寫的是 Console 程式或是 ASP.NET Core 程式,一般來說沒有什麼差別 (除非你有特殊情況).如果你寫的是 WinForm/WPF/ASP.NET 4.x 的程式,通常來說會有差別.有沒有差別的原因在於 await DoSomethingAsync() 之後還需不需要和 UI 互動.如果不需要和 UI 互動的話,最好就不要用 UI thread 來執行.如果需要和 UI 互動的話,那得再視情況來決定是否要用 UI thread 或其他 worker thread 繼續執行.

如果 DoSomethingAsync() 真的做了一些 I/O,例如讀寫檔案或是發送 HTTP request 等,如前面說的,在 I/O 發行時,Task 已經 Yield 了,所以 UI thread 是自由的.此時你對 UI 畫面做一些動作,如移動視窗等,這些動作都可以進行,這表示 UI thread 有空幫你完成了那些動作.如果 DoSometingAsync() 並沒有做真的做 I/O,只是做了一些數學運算或甚至是 Thread Sleep(),此時 Task 並沒有 Yield,因此 UI thread 仍在忙於做數學運算或在做 Sleep(),此時你做了移動視窗的動作時,你就會發現整個 UI 介面是沒有反應.也許你會問同樣都是非同步呼叫,怎麼結果不一樣.這若要再講下去就得講到 Windows Kernel 的部份,所以先在此打住.不論是那一種情況,DoSomethingAsync() 總是會完成 (沒有別的意外的話),完成之後,接下來是由那一個 thread 來執行後面的程式碼,這將是由等待器來決定.

當程式執行到 await DoSomethingAsync() 時,等待器建立起來後,此時等待器已經記錄 thread 的 SynchronizationContext (如果有的話).WinForm/WPF/ASP.NET 4.x 有 SynchronizationContext,Console 程式/ASP.NET Core 沒有 SynchronizationContext.這東西是什麼用途呢 ? 簡單的說,這是 UI thread 特別有的 (預設上),好讓其他 thread 方便和 UI thread 互動,也就是讓其他的 thread 呼叫 UI thread 去做更新畫面的動作.其實說穿了就是一堆 delegate 的運用.所以,UI thread 執行了 await DoSomethingAsync(); ,等這完成後,等待器就會讓 UI thread 繼續執行,因為等待器有了 UI thread 的 SynchronizationContext,等待器會將後面的程式碼包成一個 delegate 送給 UI thread SynchronizationContext 來執行.如果是其他的 worker thread 執行了 await DoSomethingAsync(),預設上來說,worker thread 的 SynchronizationContext 是空的,所以當 DoSomethingAsync() 完成後,等待器則將後續的程式碼丟給 ThreadPool 來處理,所以 ThreadPool 就會依它自己的演算法來決定用那一個 worker thread 來執行後續的程式碼,有可能還是剛剛的 worker thread,也有可能不是,完全由 ThreadPool 來決定.

上一段的說明讓你明白非同步的程式碼放在 WinForm/WPF/ASP.NET Core 與 Console/ASP.NET 4.x 時,你會面臨到不同的狀況.這完全是等待器所導致的結果.

接下來講到這篇文章要講的事情 ConfigureAwait().看此 method 的名字,你就知道它是用來設定等待器.這是用來設定等待器的種類.如果你輸入的是 ConfigureAwait(true) ,這是建立預設的等待器,此等待器會記錄 calling thread 的 SynchronizationContext.當 SynchronizationContext 不為空時,後續的程式碼將透過 SynchronizationContext 的 Post() 來完成,換句話話,執行後續程式碼的 thread 和 calling thread 是同一個.當 SynchronizationContext 為空時,後續程式碼將由 ThreadPool 來決定用那一個 worker thread 來執行.

如果輸入的是 ConfigureAwait(false),這是一種 type 為 ConfiguredTaskAwaiter,這種等待器會將後續程式碼一律丟給 ThreadPool 來決定用那一個 worker thread 執行,跟 calling thread 的 SynchronizationContext 存不存在完全沒有關係.換句話說,這種等待器少了和 SynchronizationContext 互動的程式碼.

因此,為了擠出那麼點的效能,如果後續程式碼不需要和 UI 互動,那麼在非同步程式碼後面加上 ConfigureAwait(false) 是有好處的.程式碼就變成

await DoSomethingAsync().ConfigureAwait(false);

文章寫到這邊希望能幫助你了解為何 ConfigureAwait(false) 能幫助你擠出那麼點效能,也讓你明白什麼情況下去使用它.

最後,如果 await 之後的程式碼仍有些地方需要和 UI 互動,那就不使用 ConfigureAwait(false) 了嗎 ? 這個答案還得取決於後續的程式碼做了些什麼.如果後續的程式碼沒有什麼運算了,只是簡單的 UI 更新而己,那麼就不用使用 ConfigureAwait(false).反之,如果仍有其他可觀的程式碼可以讓 woker thread 執行且讓 UI thread 可以去反應 UI 的動作,則還是把 ConfigureAwait(false) 加上去.等到你需要更新 UI 時,再透過 UI Control 的 BeginInvoke() 或是用 UI thread 的 SynchronizationContext Post() 去進行更新 UI 畫面的動作.如果你是 Visual Studio extension 的開發者,在 VS SDK 裡面有個 JoinableTaskFactory class,它有個 SwitchToMainThreadAsync() 可以幫你快速地從 worker thread 切換到 UI thread 以便進行更新畫面的動作.

Hope it helps,

後記: ASP.NET Core 基本上不用去擔心和 UI 互動是不是會發生 deadlock 的問題.ASP.NET Core 已經沒有 AspNetSynchronizationContext 的設計了,所以任何的非同步呼叫基本上都可以一直採用 ConfigureAwait(false) 來幫你擠出那麼點效能出來.