本文只重点介绍一些淹没在文档海洋中的最佳做法。
图 1 中总结了这些指导原则;我将在以下各节中逐一讨论。
图 1 异步编程指导原则总结
| “名称” | 说明 | 异常 |
| 避免 Async Void | 最好使用 async Task 方法而不是 async void 方法 | 事件处理程序 |
| 始终使用 Async | 不要混合阻塞式代码和异步代码 | 控制台 main 方法 |
| 配置上下文 | 尽可能使用 ConfigureAwait(false) | 需要上下文的方法 |
避免 Async Void
下面的代码段演示了一个返回 void 的同步方法及其等效的异步方法:
void MyMethod()
{
// Do synchronous work.
Thread.Sleep(1000);
}
async Task MyMethodAsync()
{
// Do asynchronous work.
await Task.Delay(1000);
}
但是,async void 方法的一些语义与 async Task 或 async Task<T> 方法的语义略有不同。
图 2 演示本质上无法捕获从 async void 方法引发的异常。
图 2 无法使用 Catch 捕获来自 Async Void 方法的异常
private async void ThrowExceptionAsync()
{
throw new InvalidOperationException();
}
public void AsyncVoidExceptions_CannotBeCaughtByCatch()
{
try
{
ThrowExceptionAsync();
}
catch (Exception)
{
// The exception is never caught here!
throw;
}
}
可以通过对 GUI/ASP.NET 应用程序使用 AppDomain.UnhandledException 或类似的全部捕获事件观察到这些异常,但是使用这些事件进行常规异常处理会导致无法维护。
Async void 方法会在启动和结束时通知 SynchronizationContext,但是对于常规应用程序代码而言,自定义 SynchronizationContext 是一种复杂的解决方案。
可以安装 SynchronizationContext 来检测所有 async void 方法都已完成的时间并收集所有异常,不过只需使 async void 方法改为返回 Task,这会简单得多。
下面的代码演示了这一方法,该方法通过将 async void 方法用于事件处理程序而不牺牲可测试性:
private async void button1_Click(object sender, EventArgs e)
{
await Button1ClickAsync();
}
public async Task Button1ClickAsync()
{
// Do asynchronous work.
await Task.Delay(1000);
}
一般而言,仅当 async lambda 转换为返回 Task 的委托类型(例如,Func<Task>)时,才应使用 async lambda。
此例外情况包括逻辑上是事件处理程序的方法,即使它们字面上不是事件处理程序(例如 ICommand.Execute implementations)。
始终使用 Async
此行为是所有类型的异步编程中所固有的,而不仅仅是新 async/await 关键字。
在 MSDN 论坛、Stack Overflow 和电子邮件中回答了许多与异步相关的问题之后,我可以说,迄今为止,这是异步初学者在了解基础知识之后最常提问的问题: “为何我的部分异步代码死锁?”
在调用 Task.Wait 时,导致死锁的实际原因在调用堆栈中上移。
图 3 在异步代码上阻塞时的常见死锁问题
public static class DeadlockDemo
{
private static async Task DelayAsync()
{
await Task.Delay(1000);
}
// This method causes a deadlock when called in a GUI or ASP.NET context.
public static void Test()
{
// Start the delay.
var delayTask = DelayAsync();
// Wait for the delay to complete.
delayTask.Wait();
}
}
它们相互等待对方,从而导致死锁。
当程序员编写测试控制台程序,观察到部分异步代码按预期方式工作,然后将相同代码移动到 GUI 或 ASP.NET 应用程序中会发生死锁,此行为差异可能会令人困惑。
图 4 演示了指导原则的这一例外情况: 控制台应用程序的 Main 方法是代码可以在异步方法上阻塞为数不多的几种情况之一。
图 4 Main 方法可以调用 Task.Wait 或 Task.Result
class Program
{
static void Main()
{
MainAsync().Wait();
}
static async Task MainAsync()
{
try
{
// Asynchronous implementation.
await Task.Delay(1000);
}
catch (Exception ex)
{
// Handle exceptions.
}
}
}
我现在说明错误处理问题,并在本文后面演示如何避免死锁问题。
当没有 AggregateException 时,错误处理要容易处理得多,因此我将“全局”try/catch 置于 MainAsync 中。
请考虑此简单示例:
public static class NotFullyAsynchronousDemo
{
// This method synchronously blocks a thread.
public static async Task TestNotFullyAsync()
{
await Task.Yield();
Thread.Sleep(5000);
}
}
图 5 是将同步操作替换为异步替换的速查表。
图 5 执行操作的“异步方式”
| 执行以下操作… | 替换以下方式… | 使用以下方式 |
| 检索后台任务的结果 | Task.Wait 或 Task.Result | await |
| 等待任何任务完成 | Task.WaitAny | await Task.WhenAny |
| 检索多个任务的结果 | Task.WaitAll | await Task.WhenAll |
| 等待一段时间 | Thread.Sleep | await Task.Delay |
此指导原则的例外情况是控制台应用程序的 Main 方法,或是(如果是高级用户)管理部分异步的基本代码。
配置上下文
这可能会形成迟滞,因为会由于“成千上万的剪纸”而降低响应性。
下面的代码段说明了默认上下文行为和 ConfigureAwait 的用法:
async Task MyMethodAsync()
{
// Code here runs in the original context.
await Task.Delay(1000);
// Code here runs in the original context.
await Task.Delay(1000).ConfigureAwait(
continueOnCapturedContext: false);
// Code here runs without the original
// context (in this case, on the thread pool).
}
通过使用 ConfigureAwait,可以实现少量并行性: 某些异步代码可以与 GUI 线程并行运行,而不是不断塞入零碎的工作。
如果需要逐渐将应用程序从同步转换为异步,则此方法会特别有用。
图 6 显示了一个修改后的示例。
图 6 处理在等待之前完成的返回任务
async Task MyMethodAsync()
{
// Code here runs in the original context.
await Task.FromResult(1);
// Code here runs in the original context.
await Task.FromResult(1).ConfigureAwait(continueOnCapturedContext: false);
// Code here runs in the original context.
var random = new Random();
int delay = random.Next(2); // Delay is either 0 or 1
await Task.Delay(delay).ConfigureAwait(continueOnCapturedContext: false);
// Code here might or might not run in the original context.
// The same is true when you await any Task
// that might complete very quickly.
}
图 7 演示 GUI 应用程序中的一个常见模式:让 async 事件处理程序在方法开始时禁用其控制,执行某些 await,然后在处理程序结束时重新启用其控制;因为这一点,事件处理程序不能放弃其上下文。
图 7 让 async 事件处理程序禁用并重新启用其控制
private async void button1_Click(object sender, EventArgs e)
{
button1.Enabled = false;
try
{
// Can't use ConfigureAwait here ...
await Task.Delay(1000);
}
finally
{
// Because we need the context here.
button1.Enabled = true;
}
}
图 8 演示的代码对图 7 进行了少量改动。
图 8 每个 async 方法都具有自己的上下文
private async Task HandleClickAsync()
{
// Can use ConfigureAwait here.
await Task.Delay(1000).ConfigureAwait(continueOnCapturedContext: false);
}
private async void button1_Click(object sender, EventArgs e)
{
button1.Enabled = false;
try
{
// Can't use ConfigureAwait here.
await HandleClickAsync();
}
finally
{
// We are back on the original context for this method.
button1.Enabled = true;
}
}
即使是编写 ASP.NET 应用程序,如果存在一个可能与桌面应用程序共享的核心库,请考虑在库代码中使用 ConfigureAwait。
此指导原则的例外情况是需要上下文的方法。
了解您的工具
图 9 是常见问题的解决方案的快速参考。
图 9 常见异步问题的解决方案
| 问题 | 解决方案 |
| 创建任务以执行代码 | Task.Run 或 TaskFactory.StartNew(不是 Task 构造函数或 Task.Start) |
| 为操作或事件创建任务包装 | TaskFactory.FromAsync 或 TaskCompletionSource<T> |
| 支持取消 | CancellationTokenSource 和 CancellationToken |
| 报告进度 | IProgress<T> 和 Progress<T> |
| 处理数据流 | TPL 数据流或被动扩展 |
| 同步对共享资源的访问 | SemaphoreSlim |
| 异步初始化资源 | AsyncLazy<T> |
| 异步就绪生产者/使用者结构 | TPL 数据流或 AsyncCollection<T> |
msdn.microsoft.com/library/hh873175),该模式详细说明了任务创建、取消和进度报告。
TPL 数据流和 Rx 都具有异步就绪方法,十分适用于异步代码。
下面是一个异步代码示例,该代码如果执行两次,则可能会破坏共享状态,即使始终在同一个线程上运行也是如此:
int value;
Task<int> GetNextValueAsync(int current);
async Task UpdateValueAsync()
{
value = await GetNextValueAsync(value);
}
图 10 演示 SemaphoreSlim.WaitAsync。
图 10 SemaphoreSlim 允许异步同步
SemaphoreSlim mutex = new SemaphoreSlim(1);
int value;
Task<int> GetNextValueAsync(int current);
async Task UpdateValueAsync()
{
await mutex.WaitAsync().ConfigureAwait(false);
try
{
value = await GetNextValueAsync(value);
}
finally
{
mutex.Release();
}
}
nitoasyncex.codeplex.com) 中提供了更新版本。
而 AsyncEx 提供了 AsyncCollection<T>,这是异步版本的 BlockingCollection<T>。
异步真的是非常棒的语言功能,现在正是开始使用它的好时机!