为什么当我尝试访问我的任务的结果属性时,这个异步操作会挂起?

117

我有一个多层的.Net 4.5应用程序,使用C#的新asyncawait关键字调用一个方法,但它卡住了,我不知道原因。

底部有一个异步方法,它扩展了我们的数据库实用工具OurDBConn(基本上是底层DBConnectionDBCommand对象的包装器):

public static async Task<T> ExecuteAsync<T>(this OurDBConn dataSource, Func<OurDBConn, T> function)
{
    string connectionString = dataSource.ConnectionString;

    // Start the SQL and pass back to the caller until finished
    T result = await Task.Run(
        () =>
        {
            // Copy the SQL connection so that we don't get two commands running at the same time on the same open connection
            using (var ds = new OurDBConn(connectionString))
            {
                return function(ds);
            }
        });

    return result;
}

然后我有一个中等级别的异步方法调用这个方法来获取一些缓慢运行的总数:

public static async Task<ResultClass> GetTotalAsync( ... )
{
    var result = await this.DBConnection.ExecuteAsync<ResultClass>(
        ds => ds.Execute("select slow running data into result"));

    return result;
}

最终我有了一个同步运行的UI方法(MVC操作):

Task<ResultClass> asyncTask = midLevelClass.GetTotalAsync(...);

// do other stuff that takes a few seconds

ResultClass slowTotal = asyncTask.Result;

问题是它永远卡在最后一行。如果我调用asyncTask.Wait(),它将做同样的事情。如果我直接运行缓慢的SQL方法,需要约4秒钟。
我的期望行为是,当它到达asyncTask.Result时,如果尚未完成,则应等待直到完成,并且一旦完成,应返回结果。
如果我使用调试器单步执行,SQL语句完成并且lambda函数完成,但从GetTotalAsyncreturn result;行永远不会到达。
你有什么想法我做错了什么吗?
你有什么建议我需要调查以修复这个问题吗?
这可能是死锁,如果是,是否有任何直接的方法来找到它?
5个回答

168

没错,这确实是一个死锁。这是TPL常见的错误,所以不要感到难过。

当你写await foo时,默认情况下,运行时会在与方法启动时相同的同步上下文中安排函数的继续操作。简单来说,假设你从UI线程调用了ExecuteAsync。你的查询在线程池线程上运行(因为你调用了Task.Run),但是你等待结果。这意味着运行时将安排你的“return result;”行在UI线程上运行,而不是将其安排回线程池。

那么这个死锁是如何发生的呢?想象一下你只有这段代码:

var task = dataSource.ExecuteAsync(_ => 42);
var result = task.Result;

因此,第一行开启了异步工作。第二行会阻塞UI线程。所以当运行时想要在UI线程上运行“return result”行时,它无法做到,直到Result完成。但是,当然,在返回发生之前是无法给出Result的。死锁。

这说明使用TPL的一个关键规则:当您在UI线程(或其他一些花哨的同步上下文)上使用.Result时,必须小心确保没有将Task所依赖的任何内容调度到UI线程。否则会发生邪恶的事情。

那么该怎么办呢?选项#1是在所有地方都使用await,但正如您所说,这已经不是一个选项了。您可以选择第二个选项,停止使用await。您可以重写您的两个函数为:

public static Task<T> ExecuteAsync<T>(this OurDBConn dataSource, Func<OurDBConn, T> function)
{
    string connectionString = dataSource.ConnectionString;

    // Start the SQL and pass back to the caller until finished
    return Task.Run(
        () =>
        {
            // Copy the SQL connection so that we don't get two commands running at the same time on the same open connection
            using (var ds = new OurDBConn(connectionString))
            {
                return function(ds);
            }
        });
}

public static Task<ResultClass> GetTotalAsync( ... )
{
    return this.DBConnection.ExecuteAsync<ResultClass>(
        ds => ds.Execute("select slow running data into result"));
}

什么是区别?现在没有任何等待,因此没有隐式地将任何内容安排到UI线程。对于像这样只有一个返回值的简单方法,没有必要使用“var result = await...; return result”模式;只需删除async修饰符并直接传递任务对象即可。如果没有其他问题,它的开销会更小。
选项#3是指定您不希望您的等待调度回UI线程,而只是调度到线程池。您可以使用ConfigureAwait方法来实现此目的,如下所示:
public static async Task<ResultClass> GetTotalAsync( ... )
{
    var resultTask = this.DBConnection.ExecuteAsync<ResultClass>(
        ds => return ds.Execute("select slow running data into result");

    return await resultTask.ConfigureAwait(false);
}

等待任务通常会在UI线程上安排,如果您正在使用它; 等待ContinueAwait的结果将忽略您所在的任何上下文,并始终安排到线程池。这样做的缺点是您必须在所有依赖于 .Result 的函数中随处添加每个.ConfigureAwait,因为任何遗漏的.ConfigureAwait可能会导致另一个死锁。


9
顺便提一下,这个问题是关于ASP.NET的,因此没有UI线程。 但由于ASP.NET的“同步上下文”,死锁问题完全相同。 - svick
1
这解释了很多问题,因为我有类似的 .Net 4 代码,但是没有使用 async/await 关键字的 TPL,也没有出现这个问题。 - Keith
5
TPL 是任务并行库的缩写,它是一个由 Microsoft 开发的 .NET 平台下的并行编程框架。详细信息请参见链接:https://msdn.microsoft.com/zh-cn/library/dd460717(v=vs.110).aspx - Jamie Ide
1
如果有人正在寻找VB.net代码(就像我一样),可以在这里找到解释:https://learn.microsoft.com/en-us/dotnet/visual-basic/programming-guide/concepts/async/index - MichaelDarkBlue
1
你能帮我解决https://stackoverflow.com/questions/54360300/bulk-read-images-from-url-and-then-process-threads-get-stuck-hang-somewhere的问题吗? - Jitendra Pancholi
显示剩余3条评论

40
这是经典的混合异步死锁场景,如我在博客中所述。Jason描述得很好:默认情况下,每个await都保存了一个“上下文”,用于继续执行async方法。这个“上下文”是当前SynchronizationContext,除非它为null,否则它是当前的TaskScheduler。当async方法尝试继续时,它首先重新进入捕获的“上下文”(在这种情况下,为ASP.NET SynchronizationContext)。ASP.NET SynchronizationContext只允许一个线程在上下文中运行,并且已经有一个线程在上下文中——在Task.Result上被阻塞的线程。

有两个指南可以避免这种死锁:

  1. 一路使用async。您提到您“不能”这样做,但我不确定为什么不能。在.NET 4.5上的ASP.NET MVC肯定支持async操作,并且这并不是一个困难的更改。
  2. 尽可能使用ConfigureAwait(continueOnCapturedContext: false)。这将覆盖默认行为,以在未捕获的上下文中继续执行。

ConfigureAwait(false) 是否保证当前函数在不同的上下文中恢复执行? - chue x
MVC框架支持它,但这是现有MVC应用程序的一部分,已经存在大量客户端JS。如果我轻易地切换到“async”操作,就会破坏客户端的工作方式。尽管如此,我肯定会长期调查这个选项。 - Keith
只是想澄清一下我的评论 - 我很好奇在调用树中使用 ConfigureAwait(false) 是否可以解决 OP 的问题。 - chue x
3
@Keith:将MVC动作设置为“async”并不会对客户端产生任何影响。我在另一篇博客文章中解释了这一点,该文章名为 async Doesn't Change the HTTP Protocol - Stephen Cleary
1
@Keith:async在代码库中“增长”是很正常的。如果您的控制器方法可能依赖于异步操作,则基类方法应该返回Task<ActionResult>。将大型项目转换为async始终是棘手的,因为混合使用async和同步代码很困难且棘手。纯async代码要简单得多。 - Stephen Cleary
显示剩余4条评论

17

我遇到了同样的僵局,但我的情况是从同步方法调用异步方法,对我有效的方法是:

private static SiteMetadataCacheItem GetCachedItem()
{
      TenantService TS = new TenantService(); // my service datacontext
      var CachedItem = Task.Run(async ()=> 
               await TS.GetTenantDataAsync(TenantIdValue)
      ).Result; // dont deadlock anymore
}

这是一个好的方法吗?有什么想法吗?


这个解决方案对我也有效,但我不确定它是一个好的解决方案还是可能会在某些地方出问题。有人可以解释一下吗? - Konstantin Vdovkin
最终我选择了这个解决方案,在生产环境中运行良好,没有出现任何问题。 - Danilow
1
我认为你在使用Task.Run时会受到性能影响。在我的测试中,对于一个100毫秒的http请求,Task.Run几乎将执行时间增加了一倍。 - Timothy Gonzalez
1
这是有意义的,您正在创建一个新任务来包装异步调用,性能是权衡的结果。 - Danilow
太棒了,这对我也起作用了,我的情况也是由同步方法调用异步方法引起的。谢谢! - Leonardo Spina
我不确定,但我认为你必须在try/catch中包装这段代码;任何Task.Run内部的错误都会默默死亡... - Marcelo Scofano Diniz

5

仅补充一下已接受的答案(没有足够的声望来发表评论),当使用task.Result阻塞时,我遇到了这个问题,即使在它下面的每个await都有ConfigureAwait(false),例如:

public Foo GetFooSynchronous()
{
    var foo = new Foo();
    foo.Info = GetInfoAsync.Result;  // often deadlocks in ASP.NET
    return foo;
}

private async Task<string> GetInfoAsync()
{ 
    return await ExternalLibraryStringAsync().ConfigureAwait(false);
}

问题实际上在于外部库代码。无论我如何配置等待,异步库方法都试图在调用同步上下文中继续,导致死锁。

因此,答案是编写自己版本的外部库代码ExternalLibraryStringAsync,以便它具有所需的连续属性。


历史目的的错误答案

经过长时间的痛苦和煎熬,我发现解决方案 buried in this blog post(Ctrl-f for 'deadlock')。它围绕着使用task.ContinueWith而不是裸的task.Result

以前的死锁示例:

public Foo GetFooSynchronous()
{
    var foo = new Foo();
    foo.Info = GetInfoAsync.Result;  // often deadlocks in ASP.NET
    return foo;
}

private async Task<string> GetInfoAsync()
{ 
    return await ExternalLibraryStringAsync().ConfigureAwait(false);
}

避免死锁,像这样:
public Foo GetFooSynchronous
{
    var foo = new Foo();
    GetInfoAsync()  // ContinueWith doesn't run until the task is complete
        .ContinueWith(task => foo.Info = task.Result);
    return foo;
}

private async Task<string> GetInfoAsync
{
    return await ExternalLibraryStringAsync().ConfigureAwait(false);
}

踩票是为什么?这个解决方案对我有效。 - Cameron Jeffers
你在 Task 完成之前就返回了对象,并且没有提供调用者任何确定返回对象变异实际发生的方法。 - Servy
1
如果你这么做,它会死锁。你需要通过返回一个“Task”而不是阻塞来异步化所有操作。 - Servy
如果 ExternalLibraryStringAsync 在完成自身之前向当前同步上下文发布继续操作,则在从当前同步上下文同步等待它时,它将死锁。 - Servy
好的,我自己编写了外部代码的版本,现在似乎可以工作了。非常感谢@Servy! - Cameron Jeffers
显示剩余3条评论

2
快速答复:修改此行

ResultClass slowTotal = asyncTask.Result;

为了

ResultClass slowTotal = await asyncTask;

为什么?除了控制台应用程序之外,大多数应用程序不应使用.result来获取任务的结果。如果这样做,当程序到达那里时,它将会挂起。

如果您想使用.Result,您也可以尝试下面的代码:

ResultClass slowTotal = Task.Run(async ()=>await asyncTask).Result;

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