简单示例,其中一个方法发生阻塞,等待 async 方法的结果。 此代码仅在控制台应用程序中工作良好,但是在从 GUI 或 ASP.NET 上下文调用时会死锁。 此行为可能会令人困惑,尤其是通过调试程序单步执行时,这意味着没完没了的等待。 在调用 Task.Wait 时,导致死锁的实际原因在调用堆栈中上移。
在异步代码上阻塞时的常见死锁问题
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 方法是代码可以在异步方法上阻塞为数不多的几种情况之一。
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 中。
引用他人的总结:await/async和Task.Wait是完全不同的两套运行机制,其中await/async其实只是一个语法糖,好像yied return一样,核心是一个状态机,当执行到await的时候,状态机会设定一个初始量,然后切换执行上下文,当前线程的执行就结束了,直到async所在的方法结束并设置另一个量给状态机,恢复执行上下文,接着跑await之后的内容。Task.Wait则直接粗暴的阻塞当前线程,直到Task执行结束;如果在Wait的时候,后面的线程又请求当前线程的时候,就一定死掉了。
打个比方来说,你和你小弟说,给我买些面包吃,小弟说,我记下了这个任务,然后走了;然后你觉得这么一直等着不是个事,还是睡一觉吧,然后就沉睡了;你小弟一段时间之后有空了,想起来要去给你买面包这个事,但是他发现钱在你口袋里,于是他搬了个凳子在你旁边坐等;嗯,然后你和小弟就这么一直等到地老天荒,死锁啦!