线程饥饿到底是什么?不是卡死,是“饿着等不到饭”
线程饥饿不是程序崩溃或死锁,而是某个线程**一直有活干、一直想干活,但永远轮不上执行**——就像食堂窗口只给穿工装的师傅打饭,穿便装的实习生端着餐盘站一小时,饭没吃上,肚子咕咕叫。在 C# 中,典型表现是:
Task提交后长期不调度、
await卡住不动、日志停在某一步、监控显示线程池
ThreadPool.GetAvailableThreads()接近 0 且长时间不恢复。
根本原因就三条:线程池被“占着茅坑不拉屎”的同步等待堵死;锁/信号量非公平争抢下某些线程总排末尾;高优先级线程持续霸占 CPU,低优先级线程拿不到时间片。
Task.Run + .Wait() / .Result 是线程池饥饿头号推手
这是最常见、最容易踩的坑。你写
Task.Run(() => DoWork()).Wait(),表面看只是“等一下”,实际效果是:当前线程(很可能是线程池线程)立刻被挂起阻塞,且不释放资源。如果这个调用发生在另一个
Task内部(比如 ASP.NET Core 的中间件、或
async方法里),等于用一个线程去等另一个线程——而那个“另一个线程”可能正排队等着上线程池……结果就是雪球越滚越大。 ❌ 错误示范:
public async Task<IActionResult> HandleRequest()
{
var result = Task.Run(() => HeavyCalc()).Wait(); // 饿死起点
return Ok(result);
}
✅ 正确做法:该异步就异步,别混搭public async Task<IActionResult> HandleRequest()
{
var result = await Task.Run(() => HeavyCalc()); // 释放线程,让别人先干活
return Ok(result);
}
特别注意:MySql.Data9.1.0+ 版本中,
Open()、
ExecuteReader()等“同步方法”底层其实是
GetAwaiter().GetResult()封装的异步调用,本质仍是同步等待 —— 这类 SDK 要么降级,要么显式改用
OpenAsync()等真异步 API。
线程池配置和资源隔离才是治本之策
靠默认线程池扛高并发,就像用自行车拉集装箱。当大量任务嵌套等待(父等子、子等孙),线程池很快被“逻辑阻塞”填满,新任务只能干等。这时调大
ThreadPool.SetMaxThreads()只是延缓死亡,不能根治。 ✅ 为不同负载类型划分专用线程池(哪怕逻辑隔离):
— IO 密集型(数据库、HTTP 调用):走
async/await,不占线程池;
— CPU 密集型(图像处理、加密):用
Task.Run,但避免嵌套等待;
— 关键后台任务(如定时统计):单独起
Thread或用
BackgroundService,不和请求线程池共用资源。 ✅ 合理设置最小线程数(尤其 Windows Server):
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()。这些代码能跑通、单元测试也过,但一上生产,流量稍涨,线程池就悄悄饿扁了。
