ASP.NET6中async Task配合SemaphoreSlim死锁问题咨询
死锁根本原因
你的死锁是同步等待混用异步代码导致线程池耗尽,和SemaphoreSlim本身的实现没有关系,具体触发流程如下:
- 控制台程序线程池初始工作线程数和逻辑CPU核心数一致(通常为416个),新线程注入速度约为每5001000ms新增1个,速度极慢。
- 你在
DoWork这个异步方法中使用了semaphore.Wait()这个同步阻塞方法等待信号量:任务一旦调用Wait()但没拿到锁,就会一直占着当前线程池线程不释放,直到拿到锁或者超时。 - 你一次性排队1000个任务到线程池:第一个拿到信号量的任务执行到
await Task.Delay(10)时,会将当前线程归还线程池,Delay结束后的续体(包含semaphore.Release()逻辑)会重新投递到线程池队列等待调度。 - 此时线程池剩余的初始工作线程,全部被其他排队任务占用,这些任务全部卡在
semaphore.Wait()的同步阻塞调用上,没有空闲线程。 - 10ms后Delay到期,释放信号量的续体被投递到线程池队列,但排在它前面的还有近千个等待执行的任务。线程池每新增一个线程,马上就被队列前面卡在Wait()的任务抢走继续阻塞,根本轮不到执行释放锁的续体,最终形成死锁。
对照测试结果的解释
你做的两组对照测试能正常运行,核心原因是没有出现「持有锁的任务需要重新排队等线程池线程才能释放锁」的情况:
- 将
Task.Delay替换为Thread.Sleep时:拿到锁的任务全程不会释放持有的工作线程,Sleep 10ms后直接在当前线程执行semaphore.Release(),不需要依赖额外的空闲线程跑续体,自然不会卡死。 - 将
Task.Run替换为ThreadPool.QueueUserWorkItem时:如果工作项内部没有异步await逻辑,执行流全程同步,和上面Thread.Sleep的场景一致,拿到锁的线程执行完逻辑直接释放锁,不会触发线程池耗尽。
修复方案
- 异步场景下永远使用SemaphoreSlim的异步等待方法,替换同步Wait调用:
把DoWork中的
替换为if (semaphore.Wait(timeoutSec * 1000))
异步等待不会阻塞线程池线程,就算有上千个任务在等锁,也不会占用线程,线程池始终有空闲线程处理续体,不会出现耗尽问题。if (await semaphore.WaitAsync(TimeSpan.FromSeconds(timeoutSec))) - 异步代码中禁止混用同步阻塞调用:不要在async方法中使用
.Wait()、.Result、同步版本的Wait等阻塞API,这是异步场景下死锁和线程池耗尽的最高频诱因。 - (非死锁相关bug修复)你代码中循环启动的Task没有加入
tasks列表,Task.WaitAll实际等待的是空任务列表;另外successCount/failedCount/doneCounter多线程修改没有加锁,存在竞态条件,会出现计数不准的问题。
生产环境ASP.NET 6应用偶发冻结的原因
和测试代码的根因完全一致:平时流量平稳时线程池线程充足,不会触发耗尽;当遇到流量波动、请求量突增时,大量请求同时卡在SemaphoreSlim的同步Wait调用上占满线程池,持有锁的请求的异步续体没有线程可调度,无法释放锁,最终整个应用无响应。这种问题因为需要线程池积压到阈值才会触发,所以往往会在运行数周、遇到特定流量场景时才出现。
内容的提问来源于stack exchange,提问作者michlG
相关产品推荐
相关产品推荐

