在WebAPI中使用ContinueWith进行死锁

Ste*_*iel 5 c# deadlock task-parallel-library asp.net-web-api

作为通过Web API公开一些现有代码的一部分,我们遇到了很多死锁.我已经能够将这个问题提炼到这个非常简单的例子中,这个例子将永远挂起:

public class MyController : ApiController
{
    public Task Get()
    {
        var context = TaskScheduler.FromCurrentSynchronizationContext();

        return Task.FromResult(1)
            .ContinueWith(_ => { }, context)
            .ContinueWith(_ => Ok(DateTime.Now.ToLongTimeString()), context);
    }
}
Run Code Online (Sandbox Code Playgroud)

对我来说,这段代码看起来很简单.这可能看起来有点人为,但这只是因为我尽可能地尝试简化问题.看起来像这样链接的两个ContinueWiths会导致死锁 - 如果我注释掉第一个ContinueWith(它实际上并没有做任何事情),它会正常工作.我也可以通过不给出特定的调度程序来"修复"它(但这对我们来说不是一个可行的解决方案,因为我们的实际代码需要在正确/原始的线程上).在这里,我将两个ContinueWiths放在一起,但在我们的实际应用中,有很多逻辑正在发生,而ContinueWiths最终来自不同的方法.

我知道我可以使用async/await重新编写这个特定的例子,它会简化一些事情并且似乎可以修复死锁.但是,我们在过去几年中已经编写了大量遗留代码 - 其中大部分都是在异步/等待出现之前编写的,所以它大量使用了ContinueWith.如果我们能够避免它,重写所有逻辑并不是我们现在想要做的事情.像这样的代码在我们遇到的所有其他场景(桌面应用程序,Silverlight应用程序,命令行应用程序等)中运行良好 - 它只是给我们这些问题的Web API.

有没有什么可以一般性地完成,可以解决这种僵局?我正在寻找一种解决方案,希望不会重写所有的ContinueWith来使用async/await.

更新:

上面的代码是我控制器中的整个代码.我试图用最少量的代码使这个可重复.我甚至在一个全新的解决方案中做到了这一点.我做的全部步骤:

  1. 从Windows 7上的Visual Studio 2013 Update 1(使用.NET Framework 4.5.1),使用ASP.NET Web应用程序模板创建一个新项目
  2. 选择Web API作为模板(在下一个屏幕上)
  3. 使用我原始代码中给出的示例替换自动创建的ValuesController中的Get()方法
  4. 点击F5启动应用程序并导航到./api/values - 请求将永久挂起
  5. 我也尝试在IIS中托管网站(而不是使用IIS Express)
  6. 我也尝试更新所有各种Nuget包,所以我在最新的一切

web.config不受模板创建的影响.具体来说,它具有:

<system.web>
   <compilation debug="true" targetFramework="4.5" />
   <httpRuntime targetFramework="4.5" />
</system.web>
Run Code Online (Sandbox Code Playgroud)

Ste*_*iel 1

根据 Noseratio 的回答,我想出了以下“安全”版本的ContinueWith。当我更新代码以使用这些安全版本时,我不再遇到死锁。用这些 SafeContinueWith 替换我现有的所有ContinueWith可能不会太糟糕......它肯定比重写它们以使用 async/await 更容易、更安全。当它在非 ASP.NET 上下文(WPF 应用程序、单元测试等)下执行时,它将回退到标准的ContinueWith 行为,因此我应该具有完美的向后兼容性。

我仍然不确定这是最好的解决方案。看起来这是一种相当严厉的方法,对于看起来如此简单的代码来说是必要的。

话虽如此,我提出这个答案是为了防止它引发其他人的好主意。我觉得这不是理想的解决方案。

新控制器代码:

public Task Get()
{
    return Task.FromResult(1)
               .SafeContinueWith(_ => { })
               .SafeContinueWith(_ => Ok(DateTime.Now.ToLongTimeString()));
}
Run Code Online (Sandbox Code Playgroud)

然后SafeContinueWith的实际实现:

public static class TaskExtensions
{
    private static bool IsAspNetContext(this SynchronizationContext context)
    {
        //Maybe not the best way to detect the AspNetSynchronizationContext but it works for now
        return context != null && context.GetType().FullName == "System.Web.AspNetSynchronizationContext";
    }

    /// <summary>
    /// A version of ContinueWith that does some extra gynastics when running under the ASP.NET Synchronization 
    /// Context in order to avoid deadlocks.  The <see cref="continuationFunction"/> will always be run on the 
    /// current SynchronizationContext so:
    /// Before:  task.ContinueWith(t => { ... }, TaskScheduler.FromCurrentSynchronizationContext());
    /// After:   task.SafeContinueWith(t => { ... });
    /// </summary>
    public static Task<T> SafeContinueWith<T>(this Task task, Func<Task,T> continuationFunction)
    {
        //Grab the context
        var context = SynchronizationContext.Current;

        //If we aren't in the ASP.NET world, we can defer to the standard ContinueWith
        if (!context.IsAspNetContext())
        {
            return task.ContinueWith(continuationFunction, TaskScheduler.FromCurrentSynchronizationContext());
        }

        //Otherwise, we need our continuation to be run on a background thread and then synchronously evaluate
        //  the continuation function in the captured context to arive at the resulting value
        return task.ContinueWith(t =>
        {
            var result = default(T);
            context.Send(_ => result = continuationFunction(t), null);
            //TODO: Verify that Send really did complete synchronously?  I think it's required to by Contract?
            //      But I'm not sure I'd want to trust that if I end up using this in producion code.
            return result;
        });
    }

    //Same as above but for non-generic Task input so a bit simpler
    public static Task SafeContinueWith(this Task task, Action<Task> continuation)
    {
        var context = SynchronizationContext.Current;
        if (!context.IsAspNetContext())
        {
            return task.ContinueWith(continuation, TaskScheduler.FromCurrentSynchronizationContext());
        }

        return task.ContinueWith(t => context.Send(_ => continuation(t), null));
    }
}
Run Code Online (Sandbox Code Playgroud)