5

重新認識 C# [5] - C# 5,走向非同步時代

 1 year ago
source link: https://blog.darkthread.net/blog/cs-in-depth-notes-6/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

C# 5,走向非同步時代-黑暗執行緒

【本系列是我的 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. 執行更多工作

在 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 時有兩種情況,已完成或等結果(先返回):

Fig1_638022122280943473.png

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));
        }
    }
}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK