SemaphoreSlim 是控制异步并发的首选,因其是 .NET 唯一原生支持 async/await 的同步基元,提供非阻塞的 WaitAsync() 方法,避免线程浪费与死锁,且需配对调用 WaitAsync() 和 Release() 并用 try/finally 确保释放。

为什么 SemaphoreSlim 是控制异步并发的首选
因为 SemaphoreSlim 是唯一原生支持 async/await 的 .NET 同步基元——它提供 WaitAsync(),能真正释放线程,不像 Monitor 或 lock 会阻塞线程。如果你用 Task.Run(() => { Thread.Sleep(100); }) 模拟耗时操作再套 lock,实际只是把阻塞搬到了线程池线程上,没解决资源争抢本质问题。
常见错误是误用 new Semaphore(1, 1)(这是同步版 Semaphore),它没有 WaitAsync(),强行 await Task.Run(() => s.Wait()) 属于“伪异步”,反而增加调度开销。
-
SemaphoreSlim构造函数第一个参数是初始计数(即最多允许几个并发),第二个是最大计数(通常与第一个相同) - 必须显式调用
Release(),否则计数不会归还,最终所有WaitAsync()都会挂起 - 它不绑定线程,
WaitAsync()和Release()可在不同线程或不同async方法中调用
如何在 HttpClient 并发请求中安全限流
直接对整个 HttpClient 实例加锁不行:锁粒度太大,且 HttpClient 本身是线程安全的;但后端 API 有 QPS 限制,需要控制“同时发出的请求数”,不是“是否复用客户端”。这时 SemaphoreSlim 是最轻量、无副作用的方案。
private static readonly SemaphoreSlim _rateLimiter = new SemaphoreSlim(5, 5); // 最多 5 个并发
public async Task<string> FetchDataAsync(string url)
{
await _rateLimiter.WaitAsync(); // 等待许可
try
{
return await _httpClient.GetStringAsync(url);
}
finally
{
_rateLimiter.Release(); // 必须放行,哪怕抛异常也要执行
}
}</string>
- 不要把
SemaphoreSlim声明为局部变量,否则每次调用都新建一个,完全失效 -
try/finally是硬性要求:Release()必须在WaitAsync()成功后、无论成功失败都执行,否则信号量泄漏 - 如果请求可能超时,
WaitAsync()本身也支持超时:await _rateLimiter.WaitAsync(TimeSpan.FromSeconds(3)),超时会抛OperationCanceledException
WaitAsync() 被取消时会发生什么
当传入的 CancellationToken 触发取消,WaitAsync() 会立即抛出 OperationCanceledException,且**不会占用信号量计数**。这点和同步的 WaitOne() 不同——后者在取消时可能已进入等待队列但未获得锁,导致状态不一致。
- 推荐始终传入
ct:比如await _sem.WaitAsync(ct),尤其在 Web API 中配合HttpContext.RequestAborted - 捕获
OperationCanceledException后,不要吞掉它——除非你明确要忽略取消(例如后台任务重试场景) - 注意:如果
WaitAsync()已成功返回,再触发取消令牌,不影响后续Release(),也不影响当前正在执行的异步操作
和 Parallel.ForEachAsync 的并发控制有什么区别
Parallel.ForEachAsync 的 MaxDegreeOfParallelism 参数看起来像限流,但它只控制“同时进入循环体的 Task 数量”,不控制这些 Task 内部是否继续发起异步 I/O(比如又调一次 HttpClient)。而 SemaphoreSlim 是跨方法、跨调用栈的全局计数器,只要共用同一个实例,就能精确约束任意位置的并发行为。
- 如果你的逻辑是“下载 100 个 URL,但任何时候最多发 5 个 HTTP 请求”,用
SemaphoreSlim;如果是“对 100 个本地文件做 CPU 密集解析,最多用 5 个线程”,才考虑Parallel.ForEachAsync -
SemaphoreSlim可以嵌套使用(比如外层控总并发,内层控某类资源),Parallel.ForEachAsync的并发参数无法分层 - 别试图用
SemaphoreSlim控制 CPU 密集型任务——它设计用于 I/O 等待场景;CPU 密集型该用Task.Run+ 线程池管理
真正容易被忽略的是:信号量的生命周期和作用域。它不是魔法开关,一旦创建就要管到底;Release() 漏写、WaitAsync() 后没进 try 块、或者多个逻辑路径(如 if/else 分支)里只在部分路径调用 Release(),都会让计数永久卡住——这种 bug 很难复现,但压测时会突然全链路阻塞。











