防止Async代码中的阻塞

原文地址:Don’t Block on Async Code


这个问题在论坛和Stack Overflow上被反复提及。这恐怕是学会了异步之后新手最常问的问题。

UI范例

考虑下面的例子。按键会触发一个REST请求,并且在一个文本框中显示结果(这是一个Windows Forms的范例,但是同样适用于任何UI应用)。

// My "library" method.
public static async Task<JObject> GetJsonAsync(Uri uri)
{
  using (var client = new HttpClient())
  {
    var jsonString = await client.GetStringAsync(uri);
    return JObject.Parse(jsonString);
  }
}

// My "top-level" method.
public void Button1_Click(...)
{
  var jsonTask = GetJsonAsync(...);
  textBox1.Text = jsonTask.Result;
}

“GetJson”方法发出实际的REST请求,并将结果转化为JSON。按键的click handler方法等待“GetJson”完成然后显示结果。

这段代码会造成死锁。

ASP.NET范例

这同样是相似的范例;在库中有一个方法来执行REST请求,不过这次是在ASP.NET的上下文中使用(在本例子中为Web API,但是对于任何ASP.NET应用同样适用)。

// My "library" method.
public static async Task<JObject> GetJsonAsync(Uri uri)
{
  using (var client = new HttpClient())
  {
    var jsonString = await client.GetStringAsync(uri);
    return JObject.Parse(jsonString);
  }
}

// My "top-level" method.
public class MyController : ApiController
{
  public string Get()
  {
    var jsonTask = GetJsonAsync(...);
    return jsonTask.Result.ToString();
  }
}

这段代码也会造成死锁。同样的原因。

为何造成死锁(Deadlock)

有一种情况:在之前的简介文章中,在等待了一个Task之后,当方法继续执行的时候,将在一个上下文环境中执行。

在第一个例子中,上下文环境是UI的上下文(对于除了Console命令行应用之外的所有UI同样适用)。第二个例子中,上下文是ASP.NET请求的上下文。

另外重要的一点是:一个ASP.NET请求上下文并不限定于一个特定的县城(就像UI的上下文一样),但是缺只允许同一时间只有一个线程运行。据说这个有趣的一点在官方的任何文档中都没有提及,但是在关于SynchronizationContext的MSDN文档中提到。

于是就发生了这些,从外往里开始说(UI范例中的Button1_Click方法或ASP.NET范例中MyController.Get方法):

  1. 最外部的方法调用GetJsonAsync方法(在UI或ASP.NET的上下文中国呢)。
  2. 通过调用HttpClient.GetStringAsync方法,GetJsonAsync开始REST请求。
  3. GetStringAsync返回一个未完成的Task,标志REST请求并未完成。
  4. GetJsonAsync方法等待GetStringAsync返回Task。上下文环境被捕捉到,并且会在接下来运行GetJsonAsync方法中依旧使用。GetJsonAsync返回一个未完成的Task,标志GetJsonAsync方法并为完成。
  5. 最外城的方法同步阻塞了GetJsonAsync方法。这就阻塞了上下文线程。
  6. 最终,REST请求完成。GetStringAsync返回的Task完成。
  7. GetJsonAsync可以继续运行,将等待上下文环境可用从而让其可以在此上下文环境中运行。
  8. 死锁。最外层的方法阻塞了上下文线程,一直等待GetJsonAsync方法完成,GetJsonAsync方法正等着上下文环境被释放从而让其可完成运行。

对于UI范例来说,上下文环境就是UI上下文;对于ASP.NET范例来说,上下文环境就是ASP.NET请求上下文。在任一这种情况下都会引起死锁。

防止死锁

这儿有两种常见做法(在简介文章中都有所涉及)来避免这种情况:

  1. 在“library”的async方法中,尽可能使用ConfigureAwait(false)。
  2. 不要在Tasks上阻塞,一路使用async。

对于第一种做法,新的“library”方法看起来如下:

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将会消耗线程池里面的一个线程。这让GetJsonAsync方法无需重新影响上下文的环境下返回Task。

使用ConfigureAwait(false)来防范死锁是危险的做法。需要在每个阻塞的代码段的每个await中都使用ConfigureAwait(false),包括所有的第三方库代码。使用ConfigureAwait(false)来防范死锁只是一个hack。

正如文本标题,最好的解决方案是“在asyunc代码中不要阻塞”。

考虑第二种实现方式。新的外部方法如下所示:

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();
  }
}

这会改变外层方法中阻塞的行为,从而上下文环境永远不会真正的阻塞;所有的“等待”都是“异步等待”。

Majirefy

Majirefy

喜欢折腾,喜欢各种各样的生活。曾经年少不懂事,看着别人写代码的样子感觉好帅,于是走上了半个不归路……然而,比起代码更喜欢写一些纯粹的文章,却经常因为自我不满意删掉重来。喜欢分享,无论是生活美好的瞬间,还是技术上的发现,虽然经常苦恼技术能力不强。由于喜欢买qiong买qiong买qiong,所以时常写一些类似使用体验的文章。

您可能还喜欢...

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注