Skip to main content

正確理解 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();
}
}
  1. 呼叫 MyController 的 Get( ), 假定此時為thread1, 執行環境為 AspnetSynchronizationContext
  2. 呼叫 GetJsonAysnc()
  3. 在 GetJsonAysnc() 遇到 await, 確認 client.GetStringAsync 任務尚未完成,捕捉當下的 SynchronizationContext,直接回傳未完成任務。
  4. thread 1繼續執行,遇到 jsonTask.Result,將 thread 1 block住,直到 jsonTask 完成為止。
  5. thread 2 完成 client.GetStringAsync,並接續當時捕捉到的 AspnetSynchronizationContext,嘗試 Post 繼續並以thread 2 繼續執行。
  6. 但 AspNetSynchronizationContext 會確保一次只有一個thread做一件事,完成後才能做下一件事。thread 1的事情尚未做完,因為他被未完成的 jsonTask 完成 block 住。
  7. 但 thread 2 必須等 捕捉到的context 做完上一件事,才能繼續執行後續使GetJsonAsync Task完成,雖然他們是不同的thread,但依然發生死鎖。

Reference

如何避免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 會阻止你)

  1. await 會讓 compiler 幫你生成狀態機,捕捉當下的Context並返回,但返回後並沒有保證一定是相同的 thread,而很多 lock 的設計是只能由相同的 thread 釋放。這會讓 lock 很難做人。
  2. 在 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。寫完之後就不用回答同事的問題惹。