1. 程式人生 > >[翻譯]用一個用戶場景來掌握它們

[翻譯]用一個用戶場景來掌握它們

cli 隱式 正常 改變 開始 等待 異步 什麽 解釋

翻譯自一篇博文,原文:One user scenario to rule them all

異步系列

  • 剖析C#中的異步方法
  • 擴展C#中的異步方法
  • C#中異步方法的性能特點。
  • 用一個用戶場景來掌握它們

c#中異步方法的幾乎所有重要行為都可以基於一個用戶場景進行解釋:盡可能簡單地將現有的同步代碼遷移到異步。你應該能在方法的返回類型前面加上async關鍵字,在方法名最後加上Async後綴,在方法內部加上一些await關鍵字,就能得到一個功能完整的異步方法。

技術分享圖片

這個“簡單”場景以許多不同的方式極大地影響異步方法的行為:從調度任務的延續到異常處理。這個場景聽起來似乎很合理,也很重要,但它使異步方法背後的簡單性變得非常具有欺騙性。

同步上下文(synchronization context)

UI開發是上面提到的場景特別重要的領域之一。UI線程中的耗時較長的操作使應用程序無法響應,而異步編程一直被認為是一個很好的解決方法。

private async void buttonOk_ClickAsync(object sender, EventArgs args)
{
    textBox.Text = "Running.."; // 1 -- UI Thread
    var result = await _stockPrices.GetStockPricesForAsync("MSFT"); // 2 -- Usually non-UI Thread
    textBox.Text = "Result is: " + result; //3 -- Should be UI Thread
}

這段代碼看起來十分簡單,但我們現在有一個問題。大多數UI框架都有只有專門的UI線程可以改變UI元素的限制。這意味著如果第三行代碼是在線程池上的線程被調度的任務延續,它將失敗。幸運的是,這個問題相對較老,從.NET Framework 2.0開始,就引入了同步上下文的概念。

每一個UI框架都為將代碼在專用UI線程上執行提供了特殊的實用工具。Windows Forms依靠Control.Invoke,WPF依靠Dispatcher.Invoke,而其他UI框架可能依靠其他什麽東西。這個概念在所有的情況下都是相似的,但是底層的細節是不同的。同步上下文把差異抽象掉,並提供一個API用於在“特殊”的上下文中執行代碼,將細節留給派生類,如WindowsFormsSynchronizationContext

DispatcherSynchronizationContext

為了解決線程關聯問題,C#語言作者決定在異步方法的開頭捕獲當前同步上下文,並將所有延續調度到所捕獲的上下文中。現在,await語句之間的每個代碼塊都在UI線程中執行,這使得主場景成為可能。但解決方案也帶來了一系列其他挑戰。

死鎖

讓我們來審核一段相對較簡單的代碼。你能看出其中的問題嗎?

// UI code
private void buttonOk_Click(object sender, EventArgs args)
{
    textBox.Text = "Running..";
    var result = _stockPrices.GetStockPricesForAsync("MSFT").Result;
    textBox.Text = "Result is: " + result;
}
 
// StockPrices.dll
public Task<decimal> GetStockPricesForAsync(string symbol)
{
    await Task.Yield();
    return 42;
}

這段代碼會造成死鎖。UI線程調用了一個異步方法,並且同步地等待它的結果。但是那個異步方法卻不能完成,因為它的第二行必須在UI線程下執行,從而造成死鎖

你可能會說,這個問題比較容易發現,我同意你的觀點。在UI代碼中,任何對Task.ResultTask.Wait的調用都應該被禁止。但是如果UI代碼依賴的組件仍然同步地等待一個異步操作的結果,那麽問題依然是可能存在的:

// UI code
private void buttonOk_Click(object sender, EventArgs args)
{
    textBox.Text = "Running..";
    var result = _stockPrices.GetStockPricesForAsync("MSFT").Result;
    textBox.Text = "Result is: " + result;
}
 
// StockPrices.dll
public Task<decimal> GetStockPricesForAsync(string symbol)
{
    // We know that the initialization step is very fast,
    // and completes synchronously in most cases,
    // let‘s wait for the result synchronously for "performance reasons".
    InitializeIfNeededAsync().Wait();
    return Task.FromResult((decimal)42);
}
 
// StockPrices.dll
private async Task InitializeIfNeededAsync() => await Task.Delay(1);

這段代碼也會導致死鎖。現在,C#中兩個“眾所周知的”異步編程最佳實踐應該讓你更明白了:

  • 不要通過Task.Wait()Task.Result阻塞異步代碼。
  • 在類庫代碼中使用ConfigureAwait(false)

上述第一條建議已經明了,現在我們解釋另一條。

Configure "awaits"

上一個例子中有兩個造成死鎖的原因:在GetStockPricesForAsync中Task.Wait()的調用是阻塞的,以及在InitializeIfNeededAsync中對任務延續的調度隱式地捕獲了同步上下文。盡管C#作者不鼓勵在異步方法中使用阻塞調用,但在很多情況下這種情況可能會發生。為了解決死鎖問題,C#語言作者提出了解決方案:Task.ConfigureAwait(continueOnCapturedContext:false)

public Task<decimal> GetStockPricesForAsync(string symbol)
{
    InitializeIfNeededAsync().Wait();
    return Task.FromResult((decimal)42);
}
 
private async Task InitializeIfNeededAsync() => await Task.Delay(1).ConfigureAwait(false);

如此一來,Task.Delay(1)的任務延續(在這個例子中也就是空語句)是在一個線程池的線程中被調度的,而不是在UI線程中,於是解決了死鎖問題。

分離(detach)同步上下文

我知道ConfigureAwait是解決這個問題的實際辦法,但我發現它有一個很大的問題。這裏有一個小例子:

public Task<decimal> GetStockPricesForAsync(string symbol)
{
    InitializeIfNeededAsync().Wait();
    return Task.FromResult((decimal)42);
}
 
private async Task InitializeIfNeededAsync()
{
    // Initialize the cache field first
    await _cache.InitializeAsync().ConfigureAwait(false);
    // Do some work
    await Task.Delay(1);
}

你能看出其中的問題嗎?我們已經使用了ConfigureAwait(false)所以一切都應該正常,但是並不一定。

ConfigureAwait(false)返回一個叫ConfiguredTaskAwaitable的自定義awaiter,並且我們已經知道:awaiter只有在任務沒有同步地完成的情況下才會被使用。也就是說如果_cache.InitializeAsync()是同步執行完畢的,那麽我們依然可能面臨死鎖。

為了解決死鎖問題,每一個被await的task都應該被一個ConfigureAwait(false)調用所“裝飾”。這是很繁瑣並且很容易出錯的。

另一個解決方案是:在每一個public方法中都使用一個自定義awaiter來將同步上下文從異步方法中分離:

private void buttonOk_Click(object sender, EventArgs args)
{
    textBox.Text = "Running..";
    var result = _stockPrices.GetStockPricesForAsync("MSFT").Result;
    textBox.Text = "Result is: " + result;
}
 
// StockPrices.dll
public async Task<decimal> GetStockPricesForAsync(string symbol)
{
    // The rest of the method is guarantee won‘t have a current sync context.
    await Awaiters.DetachCurrentSyncContext();
 
    // We can wait synchronously here and we won‘t have a deadlock.
    InitializeIfNeededAsync().Wait();
    return 42;
}

Awaiters.DetachCurrentSyncContext返回下面的自定義awaiter:

public struct DetachSynchronizationContextAwaiter : ICriticalNotifyCompletion
{
    /// <summary>
    /// Returns true if a current synchronization context is null.
    /// It means that the continuation is called only when a current context
    /// is presented.
    /// </summary>
    public bool IsCompleted => SynchronizationContext.Current == null;
 
    public void OnCompleted(Action continuation)
    {
        ThreadPool.QueueUserWorkItem(state => continuation());
    }
 
    public void UnsafeOnCompleted(Action continuation)
    {
        ThreadPool.UnsafeQueueUserWorkItem(state => continuation(), null);
    }
 
    public void GetResult() { }
 
    public DetachSynchronizationContextAwaiter GetAwaiter() => this;
}
 
public static class Awaiters
{
    public static DetachSynchronizationContextAwaiter DetachCurrentSyncContext()
    {
        return new DetachSynchronizationContextAwaiter();
    }
}

DetachSynchronizationContextAwaiter做了以下幾點:如果異步方法是在一個非null的同步上下文中被調用的,這個awaiter會探測到這一點並且將延續調度給一個線程池線程。但如果異步方法的調用沒有任何同步上下文,那麽IsCompleted屬性返回true,並且任務延續將同步地執行。

這意味著,如果異步方法是被線程池中的線程調用的,那麽開銷接近於0,如果是從UI線程中被調用的,那麽你只需要付出這一次,就能從UI線程轉移到線程池線程。

這種方法的好處:

  • 更不容易出錯。只有在所有被await的task被ConfigureAwait(false)所裝飾時,ConfigureAwait(false)才有效。如果你不小心忘了一個,死鎖就有可能發生。而用上述的自定義awaiter方法,你只需要記住一件事:所有你類庫中的public方法的開頭都應該先調用Awaiters.DetachCurrentSyncContext()。雖然仍有可能出錯,但概率更低了。
  • 代碼更具聲明性,且更簡潔。在我看來,一個有好幾個ConfigureAwait調用的方法更難閱讀,對於一個新人來說可理解性也更低。

異常處理

下面兩種情況有什麽不同:

Task mayFail = Task.FromException(new ArgumentNullException());
 
// Case 1
try { await mayFail; }
catch (ArgumentException e)
{
    // Handle the error
}
 
// Case 2
try { mayFail.Wait(); }
catch (ArgumentException e)
{
    // Handle the error
}

第一種情況完全符合你的預期——處理錯誤,但是第二種情況並不會。TPL是為異步和並行編程設計的,而Task/Task<T>可以代表多個操作的結果。這就是為什麽Task.ResultTask.Wait()總是會拋出一個可能包含多個錯誤的AggregateException

但是我們的主場景改變了一切:用戶應該能夠添加async/await而無需更改錯誤處理邏輯。這也就意味著await語句應該與Task.Result/Task.Wait()不同:它應該從AggregateException實例中“unwrap”一個異常出來,今天它選擇了第一個。

如果所有基於task的方法都是異步,並且這些task不是基於並行計算,那麽一切就沒問題。但是事實並非總是如此:

try
{
    Task<int> task1 = Task.FromException<int>(new ArgumentNullException());
 
    Task<int> task2 = Task.FromException<int>(new InvalidOperationException());
 
    // await will rethrow the first exception
    await Task.WhenAll(task1, task2);
}
catch (Exception e)
{
    // ArgumentNullException. The second error is lost!
    Console.WriteLine(e.GetType());
}

Task.WhenAll返回一個代表了兩個錯誤的失敗任務,但是await語句只會抽取其中第一個錯誤,然後拋出。

有兩種方法解決這個問題:

  1. 如果你有訪問這些任務的權限,可以手動觀察它們。
  2. 強制TPL將異常報裝進另一個AggregateException中。
try
{
    Task<int> task1 = Task.FromException<int>(new ArgumentNullException());
 
    Task<int> task2 = Task.FromException<int>(new InvalidOperationException());
 
    // t.Result forces TPL to wrap the exception into AggregateException
    await Task.WhenAll(task1, task2).ContinueWith(t => t.Result);
}
catch(Exception e)
{
    // AggregateException
    Console.WriteLine(e.GetType());
}

async void方法

基於任務的方法返回一個承諾(promise)——一個可以用於在將來處理結果的令牌(token)。如果這個任務對象丟失,用戶的代碼將就無法觀察到該承諾。返回void的異步操作就使得用戶代碼不可能處理錯誤情況。這就使它們變得有點兒沒什麽用,而且危險(我們馬上就會看到)。但我們的主場景卻需要這麽做:

private async void buttonOk_ClickAsync(object sender, EventArgs args)
{
    textBox.Text = "Running..";
    var result = await _stockPrices.GetStockPricesForAsync("MSFT");
    textBox.Text = "Result is: " + result;
}

如果GetStockPricesForAsync隨著一個錯誤而失敗了會發生什麽?這個async void方法的未處理異常會進入當前的同步上下文,觸發與同步代碼相同的行為(詳見AsyncMethodBuilder.cs的 ThrowAsync方法)。在Windows Forms中一個事件處理器的未處理異常會觸發Application.ThreadException事件,WPF則是Application.DispatcherUnhandledException事件等等。

但是如果一個async void方法沒有一個捕獲的同步上下文怎麽辦?在這種情況下,一個未處理異常將導致應用程序崩潰,而無法從中恢復。它不會觸發可恢復的TaskScheduler.UnobservedTaskException事件,而會觸發不可恢復的AppDomain.UnhandledException事件並關閉應用程序。這是有意為之的,也是應該的。

現在你應該了解另一個著名的最佳實踐:僅對UI事件處理器使用async-void方法。

不幸的是,不小心且未察覺地引入一個async void方法是相對比較容易的:

public static Task<T> ActionWithRetry<T>(Func<Task<T>> provider, Action<Exception> onError)
{
    // Calls ‘provider‘ N times and calls ‘onError‘ in case of an error.
}
 
public async Task<string> AccidentalAsyncVoid(string fileName)
{
    return await ActionWithRetry(
        provider:
        () =>
        {
            return File.ReadAllTextAsync(fileName);
        },
        // Can you spot the issue?
        onError:
        async e =>
        {
            await File.WriteAllTextAsync(errorLogFile, e.ToString());
        });
}

僅通過查看lambda表達式是很難判斷這個函數到底是返回task還是void,即使有徹底的代碼審核,這個錯誤也很容易潛入代碼庫。

結論

有一個用戶場景——對現有的UI應用程序從同步到異步代碼的簡單遷移——在很多方面影響了C#中的異步編程:

  • 異步方法的延續會被調度進一個捕獲的同步上下文,可能會造成死鎖。
  • 為了避免死鎖,類庫中所有的異步代碼都應該加上ConfigureAwait(false)
  • await task;只會拋出第一個錯誤,這使得對並行編程的異常處理更加復雜。
  • async void方法被用於處理UI事件,但它們可能會被不慎使用,造成在發生未處理異常時應用程序的崩潰。

天下沒有免費的午餐。在一種情況下的易用性可能會使其他情況復雜化。了解C#異步編程的歷史可以使奇怪的行為變得不那麽奇怪,並且減少異步代碼中出現錯誤的可能性。

[翻譯]用一個用戶場景來掌握它們