C#异步编程:线程池饥饿

现在C#编程首选异步模式,即采用async/await的方式,但是如果使用不当,可能会造成各种问题,其中一个就是线程池饥饿

线程池饥饿
线程池饥饿指线程池中的所有可用工作线程,全部被长时间阻塞的任务占用,没有空闲线程来处理新提交的工作项,导致新任务长时间排队等待,无法及时被调度执行的现象。典型表现有
* 应用CPU占用率很低,但大量请求/任务处于长时间等待状态,响应速度急剧下降。
* 线程池会被迫快速创建大量新线程,导致线程上下文切换开销飙升,进一步拖慢整体性能。
* 严重时会直接引发大面积死锁,导致应用完全失去响应。

我们看一个例子:

public static void Main(string[] args)
{
    ThreadPool.SetMinThreads(4, 4);
    ThreadPool.SetMaxThreads(4, 4);

    _ = Run(10);
    Console.ReadKey();
}
static async Task Run(int count)
{
    ThreadPool.GetAvailableThreads(out var workerThreads, out _);
    Console.WriteLine($"Run Start: count={count}, ThreadId={Thread.CurrentThread.ManagedThreadId}, workerThreads={workerThreads}");
    if (count > 0)
    {
        await Task.Yield();
        Run(count - 1).Wait();
    }
    ThreadPool.GetAvailableThreads(out workerThreads, out _);
    Console.WriteLine($"Run End: count={count}, ThreadId={Thread.CurrentThread.ManagedThreadId}, workerThreads={workerThreads}");
}

运行后,发现代码并不能完整的执行输出,而是输出几行后停止了,原因就是线程池饥饿
image

每次执行显示的线程ID和剩余工作线程数可能会变动,但是结果都是会卡住的

我们分析一下这个代码:

  1. 首先我们设置线程池最大工作线程数和最小工作线程数都是4。
  2. 递归调用Run这个异步方法:
    a. Main主线程(ThreadId=1)进入Run(10),输出第一行:Run Start: count=10, ThreadId=1, workerThreads=3,接着遇到await Task.Yield(),则退出Run(10),执行Console.ReadKey()被hold住。
    b. 从线程池分配工作线程(ThreadId=5)执行Run(9).Wait(),它会进入Run(9),输出:Run Start: count=9, ThreadId=5, workerThreads=3,接着遇到await Task.Yield(),则退出Run(9),并返回Task,调用Task.Wait()方法导致工作线程(ThreadId=5)被挂起,此时1个线程池线程被占用
    c. 从线程池分配工作线程(ThreadId=8)执行Run(8).Wait(),它会进入Run(8),输出:Run Start: count=8, ThreadId=8, workerThreads=2,接着遇到await Task.Yield(),则退出Run(8),并返回Task,调用Task.Wait()方法导致工作线程(ThreadId=8)被挂起,此时2个线程池线程被占用
    d. 从线程池分配工作线程(ThreadId=3)执行Run(7).Wait(),它会进入Run(7),输出:Run Start: count=7, ThreadId=3, workerThreads=0,接着遇到await Task.Yield(),则退出Run(7),并返回Task,调用Task.Wait()方法导致工作线程(ThreadId=3)被挂起,此时3个线程池线程被占用
    d. 从线程池分配工作线程(ThreadId=9)执行Run(6).Wait(),它会进入Run(6),输出:Run Start: count=6, ThreadId=9, workerThreads=0,接着遇到await Task.Yield(),则退出Run(6),并返回Task,调用Task.Wait()方法导致工作线程(ThreadId=9)被挂起,此时4个线程池线程被占用
    e. 因为设置了线程池最大工作线程数为,所以此时线程池已经没有可用线程,无法分配线程池线程去执行Run(5).Wait(),因此停止输出

解决办法

其实上面线程池线程被消耗光,是因为程序中采用了Task.Wait().Result.GetAwaiter().GetResult()或者Thread.Sleep()等方法导致当前线程池线程被挂起,无法进入线程池接着分配,直至所有线程池线程被消耗光,所以在异步编程中,有以下几点建议:

  1. 尽量采用async/await的方式进行调用,避免采用.Wait().Result.GetAwaiter().GetResult()等阻塞线程的代码。

  2. 如果确实需要在代码中进行等待,避免使用Thread.Sleep(),应该采用await Task.Delay()

  3. 有时候我们需要等多个并发的Task执行完,避免调用Task.WaitAllTask.WaitAny等方法,应采用Task.WhenAllTask.WhenAny的异步调用。

  4. 如果调用线程是同步方法,无法使用async/await,那么建议改造调用线程为异步代码,确实改动较大,可以尝试增大最大工作线程数(默认最大为32767,最小为当前运行环境的‌逻辑CPU核心数)。

就比如上面的例子,如果采用async/await的方式进行调用,就不会造成死锁。

异步的理解

现在异步是主流,就比如C#、JavaScript等语言都支持异步,网上太多讲解异步的文章,状态机,编译设置等,都很详细,我这里也简单举个例子方便理解:

比如:
老板给主管派发任务,需要制作一批工件,主管让每个员工先去仓库领物料,领完回来加工制作。
但是仓库管理员需要根据你的清单去找物料,然后出库,把物料从仓库搬出来给员工,这个需要时间,所以员工需要等仓库管理员,等领到物料之后才能去加工制作。

一般想法:每个员工就好比线程,仓库管理员就好比IO,当线程碰到IO操作时,就需要等待IO操作完成,不能去干其他事,只能等。

异步思维:每个线程就是员工,线程池调度器就是主管,仓库管理员就好比IO
当线程碰到IO操作时,就记录一下,然后返回线程池,就好比跟仓库管理员说:准备好了就通知一下领导。然后直接去跟领导反馈然后做其他任务去了。
当IO操作完成时,线程池会分配一个线程去处理(可能是原来的线程也可能是其它线程),就好比仓库管理员准备好了,通知到主管,主管安排一个员工去领物料(可能是原来那个员工,也可能不是)
如果IO操作完成时,需要捕捉上下文,线程池会等原先那个线程空闲了去处理(否则需要等),就好比仓库管理员准备好了,说物料是谁谁谁的,必须让那个员工来,主管就只能等那个员工有空了去处理。

总结

异步的好处多多,提供并发能力,最大化I/O场景性能,提升性能等,但是我们还是需要知道为什么,否则出了问题我们都无法解决,还容易闹笑话,比如我们说异步可以提升性能,并不是一段代码的性能,往往是只整个模块或者系统的性能。

posted @ 2026-08-29 18:47  没有星星的夏季  阅读(4)  评论(0)    收藏  举报