其实好久以前就看过这个文章,以及类似的很多篇文章。最近在和一个新同事的交流中发现原来对async的死锁理解不是很透彻,正好最近时间比较充裕就再当一回搬运工。
本文假定你对.NET Framework 4.5 的异步编程有基本的了解,相关的建议你能够在Stack Overflow, MSDN 以及async/awai FAQ中找到。这里并不试图想讲述多少新的知识点,而是想强调几点最佳实践,以减少你阅读大量文档排查问题的时间。本文的最佳实践更多的是一些指导意见,并不是实际意义上的规则。每条意见后面都有一些例外情况,这里都已经一一列出了,具体情况下面我们分条讲解。
规则 | 描述 | 例外 |
避免使用 async void | 优先使用 async Task 而不用 async void | Event handlers |
Async到顶 | 不要混合使用 blocking 和 async 的代码 | Console main method |
注意配置好执行的context | 尽量设置 ConfigureAwait(false) | 需要context的除外 |
避免使用 async void
Task 和 Task<T> 是异步方法的通常返回值类型。在将同步代码改造成async方式过程中,我们将void 类型返回值改成async Task.
其实,这里你改写成async void 编译器不会报错。事实上,async void 存在的意义仅仅是使用在event handlers上。如果你在普通的代码中使用async void,该代码块中的异
常处理就会变得比较麻烦。
可以看到,上面的Exception 无法被直接catch. 当然你可以使用AppDomain.UnhandledException等类似方法去捕捉该异常,但是这种代码维护起来就比较麻烦。另外,Task和Task<T>能够使用await, Task.WhenAny, Task.WhenAll等方式组合使用。Async Void 不行。同时Async void 方式比较难测试,具体原因可以参考原文。
让Async传染到顶。
这里可以称呼为async具有僵尸病毒般的传染性,async会感染周围的代码,直到顶层。其实我们只需要顺其自然,让所有代码都传染上异步特性即可。如果我们使用Task.Wait 或者Task.Result去试图阻塞async 的传播,往往便会自找苦吃。这个往往是刚接触async 异步的人最容易犯的错误,这些人往往试图将async 方法做个小小包装搞成同步的方法,以达到不修改旧代码的目的。不幸的是,这样往往会导致死锁。这个死锁的问题在MSDN,StackOverFlow已经烂大街了。
如上所示,重现这个死锁实在是太容易了。理解该死锁的原因在于理解await 处理contexts的方式。默认的,当一个未完成的Task 被await的时候,当前的上下文将在该Task完成的时候重新获得并继续执行剩余的代码。这个context就是当前的SynchronizationContext,除非它是空的。GUI和ASP.NET 应用程序的SynchronizationContext 有排他性,只允许一个线程运行。当await 完成的时候,它试图在它原来的代码上下文执行它剩余的部分,但是该代码上下文中已经有一个线程在了,就是那个一直在同步等待async 完成的那个线程,它们两个相互等待,因此就死锁了。
注意控制台程序不会导致死锁。控制台的SynchronizationContext 是类似于一个线程池的机制而不是排他的。因此当await 结束的时候,它可以重新获得原来的上下文然后执行完剩余代码并返回成功。这个不同是很多人产生困惑的根源,当他们在控制台测试的时候程序跑的好好的,拷贝到GUI或者ASP.NET 程序中就发生了死锁。
因此,最佳的解决方案就是允许async 自动传染直到最上层,通常到入口点为止。控制台的Main 方法无法被标注成async,原因你懂的。因此Main 方法这里是个例外。
通常全套异步需要做一些额外的工作,下面是一些必须做的额外工作。
当 | 不要使用 | 使用 |
需要获得值的时候 | Task.Wait or Task.Result | await |
需要等待任何一个任务 | Task.WaitAny | await Task.WhenAny |
需要等待所有任务完成 | Task.WaitAll | await Task.WhenAll |
需要等待 | Thread.Sleep | await Task.Delay |
当然如果你不打算全套使用async, 那么请必须注意处理好异常信息以及防止死锁的一些方法。
注意配置好执行的context
配置好continueOnCapturedContext为false可以提高性能。这个我建议直接看代码。
我们将task 配置为不需要切换为原context, 这样后面的代码将不会在GUI 线程中运行,可以减少对性能的影响。除了性能有一点点提升以外,设置好context 还能够避免死锁。当然这种解决方式需要注意的点实在太多,稍不注意就又死锁了。
同步调用异步async防止死锁方案
因此网上有人给出了以下比较通用的解决方案。具体代码就不贴全了,大概思路就是对上下文的处理,以及对一些异常信息的包装等,需要代码的同学自己去翻。