[翻译]用一个用户场景来掌握它们

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

异步系列

  • 剖析C#中的异步方法
  • 扩展C#中的异步方法
  • C#中异步方法的性能特点。
  • 用一个用户场景来掌握它们

c#中异步方法的几乎所有重要行为都可以基于一个用户场景进行解释:尽可能简单地将现有的同步代码迁移到异步。你应该能在方法的返回类型前面加上async关键字,在方法名最后加上Async后缀,在方法内部加上一些await关键字,就能得到一个功能完整的异步方法。

![](D:\OneDrive\blog posts\pics\One_Scenario_Figure_11.png)

这个“简单”场景以许多不同的方式极大地影响异步方法的行为:从调度任务的延续到异常处理。这个场景听起来似乎很合理,也很重要,但它使异步方法背后的简单性变得非常具有欺骗性。

同步上下文(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用于在“特殊”的上下文中执行代码,将细节留给派生类,如WindowsFormsSynchronizationContextDispatcherSynchronizationContext

为了解决线程关联问题,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事件并关闭应用程序。这是有意为之的,也是应该的。(译注:我试了一下,即使一个async void方法没有一个捕获的同步上下文,还是会触发Application.ThreadException,但是如果用Thread就会触发AppDomain.UnhandledException,我想这应该和底层的task scheduler的实现有关。)

现在你应该了解另一个著名的最佳实践:仅对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#异步编程的历史可以使奇怪的行为变得不那么奇怪,并且减少异步代码中出现错误的可能性。

posted @ 2018-07-28 20:17  raytheweak  阅读(628)  评论(0编辑  收藏  举报