本文手动翻译自:One user scenario to rule them all – Developer Support (microsoft.com)
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
}
.NET Framework2.0
问世时,【】(Synchronization Contexts)概念也随之到来。
每个UI框架都会提供用于将工作编组到专用UI线程(可能有多个UI线程)中的公共接口。Windows Forms
有 ,WPF
则有 ,其他框架大概率也会有自己的接口。所有例子的原理都是相似的,但实现细节却有所不同。同步上下文作为基类,将差异抽象出来,并提供一个API用于在某个上下文中运行代码,最后把细节留给派生类,比如 , 等等。
为了解决线程关联问题,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代码应该禁止对 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);
-
不要使用
Task.Wait()
和Task.Result
来阻塞异步代码。 -
在库代码中使用
ConfigureAwait(fasle)
第一点我们已经清楚了,下面我们将解释第二点。
Configure “awaits”
造成上面例子死锁的原因有两个:GetStockPricesForAsync
里阻塞调用 Task.Wait()
和 InitializaIfNeededAsync
里同步上下文的隐式使用。虽然C#作者不鼓励阻塞调用异步方法,但很明显这种情况发生的概率依然很大。针对死锁这个问题,C#作者们给出了一个解决方案:。
上面特别标注了方法参数的名字,如果你不知道该参数名,那这个方法绝对是晦涩难懂的:该参数强制让续体不在同步上下文中运行。
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)
,因此一切看起来都那么美好。但这并不是必要的。
会返回一个自定义 awaiter
:。我们知道这个 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
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();
}
}
做了这些事:
-
如果异步方法运行时同步上下文不为空(说明该异步方法还未完成),
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
}
Task/Task<T>
可以表示多个操作的结果。这就是为啥 Task.Result
和 Task.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
语句只获取并传递第一个错误。
-
如果你有权访问单个
task
,那就手动检查它们。(? -
勒令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 中的 )。在 Windows Forms 中,未被处理的异常将触发 事件,WPF 里将触发 事件。
但如果 void
异步方法没有当前同步上下文呢?这种情况下,未处理异常会让程序崩溃闪退。它不会触发可恢复事件 TaskScheduler.UnobservedTaskException
,而是触发不可恢复事件 ,并且关闭程序。这是故意设计的,也有它的合理性。
现在你应该学会了另一个最佳实践:只在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#异步编程的历史能增进你对其的理解,并减少异步代码出错的可能性。