线程饥饿是线程持续就绪却无法获得CPU执行权,表现为Task不调度、await卡住、线程池可用数长期为0;主因是同步等待阻塞线程池、非公平锁导致排队靠后、高优先级线程垄断时间片。
线程饥饿不是程序崩溃或死锁,而是某个线程**一直有活干、一直想干活,但永远轮不上执行**——就像食堂窗口只给穿工装的师傅打饭,穿便装的实习生端着餐盘站一小时,饭没吃上,肚子咕咕叫。在 C# 中,典型表现是:Task 提交后长期不调度、await 卡住不动、日志停在某一步、监控显示线程池 ThreadPool.GetAvailableThreads() 接近 0 且长时间不恢复。
根本原因就三条:线程池被“占着茅坑不拉屎”的同步等待堵死;锁/信号量非公平争抢下某些线程总排末尾;高优先级线程持续霸占 CPU,低优先级线程拿不到时间片。
这是最常见、最容易踩的坑。你写 Task.Run(() => DoWork()).Wait(),表面看只是“等一下”,实际效果是:当前线程(很可能是线程池线程)立刻被挂起阻塞,且不释放资源。如果这个调用发生在另一个 Task 内部(比如 ASP.NET Core 的中间件、或 async 方法里),等于用一个线程去等另一个线程——而那个“另一个线程”可能正排队等着上线程池……结果就是雪球越滚越大。
public async TaskHandleRequest() { var result = Task.Run(() => HeavyCalc()).Wait(); // 饿死起点 return Ok(result); }
public async TaskHandleRequest() { var result = await Task.Run(() => HeavyCalc()); // 释放线程,让别人先干活 return Ok(result); }
MySql.Data 9.1.0+ 版本中,Open()、ExecuteReader() 等“同步方法”底层其实是 GetAwaiter().GetResult() 封装的异步调用,本质仍是同步等待 —— 这类 SDK 要么降级,要么显式改用 OpenAsync() 等真异步 API。靠默认线程池扛高并发,就像用自行车拉集装箱。当大量任务嵌套等待(父等子、子等孙),线程池很快被“逻辑阻塞”填满,新任务只能干等。这时调大 ThreadPool.SetMaxThreads() 只是延缓死亡,不能根治。
async/await,不占线程池;Task.Run,但避免嵌套等待;Thread 或用 BackgroundService,不和请求线程池共用资源。ThreadPool.SetMinThreads(100, 100); // 避免冷启动时创建太慢但最大值别乱调,OS 有开销,建议结合压测调整。
SemaphoreSlim 控制并发上限,比“全放开再等”更可控:private static readonly SemaphoreSlim _dbSemaphore = new(5); // 最多5个并发DB操作
await _dbSemaphore.WaitAsync();
try { await db.QueryAsync(...); }
finally { _dbSemaphore.Release(); }默认 lock 是非公平的——谁抢到算谁的,老老实实排队的线程可能永远等不到。这不是 bug,是设计取舍;但业务场景需要公平性时,就得换工具。
SemaphoreSlim(支持构造函数传 true 开启公平模式):var sem = new SemaphoreSlim(1, 1, true); // true = 公平队列
ReaderWriterLockSlim 默认也是非公平,但可启用公平模式:var rwLock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion); rwLock.EnterReadLock(); // 或 EnterWriteLock()不过要注意:开启公平会轻微降低吞吐,权衡而定。
HttpClient.Send()、File.ReadAllText())——这会让整个锁队列卡住,后面所有人一起饿。真正难防的不是技术细节,而是“看起来没问题”的混合写法:比如在 async 方法里调 .Result,或者用 Task.Run 包一层同步 DB 调用再 Wait()。这些代码能跑通
、单元测试也过,但一上生产,流量稍涨,线程池就悄悄饿扁了。