.NET 中小心嵌套等待的 Task,它可能会耗尽你线程池的现有资源,出现类似死锁的情况


一个简单的 Task 不会消耗多少时间,但如果你不合适地将 Task 转为同步等待,那么也可能很快耗尽线程池的所有资源,出现类似死锁的情况。

本文将以一个最简单的例子说明如何出现以及避免这样的问题。


本文内容

      • 耗时的 Task.Run
      • 最简复现代码
      • 原因
      • 解决
      • 更多死锁问题

.NET/C# 在代码中测量代码执行耗时的建议(比较系统性能计数器和系统时间)

统计图表

从图中,我们可以很直观地观察到,每多一个任务,就会多花 1 秒的事件。这可以认为默认情况下线程池在增加线程的时候,发现如果线程不够,会等待 1 秒之后才会创建新的线程。

.NET 默认的 TaskScheduler 和线程池(ThreadPool)设置 了解线程池创建新工作线程的规则。这里其实真的是类似于死锁的一个例子。

  1. 一开始,我们创建了 n 个 Task,然后分别安排在线程池中执行,并在每个 Task 中等待任务执行完毕;
  2. 随后这 n 个 Task 分别再创建了 n 个子 Task,并继续安排在线程池中执行;
  3. 这时问题来了,由于前面 n 个 Task 在等待中,所以占用了线程池的线程资源:
    • 如果 n < 线程池最小线程数,那么当前线程池中还有剩余工作线程帮助完成子 Task;
    • 但如果 n >= 线程池最小线程数,那么当前线程池中便没有新的工作线程来完成子 Task;于是一开始的等待也不会完成;必须等线程池开启新的工作线程后,任务才可以继续。

带线程池开启新的线程之前,以上那些线程就是处于死锁的状态!由于线程池开启新的工作线程需要等待一段时间(例如每秒最多开启一个新的线程),所以每增加一个这样的任务,那么消耗的时间便会持续增加。

使用 Task.Wait()?立刻死锁(deadlock) - walterlv
  • 不要使用 Dispatcher.Invoke,因为它可能在你的延迟初始化 Lazy 中导致死锁 - walterlv
  • 在有 UI 线程参与的同步锁(如 AutoResetEvent)内部使用 await 可能导致死锁
  • .NET 中小心嵌套等待的 Task,它可能会耗尽你线程池的现有资源,出现类似死锁的情况 - walterlv
  • 解决方法:

    • 在编写异步方法时,使用 ConfigureAwait(false) 避免使用者死锁 - walterlv
    • 将 async/await 异步代码转换为安全的不会死锁的同步代码(使用 PushFrame) - walterlv