先讲一个我真实踩过的坑。去年凌晨,线上服务突然大面积超时报警,数据库连接池被打满,CPU 直接飙到 90% 以上。查了半天,最后定位到一个定时消息处理任务:因为消息积压,它对积压数据做了一次全量补推,瞬间把外部接口调用并发从个位数拉到了上百,第三方服务开始拒绝连接,超时请求不断堆积,整个链路跟着雪崩。那之后,凡是要写并发访问共享资源或外部依赖的代码,我第一个想到的就是 SemaphoreSlim。这是 .NET 提供的一个轻量级信号量类,专门用于控制对共享资源的并发访问,特别适合高性能和异步编程场景。
这篇文章不打算只讲 API,我想把原理、实战和踩坑放在一起,把这个类彻底讲透。适合正在写 .NET 并发代码、做接口限流,或者被异步死锁折磨过的开发者。文章里的代码都是我实际在项目中用过的模式,你可以直接抄,但更重要的是理解背后的想法。
1. 为什么业务代码需要 SemaphoreSlim:从一次线上事故说起
1.1 事故复盘:并发暴涨如何打垮一个服务
那次事故的过程其实很典型。消息队列里积压了十几万条数据,业务方催着尽快处理完。接手的人写了一个简单的补推逻辑:把消息取出来,用 Task.WhenAll 每批跑 200 个并发,每个任务都要先调第三方接口拿数据,再写回本地数据库。
单看代码问题不大,问题出在第三方接口的承载能力上。它只允许 50 个并发,200 个任务同时压过去,大量请求直接超时。超时后代码重试,重试又叠加新的请求,很快第三方接口开始拒绝连接。本地数据库连接池也经不住这么多线程同时操作,最后整个服务响应越来越慢,接口大面积 5xx。
这种事故的本质不是代码逻辑错了,而是没有一个“闸门”控制同时进入的请求数量。你可以把接受请求的一方想象成一个只能同时服务 50 个人的柜台,无论外面排队多少人,柜台一次只能接待 50 个,处理完一个才能放进下一个。
当时如果用了 SemaphoreSlim,在调用第三方接口前加一个并发上限,比如 40,那 200 个任务会排队进入,而不是一窝蜂全冲进去。这正是 SemaphoreSlim 的核心价值:它给并发访问加了一道可控的闸门。
1.2 为什么 lock 不是并发限流的答案
很多人第一反应是:控制并发,用 lock 不就行了?不行,至少在异步场景下不行。
lock 的本质是互斥锁,同一时间只允许一个线程进入临界区。而我们要做的是“允许 N 个并发”,不是“只允许 1 个”。如果把并发从 50 改成 1,虽然不会打垮第三方接口,但请求处理速度和串行差不多,吞吐量完全不够用。
更重要的是,C# 编译器不允许在 lock 语句块内使用 await。原因是 Monitor 的进入和退出是线程关联的,而异步方法可能在 await 前后切换线程,一旦切换,Monitor.Exit 就不知道要释放哪个线程持有的锁了。这个限制直接让 lock 退出了异步并发控制的候选名单。
Monitor 类的 Enter / Exit 也一样,本质还是线程关联。异步代码需要的是一个不绑定线程的机制,等待资源释放时不要阻塞线程,而是让线程去做别的事。
1.3 信号量的通俗理解:停车场车位模型
理解信号量,最好用的类比就是停车场。
假设停车场有 20 个车位。一辆车来,先看有没有空位,有空位直接进去;没空位就排队等待。等某辆车开走,空出一个车位,门卫放下一辆排队的车进去。这里的“车位数量”就是信号量的计数,SemaphoreSlim 本质上是一个带计数器的闸门。
更具体一点:信号量内部维护一个计数器。调用 WaitAsync 时,如果计数器大于 0,就把它减 1,然后放行;如果计数器等于 0,调用者进入等待队列。调用 Release 时,计数器加 1,表示释放一个资源,然后从等待队列里放一个任务进来。
这个模型比锁更精细,因为它允许同时有 N 个“车位”,而不是只有一个“门锁”。而 SemaphoreSlim 相对 Semaphore 的“轻量级”三个字,主要体现在它特别适合单进程内的高频异步调用场景。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心原理与 API 拆解:SemaphoreSlim 到底是怎么工作的
2.1 构造函数:initialCount 和 maxCount 是两回事
SemaphoreSlim 有两个构造重载:
csharp复制// 只传入初始计数
var semaphore = new SemaphoreSlim(5);
// 传入初始计数和最大计数
var semaphore = new SemaphoreSlim(5, 10);
initialCount 表示初始状态下有多少个可用资源,maxCount 表示最多能有多少个资源。两者含义完全不同,很多人容易搞混。
| 参数 | 含义 | 注意事项 |
|---|---|---|
initialCount |
初始可用资源数 | 不能大于 maxCount |
maxCount |
资源上限 | 调用 Release 后计数不能超过它 |
比较常见的写法是 new SemaphoreSlim(10, 10),表示一开始就有 10 个资源,最多也是 10 个。另一种常见写法是 new SemaphoreSlim(0, 10),表示一开始不放行任何请求,等所有准备工作完成后再调用 Release(10) 释放全部资源,这类场景在服务预热时比较有用。
如果 initialCount 大于 maxCount,构造函数会直接抛 ArgumentException,这一点可以在代码里做参数校验时留意。
2.2 三个核心方法:Wait、WaitAsync、Release
SemaphoreSlim 的使用套路非常固定,核心就是三个方法:Wait、WaitAsync、Release。
Wait():同步等待,调用线程会阻塞,直到获取到资源。适合非异步上下文。WaitAsync():异步等待,调用线程不阻塞,返回一个Task,资源可用时任务完成。这是异步编程场景的标准选择。Release()/Release(int count):释放资源,计数器加 1 或加 N。
CurrentCount 属性表示当前可用资源数,只读。这个属性在调试和监控时很有用,但需要注意它只是一个瞬时快照,不能作为“先判断再获取”的依据,后面我会专门讲这个坑。
超时和取消在实战中几乎每次都会用到,有几个常用的重载:
csharp复制// 最多等待 5 秒,5 秒内拿到资源返回 true,超时返回 false
bool acquired = await semaphore.WaitAsync(TimeSpan.FromSeconds(5));
// 支持取消令牌,取消时抛出 OperationCanceledException
bool acquired = await semaphore.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
// 立即检查:有资源就直接获取,没有就返回 false,不等待
bool acquired = await semaphore.WaitAsync(0);
WaitAsync(0) 这个重载很关键,它实现的是“快速失败”逻辑:如果当前没有可用资源,不排队直接放弃。相比让请求一直排队等下去,很多场景下快速失败反而用户体验更好。
2.3 异步等待背后的实现细节
为什么 SemaphoreSlim 在异步场景下性能好?这要看一下它的内部机制。
当调用 WaitAsync 时,如果当前有可用资源,它直接递减计数并返回一个已完成的任务,整个过程没有线程阻塞,也没有额外分配。如果没有可用资源,它会创建一个 TaskCompletionSource 放进等待队列,返回一个未完成的任务,调用方 await 后线程会释放出来去处理其他工作。
当某个线程调用 Release 时,计数加 1,同时从等待队列里取出一个 TaskCompletionSource,调用它的 SetResult 方法通知对应任务可以继续执行了。这个设计就是异步编程里典型的“回调驱动”,不靠阻塞线程来等待资源。
在多核环境下,同步的 Wait 方法还内置了一个短暂的 SpinWait 自旋优化。如果资源预计很快就会释放,线程短暂自旋一会儿就可以避免一次昂贵的上下文切换。自旋时间超过阈值才会真正进入内核态等待。
这些细节解释了为什么 SemaphoreSlim 比老式 Semaphore 快:老式 Semaphore 的等待、释放都需要经过操作系统内核,每次都是用户态和内核态的切换;SemaphoreSlim 主要工作在用户态,只在长时间等待时才借助内核机制,所以被称为“轻量级”。
2.4 和 Semaphore 有哪些不一样
很多初学者分不清 Semaphore 和 SemaphoreSlim,它们确实干的是同一类事情,但定位完全不同。
| 维度 | Semaphore | SemaphoreSlim |
|---|---|---|
| 跨进程 | 支持,可命名 | 不支持,单进程内 |
| 性能 | 内核态同步,偏慢 | 用户态实现,轻量 |
| 异步等待 | 不支持 WaitAsync |
原生支持 |
| API 复杂度 | 需要调用 WaitOne / Release |
Wait / WaitAsync / Release |
| 适用场景 | 跨进程资源限制 | 单进程内异步并发控制 |
如果你要限制的是“当前进程内”的并发数,比如控制 HTTP 调用并发、限制数据库操作并发、控制后台任务并行度,选 SemaphoreSlim 就对了。只有在需要跨多个进程同步,比如多个独立进程不能同时操作同一个文件,才需要考虑用命名 Semaphore。
3. 实战演练:给第三方接口调用加一道闸门
3.1 需求先讲清楚:不是限 QPS,是限并发
有个第三方接口,文档写得很清楚:并发上限 20,超出后直接拒绝。单次调用耗时平均 200ms,业务高峰期每秒大约有 100 个请求要发出去。
这里先明确一个概念:并发上限和 QPS 上限不是一回事。并发 20 意味着同一时刻最多 20 个请求在飞,但每个请求 200ms 完成,一秒钟最多能完成 100 个。也就是说,限制并发 20,实际上已经把 QPS 限制在了 100 以内,已经大于业务需求。这个场景的核心是控制“同时进行的任务数”,而不是控制“每秒请求数”,SemaphoreSlim 正是干这个的。
3.2 最基础的实现:一个限流器类
我习惯把 SemaphoreSlim 封装成一个独立的限流器类,而不是直接散落在业务代码里。这样所有获取和释放的逻辑都收拢在一处,排查问题也方便。
csharp复制public class ThirdPartyApiClient
{
private readonly SemaphoreSlim _gate = new SemaphoreSlim(20, 20);
private readonly HttpClient _httpClient;
public ThirdPartyApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<string> CallAsync(string payload, CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken);
try
{
var response = await _httpClient.PostAsync(
"/api/endpoint",
new StringContent(payload),
cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
finally
{
_gate.Release();
}
}
}
这个代码的核心就一个原则:WaitAsync 拿到的许可,无论如何都要在 finally 里 Release。业务代码可能成功、可能抛异常、可能被取消,但只要拿到了许可,就必须在路径结束前归还,否则就是信号量泄漏。
3.3 加上超时和取消:不能无限排队
上面的代码有个隐患:如果第三方接口持续不稳定,20 个许可全被占着,新的请求就一直在 WaitAsync 上排队。如果调用方没有设置超时,这些请求会一直占用线程和资源,等到第三方恢复时一下子全部涌入,又造成新一轮冲击。
所以线上代码基本都要加超时:
csharp复制public async Task<string> CallAsync(string payload, CancellationToken cancellationToken)
{
TimeSpan timeout = TimeSpan.FromSeconds(5);
// 等不到许可,5 秒后返回 false,不再继续等
if (!await _gate.WaitAsync(timeout, cancellationToken))
{
throw new TimeoutException("当前请求过多,请稍后重试");
}
try
{
var response = await _httpClient.PostAsync(
"/api/endpoint",
new StringContent(payload),
cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
finally
{
_gate.Release();
}
}
注意 WaitAsync(timeout, cancellationToken) 有两种非正常返回方式:超时返回 false;取消令牌触发时直接抛 OperationCanceledException。所以这里判断返回值只能是 true 还是 false,异常交给上层处理。
CancellationToken 还有一个容易被忽略的作用:如果等待期间用户已经取消请求,就没必要再排队等许可了,直接退出可以释放一部分压力。
3.4 进阶:排队区和执行区分离,实现快速失败
有时候业务不能接受无限排队,但也不是所有的请求都直接拒绝。一种常见的折衷方案是:允许一部分请求排队,超过排队上限的直接快速失败。
实现方式是用两个 SemaphoreSlim,一个控制排队区,一个控制执行区。
csharp复制public class SmartThrottler
{
private readonly SemaphoreSlim _queueGate = new SemaphoreSlim(30, 30);
private readonly SemaphoreSlim _executionGate = new SemaphoreSlim(20, 20);
public async Task<T> ExecuteAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken)
{
// 排队区:0 超时实现快速失败,最多允许 30 个请求排队等待
if (!await _queueGate.WaitAsync(0, cancellationToken))
{
throw new InvalidOperationException("系统繁忙,请稍后再试");
}
try
{
// 执行区:控制真正的并发数量
await _executionGate.WaitAsync(cancellationToken);
try
{
return await action();
}
finally
{
_executionGate.Release();
}
}
finally
{
_queueGate.Release();
}
}
}
这段代码的逻辑值得稍微拆一下。第一步用 WaitAsync(0) 检查排队区,有位置就进去,没位置立刻返回 false,这个“0 超时”的巧劲实现了不排队直接失败的效果。第二步等待执行区的许可,这里才真正控制并发数。最终无论成功失败,两个许可都要逐级归还。
这一招在控制第三方接口调用、限制爬虫并发这些场景下非常实用。线上遇到过很多次排队时间过长导致用户反复刷新,反而加重系统负担的问题,快速失败至少让用户知道“现在不行”,而不是让请求一直卡在那里。
3.5 扩展思路:动态调整并发数和策略组合
SemaphoreSlim 的 maxCount 是构造时确定的,不能直接修改。但实践中我见过一些间接“扩容”的方式:比如先用 Release(extraCount) 额外释放资源,把一个 new SemaphoreSlim(20, 20) 的可用资源变成 25。注意,这样操作后并发峰值确实增加了,但 maxCount 还在起作用,如果后续 Release 的次数把计数推过了 maxCount,会抛 SemaphoreFullException。这种动态调整方式极其容易出问题,不建议在生产环境使用,遇到需要动态调整并发数的需求,更好的做法是重启服务前修改配置,或者用支持动态调整的限流组件。
另外一个比较常见的做法是把 SemaphoreSlim 和 Polly 的 Bulkhead 策略结合。Polly 的 Bulkhead 内部本身就基于 SemaphoreSlim,它额外提供了排队策略、容量溢出处理等高级功能。如果项目里已经用了 Polly,可以考虑直接用 Bulkhead,不必重复造轮子。
4. 最容易踩的五个坑:信号量泄漏、死锁、线程池饥饿
4.1 信号量泄漏:拿到许可不归还
信号量泄漏是 SemaphoreSlim 使用中最常见的问题,没有之一。代码长这样:
csharp复制// 错误写法:DoWorkAsync 抛异常时,Release 永远不会执行
await _gate.WaitAsync();
var result = await DoWorkAsync();
_gate.Release();
一旦 DoWorkAsync 抛异常,Release 就被跳过了。一次两次没啥感觉,积累到一定程度,可用资源越来越少,最后所有调用者都卡在 WaitAsync 上,形成假死状态。
排查信号量泄漏有个简单的方法:定期打印 CurrentCount 值,如果发现它持续下降且没有回升趋势,基本可以断定哪里丢了 Release。更稳妥的做法是从一开始就严格执行 try/finally 的模式,让 Release 在 finally 块里执行,这样无论业务代码成功还是失败,资源都会归还。
我在团队 Code Review 清单里加了一条硬性规则:凡是 Wait 或 WaitAsync 拿到许可的代码路径,必须能看到对应的 Release,而且必须放在 finally 中。
4.2 可重入死锁:同一逻辑路径获取两次许可
这是另一类常见的死锁。区别于信号量泄漏,这种问题出在代码写法上:
csharp复制public async Task ProcessAsync()
{
await _gate.WaitAsync();
try
{
await NestedAsync();
}
finally
{
_gate.Release();
}
}
public async Task NestedAsync()
{
// 内部又获取同一个信号量,但外层还没释放,这里会一直等待
await _gate.WaitAsync();
try
{
// ...
}
finally
{
_gate.Release();
}
}
ProcessAsync 先拿到了许可,进入 NestedAsync 后再次等待同一个信号量。由于外层还没有 Release,信号量计数为 0,内层的 WaitAsync 就会一直等,而外层又在等内层完成,形成死循环等待。
这个坑的本质是 SemaphoreSlim 不追踪“谁持有许可”,所以也不存在可重入的概念。Monitor 是可重入的,同一个线程可以多次 Enter 再多次 Exit,但 SemaphoreSlim 只看计数,不看调用者身份。
规避方法很简单:不要在已经持有信号量的代码路径里再次获取同一个信号量。如果确实存在递归调用,可以在一开始就把并发控制的逻辑放在最外层入口处,内层方法只管业务,不再碰信号量。实在无法避免重入,就需要借助 AsyncLocal 之类的方案手动记录当前上下文是否已经持有许可,这种做法相对复杂,能用结构规避的话优先规避。
4.3 线程池饥饿:用 Wait 而不是 WaitAsync
这个问题在 Web 应用里特别隐蔽。代码可能长这样:
csharp复制public ActionResult Index()
{
var semaphore = new SemaphoreSlim(5, 5);
// 错误用法:同步阻塞线程池线程
semaphore.Wait();
var result = _service.GetDataAsync().Result;
semaphore.Release();
return View(result);
}
表面上它控制了并发数,但问题在于阻塞了线程池里的线程。线程池中的线程是处理请求的关键资源,当大量请求同时进入,每个都阻塞一个线程在等信号量,同时真正执行异步操作的线程又需要从线程池中分配,而线程已经被等信号量的请求占满了,线程池无法分配新线程去执行异步方法,最终整个服务陷入停顿。这就是“线程池饥饿”。
ASP.NET Core 和 UI 线程场景下尤其要警惕这个问题。正确的做法是全程使用异步方法,async 一路到底,WaitAsync 代替 Wait。如果遇到既有代码里大量使用 .Result 或 .Wait() 的情况,要优先考虑改造,而不是继续往上叠加并发控制。
排查线程池饥饿一般靠 dump 线程堆栈,如果看到大量线程阻塞在 SemaphoreSlim.Wait 相关调用点上,同时线程池可用线程数降到 0,那基本可以断定是这个原因。
4.4 CurrentCount 快照陷阱:判断完就被抢走
CurrentCount 是只读属性,但它反映的是某一瞬间的计数快照,不能当成业务判断的最终依据。很多人会写出这样的代码:
csharp复制if (_gate.CurrentCount > 0)
{
// 这个判断是竞态的,可能刚判断完,资源就被其他线程抢走
await _gate.WaitAsync();
}
两个线程同时执行到 if 时,CurrentCount 都是 1,都认为有资源,然后同时进入 WaitAsync,其中一个会永远等下去。更糟糕的是,如果等待过程没有设置超时,这个线程可能一直挂在那里。
CurrentCount 的正确用途是监控和日志。比如定时输出一下当前可用资源数,观察整体负载趋势;或者在测试中验证信号量释放是否正常。业务逻辑要判断“能不能拿到许可”,直接用 WaitAsync(0) 的返回值,而不是先看 CurrentCount 再决定。
4.5 Dispose 后继续使用
SemaphoreSlim 实现了 IDisposable,本身是为了释放内部等待句柄等非托管资源。但如果一个线程在对象被 Dispose 之后仍调用 Wait 或 Release,就会抛 ObjectDisposedException。
这个坑在应用级代码里不那么常见,因为大多数 SemaphoreSlim 实例声明为静态或长期存活,生命周期跟随整个应用。但如果把它封装进一个可销毁的类,并且这个类会被频繁创建和销毁,就要特别注意使用方和销毁方的时序。
我的建议是:SemaphoreSlim 实例要么声明为长期存活的对象,要么在真正确认所有使用方已经停止后再销毁,而不是在某个方法结束时就顺手 Dispose。毕竟它占用的资源非常小,不是那种需要严格释放的稀缺资源。
5. 选型对比:SemaphoreSlim 之外还有哪些并发控制方案
5.1 横向对比表
总有人问:并发控制该用哪个?这里把 .NET 里常见的几种方案放在一起对比一下。
| 方案 | 控制粒度 | 支持异步 | 可跨进程 | 典型场景 |
|---|---|---|---|---|
lock / Monitor |
独占 | 否 | 否 | 保护共享状态临界区 |
SemaphoreSlim |
多许可 | 是 | 否 | 单进程内并发数限流 |
Semaphore |
多许可 | 否 | 是 | 跨进程命名信号量 |
Mutex |
独占 | 否 | 是 | 跨进程互斥,单实例应用 |
ReaderWriterLockSlim |
多读单写 | 否 | 否 | 缓存/配置读多写少 |
Channel |
队列 | 是 | 否 | 生产者消费者、背压 |
每个方案解决的核心问题不一样,没有绝对的优劣,只有合不合适的场景。
5.2 什么场景选什么:我的选择习惯
如果只是保护一段共享内存的读写,代码全是同步,没有异步操作,直接上 lock 就够了,没必要引入信号量。lock 语法简单,编译器保证释放,出错概率低。
只要有 await,同时要控制并发数在 N 以内,优先选 SemaphoreSlim。这是异步场景下的标准答案,性能和语义都合适。
要控制跨进程互斥,比如保证整个机器上只有一个应用实例在运行,用 Mutex。它的命名版本可以在多个进程间协同,但代价是性能较差,不适合频繁调用。
读多写少的场景,比如配置文件、缓存字典,可以用 ReaderWriterLockSlim 允许多个线程同时读,只在写的时候独占,能明显提升并发读的性能。不过它没有异步版本,注意这一点。
生产者消费者场景,比如一堆请求要排队处理,处理速度跟不上生产速度,用 Channel 更合适。它有界通道天然带背压能力,队列满了可以阻塞生产者,也可以选择丢弃,这种“排队 + 背压”的模型不是信号量擅长的。
5.3 分布式部署下的局限
SemaphoreSlim 有一个绕不开的边界:它只作用于当前进程。如果服务部署了 3 个实例,每个实例都用一个 new SemaphoreSlim(20, 20) 限流,那整个系统的实际并发上限是 60,而不是 20。
如果业务要求的是“整个集群最多 20 个并发”,需要的是分布式限流,常见做法是借助 Redis 的计数能力,或者用网关层的全局限流。这种场景下 SemaphoreSlim 只能作为单实例的最后一道本地保护,用于防止突发流量对进程造成过大压力,但不能替代全局限流。
我见过一些项目在单实例阶段用 SemaphoreSlim 做限流,后来扩容到多实例后忘了调整,结果线上并发量是预期的几倍,第三方服务开始报错。这个过渡问题,从设计第一天就应该想清楚:SemaphoreSlim 是本地保护,不是全局策略。
5.4 一个小技巧:用信号量保护非线程安全的 HttpClient 使用模式
可能有人会觉得 HttpClient 是线程安全的,所有请求可以随便并发调用,不需要信号量。但实际项目里,如果多个请求共享同一个 HttpClient,同时设置的默认请求头又不一样,就会出现 header 互相覆盖的问题。这种情况下,可以用 SemaphoreSlim 限制同时使用该 HttpClient 的请求数,或者每次请求显式创建 HttpRequestMessage 并设置独立的 header,避免共享可变状态。
类似的思路可以推广到其他非线程安全资源的保护上:只要是一份资源、多个调用方、并且资源本身不能并发使用,信号量就能派上用场。
6. 排查信号量问题的完整链路:从现象到根因
6.1 现象:请求全部卡住,接口无响应
有一次我把一个定时任务从同步改造成异步时,不小心引入了信号量问题。现象是:服务偶尔大量请求超时,但不是每次都出现,重启后恢复,过一段时间又复现。
这类“偶发性假死”最让人头疼。我当时第一反应是数据库的问题,查了连接池、慢查询都正常;又怀疑第三方接口的问题,看日志确认第三方并没有超时。最后把线程池指标拉出来,才发现大量线程阻塞在同一个调用栈位置——SemaphoreSlim.WaitAsync。
6.2 排查链路:日志、CurrentCount、线程池指标三管齐下
第一步:加日志。在信号量获取前和释放后各打一条日志,带上 CurrentCount 值。上线后观察,如果发现 CurrentCount 持续下降,到 0 之后请求开始堆积,说明信号量被占用后没有正常释放。
第二步:看线程池状态。如果阻塞线程数持续增长,而且调用栈全部指向 WaitAsync,就验证了是信号量的问题,而不是数据库或网络的问题。
第三步:定位到具体是哪条代码路径没有释放。这时候我在 finally 里加日志的做法派上了用场:如果发现某个请求进入了 try 块但迟迟没有走到 finally,说明它在业务逻辑中异常退出或长时间挂起,导致许可一直没归还。
6.3 根因:异常分支遗漏了 Release
最终定位到的根因非常简单:有一个业务分支在 try 块内提前 return 了,而对应的 finally 块是在更外层的代码里,内层提前返回时恰好绕过了释放逻辑。这个代码耦合了两层方法,外层获取信号量,内层业务逻辑里多处 return,结果有一处 return 路径没有归还许可。
这类问题的根本解法还是那句话:获取和释放必须在一个方法内成对出现,并且释放写在 finally 里。这个模式无脑套用,能解决 90% 的信号量问题。如果不小心写了嵌套调用,把释放逻辑放到嵌套方法里,那就要格外小心每个 return 分支了。
6.4 修复后如何验证
修复后,我做了几件事来验证:
- 连续压测 30 分钟,观察
CurrentCount是否始终能在任务结束后回到初始值; - 模拟异常场景:让业务代码抛异常、模拟超时、模拟取消,确认每次
Release都会执行; - 查看线程池可用线程数,确保没有大量线程卡在
WaitAsync上。
这套验证流程已经成为我写并发代码的固定动作,随手做一遍,能省去后面很多排查时间。
7. 写在最后:几个我每天都在用的习惯
SemaphoreSlim 本身不复杂,复杂的是它所在的并发场景。不管代码写得多小心,线上环境总会出现想象不到的交错顺序。我自己养成了几个固定习惯,分享出来供你参考。
第一,凡是项目里需要并发上限控制,我会先写一个小的封装类,把 SemaphoreSlim 的获取和释放收敛到一个类里,而不是让 Wait / Release 散落在各个业务方法中。封装的好处是排查时只有一个地方需要检查,不需要全局搜索。
第二,获取信号量的一律用 WaitAsync,不要用 Wait,并且设置合理的超时时间。同步等待在某些 console 工具或批处理场景下问题不大,但在 Web 服务和 UI 线程上后患无穷。超时时间的设置也不能拍脑袋,得结合业务可接受的等待时间来确定,比如用户请求最多等 5 秒,那就让信号量等待 5 秒超时,超时后返回明确错误而不是无限挂起。
第三,Release 永远放在 finally 里,这条规则写进团队 Code Review 清单。怎么强调都不过分,我在生产环境见到的大部分信号量问题都出在这里。
最后,如果你正在设计一个高并发的核心链路,建议把 SemaphoreSlim 作为进程内自我保护的第一道防线,同时配合超时、取消、快速失败,形成一个完整的降级策略。它不是一个能解决所有并发问题的银弹,但却是 .NET 异步并发工具箱中不可缺少的一个。
