用一个用例更深入了解C#异步方法

本文手动翻译自:One user scenario to rule them all – Developer Support (microsoft.com)

几乎所有C#异步方法的特殊行为都可以用一个用例解释:将现有的同步代码修改为异步代码应该尽可能简单。你应该能够在方法的返回类型前面添加 async 关键字,再在方法名后添加 Async 后缀,最后在方法中各处使用 await 关键字,来得到拥有完整功能的异步方法:

//同步代码
private void buttonOk_Click(object sender, EventArgs args)
{
    textBox.Text = "Running..";
    var result = _stockPrices.GetStockPricesFor("MSFT");
    textBox.Text = "Result is:" + result;
}
//将同步代码转换为异步代码
//异步代码
private async void buttonOk_ClickAsync(object sender, EventArgs args)
{
    textBox.Text = "Running..";
    var result = await _stockPrices.GetStockPricesFor("MSFT");
    textBox.Text = "Result is:" + result;
}

这个【简单的】用例(同步代码转换为异步方法)改变了异步方法行为的许多方面:例如续体任务的调度和异常处理。该用例看上去很简单,但它会让这种简单性非常具有欺骗性。

后文会再次提到该用例。

同步上下文

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元素。这意味着如果第三行代码被调度到线程池中的线程时会产生错误。幸运的是,这个问题比较久远了,在 .NET Framework2.0 问世时,【同步上下文】(Synchronization Contexts)概念也随之到来。

每个UI框架都会提供用于将工作编组到专用UI线程(可能有多个UI线程)中的公共接口。Windows FormsControl.InvokeWPF 则有 Dispatcher.Invoke,其他框架大概率也会有自己的接口。所有例子的原理都是相似的,但实现细节却有所不同。同步上下文作为基类,将差异抽象出来,并提供一个API用于在某个上下文中运行代码,最后把细节留给派生类,比如 WindowsFormsSynchronizationContextDispatcherSynchronizatioinContext 等等。

为了解决线程关联问题,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();//line 1
    return 42;//line 2
}

该代码会导致一项死锁。UI线程启动异步操作然后同步等待结果。但因为 GetStockPricesForAsync 的第二行应该在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(fasle)

第一点我们已经清楚了,下面我们将解释第二点。

Configure “awaits”

造成上面例子死锁的原因有两个:GetStockPricesForAsync 里阻塞调用 Task.Wait()InitializaIfNeededAsync 里同步上下文的隐式使用。虽然C#作者不鼓励阻塞调用异步方法,但很明显这种情况发生的概率依然很大。针对死锁这个问题,C#作者们给出了一个解决方案:Task.ConfigureAwait(continueOnCapturedContext:fasle)

上面特别标注了方法参数的名字,如果你不知道该参数名,那这个方法绝对是晦涩难懂的:该参数强制让续体不在同步上下文中运行。

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线程,因此死锁问题就没有了。

分离同步上下文

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) 会返回一个自定义 awaiterConfiguredTaskAwaitable。我们知道这个 awaiter 只有在 task 没有同步完成时才会用到。这就意味着如果 _cache.InitializeAsync() 同步完成,我们仍会遇到死锁。

为了解决这个死锁问题,每个可等待 task 后面都需要用 ConfigureAwait(false) 来【装饰】。很烦人并且容易出错。

另一种解决方案是在每个公共方法里使用自定义 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;
}

Awaiter.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 做了这些事:

  • 如果异步方法运行时同步上下文不为空(说明该异步方法还未完成),awaiter 将同步上下文分离,并且把续体任务分配给线程池中的线程。

  • 如果异步方法没有同步上下文,IsCompleted 属性返回 true ,那么该方法的续体将同步运行。

这意味着异步方法在线程池的线程中运行的开销接近于0,并且只有将程序从UI线程转换到线程池线程这一个代价。

这种做法的其他好处:

  • 更少的出错几率。只有在所有可等待 task 都加上 ConfigureAwait(false) 时,它才有用。即使你只忘掉了一个,死锁仍然会发生。用上面的自定义 awaiter 的话,你只需要记住一件事:在你所有公共库方法开始添加 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 对象中【解包】一个异常对象出来。在今天它将取第一个异常对象。

如果所有基于 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 返回一个有俩错误的 task ,但 await 语句只获取并传递第一个错误。

有两种解决该问题的方法:

  1. 如果你有权访问单个 task ,那就手动检查它们。(?

  2. 勒令TPL将异常对象打包进另一个 AggregateException 。下面代码 ContinueWith(t => t.Result) 就是例子:

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

异步方法,但返回类型为void

为了方便理解,你可以将基于 Task/Task<T> 的方法所返回的 task 看作一块令牌(token),你可以用它在未来访问并处理结果。如果因程序员的代码导致 task 丢失——例如返回 void 的异步方法,那么错误情况将无法被处理。这导致 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 发生异常会怎样?未被处理的 void 异步方法将会被编组到当前同步上下文,和同步代码类似地触发异常(要深入了解可以查看源码 AsyncMethodBuilder.cs 中的 ThrowAsync 方法)。在 Windows Forms 中,未被处理的异常将触发 Application.ThreadException 事件,WPF 里将触发 Application.DispatcherUnhandledException 事件。

但如果 void 异步方法没有当前同步上下文呢?这种情况下,未处理异常会让程序崩溃闪退。它不会触发可恢复事件 TaskScheduler.UnobservedTaskException ,而是触发不可恢复事件 AppDomain.UnhandledException,并且关闭程序。这是故意设计的,也有它的合理性。

现在你应该学会了另一个最佳实践:只在UI事件方法身上采用 void 异步方案。

不幸的是,你很容易无意中引入 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; 只抛出第一个错误这一设定导致并行编程的异常处理更加复杂。

  • void 异步方法可以用来处理UI事件,但如果出现未处理的异常,它们可能会意外地导致程序崩溃。

天下没有免费的午餐。在一种情况下易于使用会使其他情况变得非常复杂。了解C#异步编程的历史能增进你对其的理解,并减少异步代码出错的可能性。

上一篇
下一篇