不要阻塞异步代码
原文地址:https://blog.stephencleary.com/2012/07/dont-block-on-async-code.html
这是在论坛和 Stack Overflow 上经常出现的问题。我认为这是最常问的问题,对于新手学异步的人来说。
UI 例子
看下面给出的例子。一个按钮点击将启动 REST 调用以及结果显示在文本框中(这个例子是针对 Windows Forms,但是对于其他任何 UI 应用程序本质都是一样的)
//库方法
public static async Task<JObject> GetJsonAsync(Uri uri)
{
//真是场景的代码不应该用 using 包裹 HttpClient;这只是个例子
using(var client = new HttpClient())
{
var jsonString = await client.GetStringAsync(uri);
return JObject.Parse(jsonString);
}
}
public void Button1_Click(...)
{
var jsonToken = GetJsonAsync(...);
textbox1.Text = jsonToken.Result;
}
“GetJson” 帮助方法 REST 调用以及转化 JSON。按钮事件程序等待方法完成并显示其结果。
这段代码将会死锁。
ASP.NET 例子
下面这个例子也非常相似。我们有一个库方法同样去执行 REST 调用,只是这次是在 ASP.NET 上下文中使用(以 WebApi 为例,本质上在任何 ASP.NET 应用程序都是相同)。
//库方法
public static async Task<JObject> GetJsonAsync(Uri uri)
{
using (var client = new HttpClient())
{
var jsonString = await client.GetStringAsync(uri);
return JObject.Parse(jsonString);
}
}
public class MyController : ApiController
{
public string Get()
{
var jsonToken = GetJsonAsync(...);
return jsonToken.Result.ToString();
}
}
这同样会死锁。理由是一致的。
什么导致了死锁
首先这里有一些场景:回想一下我的介绍 async-await 的文章,在你 await Task,那么这个时候方法延续会继续在上下文中。首先一点,这个上下文是 UI 上下文(它能提供任何 UI,除了控制台应用程序)。第二点,这个上下文是 ASP.NET 请求上下文。
另一个重要的点:ASP.NET 请求上下文不会尝试特定指定线程(就像 UI 线程一样),但是它只允许一次一个线程。有趣的是这方面据我所知就是在任何地方官方文档,但是在我的这篇文章 http://msdn.microsoft.com/en-us/magazine/gg598924.aspx 中有提到过。
那么这里到底发生了什么呢?让我们回到最高层的方法(Botton1_Click 无论 UI 还是控制器):
- 最上层方法(调用层)调用 GetJsonAsync(在 UI/ASP.NET 上下文中)
- GetJsonAsync 开始通过调用 HttpClient.GetStringAsync 开始 REST 请求(仍然在 UI/ASP.NET 上下文中)
- GetStringAsync 返回一个未完成的 Task,表明这个 REST 请求还未完成。
- GetJsonAsync 通过等待 GetStringAsync 返回结果。这个上下文会被捕捉到并且会在 GetJsonAsync 方法之后继续沿用。GetJsonAsync 返回一个未完成的任务,这表示 GetJsonAsync 方法还未完成。
- 最上层方法在 GetJsonAsync 返回的任务上同步阻塞。这会阻塞上下文线程。
- 最终,REST 请求将会完成。这个已经完成的任务通过 GetStringAsync 返回。
- GetJsonAsync 就会准备延续继续执行,并且要等待上下文直到可用,这样它才能在这个上下文中继续执行。
- 发生死锁。调用层方法正在阻塞上下文线程,并正在等待 GetJsonAsync 完成,而 GetJsonAsync 正在等待这个上下文释放它才能完成。
对于 UI 这个例子,上下文是 UI 上下文;对于 ASP.NET 例子,上下文是 ASP.NET 请求上下文。导致这个死锁的都是上下文。
防止死锁
这里有两个最佳实践(都涵盖在我的这篇博文里 async-await:
- 在你自己的库异步函数里尽可能的使用 ConfigureAwait(false)。
- 不要在 Task 上阻塞;一路用 async 到低。
对于第一个最佳实践。新的修改方法如下:
public static async Task<JObject> GetJsonAsync(Uri uri)
{
using(var client = new HttpClient())
{
var jsonString = await client.GetStringAsync(uri).ConfigureAwait(false);
return JObject.parse(jsonString);
}
}
这个将更改 GetJsonAsync 的延续行为,它不会在上下文中恢复。而是将会在线程池线程上恢复执行。这样能够使 GetJsonAsync 能完成任务并返回结果并且不会重新进入上下文。与此同时,最高层的代码必须要上下文,所以他们不能使用 ConfigureAwait(false)。
使用
ConfigureAwait(false)
来避免死锁是个危险操作。你必须在标记await
每一处都要使用ConfigureAwait(false)
,包括所有的第三方库代码。使用 ConfigureAwait(false) 来避免死锁仅仅只是为了 hack就像文章标题指出的一样,最好的解决方法就是 “不要阻塞异步代码”。
第二个最佳实践,在最高层方法就像这样写:
public async void Button1_Click(...)
{
var json = await GetJsonAsync(...);
textBox1.Text = json;
}
public class MyController : ApiController
{
public async Task<string> Get()
{
var json = await GetJsonAsync(...);
return json.ToString();
}
}
这改变了最外层方法的阻塞行为,这样上下文永远不会被阻塞;所有的 await
都是异步的。
注意:这都是最佳的事件。它们都能防止死锁,但是必须同时应用这两种方法才能实现最大的性能和响应能力。
资源
- https://blog.stephencleary.com/2012/02/async-and-await.html
- http://blogs.msdn.com/b/pfxteam/archive/2011/01/13/10115163.aspx
- http://channel9.msdn.com/Events/BUILD/BUILD2011/TOOL-829T
- http://blogs.msdn.com/b/lucian/archive/2012/03/29/talk-async-part-1-the-message-loop-and-the-task-type.aspx
- http://blogs.msdn.com/b/pfxteam/archive/2012/04/12/10293335.aspx
这种经常引起死锁是由于同步代码和异步代码混合编程导致的。通常是因为人们只是在有一小段地方使用异步,其他地方使用同步。不幸的是,部分的异步代码是要比全部异步来说更复杂棘手。
如果你的确需要维护部分异步代码,那么你一定要看 Stephen Toub 的那两篇文章:http://blogs.msdn.com/b/pfxteam/archive/2012/03/24/10287244.aspx、http://blogs.msdn.com/b/pfxteam/archive/2012/04/13/10293638.aspx 以及我的库 https://github.com/StephenCleary/AsyncEx