【问题标题】:Why does this async action hang when I try and access the Result property of my Task?当我尝试访问任务的 Result 属性时,为什么这个异步操作会挂起?
【发布时间】:2019-10-21 06:56:38
【问题描述】:

我有一个多层 .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; 行。

知道我做错了什么吗?

对于我需要在哪里进行调查以解决此问题有什么建议吗?

这可能是某个地方的死锁,如果是这样,有没有直接的方法可以找到它?

【问题讨论】:

    标签: c# asynchronous async-await task-parallel-library


    【解决方案1】:

    是的,这是一个僵局。还有一个 TPL 的常见错误,所以不要难过。

    当您编写await foo 时,默认情况下,运行时会在方法启动所在的同一个 SynchronizationContext 上安排函数的延续。在英语中,假设您从 UI 线程调用了您的 ExecuteAsync。您的查询在线程池线程上运行(因为您调用了Task.Run),但您随后等待结果。这意味着运行时将安排您的“return result;”行在 UI 线程上运行,而不是将其安排回线程池。

    那么这个死锁是怎么发生的呢?想象一下,您只有以下代码:

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

    所以第一行开始了异步工作。然后第二行阻塞了 UI 线程。因此,当运行时想要在 UI 线程上运行“返回结果”行时,它不能这样做,直到 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 都可能导致另一个死锁。

    【讨论】:

    【解决方案2】:

    这是经典的混合-async 死锁场景,as I describe on my blog。 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) 是否保证当前函数在不同的上下文中恢复?
    • MVC 框架支持它,但这是现有 MVC 应用程序的一部分,其中已经存在许多客户端 JS。我不能轻易切换到async 操作而不破坏客户端的工作方式。不过,我当然计划长期研究该选项。
    • 只是为了澄清我的评论 - 我很好奇在调用树下使用 ConfigureAwait(false) 是否可以解决 OP 的问题。
    • @Keith:进行 MVC 操作 async 根本不会影响客户端。我在另一篇博文中解释了这一点,async Doesn't Change the HTTP Protocol
    • @Keith:async 通过代码库“成长”是正常的。如果你的控制器方法可能依赖于异步操作,那么基类方法应该返回Task&lt;ActionResult&gt;。将大型项目转换为 async 总是很尴尬,因为将 async 和同步代码混合起来既困难又棘手。纯async 代码要简单得多。
    【解决方案3】:

    我处于同样的死锁情况,但在我的情况下,从同步方法调用异步方法,对我有用的是:

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

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

    【讨论】:

    • 这个解决方案也对我有用,但我不确定它是否是一个好的解决方案,或者它可能会在某个地方中断。任何人都可以解释一下
    • 好吧,最后我选择了这个解决方案,它在一个生产环境中工作,没有麻烦.....
    • 我认为您使用 Task.Run 会降低性能。在我的测试中,Task.Run 几乎使 100 毫秒 http 请求的执行时间增加了一倍。
    • 有道理,你正在创建一个新任务来包装异步调用,性能是权衡
    • 太棒了,这对我也有用,我的情况也是由调用异步方法的同步方法引起的。谢谢!
    【解决方案4】:

    只是为了添加到已接受的答案(没有足够的代表发表评论),我在使用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 表示“死锁”)。它使用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);
    }
    

    【讨论】:

    • 反对票有什么用?这个解决方案对我有用。
    • 您在 Task 完成之前返回对象,并且无法让调用者确定返回对象的突变何时实际发生。
    • 嗯,我明白了。那么我应该公开某种使用手动阻塞while循环(或类似的东西)的“等待任务完成”方法吗?还是将这样的块打包到GetFooSynchronous方法中?
    • 如果你这样做,它会死锁。您需要通过返回 Task 而不是阻塞来一直异步。
    • 不幸的是,这不是一个选项,该类实现了一个我无法更改的同步接口。
    【解决方案5】:

    快速回答: 改变这一行

    ResultClass slowTotal = asyncTask.Result;
    

    ResultClass slowTotal = await asyncTask;
    

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

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

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

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2014-05-15
      相关资源
      最近更新 更多