【问题标题】:Deadlock with ContinueWiths in WebAPIWeb API 中的 ContinueWith 死锁
【发布时间】:2014-05-15 00:14:17
【问题描述】:

在通过 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);
    }
}

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

我知道我可以使用 async/await 重写这个特定的示例,它会简化事情并且似乎可以解决死锁。然而,我们在过去几年中编写了大量遗留代码——其中大部分是在 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>

【问题讨论】:

  • 我们需要实际的代码位来重现问题。
  • @YuvalItzchakov 我发布的代码实际上就是我编写的整个代码。我一直在使用标准模板在一个全新的项目中进行所有这些测试,而没有其他自定义代码。我已更新问题以包含重现此问题所需的确切步骤(希望如此)。
  • 这似乎是我在这里描述的相同性质的死锁:stackoverflow.com/q/23062154/1768303
  • @Noseratio 我似乎确实遇到了与该问题中提到的问题类似的问题。但在那个问题上,僵局似乎是一个没有真正解决的次要问题。 :-(
  • @StephenMcDaniel,也许,this 可以提供帮助,但我自己没有尝试过。

标签: c# asp.net-web-api task-parallel-library deadlock


【解决方案1】:

尝试以下(未经测试)。它基于AspNetSynchronizationContext.Send 同步执行回调的想法,因此不应导致the same deadlock。这样,我们在随机池线程上输入AspNetSynchronizationContext

public class MyController : ApiController
{
    public Task Get()
    {
        // should be AspNetSynchronizationContext
        var context = SynchronizationContext.Current;

        return Task.FromResult(1)
            .ContinueWith(_ => { }, TaskScheduler.Default)
            .ContinueWith(_ =>
            {
                object result = null;
                context.Send(__ => { result = Ok(DateTime.Now.ToLongTimeString()); }, 
                    null);
                return result;
            }, TaskScheduler.Default);
    }
}

更新,基于 cmets,显然它可以工作并消除死锁。此外,我将在此解决方案之上构建一个自定义任务调度程序,并使用它来代替 TaskScheduler.FromCurrentSynchronizationContext(),对现有代码库的更改非常少。

【讨论】:

  • 如果代码会同步执行,使用Task的原因是什么?
  • @ToanNguyen,哪个代码?传递给ContinueWith 的任务的lambda 正在执行异步,正如TaskSheduler.Default 排队到线程池中一样。在其中,context.Send lambda 是同步执行的,完全符合我的要求。它的唯一目的是在正确的同步上下文中执行。如果我没有将contex.Send 放在那里,Ok() 方法无论如何都会同步执行,但是在错误的上下文中。
  • 该代码运行时没有死锁(通过一些小的调整使其可以编译)。但这似乎不是一个非常可行的解决方案。在我的真实代码库中,ContinueWith 内部的逻辑深埋在业务逻辑中,它不知道它在 Web API 下运行,因此它不知道它需要跳过所有这些圈......我不会无论如何,真的希望它知道。除非我遗漏了什么,否则这似乎是一个非常“本地”和侵入性的变化。但它不会陷入僵局,所以这是一个好的开始!
  • @StephenMcDaniel,从第二个想法开始,您可以在我的解决方案之上构建一个自定义任务调度程序,并使用它来代替TaskScheduler.FromCurrentSynchronizationContext(),对您的代码库进行非常小的更改。从TaskScheduler 派生并相应地实现QueueTask。 SO上有一堆自定义任务调度程序示例,拿一个作为骨架。
  • @StephenMcDaniel,我更喜欢静态自定义任务调度程序,因为这会模仿现有模式并说明 TPL 或您项目中涉及的任何其他库可能隐式使用 TaskSchduler.Current .但话又说回来,我更喜欢async/await。无论如何,这就是我能在这里提供的最大帮助。
【解决方案2】:

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

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

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

新控制器代码:

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

然后是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));
    }
}

【讨论】:

  • 或者您可以在自定义同步上下文中创建包装登录,然后将其设置为当前同步上下文。
  • @ToanNguyen,这可能不是一个好主意,因为 ASP.NET 中的一些代码专门检查 SynchronizationContext.Current 是否为 AspNetSynchronizationContext
  • @Noseratio 您仍然可以在包装上下文中检查它。
  • @ToanNguyen,我说的是 ASP.NET 运行时代码,而不是任何自定义代码。此外,在 ASP.NET 应用程序中安装类似的自定义同步上下文可能会影响任何基于 async/await 的代码。这将是一个滑坡。
【解决方案3】:

你可以设置TaskContinuationOptions.ExecuteSynchronously:

return Task.FromResult(1)
    .ContinueWith(_ => { }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, context)
    .ContinueWith(_ => Ok(DateTime.Now.ToLongTimeString()), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, context);

还有一种“全局”的方式可以让它发挥作用;在您的 web.config 中,将此添加到您的 appSettings:

<add key="aspnet:UseTaskFriendlySynchronizationContext" value="false" />

但是,我不能真正推荐全局方法。使用该应用设置,您不能在您的应用程序中使用 async/await

【讨论】:

  • ExecuteSynchronously 似乎不是一个安全的解决方案。这不只是一个可能被忽略的“提示”吗?此外,这是否会导致继续在后台线程上运行 - 比如说,如果第一个任务在后台线程上完成......?在那种情况下,我不能让 Continuation 同步运行,因为这样下去 continuation 将在“错误”线程上,谁知道会出什么问题。
  • 是的,回退到旧的同步上下文不是我们的选择。当我们还回到 .NET 4.0 时,我们遇到了各种各样的死锁和其他问题。我们有很多使用 ContinueWith 的旧代码,但也有很多使用 async/await 的新代码。
  • 如果代码改成使用TaskContinuationOptions.ExecuteSynchronously,那为什么不去掉所有Task相关的操作呢?
  • ExecuteSynchronously can be ignored in some situations;具体来说,如果代码接近堆栈溢出,如果请求线程被中止,或者如果目标调度程序拒绝任务。这些都不应该在这里发生。调度程序“覆盖”ExecuteSynchronously,因此不存在在错误线程上运行的危险。
  • 如果您想要最佳解决方案,请转换为async/await。其他任何事情都至少有点骇人听闻。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2017-08-03
  • 2021-11-08
  • 1970-01-01
  • 2019-04-26
  • 1970-01-01
  • 2014-08-26
相关资源
最近更新 更多