结论:semaphoreslim限流必须复用单例实例、waitasync与release严格配对于try/finally中,并显式指定maxcount(如new semaphoreslim(5,5)),否则限流失效或服务卡死。

直接说结论:用 SemaphoreSlim 限制并发请求数,必须复用单例实例、WaitAsync() 和 Release() 严格配对在 try/finally 中,且初始化时显式指定 maxCount(如 new SemaphoreSlim(5, 5)),否则限流大概率失效甚至卡死。
为什么不能在方法里 new SemaphoreSlim(5)
每次调用都新建实例 → 每个请求拿到的都是全新信号量,初始许可数全是 5,完全不共享计数。限流形同虚设。
常见表现:压测时并发数轻松突破设定值,日志里看到几十个请求同时打下游。
正确做法:
- ASP.NET Core 中注册为
Singleton:services.AddSingleton(new SemaphoreSlim(5, 5)) - 或声明为
private static readonly SemaphoreSlim _sem = new SemaphoreSlim(5, 5) - 绝对不要写成
var sem = new SemaphoreSlim(5)放在方法体或using块里
WaitAsync() 后 Release() 必须写在 finally 里
这是线上最常导致服务卡死的点:一次 Release() 漏掉,许可计数就永久少 1;漏三次,第 4 个请求开始永远 WaitAsync() 挂起。
错误写法示例:
await _sem.WaitAsync();
try {
await DoSomethingAsync();
_sem.Release(); // 异常一抛,这行根本不会执行
} catch { ... }
正确结构只有一种:
await _sem.WaitAsync();
try {
await DoSomethingAsync();
} finally {
_sem.Release(); // 同步调用,不 await
}
注意:Release() 不能放 try 里,也不能放 catch 里重写一遍——重复释放会导致许可溢出(比如从 0 变成 1),后续限流彻底失准。
超时和取消必须显式处理,且别信 WaitAsync(TimeSpan) 的返回值
.NET 6+ 中,WaitAsync(TimeSpan) 重载已废弃,超时直接抛 OperationCanceledException,**不返回 false**。
容易踩的坑:
- 只
catch (Exception)却没专门捕获OperationCanceledException,结果超时被吞,逻辑误判为成功 - 用
if (!await _sem.WaitAsync(ts))—— 这段代码在新版本编译不过或永远不进分支 - 传入的
CancellationToken被忽略,导致外部主动取消(如 HTTP 请求中止)无法及时响应
推荐写法:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
try {
await _sem.WaitAsync(cts.Token);
} catch (OperationCanceledException) when (!cts.Token.IsCancellationRequested) {
throw new TimeoutException("获取并发许可超时");
}
别把它套在 HttpClient.SendAsync 外层当“锁”用
HttpClient 本身有 MaxConnectionsPerServer 和连接池管理,再叠一层 SemaphoreSlim 只是掩盖真实瓶颈,还可能因异常漏 Release() 引发死锁。
真正该控的是「请求发起节奏」,不是「连接执行状态」。
安全封装的关键点:
- 把
WaitAsync()放在构造完HttpRequestMessage之后、调用SendAsync()之前 - 整个
SendAsync()调用必须包在try/finally内,哪怕抛HttpRequestException也要Release() - 不要对健康检查、静态资源路径做限流,否则
/health接口自己先卡住
一句话收尾:SemaphoreSlim 的本质是计数门控,不是线程锁;它的脆弱点不在语法,而在生命周期管理、异常路径覆盖和超时语义理解——漏掉任一环,限流就从保护变成隐患。











