线程池在使用 Task.Result 时出现死锁问题

6
我们有一个较大的asp.net系统,并开始使用一些基础设施库中不可更改的异步方法。虽然该系统在大多数地方没有使用任务,但基础设施却只提供了异步方法。
在代码中,我们采用以下模式使用异步方法:Task.Run(() => Foo()).Result。我们使用Task.Run来防止死锁,如果某处代码中没有使用ConfigureAwait(false),则会出现死锁,而且可能会被遗漏。为了将其整合到现有的同步代码库中,我们使用Task.Result
在经历了重负载后,我们发现我们收到了超时错误,但服务器并没有进行任何工作(低CPU)。我们发现在向服务器发出大量调用并且线程池达到最大线程数时,任务结果造成死锁,因为它会阻塞线程,直到任务完成,但由于没有线程池线程可用来运行它,任务无法运行。
最好的解决方案是将代码全部改为异步处理,但目前还不是一个选项。另外,删除Task.Run也可能有效,但风险太大,因为测试覆盖范围不足,无法确定是否会在未经测试的流程中导致新的死锁。
我尝试实现一个新的任务调度程序,它不使用线程池,而使用不同的一组线程来运行Foo任务,但内部任务仍然在默认任务调度程序上执行,而我不想将其替换。
有没有什么办法可以在不对代码进行大规模更改的情况下解决这个问题?
这是一个重现该问题的小样本应用程序,只使用10个线程而不是真实的限制。在此示例中,Foo永远不会被调用。
class Program
{
    static void Main(string[] args)
    {
        ThreadPool.SetMaxThreads(10, 10);
        ThreadPool.SetMinThreads(10, 10);

        for (int i = 0; i < 10; i++)
        {
            ThreadPool.QueueUserWorkItem(CallBack);
        }

        Console.ReadKey();
    }

    private static void CallBack(object state)
    {
        Thread.Sleep(1000);

        var result = Task.Run(() => Foo()).Result;
    }

    public static async Task<string> Foo()
    {
        await Task.Delay(100);
        return "";
    }
}

谢谢回复,但看起来这只解决了单线程上的普通死锁问题,但它仍然在线程池上运行任务,导致我们现在遇到的死锁。 - Barak Hirsch
2
最好的解决方案是将代码全部改为异步工作,但目前这不是一个选项。在您的示例中,明显的修复方法是将Callback()更改为async并使用await来等待Task.Run()。事实上,通过在Task上同步等待Result是一个非常糟糕的想法,因为它实际上保证了您最终会遇到死锁问题。在这里,“all the way”指的是整个代码流程都应该是异步的,而“not an option”则表示目前无法进行此更改。 - Peter Duniho
1
@BarakHirsch 看起来 Task.Delay 需要一个空闲的线程池线程来完成... -:( - Pavel Mayorov
2
当线程池达到极限并且其大小增加时,会创建新的线程。它们将从全局队列(传入请求)中获取工作,而不是从本地队列(连续性)中获取工作:因为它们是新生线程,所以它们不可能在本地队列中有任何东西。这意味着您可以继续接受新请求而永远无法完成它们,导致您正在观察的问题。不幸的是,没有完美的解决方案(除了完全迁移到任务模型并完全删除同步等待,这可能需要数月/数年,具体取决于代码库的大小)。 - Kevin Gosse
1
如果你遇到了扩展问题并且不想采用适当的解决方案(转换为“async”),那么你唯一的其他真正解决方案就是升级硬件并增加线程池线程的最小数量。 - Stephen Cleary
显示剩余5条评论
2个回答

4
你已经非常清楚地解释了你的问题。
由于你使用了Task.Run并阻塞了结果,因此你使用了1-2个线程,而实际上使用异步时你想要使用0-1个线程。
如果你在代码中过度使用Task.Run,那么有可能会有多层阻塞线程,使得线程使用变得非常糟糕,你会像你所描述的那样达到最大容量。
顺便说一下,在单元测试(或控制台应用程序)中尝试查找异步死锁是不现实的,因为它需要一个非默认的SynchronizationContext
最好和正确的解决方案是将所有内容从上到下都变成异步或同步的,但鉴于你的限制,我建议调查一下Microsoft vs团队的这个绝妙的库并查看JoinableTaskFactory.Run(...),这将在阻塞线程上运行连续项,并且当你在多个级别嵌套此模式时效果很好。使用这种方法,你将更接近同步等效的代码。
重申一下,这些技术都是解决方法,如果你通过尊重现有代码来证明这些解决方法,那么尊重它的最好方式是做正确的事情,使它完全同步或从上到下异步。

给那位点了踩的人,请告诉我您想解决什么问题。 - Stuart
我这样做只是为了调用那些仅有异步版本的库函数。 - Joshua

0

如果您禁用了aspnet:UseTaskFriendlySynchronizationContext设置,那么您可以安全地使用短格式(Foo().Result),而不是Task.Run(() => Foo()).Result

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

禁用任务友好的同步上下文意味着在任何await操作之后HttpContext.Current将为null - 但现在它在Task.Run内部也是null。
使用Foo().Result而不是Task.Run(() => Foo()).Result将导致线程池使用减少2倍,因此可以解决您的问题。
此外,您还可以使用<httpRuntime><processModel>来配置最小空闲线程池大小:
<system.web>
  <processModel autoConfig="false" maxWorkerThreads="..." maxIoThreads="..." />
  <httpRuntime minFreeThreads="..." />
</system.web>

请注意默认值为:

每 CPU 最大工作线程数 = 100

每 CPU 最大 I/O 线程数 = 100

每 CPU 最小空闲线程数 = 88


这段内容与编程有关。

1
我不能使用aspnet:UseTaskFriendlySynchronizationContext=false,因为有些地方我们确实使用了任务和HttpContext.Current。改变线程数可能是一个不错的解决方法,当我们负载较重时,但这并不是完整的解决方案。我会去检查一下。 - Barak Hirsch
应该只是 Foo(),而不是 Foo().Result,并且去掉 Task.Run 吗? - Enigmativity
如果 aspnet:UseTaskFriendlySynchronizationContextfalse,那么你就不能使用 async/await - Stephen Cleary
@StephenCleary 为什么? - Pavel Mayorov
@PavelMayorov:因为当它为“false”时,“async”的行为是未定义的(https://blogs.msdn.microsoft.com/webdev/2012/11/19/all-about-httpruntime-targetframework/)。 - Stephen Cleary
实际上,如果你将 aspnet:UseTaskFriendlySynchronizationContext 设置为 false,并等待一个在请求结束后才会完成的任务(例如由于超时),那么你将会使进程崩溃。所以,最好不要这样做。 - Kevin Gosse

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