0%

【C#】 關於 Async 的三兩事

說來真的好久沒有寫文章了,除了工作很忙之外,目前碰到的問題也通常不是一兩篇文章可以交代的清,所以也就更新部落格的時間也就間隔越來越長了。

這次主要是想把最近針對 Async 的一些測試研究記錄下來,那我們就開始吧 !

# Async 是什麼?

Async 在 C# 語言中用來支援非同步處理的一種語法,而它的使用往往搭配 Await 一起使用,先來看看一段簡單的程式碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Program
{
static async Task Main(string[] args)
{
Console.WriteLine($"1. Hi I'm Async Demo");

//拿到非同步的任務
var task = DoAsync();

//繼續執行
Console.WriteLine($"2. Hello World!");

//等待非同步任務執行完成
await task;
Console.WriteLine($"4. End!");
}

public static async Task DoAsync()
{
//碰到 await 時,會將控制項回傳給呼叫端,並且等待非同步的方法執行完成
await Task.Run(()=>
{
Thread.Sleep(TimeSpan.FromSeconds(5));
Console.WriteLine($"3. Async method Done");
});
}
}

這段程式碼執行後可以看到以下結果

1
2
3
4
1. Hi I'm Async Demo
2. Hello World!
3. Async method Done
4. End!

觀察執行緒的切換

接著加上執行緒 Id 看看,讓我們更清楚執行緒之間是如何切換的

/images/20200919/1.png

執行結果

1
2
3
4
[1] 1. Hi I'm Async Demo
[1] 2. Hello World!
[4] 3. Async method Done
[4] 4. End!

執行流程

  • 執行緒_1 在 11 行印出了 [1] 1. Hi I'm Async Demo

  • 執行緒_1 在 14 行進入了 DoAsync 的方法中

  • 執行緒_1 在 27 行碰到 Task.Run 開啟了非同步執行的方法,並因為 await 跳出了這個 DoAsync()

  • 執行緒_1 在 16 行碰到印出了 [1] 2. Hello World!

  • 執行緒_1 在 19 行碰到 await task ,開始等待 task 執行完成,執行緒_1 釋放回到 Thread Pool

  • 執行緒_4 在非同步方法的第 30 行 印出了 [4] 3. Async method Done ,並通知 await task 執行完成

  • TaskScheduler(通常,這會是以執行緒集區為目標的預設工作排程器)依據最有效率的判斷,讓 執行緒_4 往下執行未完的部分

  • 執行緒_4 接手繼續將 await 之後還沒做完的工作執行完成,意即 21 行,印出 [4] 4. End!

稍微修改一下程式…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Program
{
static async Task Main(string[] args)
{
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 1. Hi I'm Async Demo");

//強制將非同步方法改成同步
DoAsync().Wait();

Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 2. Hello World!");

Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 4. End!");
}

public static async Task DoAsync()
{
//碰到 await 時,會將控制項回傳給呼叫端,並且等待非同步的方法執行完成
await Task.Run(()=>
{
Thread.Sleep(TimeSpan.FromSeconds(5));
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 3. Async method Done");
});
}
}

執行結果

1
2
3
4
[1] 1. Hi I'm Async Demo
[4] 3. Async method Done
[1] 2. Hello World!
[1] 4. End!

這邊可以看到 執行緒_1 在執行到 await Task.Run(()=> …) 時跳出,但因為我們下了 Wait(),所以強迫 執行緒_1 進行同步的等待。

執行緒_4 印完 [4] 3. Async method Done 後,通知 執行緒_1 繼續往下進行,所以看到 執行緒_1 接著將剩下的程式跑完

差別在哪?

當呼叫非同步執行方法時,如果一路都是用 await 並不會造成任何執行緒被封鎖,換言之該執行緒還可以在別的地方繼續服務,一旦呼叫了 ResultWait() 這類的強制同步方法,則該執行緒會被封鎖,並等待到非同步方法執行完成後才繼續完成尚未完成的後續工作,這會嚴重消耗執行緒的使用效率。

我曾經對一個 Web API 專案進行壓測,在資源給得非常有限的情境下 (約 0.5 core cpu),await 搭配 Task.Run()Result 搭配 Task.Run ,兩個 RPS 測起來差了快一倍之多,在資源極度有限下,執行緒的使用效率將大大影響整體服務的效率。

# SynchronizationContext

SynchronizationContext 是用來記錄當前執行緒環境的類別,在 ASP.NET、WPF、WinForm 都有類似的類別只是名字可能有些差異,其最主要的目的都是在非同步方法中要能調用 UI 執行緒來更新介面之類的操作,而 SynchronizationContext 就紀錄著 UI 執行緒。

Deadlock

過去寫 ASP.NET 的時候曾經踩過一次 SynchronizationContext 的雷,在 ASP.NET 中如果如果呼叫 SynchronizationContext.Currnt 會發現並不為 Null,且型別是 AspNetSynchronizationContext ,當我們用上述的 Result / Wait() 搭配 await 將會導致 Deadlock。

原因是程式碰到 await 時會先判斷 SynchronizationContext.Currnt 是否為 Null,如果是則會在 Task 結束時呼叫 TaskScheduler (通常為 Thread Pool)來安排後續工作。反之,如果 SynchronizationContext.Currnt 不為 Null 時,就會透過 SynchronizationContext.Currnt 來繼續後續的動作。

1
2
3
4
5
6
//碰到 await 時,記住了 SynchronizationContext.Currnt
await Task.Run(()=>
{
Thread.Sleep(TimeSpan.FromSeconds(5));
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 3. Async method Done");
});

而 .Result / Wait() 的方法會封鎖住 SynchronizationContext.Currnt,造成兩邊互等的情況發生,產生了 Deadlock

1
DoAsync().Wait(); //封鎖 SynchronizationContext.Currnt 的物件等待 Task 完成

而這只會發生在 SynchronizationContext.Currnt 不為 Null 的系統中,像是前述提到的 ASP.NET、WPF、WinForm,在 Console Application 並不會發生。

解決方法 ConfigureAwait(false)

如果要避免上述所提到的 Deadlock ,可以在呼叫非同步方法時加上 ConfigureAwait(false),這樣非同工作完成時就會透過另一條執行緒繼續完成後續工作

1
2
3
4
await Task.Run(()=> 
{
....
}).ConfigureAwait(false);

在 ASP.Net Core 的時代…

在 ASP.Net core 呼叫 SynchronizationContext.Current 現在只會得到 Null ,換言之剛剛發生 deadlock 的情境已經不會發生,所以過往在非同步的地方常常要用 ConfigureAwait(false) 可以不用寫了,不過如果你是寫元件或 SDK 類的,並無法預測會被使用在怎樣的環境的話,建議還是都加上會比較保險。

# Best Practice

在使用 Async / await 等非同步技巧時,最好的方式還是都盡量使用非阻斷式的寫法 await ,避免使用 Result / Wait() ,即使你已經是寫 ASP.Net core 不會發生 Deadlock 的情況,阻斷式的寫法對於執行緒的使用效率來說還是會有影響的。