[翻译]用一个用户场景来掌握它们
翻译自一篇博文,原文: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用于在“特殊”的上下文中执行代码,将细节留给派生类,如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.Result
或Task.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.Result
和Task.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
语句只会抽取其中第一个错误,然后抛出。
有两种方法解决这个问题:
- 如果你有访问这些任务的权限,可以手动观察它们。
- 强制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#异步编程的历史可以使奇怪的行为变得不那么奇怪,并且减少异步代码中出现错误的可能性。