重新認識 C# [5] - C# 5,走向非同步時代
3 |
【本系列是我的 C# in Depth 第四版讀書筆記,背景故事在這裡】
C# 5 帶來非同步函式 Asynchronous Function 的概念 - 加了 async 修飾詞的方法或匿名函式、Lambda Expression,並在其中使用 await 運算子執行 Await Expression。
常目用的應用場景,WinForm 按鈕後下載網站內容:
public AsyncIntro()
{
//...
button.Click += DisplayWebSiteLength;
}
// 如果是傳統同步做法,用 WebClient 也能完成,但下載期間畫面會凍結
void DisplayWebSiteLength(object sender, EventArgs e)
{
var wc = new WebClient();
string text = wc.DownloadString("http://csharpindepth.com");
label.Text = text.Length.ToString();
}
// 非同步函式
async void DisplayWebSiteLength(object sender, EventArgs e)
{
label.Text = "Fetching...";
// 等待下載過程,這段程式先暫停,表單的其他邏輯可以繼續執行
string text = await client.GetStringAsync("http://csharpindepth.com");
// 下載完成再繼續跑這段,而且是用 UI Thread
label.Text = text.Length.ToString();
}
防止表單跑耗時動作期間畫面凍結不是難事,另開一條 Thread 執行就可以了,但要加上 .Invoke() 或 .BeginInvoke() 避免 UI Thread 限制,async/await 省去這些額外要求,用直覺又簡潔的寫法達成相同效果。
試著將 Await Expression 拆解開
async void DisplayWebSiteLength(object sender, EventArgs e)
{
label.Text = "Fetching...";
Task<string> task = client.GetStringAsync("http://csharpindepth.com");
// 排定一個 Continuation 作業,等 task 完成後觸發 (Task.ContinueWith)
// Continuation 中會使用 Control.Invoke()
string text = await task;
label.Text = text.Length.ToString();
}
背後的動作會是
- 執行一些工作
- 啟動非同步作業並記下它所傳回的 Token (Task 或 Task<TResult>)
- 也許再做一些其他工作 (一般必須等非同步作業做完邏輯才能繼續,故此步驟常常是空的)
- 等待非同步作業完成(藉由 Token)
- 執行更多工作
- 完成
在 C# 4 我們也能自己拼湊出上述流程,甚至用 Task.Wait() 便能同步化(很浪費 Thread 資源,像是訂好 Uber Eats 站在門口等東西送來)。
針對 UI Thread 限制,C# 5 借用 .NET 2.0 BackgroundWorker 在用的 SynchronizationContext,確保用適當的 Thread 執行委派。
作者提醒:書中範例有些 Task.Wait()/Task.Result,在 Console 跑沒啥問題,在 Web/WinForm 使用可能產生 Deadlock,要小心。(我有遇過:await 與 Task.Result/Task.Wait() 的 Deadlock 問題)
async 方法的傳回值可以是 void、Task、Task<TResult> 三者之一,void 是為了跟事件委派相容,其他場合不建議使用。延伸閱讀:使用 .NET Async/Await 的常見錯誤
async 方法的參數不可以是 out 或 ref。
await 的對象型別 T 必須符合 Awaitable Pattern,它沒有統一介面規範,而是進行以下檢查:
- T 必須有個 GetAwaiter() 方法,並傳回 Awaiter Type
- Awaiter Type 要實作 System.Runtime.INotifyCompletion,有 void OnCompleted (Action) 方法
- Awaiter Type 必須有 bool IsCompleted
- Awaiter Type 必須有 GetResult()
- 以上方法不一定要 public,但必須讓 async 方法存取得到
掌握以上原則,可自製一個支援 await 的物件:
public class Task
{
public static YieldAwaitable Yield();
}
public struct YieldAwaitable
{
public YieldAwaiter GetAwaiter();
public struct YieldAwaiter : INotifyCompletion
{
public bool IsCompleted { get; }
public void OnCompleted(Action continuation);
public void GetResult();
}
}
public async Task ValidPrintYieldPrint()
{
Console.WriteLine("Before yielding");
await Task.Yield();
// 以下寫法無效,因為 GetResult() 傳回 void,不能用變數接
var result = await Task.Yield();
Console.WriteLine("After yielding");
}
小故事:GetAwaiter() 被寫成擴充方法而非直接加進 Task/Task<TResult> 是為了讓 C# 4 開發者透過 NuGet 安裝就能使用 async/await。
await 只能在 async 函式使用,不支援在 unsafed 區塊中使用,也不能用 lock 包住它(實務上不該搞出此等矛盾設計,真有必要請用 SemaphorSlim.WaitAsync())。C# 5 還不允許將 await 包在 try ... catch/finally 中(但 try ... finally 可以),但 C# 6 改良了狀態機,此限制已告解除。
//寫法一
AddPayment(await employee.GetHourlyRateAsync() *
await timeSheet.GetHoursWorkedAsync(employee.Id));
//寫法二
Task<decimal> hourlyRateTask = employee.GetHourlyRateAsync();
decimal hourlyRate = await hourlyRateTask;
Task<int> hoursWorkedTask = timeSheet.GetHoursWorkedAsync(employee.Id);
int hoursWorked = await hoursWorkedTask;
AddPayment(hourlyRate * hoursWorked);
//寫法三 可讀性跟效能好,理由是 GetHourlyRateAsync()、GetHoursWorkedAsync() 可平行執行
//在寫法二 awaut GetHourlyRateAsync() 必須等結果才開始 GetHoursWorkedAsync()
Task<decimal> hourlyRateTask = employee.GetHourlyRateAsync();
Task<int> hoursWorkedTask = timeSheet.GetHoursWorkedAsync(employee.Id);
AddPayment(await hourlyRateTask * await hoursWorkedTask);
非同步行為中的返回(Return)跟完成(Complete)行為不容易解釋,過程中會返回多次,以 EatPizzaAsync 作闢喻,包含打電話、領外送、等 Pizza 涼到吃完它,每個動作後都會返回,但直到吃完才算完成。
await 時有兩種情況,已完成或等結果(先返回):
Task/Task<TResult> 發生錯誤時的處理:
- Status 變成 Faulted,IsFaulted = true
- Exception 傳回 AggregateException 包含造成失敗的例外
- 當 Task 變成 Faulted,Wait() 抛出 AggregateException
- Task<TResult>.Result 拋出 AggregateException
Task 支援 CancellationTokenSource 及 CancellationToken,取消時也會丟出包含 OperationCanceledException 的 AggregateException。
await 時若遇到 Task 拋出例外,呼叫端會得到 AggregateException 的第一個例外,而非 AggregateException,如此較貼近一般同步化程式行為,使用起來較方便。(若想檢查完整例外資訊,可查 Task.Exception)
整理 async 流程重點:
- async 通常在完成前會先返回
- 每次遇到 await 對象還沒完成的話就返回
- async 方法回傳會是 Task 或 Task<TResult> (C# 7 再增加自訂 Task 型別選擇)
- Task 負責指出 async 方法何時完成,完成時狀態變成 RanToCompletion,Result 有結果;若出錯,狀態變 Faulted 或 Canceled,Exception 屬性為包了實際例外的 AggregateException
- 將 Task 轉為上述終止型狀態,執行先前指定的 Continuation 作業
Task 若有錯誤要等到 await 才發現,這對參數檢查來說不太 OK。解法:另建一個同步方法檢查參數後傳回 Task。(待 C# 7 再介紹用區域方法進一步簡化)
static async Task MainAsync()
{
// text 參數不允許 null,但仍能順利取得 Task<int>
Task<int> task = ComputeLengthAsync(null);
Console.WriteLine("Fetched the task");
int length = await task; // 這時才回報參數是 null
Console.WriteLine("Length: {0}", length);
}
static async Task<int> ComputeLengthAsync(string text)
{
if (text == null) throw new ArgumentNullException("text");
await Task.Delay(500);
return text.Length;
}
// 解法
// 非 async 方法,檢查參數
static Task<int> ComputeLengthAsync(string text)
{
if (text == null)
{
throw new ArgumentNullException("text");
}
return ComputeLengthAsyncImpl(text);
}
static async Task<int> ComputeLengthAsyncImpl(string text)
{
await Task.Delay(500);
return text.Length;
}
Task 取消原理:建立一個 CancellationTokenSource 產生 CancellationToken,作業時檢查 ThrowIfCancellationRequested(),若 CancellationTokenSource 決定取消,會拋出 OperationCanceledException。
Race Condition 考量
static async Task ThrowCancellationException()
{
throw new OperationCanceledException();
}
...
Task task = ThrowCancellationException();
Console.WriteLine(task.Status); //會看到 Canceled 而非 Faulted
建完 Task 完上檢查 task.Status 會不會有 Race Condition? (讀取時狀態時,另一條 Thread 還沒更新好),答案是不會。要記得,沒有 await 前是用同一條 Thread 同步執行。
非同步匿名函式 Asynchronous Anonymous Function
Func<Task> lambda = async () => await Task.Delay(1000);
Func<Task<int>> anonMethod = async delegate()
{
Console.WriteLine("Started");
await Task.Delay(1000);
Console.WriteLine("Finished");
return 10;
};
C# 7 帶來了 ValueTask<TResult>,與 Task<TResult> 相比的好處是 Value Type 在記憶體管理上較輕巧,雖然效能提升微小,在某些極端情境下會有幫助。書中有個以 byte 為單位讀取 Stream 的範例,每次先取 8KB 放在 byte[] buffer,8K 讀完再 await Stream.ReadAsync() 讀下面 8K;由於大部分狀況都是由 buffer 取,不用 await,故讀 byte 時 Task 已經完成,這種案例下用 ValueTask<TResult> 效能比較好。Google.Protobuf 的 CodedInputStream 有類似的設計。極罕見的案例是透過 System.Runtime.CompilerServices.AsyncMethodBuilderAttribute 自訂 Task,實作狀態機。
C# 7.1 開始支援 static async Task Main(),允許在 Main 中 await。做法是 Compiler 在背後幫你包一層非同步轉同步。
static void <Main>()
{
Main().GetAwaiter().GetResult();
}
一些非同步小訣竅:
- 若不堅持用原 Context Thread 執行,建議加 ConfigureAwait(false),效能較好,還可避免 Deadlock
- 儘量讓多個 Task 並行(參見前面 AddPayment 範例)
- 避免同步與非同步程式交錯
- 儘可能允許取消
- 非同步程式測試不好做,可善用 Task.FromResult, Task.FromException, Task.FromCanceled, TaskCompletionSource<TResult>
其他 C# 5 改良:
- foreach 變數捕捉
List<string> names = new List<string> { "x", "y", "z" };
var actions = new List<Action>();
for (int i = 0; i < names.Count; i++)
{
actions.Add(() => Console.WriteLine(names[i]));
}
foreach (Action action in actions)
{
action(); //C# 4- 會得到 z,z,z,C# 5 為 x,y,z
}
// 改用 for 的就不行了(C# 5 只改了 foreach,for 的行為不變)
for (int i = 0; i < names.Count; i++)
{
actions.Add(() => Console.WriteLine(names[i]));
}
foreach (Action action in actions)
{
action(); // 會發生索引超出範圍,因為 i 最後等於 3
}
- Caller Information Attributes
取得呼叫端檔案、行數、方法名稱
//註:dyanamic 時抓不到
//註:contructor、finalizer、operator、indexer、field/event/poperty Initializer 不適用
static void ShowInfo(
[CallerFilePath] string file = null,
[CallerLineNumber] int line = 0,
[CallerMemberName] string member = null)
{
Console.WriteLine("{0}:{1} - {2}", file, line, member);
}
static void Main()
{
ShowInfo();
ShowInfo("LiesAndDamnedLies.java", -10);
}
- 用 Caller Information 可簡化 INotifyPropertyChanged
class OldPropertyNotifier : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private int firstValue;
public int FirstValue
{
get { return firstValue; }
set
{
if (value != firstValue)
{
firstValue = value;
//以前要傳屬性名
NotifyPropertyChanged("FirstValue");
//C# 5 簡化
NotifyPropertyChanged();
}
}
}
// (Other properties with the same pattern)
private void NotifyPropertyChanged([CallerMemberName] string propertyName)
{
PropertyChangedEventHandler handler = PropertyChanged;
if (handler != null)
{
handler(this, new PropertyChangedEventArgs(propertyName));
}
}
}
My notes for C# in Depth part 6
Comments
# by 阿豪
建完 Task 完上檢查 task.Status 會不會有 Race Condition? (讀取時狀態時,另一條 Thread 還沒更新好),答案是不會。要記得,沒有 await 前是用同一條 Thread 同步執行。 黑大好,對上面這句有些疑問,就我用win form 實驗的結果比較像是 await 前是不同Thread,到 await的時候Thread會等該方法的Thread回來 不知道我的認知是不是有誤? P.S.我把Method變成非同步的流程是將原Method改成用Task.Factory.StartNew()的方法, private async void button1_Click(object sender, EventArgs e) { var value = await this.GetValueAsync(); this.label1.Text = value; } string GetValue() { return Guid.NewGuid().ToString(); } Task<string> GetValueAsync() { return Task.Factory.StartNew(() => { return this.GetValue(); }); }
# by Jeffrey
to 阿豪,寫成 async Task<string> GetValueAsync() 時,Compiler 會將其轉換成一大段實作 State Machine 設計模式的程式,其行為不能以 Task.Factory.StartNew() 測試結果推斷。
# by 阿豪
了解了 謝謝黑大的說明