正確理解 C# async 與非同步
最近讀完 async await 狀態機的實踐後,腦袋突然某個開關打開了,彷彿撿到了航海圖,決定記錄下自己的理解,並方便之後解釋給其他同事
放隻貓咪當作preview
兩大重要觀念
非同步和多執行緒無關
這是很多人的盲區,常常提到非同步,就會想到多執行緒,然後大家腦袋就在打架,這裡是A thread在跑那裡是B thread在跑,這是新手工程師常見的抽象概念混淆,沒辦法乾淨的分離關注點。
- 同步,必須從頭到尾等待直到完成為止。
- 如果你是個同步的家庭主婦,洗衣服時,你必須等到衣服洗完才能繼續做其他事,不能去打電動看youtube。
- 非同步指的是一個任務可以處於尚未完成的狀態,之後再接續下去繼續完成
- 如果你是個非同步的家庭主婦,你可以啟動洗衣機之後,先去做其他事,之後等到洗衣機脫水完逼逼叫,再接續下去處理其他任務。
- 單執行緒
- 從頭到尾只有一個 thread 可以執行程式
- 多執行緒
- 有兩個以上的 thread 能夠執行程式
單執行緒同樣可以非同步地執行程式碼,瀏覽器上的 JS runtime 就是這樣設計的。從頭到尾就只有一個 JavaScript worker 在執行工作。遇到需要等待IO回應時,就先放下手邊做到一半的任務,先完成其他的工作。
同樣的工作量,當然越多人同時做,可以越快取得結果。非同步不會做得比較快,但是非同步讓你不用耗在等待回應上,而是可以利用等待時間去處理其他工作,效能也就跟著提升了。
任務,是非同步的抽象
這就是非同步的本質,對「任務」這件事進行抽象,讓我們可以表達一件事情是否做到一半、是否完成、接下來要繼續做什麼。在C#裡,我們用 Task 類別,在 JavaScript 裡我們用 Promise,每個支援非同步的程式語言都會有類似的設計,他們可能有不同的名字(Task, Promise, Future, Coroutine… ),但都是在表達非同步這件事。
注意到在討論非同步的時候,我們並不在乎
- 有多少人正在執行任務
- 任務是誰執行的
- 任務完成後接下來換誰執行
我們在乎
- 任務現在是否完成
- 任務是否做到一半
- 任務要做什麼
- 任務完成後接下來要做什麼
- 任務完成後的結果是什麼
C# Task
看看 C# 的 Task, constructor 開宗明義告訴我們,請定義這個 Task 要做什麼。
public Task (Action action);
TaskStatus 告訴你目前 Task 處於哪個狀態,Created, Running, WaitingToRun, RanToCompletion 等等
public TaskStatus Status { get; }
Task.ContinueWith 告訴你,這個 Task 完成後,接下來要做什麼
public Task ContinueWith (Action<Task> continuationAction);
我們省略掉許多其他複雜的欄位和多載,但 Task 的本質就是這麼一回事。
如果我們還想表達Task完成後會攜帶結果,我們可以用 Task 來表達
// constructor
Task<TResult>(Func<Object,TResult>, Object)
// property in Task<Result>
public TResult Result { get; }
實際上,C# Task 是個多才多藝的類別,他不僅乘載了非同步的概念,另外還有許多關於執行的方法。例如 Run(), Start(), Wait()等等。但我們暫且先不管誰去執行這個Task、執行完Task後該做什麼等,我們先專注在,Task能夠協助我們在C#中表達非同步的概念。
async 是實作細節
又是一個新手很容易跌進去的誤區,這句話很重要,請跟著我念三次。
- async 是實作細節,async 是實作細節,async 是實作細節。
大家有沒有注意到,其實只要有Task,就足以在程式碼中表達非同步的概念了。想要表達一件事情可以做到一半,只要回傳一個 Task 就可以了。和 async 關鍵字一點關係都沒有。
舉一個大家最常用的非同步的方法,HttpClient 的 GetAsync
public Task<HttpResponseMessage> GetAsync (Uri? requestUri);
他絕對是一個非同步方法,但是官網宣告文件上方法簽章上完全沒有async。
你不會在 C# 的任何一個介面上看到
public interface IRunnable
{
public async Task<Result> Run(); // 不會有這種事情, Compiler直接洗你臉
}
public interface IRunnable
{
public Task<Result> Run(); // 正確的寫法
}
因為 async 是非同步的實作細節,你不該也不需要在介面上定義實作細節。
async 在實作什麼?
我們講了那麼多遍,async 是實作細節,那 async 到底在幫你實作什麼?
當你將一個方法使用 async 進行修飾,這意味著你告訴 Compiler
- 我要在這個方法內進行一個非同步的任務
- 而且我需要在這個方法內等待某個非同步任務完成後,繼續進行之後的任務
如果今天沒有 async 關鍵字,你就必須要自己把任務做成一個一個的 Callback,並且把這些任務放到Task裡,然後不停的寫 ContinueWith。
有寫過 js 的人就知道 callback hell 的痛,你可以想像這件事情有多麻煩。你要不僅要應付一層一層的 continue,還要小心翼翼的處理例外。
但是當你寫了 async 時,Compiler 就會大聲說「別擔心,讓我替你處理各種麻煩事」。Compiler 會自動幫你生成一個狀態機。
async Task<int> SomeMethodAsync()
{
DoJob1();
var result = await DoJob2Async();
return DoJob3(result);
}
- 整個狀態機只有一個進入點 MoveNext,進入後直接執行 Job1 ,接著檢查Job2任務的狀態
- 如果 Job2 已經完成,就直接取得結果進行 Job3。
- 如果 Job2 尚未完成
- 改變狀態機的狀態,使下次再次進入狀態機時,不會再從頭進來一遍,而是取得 Job2 的結果,並繼續往下執行。而Job2Task還沒完成,Compiler 會生成程式碼,將目前的執行上下文,與狀態機的進入點,一同附加到Job2Task的後續( ContinueWith ),確保之後執行時,能夠保有相同的上下文。
- 介紹整個狀態機的流程過於冗長,但其實蠻有趣的。如果你想知道 Compiler 幫你生成了什麼,推薦你去裝 VisualStudio 的外掛,OpenSource 的 ILSpy,並記得取消勾選 Decompile async methods。搭配閱讀 C# in Depth。
有 async 必然有 await 相伴
這就是為什麼 async 裡面,一定要寫 await 的原因。不是因為 async 和 await 兩個湊一起好像一平一仄對仗工整,而是有了 await,Compiler 才知道非同步的斷點在哪裡,才有辦法幫你生成狀態機,當下次再進來繼續執行時,接續下去。如果你完全不需要寫await,Compiler 也不需要幫你生成狀態機,你的程式只要順順執行下去,那寫 async 幾乎是不必要的。
不要為了強調非同步方法去寫async,不是這樣用的。看你的方法回傳什麼,就可以知道你是不是非同步了。跟選舉投票一樣,看人家實際做了什麼,而不是看人家講什麼啊!
async await 替你做更多
實際上,await 替你做的不只這些。
- 當 await 的 task 拋出例外時,Compiler替你拆包,讓你可以直接捕捉實際的例外,而不是難懂的AggregationException
- 當回傳任務結果時,你只要直接return Result,Compiler替你包裝成Task of Result
- 當你在迴圈、if 判斷內await時,會自動生成對應的程式碼,使其能夠如預期般等待、繼續執行,並走向正確的邏輯分支。
儘管為求讓讀者順利理解,有些細節講的不是那麼精確。但大家對非同步應該已經有了基本的概念。現在我們把關注點放到另一個地方,誰來執行任務。
誰來執行
有些工程師會有很常見的迷思,例如 await 的任務一定是在另一個 thread 去做的。或是系統會開另一 個 thread 去做接下來的事情。這些都是錯誤的觀念,我們剛剛講了,async 和 誰負責做任務一點關係也沒有。
SynchronizationContext
真正和誰做任務有關的,是一個叫做 SynchronizationContext 的類別。
不同的程式,在執行上會有不同的需求。常見的案例是像 GUI 類型的程式,為了簡化 thread-safe 的問題,通常都會規定只能由相同的 thread 來操作 UI 畫面相關的元件,而且要確保 UI thread 的回應是快速簡短的,不然就會陷入這個程式無法回應。另外像是 Web 程式,不一定會需要確保整個 request 從頭到尾,都是由同一個 thread 處理該請求,但需要確保整個request從頭到尾,UI Culture是相同的。
儘管不同環境的程式有不同需求,但抽象來說,他們都想要做到同一件事情: 「確保執行的thread是正確的,或是攜帶必要的資訊」。
SynchronizatinoContext 就是負責幹這件事情的類別。這是個很巧妙的設計,應用程式不用在乎現在該由哪個 thread 呼叫才是正確的,而是讓 SynchronizationContext 負責安排適合的 thread 執行。
SynchronizationContext 身上有兩個最重要的方法,Send 與 Post,Send 讓呼叫者可以傳送一個要同步完成的委託,Post 讓呼叫者可以傳送一個非同步完成的委託
public virtual void Post (System.Threading.SendOrPostCallback d, object? state);
public virtual void Send (System.Threading.SendOrPostCallback d, object? state);
有很多不同的SynchronizationContext,像是
- WindowsFormsSynchronizationContext
- 出現在 Windows Form App,確保執行者是 UI Thread ,而且會按照呼叫順序執行
- DispatcherSynchronizationContext
- 出現在WPF 和 Silverlight,確保執行者是Dispatcher loop thread,並且按照呼叫順序執行
- Default (ThreadPool) SynchronizationContext
- 預設,會將Post進來的非同步任務交給ThreadPool去執行,而將Send進來的同步任務交給當前呼叫者的thread去執行。像是Console application,預設就是採用這種同步上下文。
- AspNetSynchronizationContext
- 這是比較複雜的Context,當Request進來時,執行他的thread A會攜帶特定Culture與Identity。接著如果遇到非同步的任務,會先捕捉當下的context,接著挑一個threadpool的thread B執行非同步的任務。接著thread A就沒事了(事情還沒做完但已經交給其他人做了),但是執行完成後,thread B要恢復當時捕捉的context繼續執行後續,但此context並不會讓當時的thread A繼續執行,而是交由thread B繼續完成後續的任務,可是context會協助讓thread B持有當時thread A所捕捉到的Culture與Identity,確保不會因為換thread執行,就遺失Culture Info。
- 這個Context是Exclusive的,一次只允許做一件事,避免發生reentrancy
同樣的程式本來就可能有不只一種 SynchronizationContext,而每個 SynchronizationContext 要怎麼交由適當的thread 去執行,那是他們自身的責任。關於更多 SynchronizationContext 的說明,請參閱 Stephen Cleary 的 Parallel Computing - It's All About the SynchronizationContext
而當你寫下 await 時,其實 Compiler 生成的程式碼預設會幫你捕捉進入 await 前當下執行時的SynchronizationContext,並設定若任務尚未完成,則把安排 await 後續的工作交給當時捕捉到的SynchronizationContext ,這個行為通常是我們想要的,因為對那些需要依賴 thread-affinity 相關的應用,才不會發生 await 做完回來後,世界變了,繼續執行的 thread 不是原本的 thread。
什麼時候需要設定ConfigureAwait(false)?
接下來我們談另一個重要的觀念,ConfigureAwait(false)。很多人對這個選項一知半解,google查來查去最多的是,如果你想防止deadlock,就要設定這個,但卻不知其所以然。
關於 task,我們還需要多知道兩個常用的方法與特性,一個是 Task 的 Wait()
, 一個是 Task 的 .Result
。這兩個方法都是用在等待任務完成,Wait 不會有回傳值,而 Result 會有任務完成的回傳結果。但不論是哪種方式,呼叫這兩個,都會造成當前的 thread 被 block 住,直到任務完成,才能繼續執行。
惡名昭彰的deadlock
public static async Task<JObject> GetJsonAsync(Uri uri)
{
var jsonString = await client.GetStringAsync(uri);
return JObject.Parse(jsonString);
}
public class MyController : ApiController
{
public string Get()
{
var jsonTask = GetJsonAsync(...);
return jsonTask.Result.ToString();
}
}
- 呼叫 MyController 的 Get( ), 假定此時為thread1, 執行環境為 AspnetSynchronizationContext
- 呼叫 GetJsonAysnc()
- 在 GetJsonAysnc() 遇到 await, 確認 client.GetStringAsync 任務尚未完成,捕捉當下的 SynchronizationContext,直接回傳未完成任務。
- thread 1繼續執行,遇到 jsonTask.Result,將 thread 1 block住,直到 jsonTask 完成為止。
- thread 2 完成 client.GetStringAsync,並接續當時捕捉到的 AspnetSynchronizationContext,嘗試 Post 繼續並以thread 2 繼續執行。
- 但 AspNetSynchronizationContext 會確保一次只有一個thread做一件事,完成後才能做下一件事。thread 1的事情尚未做完,因為他被未完成的 jsonTask 完成 block 住。
- 但 thread 2 必須等 捕捉到的context 做完上一件事,才能繼續執行後續使GetJsonAsync Task完成,雖然他們是不同的thread,但依然發生死鎖。
Reference
- Stephen Cleary: Don't Block on Async Code
- Stephen Cleary: SynchronizationContext Properties Summary
- 注意裏頭的AspNetSynchronizationContext,Synchronized Execution為true
- Stephen Cleary: Gotchas from SynchronizationContext
如何避免deadlock?
全程使用 await, 不寫 .Result 或 Wait()
不寫 .Result 或 Wait() ,就不會有 thread 被 block 住。不會佔用住 SynchronizationContext 資源。
public static async Task<JObject> GetJsonAsync(Uri uri)
{
var jsonString = await client.GetStringAsync(uri);
return JObject.Parse(jsonString);
}
public class MyController : ApiController
{
public Task<string> Get()
{
var json = await GetJsonAsync(...);
return json.ToString();
}
}
撰寫ConfigureAwait(false)
呼叫到 .Result 的 thread 被 block 住,但 await 時並不捕捉 SynchronizationContext,而是改由 DefaultContext 去執行,因此會由threadPool中挑一個 thread 繼續往下做。完成後,原本 .Result thread 就被解套了。
public static async Task<JObject> GetJsonAsync(Uri uri)
{
var jsonString = await client.GetStringAsync(uri).ConfigureAwait(false);
return JObject.Parse(jsonString);
}
public class MyController : ApiController
{
public string Get()
{
var jsonTask = GetJsonAsync(...);
return jsonTask.Result.ToString();
}
}
但使用這種作法時,要注意因為沒有 SynchronizationContext 替你恢復 UI Culture,你會在後續的 context 無法取用原本的 Culture。
不使用會 Synchronized Execution(同步執行) 的 Context
例如改為使用DefaultSynchronizationContext,會由 thread pool 繼續執行非同步的後續。
或是改用 Asp.Net Core。新的 Library 放棄使用了Synchronizatino Context,每次把任務推送到 Context 上執行是有成本的,這會讓效能更好,而且也不用擔心 deadlock。
https://blog.stephencleary.com/2017/03/aspnetcore-synchronization-context.html
不用擔心,UI Culture 依然能夠在 await 回來後繼續流動,這是由 CLR 控制的。請參閱 MSDN CultureInfo 文件 Culture and task-based asynchronous operations
總而言之
綜合以上幾點,我們可以導出兩個原則
- 寫應用端的程式的時候,你很清楚自己目前使用的 SynchronizationContext 是什麼,盡量總是全程使用 await,不要寫 .Result,因為可以減少 thread 被 block,效能會比較好。同時也減少 deadlock 的可能。另外 .Result 所拋出的例外會是 AggregationException,await 拋出的例外會是內層的例外,比較好讀。
- 寫 Library 的時候,你並不清楚 Library 的呼叫者是在什麼樣的 synchronization context 下。此時如果在Library裡面寫await 把 Context 捕獲住,使用 Library 的人如果寫了 .Result,就會產生 dead lock 。因此你必須要在 Library 內遇到await 之處,都要寫上 .ConfigureAwait(false),避免捕獲當前的 Context。
// Library (不良的實作)
public static async Task<JObject> GetJsonAsync(Uri uri)
{
var jsonString = await client.GetStringAsync(uri);
return JObject.Parse(jsonString);
}
public class MyController : ApiController
{
public string Get()
{
// Library user 寫了.Result就導致了deadlock
var jsonTask = GetJsonAsync(...);
return jsonTask.Result.ToString();
}
}
我看到有人寫task.GetAwaiter().GetResult(),這樣好嗎?
半斤八兩,只比寫.Result的人好一咪咪。
如果 Task 已經完成,那麼結果和 Task.Result 一模一樣。但如果 Task 拋出例外的時候,task.Result 會回傳 AggregationException,但GetAwaiter().GetResult()會傳傳內部第一個 Exception。
最好的情況下,還是盡量一路走來,始終如一的 await。C# 的官方文件有建議,GetResult() 並不是設計讓你取得結果的,而是讓 .net 基礎環境使用的。但我也沒有強烈反對你這樣做的理由,畢竟省去一個解析 AggregationException 還是比較輕鬆的。
Reference: Stephen toub Async/Await FAQ
那些你可能有興趣的更多細節
我們剛剛一直用 Task 來描述非同步,我們談到 task 可以被 await,也可以接續下去繼續執行。這是出於讓讀者方便理解的考慮。
實作了GetAwaiter() 就可以被等待
實際上,並不是 task 才能夠被 await (但對95%以上的人來說,知道這樣就夠了)
而是滿足下列條件的型別 T,都可以被 await
- T 需要具備一個無參數的instance GetAwaiter() 方法,不管這個方法是擴展方法還是類別方法,必須返回一個 awaiter 型別的物件。
- awaiter 是等待器,必須要實作
- INotifyCompletion 介面上頭有 void OnCompleted(Action)
- bool 屬性 IsCompleted
- GetResult(),這個方法回傳的結果代表等待器等待完成後回傳的結果
Task 與 Task 完全符合上述的條件,
- 具備 GetAwaiter()
Task
public System.Runtime.CompilerServices.TaskAwaiter GetAwaiter ();
Task
public System.Runtime.CompilerServices.TaskAwaiter<TResult> GetAwaiter ();
- TaskAwaiter與TaskAwaiter都實作了GetResult()和INotifyCompletion
因此其實你可以設計自己的 Task ,另外 C# 7也多了 ValueTask 的類別,只是不常用就是了。
Awaiter 是啥鬼?
Task.GetAwaiter() 回傳的 awaiter 被用在 Compiler 生成的狀態機裡面。awaiter 所實做的介面可以做到幾件事
- 判斷 awaiter 是否完成 (IsComplete)
- 替 awaiter 添加後續要做的事 (OnCompleted)
- 獲得 awaiter 的結果 (GetResult)
這三件事情其實就是執行時會用到的, 當程式在執行時遇到 await ,會走進 Compiler 生成的狀態機,並捕捉 awaiter ,判斷是否完成,如果完成就取得結果並回傳,如果未完成,就添加後續任務並將回傳尚未完成的task。
Reference: https://livebook.manning.com/book/c-sharp-in-depth-third-edition/chapter-15/172
不能在 lock 內使用 await (Compiler 會阻止你)
- await 會讓 compiler 幫你生成狀態機,捕捉當下的Context並返回,但返回後並沒有保證一定是相同的 thread,而很多 lock 的設計是只能由相同的 thread 釋放。這會讓 lock 很難做人。
- 在 await 中使用 lock 意味著會讓鎖持有很長一段時間,這通常是不良的設計。如果真的有必要使用,那請改用SemaphoreSlim的WaitAsync()代替
為什麼 Task 拋出例外時,會回傳 AggregationException
因為 Task 裡面可能由多個子 Task 組成,每個子 Task 可能都會拋出例外,而母 Task 就要 透過更大的例外把所有的子例外都包起來。
而當你寫 await Task 時,Compiler 會自動幫你拆掉外層的 AggregationException ,改為拋出內層的例外,這樣寫起來比較直覺。
public async Task<ActionResult> About()
{
try
{
await DoAsync();
}
catch (InvalidOperationException e)
{
//e is QQQ, not AggregationException
}
return View();
}
public async Task DoAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("QQQ");
}
結語
以上就是在撰寫 Async, Await 等非同步程式碼需要知道的基礎知識,盡可能將關注點分離,讓讀者可以理解該怎麼正確使用 async 和 await。寫完之後就不用回答同事的問題惹。