【本系列是我的 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();
}

背後的動作會是

  1. 執行一些工作
  2. 啟動非同步作業並記下它所傳回的 Token (Task 或 Task<TResult>)
  3. 也許再做一些其他工作 (一般必須等非同步作業做完邏輯才能繼續,故此步驟常常是空的)
  4. 等待非同步作業完成(藉由 Token)
  5. 執行更多工作
  6. 完成

在 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> 發生錯誤時的處理:

  1. Status 變成 Faulted,IsFaulted = true
  2. Exception 傳回 AggregateException 包含造成失敗的例外
  3. 當 Task 變成 Faulted,Wait() 抛出 AggregateException
  4. 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();
}

一些非同步小訣竅:

  1. 若不堅持用原 Context Thread 執行,建議加 ConfigureAwait(false),效能較好,還可避免 Deadlock
  2. 儘量讓多個 Task 並行(參見前面 AddPayment 範例)
  3. 避免同步與非同步程式交錯
  4. 儘可能允許取消
  5. 非同步程式測試不好做,可善用 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 阿豪

了解了 謝謝黑大的說明

Post a comment