一个引起死锁的async/await示例

129

我看到了一些有关使用 C# 的 async/await 关键字进行异步编程的最佳实践(我对 C# 5.0 还比较陌生)。

给出了其中一个建议:

稳定性:了解同步上下文

... 一些同步上下文是不可重入和单线程的。这意味着在给定时间内只能在上下文中执行一个工作单元。例如,Windows UI 线程或 ASP.NET 请求上下文都是如此。 在这些单线程同步上下文中,很容易死锁自己。如果你从单线程上下文中生成一个任务,然后在该上下文中等待该任务,那么你的等待代码可能会阻塞后台任务。

public ActionResult ActionAsync()
{
    // DEADLOCK: this blocks on the async task
    var data = GetDataAsync().Result;

    return View(data);
}

private async Task<string> GetDataAsync()
{
    // a very simple async method
    var result = await MyWebService.GetDataAsync();
    return result.ToString();
}

如果我自己试图分析它,主线程会在 MyWebService.GetDataAsync(); 中产生一个新的线程,但由于主线程在那里等待,它会等待 GetDataAsync().Result 的结果。同时,假设数据已经准备好了。为什么主线程不继续其连续逻辑并从 GetDataAsync() 返回一个字符串结果呢?
请问有人能解释一下为什么上面的例子会出现死锁吗? 我完全不知道问题出在哪里...

你确定GetDataAsync完成了它的任务吗?还是它会陷入死锁,导致只是锁住而非死锁? - Andrey
这是提供的示例。据我理解,它应该完成其任务并准备好某种结果... - dror
4
为什么你还在等待任务?其实你应该改为等待,因为你已经失去了异步模型的所有优势。 - Toni Petrina
补充一下@ToniPetrina的观点,即使没有死锁问题,var data = GetDataAsync().Result;也是一行代码,在不应该阻塞的上下文中绝对不应该执行(如UI或ASP.NET请求)。即使它没有死锁,也会阻塞线程不确定的时间。 所以基本上这是一个糟糕的例子。[在执行这样的代码之前,您需要从UI线程中退出,或者像Toni建议的那样也使用await] - ToolmakerSteve
5个回答

96

看一下这个例子, Stephen已经为你提供了明确的答案:

所以,从顶级方法(UI / MyController.Get for ASP.NET 中的 Button1_Click)开始发生以下情况:
1. 顶级方法调用 GetJsonAsync(在 UI / ASP.NET 上下文中)。 2. GetJsonAsync 通过调用 HttpClient.GetStringAsync(仍在上下文中)启动 REST 请求。 3. GetStringAsync 返回未完成的任务,表示 REST 请求尚未完成。 4. GetJsonAsync 等待 GetStringAsync 返回的任务。上下文被捕获并将用于稍后继续运行 GetJsonAsync 方法。GetJsonAsync 返回未完成的任务,表示 GetJsonAsync 方法尚未完成。 5. 顶级方法同步阻止由 GetJsonAsync 返回的任务。这会阻塞上下文线程。 6. ... 最终,REST 请求将完成。这将完成由 GetStringAsync 返回的任务。 7. GetJsonAsync 的继续现在已准备好运行,并等待上下文可用,以便可以在上下文中执行。 8. 死锁。顶级方法正在阻塞上下文线程,等待 GetJsonAsync 完成,而 GetJsonAsync 正在等待上下文空闲以便可以完成。对于 UI 示例,“上下文”是 UI 上下文;对于 ASP.NET 示例,“上下文”是 ASP.NET 请求上下文。此类死锁可能是由任何“上下文”引起的。

你应该阅读的另一个链接: 等待、UI和死锁!噢我的天呐!


OP是否可以通过使用await MyWebService.GetDataAsync().ConfigureAwait(false);而不是await MyWebService.GetDataAsync();来解决这个问题? - David Klempfner

31
  • 事实1: GetDataAsync().Result; 会在由GetDataAsync()返回的任务完成时运行,同时它会阻塞UI线程。
  • 事实2: 等待操作后续的代码(return result.ToString())将被排队到UI线程以供执行。
  • 事实3:GetDataAsync()返回的任务将在其排队的后续代码运行时完成。
  • 事实4: 排队的后续代码永远不会运行,因为UI线程被阻塞了(事实1)。

死锁!

可以通过提供一些替代方案来避免事实1或事实2。

  • 解决方法1: 避免1和4。使用var data = await GetDataAsync()而不是阻塞UI线程,这样UI线程就可以继续运行。
  • 解决方法2: 避免2和3。将等待后续代码的执行放入一个未被阻塞的不同线程队列中,例如使用var data = Task.Run(GetDataAsync).Result,它将把后续代码推送到线程池线程的同步上下文中。这样可以允许由GetDataAsync()返回的任务完成。

在一篇Stephen Toub的文章中,用DelayAsync()作为示例,在中间部分详细解释了这个问题。


1
关于 var data = Task.Run(GetDataAsync).Result,这对我来说是新的。我一直以为外部的 .Result 会在第一个 GetDataAsync 的 await 被触发时立即可用,所以 data 总是 default。有趣。 - nawfal
修复2解决了我的问题。 - Kumar

29

我刚刚在一个ASP.NET MVC项目中又遇到了这个问题。如果你想从PartialView调用async方法,你不能将PartialView设为async。如果这样做会出现异常。

如果你想从同步方法中调用异步方法,请使用以下简单的解决方法:

  1. 在调用之前清除SynchronizationContext
  2. 进行调用,这里将不会有死锁,等待它完成
  3. 恢复SynchronizationContext

示例:

public ActionResult DisplayUserInfo(string userName)
{
    // trick to prevent deadlocks of calling async method 
    // and waiting for on a sync UI thread.
    var syncContext = SynchronizationContext.Current;
    SynchronizationContext.SetSynchronizationContext(null);

    //  this is the async call, wait for the result (!)
    var model = _asyncService.GetUserInfo(Username).Result;

    // restore the context
    SynchronizationContext.SetSynchronizationContext(syncContext);

    return PartialView("_UserInfo", model);
}

2
另一个主要的观点是,您不应该阻塞任务,并使用异步方式避免死锁。这样,所有操作都是异步而不是同步阻塞。
public async Task<ActionResult> ActionAsync()
{

    var data = await GetDataAsync();

    return View(data);
}

private async Task<string> GetDataAsync()
{
    // a very simple async method
    var result = await MyWebService.GetDataAsync();
    return result.ToString();
}

7
如果我希望主(UI)线程在任务完成之前被阻塞,该怎么办?或者例如在控制台应用程序中?假设我想要使用仅支持异步的HttpClient,那么如何以同步方式使用它,而又不会出现死锁的风险?这一定是可行的。如果WebClient可以通过这种方式使用(因为它有同步方法)并且能够完美地工作,那么为什么不能对HttpClient进行同样的操作呢? - Dexter
请参考Philip Ngan的回答(我知道这是在此评论之后发布的):将等待的继续操作排队到不被阻塞的不同线程中,例如使用 var data = Task.Run(GetDataAsync).Result。 - Jeroen
@Dexter - 关于“如果我想让主(UI)线程被阻塞直到任务完成呢?”- 你真的想要UI线程被阻塞,意味着用户什么也做不了,甚至不能取消吗?还是说你只是不想继续当前方法?"await"或"Task.ContinueWith"可以处理后一种情况。 - ToolmakerSteve
1
@ToolmakerSteve 当然我不想继续使用这种方法。但是我根本不能使用await,因为我也无法一直使用async——HttpClient在_main_中被调用,而这当然不能是异步的。然后我提到了在控制台应用程序中完成所有这些操作——在这种情况下,我确切地想要前者——我甚至不希望我的应用程序是多线程的。阻止_一切_。 - Dexter

0
我想到的一个解决方法是在请求结果之前,在任务上使用Join扩展方法。
代码如下:
public ActionResult ActionAsync()
{
  var task = GetDataAsync();
  task.Join();
  var data = task.Result;

  return View(data);
}

join方法在哪里:

public static class TaskExtensions
{
    public static void Join(this Task task)
    {
        var currentDispatcher = Dispatcher.CurrentDispatcher;
        while (!task.IsCompleted)
        {
            // Make the dispatcher allow this thread to work on other things
            currentDispatcher.Invoke(delegate { }, DispatcherPriority.SystemIdle);
        }
    }
}

我个人在这个领域上还不够精通,无法看出这个解决方案是否存在缺陷。


我喜欢这个,Redux中的action派发很好用。我正在学习C#,想知道这段代码有什么问题,它能用吗?为什么被踩了? - Daniel Oquinn
如果没有其他任务可以运行,它能否进入紧密循环并使用100%的CPU?(如果是这种情况,它将在一个紧密循环中运行空委托吗?) - osexpert

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接