为什么我与ContinueWith的异步死锁?

Lia*_*iam 7 c# deadlock async-await dapper asp.net-web-api2

我不是在这里解决问题,更多是对正在发生的事情的解释.我已经重构了这段代码来防止这个问题,但我很好奇为什么这个调用会死锁.基本上我有一个头对象列表,我需要从DB存储库对象(使用Dapper)加载每个细节.我尝试使用ContinueWith但是失败了:

List<headObj> heads = await _repo.GetHeadObjects();
var detailTasks = heads.Select(s => _changeLogRepo.GetDetails(s.Id)
    .ContinueWith(c => new ChangeLogViewModel() {
         Head = s,
         Details = c.Result
 }, TaskContinuationOptions.OnlyOnRanToCompletion));

await Task.WhenAll(detailTasks);

//deadlock here
return detailTasks.Select(s => s.Result);
Run Code Online (Sandbox Code Playgroud)

有人可以解释导致这种僵局的原因吗?我试图了解这里发生的事情,但我不确定.我认为这与打电话.Result有关ContinueWith

附加信息

  • 这是一个在async上下文中调用的webapi应用程序
  • 回购电话一直都是这样的:

    public async Task<IEnumerable<ItemChangeLog>> GetDetails(int headId)
    {
        using(SqlConnection connection = new SqlConnection(_connectionString))
        {
            return await connection.QueryAsync<ItemChangeLog>(@"SELECT [Id]
             ,[Description]
             ,[HeadId]
                FROM [dbo].[ItemChangeLog]
                WHERE HeadId = @headId", new { headId });
        }
    }
    
    Run Code Online (Sandbox Code Playgroud)
  • 我已经用以下代码解决了这个问题:

     List<headObj> heads = await _repo.GetHeadObjects();
     Dictionary<int, Task<IEnumerable<ItemChangeLog>>> tasks = new Dictionary<int, Task<IEnumerable<ItemChangeLog>>>();
     //get details for each head and build the vm
     foreach(ItemChangeHead head in heads)
     {
           tasks.Add(head.Id, _changeLogRepo.GetDetails(head.Id));
     }
     await Task.WhenAll(tasks.Values);
    
     return heads.Select(s => new ChangeLogViewModel() {
            Head = s,
            Details = tasks[s.Id].Result
        });
    
    Run Code Online (Sandbox Code Playgroud)

小智 4

这个问题实际上是以上问题的综合。创建任务的枚举,每次迭代该枚举时,都会进行一次新的GetDetails调用。对此 Select 的调用ToList将修复死锁。在不固化可枚举的结果(将它们放入列表中)的情况下,调用WhenAll会评估可枚举并异步等待结果任务,不会出现任何问题,但是当返回的 Select 语句评估时,它会迭代并同步等待由新鲜的GetDetailsContinueWith尚未完成的呼叫。所有这些同步等待都可能在尝试序列化响应时发生。

至于为什么同步等待会导致死锁,谜团在于await 是如何做事的。这完全取决于你打电话的内容。等待实际上只是通过任何范围可见的限定GetAwaiter方法检索等待者并注册回调,该GetResult回调在工作完成时立即调用等待者。限定GetAwaiter方法可以是返回具有属性的对象的实例或扩展方法IsCompleted、无参数GetResult方法(任何返回类型,包括 void - wait 的结果)以及INotifyCompletion接口ICriticalNotifyCompletion。这两个接口都有OnComplete注册回调的方法。这里有一个令人难以置信的调用链ContinueWith和等待调用,其中大部分取决于运行时环境。从 a 获得的等待的默认行为Task<T>是使用SynchronizationContext.Current(我认为是 via TaskScheduler.Current)来调用回调,或者如果为 null 则使用线程池(我认为是 via TaskScheduler.Default)来调用回调。包含等待的方法被某个CompilerServices类包装为任务(忘记了名称),为该方法的调用者提供上述行为,包装您正在等待的任何实现。

ASynchronizationContext也可以自定义它,但通常每个上下文都在它自己的单个线程上调用。如果在a上调​​用SynchronizationContext.Currentwhen 时存在这样的实现,并且您同步等待(这本身取决于对等待线程的调用),则会出现死锁。awaitTaskResult

另一方面,如果您将原样方法分解到另一个线程,或者调用ConfigureAwait任何任务,或者隐藏调用的当前调度程序ContinueWith,或者设置您自己的SynchronizationContext.Current(不推荐),则您可以更改上述所有内容。