Async方法导致的死锁与无响应

最近在写WinUI 3时,经常遇到在各种事件中调用异步方法的场景,在事件处理方法中等待异步调用完成需要使用await关键字并将事件处理方法标志为async。几乎部分事件处理方法都是没有返回值的,即返回值为void。此前有在一些地方看到过async void修饰的方法有一些问题,所以想着不用await/async关键词,而改用Task.Result或者Task.Wait()来阻塞当前代码,以实现await的效果,没想到直接卡死了整个程序,深入探究了一番发现了卡死的原因。本文内容主要来源于以下两篇文章:

以一个按钮事件为例:

// My "library" method.
public static async Task<JObject> GetJsonAsync(Uri uri)
{
  // (real-world code shouldn't use HttpClient in a using block; this is just example code)
  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;
}

在点击此按钮后,整个程序卡死无响应,其卡死的原因是因为上面的代码会造成死锁。

为什么会产生死锁呢?代码中并没有资源争抢的操作,死锁是产生在哪里呢?

在C#中,由async修饰的方法,即异步方法,会被编译器编译为状态机。当遇到调用异步方法时,CLR将会捕获调用的上下文,保存在状态机中,以便异步方法完成后可以继续在相同的上下文中运行。简单来说就是将调用异步方法前的环境保存下来,然后去做别的事情,等异步方法调用完成后,再把保存的环境恢复,就可以继续运行后续的代码了。

在这个例子中,当遇到await client.GetStringAsync(uri)时,CLR保存了当前的上下文(UI上下文,因为事件处理程序都是在UI处理线程中完成的)到GetJsonAsync异步方法的状态机中去,调用其它线程运行GetStringAsync方法,然后UI线程就去处理后续的程序了(因为在调用GetJsonAsync时没有等待,而在调用GetStringAsync时等待了),接着遇到了jsonTask.Result,就在这里以同步方式阻塞了UI上下文来等待GetJsonAsync完成。这个时候,UI上下文被UI线程占用了并一直在等GetJsonAsync完成,而在GetJsonAsync中,GetStringAsync完成后,需要恢复上下文继续运行后续代码,它需要的上下文正是UI上下文,于是两个线程形成了互相等待,死锁就产生了。详细的过程如下:

  1. 事件处理方法调用 GetJsonAsync(在 UI上下文中)
  2. GetJsonAsync 通过调用 HttpClient.GetStringAsync(仍在上下文中)启动 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线程中,尽量不要使用同步阻塞的方式调用异步方法,即不要试图使用Task.Result和Task.Wait()来保持事件处理程序的同步性。

在ASP.NET Core中,也是类似的道理,不要在Endpoint处理程序中以同步方式调用异步方法,它也会导致死锁的产生,只不过它的上下文将会变成每个请求的上下文。

实际上,async void与async Task两种形式的无返回值异步方法,其区别在于是否能够等待。一个异步方法编译形成的状态机会把其内部代码根据await关键词分割成数个部分,每个部分的完成是通过await等待的那个task中的GetAwaiter()方法调用结果决定的,一个部分完成后,由状态机决定下一步的动作。因而,若一个方法由async void修饰,那么状态机就无法得知其完成与否,因此不会等待它的完成。而一个方法若由async Task修饰,状态机就可以根据返回的task获知其是否完成,从而可以决定等待与否。

调用使用async void修饰的异步方法,称之为“发起并遗忘(Fire-and-forget)”,它只管调用,而不管其后续执行状态和结果,通常只在异步事件处理程序中使用。同时,调用方无法捕获从该方法引发的异常,此类未经处理异常有可能导致整个应用程序崩溃1

调用使用async Task修饰的异步方法,称之为“等待并继续(Wait-and-continue)”,在发起调用后会继续跟踪任务的执行状态和结果。如果返回 Task 或 Task<TResult> 的方法引发异常,则该异常会存储在返回的task中。 在await 该 task时,将重新引发异常。

综上,在事件处理方法中等待异步调用完成最好使用await关键字并将事件处理方法标志为async(几乎都为async void),这种方式并没有什么问题,也是推荐的做法。

  1. 异步返回类型 – C# | Microsoft Learn ↩︎

留下评论

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

Captcha Code