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}");
}
运行后,发现代码并不能完整的执行输出,而是输出几行后停止了,原因就是线程池饥饿。

每次执行显示的线程ID和剩余工作线程数可能会变动,但是结果都是会卡住的
我们分析一下这个代码:
- 首先我们设置线程池最大工作线程数和最小工作线程数都是4。
- 递归调用
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()等方法导致当前线程池线程被挂起,无法进入线程池接着分配,直至所有线程池线程被消耗光,所以在异步编程中,有以下几点建议:
-
尽量采用
async/await的方式进行调用,避免采用.Wait()、.Result和.GetAwaiter().GetResult()等阻塞线程的代码。 -
如果确实需要在代码中进行等待,避免使用
Thread.Sleep(),应该采用await Task.Delay()。 -
有时候我们需要等多个并发的
Task执行完,避免调用Task.WaitAll和Task.WaitAny等方法,应采用Task.WhenAll和Task.WhenAny的异步调用。 -
如果调用线程是同步方法,无法使用
async/await,那么建议改造调用线程为异步代码,确实改动较大,可以尝试增大最大工作线程数(默认最大为32767,最小为当前运行环境的逻辑CPU核心数)。
就比如上面的例子,如果采用async/await的方式进行调用,就不会造成死锁。
异步的理解
现在异步是主流,就比如C#、JavaScript等语言都支持异步,网上太多讲解异步的文章,状态机,编译设置等,都很详细,我这里也简单举个例子方便理解:
比如:
老板给主管派发任务,需要制作一批工件,主管让每个员工先去仓库领物料,领完回来加工制作。
但是仓库管理员需要根据你的清单去找物料,然后出库,把物料从仓库搬出来给员工,这个需要时间,所以员工需要等仓库管理员,等领到物料之后才能去加工制作。
一般想法:每个员工就好比线程,仓库管理员就好比IO,当线程碰到IO操作时,就需要等待IO操作完成,不能去干其他事,只能等。
异步思维:每个线程就是员工,线程池调度器就是主管,仓库管理员就好比IO
当线程碰到IO操作时,就记录一下,然后返回线程池,就好比跟仓库管理员说:准备好了就通知一下领导。然后直接去跟领导反馈然后做其他任务去了。
当IO操作完成时,线程池会分配一个线程去处理(可能是原来的线程也可能是其它线程),就好比仓库管理员准备好了,通知到主管,主管安排一个员工去领物料(可能是原来那个员工,也可能不是)
如果IO操作完成时,需要捕捉上下文,线程池会等原先那个线程空闲了去处理(否则需要等),就好比仓库管理员准备好了,说物料是谁谁谁的,必须让那个员工来,主管就只能等那个员工有空了去处理。
总结
异步的好处多多,提供并发能力,最大化I/O场景性能,提升性能等,但是我们还是需要知道为什么,否则出了问题我们都无法解决,还容易闹笑话,比如我们说异步可以提升性能,并不是一段代码的性能,往往是只整个模块或者系统的性能。

浙公网安备 33010602011771号