原文链接
近日来,涌现了许多关于 Microsoft .NET Framework 4.5 中新增了对 async 和 await 支持的信息。本文旨在作为学习异步编程的“第二步”;我假设您已阅读过有关这一方面的至少一篇介绍性文章。本文不提供任何新内容,Stack Overflow、MSDN 论坛和 async/await FAQ 这类在线资源提供了同样的建议。本文只重点介绍一些淹没在文档海洋中的最佳做法。
本文中的最佳做法更大程度上是“指导原则”,而不是实际规则。其中每个指导原则都有一些例外情况。我将解释每个指导原则背后的原因,以便可以清楚地了解何时适用以及何时不适用。图 1中总结了这些指导原则;我将在以下各节中逐一讨论。
图 1 异步编程指导原则总结
“名称” | 说明 | 异常 |
避免 Async Void | 最好使用 async Task 方法而不是 async void 方法 | 事件处理程序 |
始终使用 Async | 不要混合阻塞式代码和异步代码 | 控制台 main 方法 |
配置上下文 | 尽可能使用 ConfigureAwait(false) | 需要上下文的方法 |
Async 方法有三种可能的返回类型: Task、Task<T> 和 void,但是 async 方法的固有返回类型只有 Task 和 Task<T>。当从同步转换为异步代码时,任何返回类型 T 的方法都会成为返回 Task<T> 的 async 方法,任何返回 void 的方法都会成为返回 Task 的 async 方法。下面的代码段演示了一个返回 void 的同步方法及其等效的异步方法:
void MyMethod(){ // Do synchronous work.Thread.Sleep(1000);}async Task MyMethodAsync(){ // Do asynchronous work.await Task.Delay(1000);}
返回 void 的 async 方法具有特定用途: 用于支持异步事件处理程序。事件处理程序可以返回某些实际类型,但无法以相关语言正常工作;调用返回类型的事件处理程序非常困难,事件处理程序实际返回某些内容这一概念也没有太大意义。事件处理程序本质上返回 void,因此 async 方法返回 void,以便可以使用异步事件处理程序。但是,async void 方法的一些语义与 async Task 或 async Task<T> 方法的语义略有不同。
Async void 方法具有不同的错误处理语义。当 async Task 或 async Task<T> 方法引发异常时,会捕获该异常并将其置于 Task 对象上。对于 async void 方法,没有 Task 对象,因此 async void 方法引发的任何异常都会直接在 SynchronizationContext(在 async void 方法启动时处于活动状态)上引发。图 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 方法具有不同的组合语义。返回 Task 或 Task<T> 的 async 方法可以使用 await、Task.WhenAny、Task.WhenAll 等方便地组合而成。返回 void 的 async 方法未提供一种简单方式,用于向调用代码通知它们已完成。启动几个 async void 方法不难,但是确定它们何时结束却不易。Async void 方法会在启动和结束时通知 SynchronizationContext,但是对于常规应用程序代码而言,自定义 SynchronizationContext 是一种复杂的解决方案。
Async void 方法难以测试。由于错误处理和组合方面的差异,因此调用 async void 方法的单元测试不易编写。MSTest 异步测试支持仅适用于返回 Task 或 Task<T> 的 async 方法。可以安装 SynchronizationContext 来检测所有 async void 方法都已完成的时间并收集所有异常,不过只需使 async void 方法改为返回 Task,这会简单得多。
显然,async void 方法与 async Task 方法相比具有几个缺点,但是这些方法在一种特定情况下十分有用: 异步事件处理程序。语义方面的差异对于异步事件处理程序十分有意义。它们会直接在 SynchronizationContext 上引发异常,这类似于同步事件处理程序的行为方式。同步事件处理程序通常是私有的,因此无法组合或直接测试。我喜欢采用的一个方法是尽量减少异步事件处理程序中的代码(例如,让它等待包含实际逻辑的 async 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 void 方法是异步的,则这些方法可能会造成严重影响。当返回类型是 Task 时,调用方知道它在处理将来的操作;当返回类型是 void 时,调用方可能假设方法在返回时完成。此问题可能会以许多意外方式出现。在接口(或基类)上提供返回 void 的方法的 async 实现(或重写)通常是错误的。某些事件也假设其处理程序在返回时完成。一个不易察觉的陷阱是将 async lambda 传递到采用 Action 参数的方法;在这种情况下,async lambda 返回 void 并继承 async void 方法的所有问题。一般而言,仅当 async lambda 转换为返回 Task 的委托类型(例如,Func<Task>)时,才应使用 async lambda。
总结这第一个指导原则便是,应首选 async Task 而不是 async void。Async Task 方法更便于实现错误处理、可组合性和可测试性。此指导原则的例外情况是异步事件处理程序,这类处理程序必须返回 void。此例外情况包括逻辑上是事件处理程序的方法,即使它们字面上不是事件处理程序(例如 ICommand.Execute implementations)。
异步代码让我想起了一个故事,有个人提出世界是悬浮在太空中的,但是一个老妇人立即提出质疑,她声称世界位于一个巨大乌龟的背上。当这个人问乌龟站在哪里时,老夫人回答:“很聪明,年轻人,下面是一连串的乌龟!”在将同步代码转换为异步代码时,您会发现,如果异步代码调用其他异步代码并且被其他异步代码所调用,则效果最好 — 一路向下(或者也可以说“向上”)。其他人已注意到异步编程的传播行为,并将其称为“传染”或将其与僵尸病毒进行比较。无论是乌龟还是僵尸,无可置疑的是,异步代码趋向于推动周围的代码也成为异步代码。此行为是所有类型的异步编程中所固有的,而不仅仅是新 async/await 关键字。
“始终异步”表示,在未慎重考虑后果的情况下,不应混合使用同步和异步代码。具体而言,通过调用 Task.Wait 或 Task.Result 在异步代码上进行阻塞通常很糟糕。对于在异步编程方面“浅尝辄止”的程序员,这是个特别常见的问题,他们仅仅转换一小部分应用程序,并采用同步 API 包装它,以便代码更改与应用程序的其余部分隔离。不幸的是,他们会遇到与死锁有关的问题。在 MSDN 论坛、Stack Overflow 和电子邮件中回答了许多与异步相关的问题之后,我可以说,迄今为止,这是异步初学者在了解基础知识之后最常提问的问题: “为何我的部分异步代码死锁?”
图 3演示一个简单示例,其中一个方法发生阻塞,等待 async 方法的结果。此代码仅在控制台应用程序中工作良好,但是在从 GUI 或 ASP.NET 上下文调用时会死锁。此行为可能会令人困惑,尤其是通过调试程序单步执行时,这意味着没完没了的等待。在调用 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(); }}
这种死锁的根本原因是 await 处理上下文的方式。默认情况下,当等待未完成的 Task 时,会捕获当前“上下文”,在 Task 完成时使用该上下文恢复方法的执行。此“上下文”是当前 SynchronizationContext(除非它是 null,这种情况下则为当前 TaskScheduler)。GUI 和 ASP.NET 应用程序具有 SynchronizationContext,它每次仅允许一个代码区块运行。当 await 完成时,它会尝试在捕获的上下文中执行 async 方法的剩余部分。但是该上下文已含有一个线程,该线程在(同步)等待 async 方法完成。它们相互等待对方,从而导致死锁。
请注意,控制台应用程序不会形成这种死锁。它们具有线程池 SynchronizationContext 而不是每次执行一个区块的 SynchronizationContext,因此当 await 完成时,它会在线程池线程上安排 async 方法的剩余部分。该方法能够完成,并完成其返回任务,因此不存在死锁。当程序员编写测试控制台程序,观察到部分异步代码按预期方式工作,然后将相同代码移动到 GUI 或 ASP.NET 应用程序中会发生死锁,此行为差异可能会令人困惑。
此问题的最佳解决方案是允许异步代码通过基本代码自然扩展。如果采用此解决方案,则会看到异步代码扩展到其入口点(通常是事件处理程序或控制器操作)。控制台应用程序不能完全采用此解决方案,因为 Main 方法不能是 async。如果 Main 方法是 async,则可能会在完成之前返回,从而导致程序结束。图 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.} }}
允许异步代码通过基本代码扩展是最佳解决方案,但是这意味着需进行许多初始工作,该应用程序才能体现出异步代码的实际好处。可通过几种方法逐渐将大量基本代码转换为异步代码,但是这超出了本文的范围。在某些情况下,使用 Task.Wait 或 Task.Result 可能有助于进行部分转换,但是需要了解死锁问题以及错误处理问题。我现在说明错误处理问题,并在本文后面演示如何避免死锁问题。
每个 Task 都会存储一个异常列表。等待 Task 时,会重新引发第一个异常,因此可以捕获特定异常类型(如 InvalidOperationException)。但是,在 Task 上使用 Task.Wait 或 Task.Result 同步阻塞时,所有异常都会用 AggregateException 包装后引发。请再次参阅图 4。MainAsync 中的 try/catch 会捕获特定异常类型,但是如果将 try/catch 置于 Main 中,则它会始终捕获 AggregateException。当没有 AggregateException 时,错误处理要容易处理得多,因此我将“全局”try/catch 置于 MainAsync 中。
至此,我演示了两个与异步代码上阻塞有关的问题: 可能的死锁和更复杂的错误处理。对于在 async 方法中使用阻塞代码,也有一个问题。请考虑此简单示例:
public static class NotFullyAsynchronousDemo{ // This method synchronously blocks a thread.public static async Task TestNotFullyAsync() { await Task.Yield(); Thread.Sleep(5000); }}
此方法不是完全异步的。它会立即放弃,返回未完成的任务,但是当它恢复执行时,会同步阻塞线程正在运行的任何内容。如果此方法是从 GUI 上下文调用,则它会阻塞 GUI 线程;如果是从 ASP.NET 请求上下文调用,则会阻塞当前 ASP.NET 请求线程。如果异步代码不同步阻塞,则其工作效果最佳。图 5是将同步操作替换为异步替换的速查表。
图 5 执行操作的“异步方式”
执行以下操作… | 替换以下方式… | 使用以下方式 |
检索后台任务的结果 | Task.Wait 或 Task.Result | await |
等待任何任务完成 | Task.WaitAny | await Task.WhenAny |
检索多个任务的结果 | Task.WaitAll | await Task.WhenAll |
等待一段时间 | Thread.Sleep | await Task.Delay |
总结这第二个指导原则便是,应避免混合使用异步代码和阻塞代码。混合异步代码和阻塞代码可能会导致死锁、更复杂的错误处理及上下文线程的意外阻塞。此指导原则的例外情况是控制台应用程序的 Main 方法,或是(如果是高级用户)管理部分异步的基本代码。
在本文前面,我简要说明了当等待未完成 Task 时默认情况下如何捕获“上下文”,以及此捕获的上下文用于恢复 async 方法的执行。图 3中的示例演示在上下文上的恢复执行如何与同步阻塞发生冲突从而导致死锁。此
新闻热点
疑难解答