什么时候必须用 Semaphore
?
只有当你需要跨进程同步时,才非用
Semaphore不可。比如两个独立的 .NET 进程(如 Web API 和后台服务)要共享同一个硬件设备、全局日志文件或数据库连接池,就得靠命名信号量协调。
Semaphore底层包装 Win32 内核对象,支持通过名称(如
"MySharedResource")在系统范围内暴露,另一进程可用
Semaphore.OpenExisting("MySharedResource") 打开它
它不支持 WaitAsync(),所有等待都是同步阻塞,
WaitOne()会吃掉线程池线程——在 ASP.NET Core 等高并发异步场景中直接禁用 每次
WaitOne()或
Release()都触发用户态→内核态切换,性能开销明显,不适合高频、短等待场景
SemaphoreSlim
是你日常该用的默认选择
95% 的 C# 并发限流、资源池控制(如 HTTP 客户端并发数、数据库连接数)都该用
SemaphoreSlim,它专为单进程内高性能异步设计。 构造时传入两个参数:
new SemaphoreSlim(initialCount, maxCount)—— 注意
initialCount可以小于
maxCount,比如
new SemaphoreSlim(2, 5)表示初始放行 2 个线程,后续还能动态“补发”最多 3 个许可 必须用
await _sem.WaitAsync(cancellationToken),别写
WaitOne();释放务必放在
finally块里:
try { ... } finally { _sem.Release(); }
它不支持命名,不能跨进程;但支持 CancellationToken,能响应超时和取消,这对 Web 请求、定时任务很关键
常见死锁/异常怎么快速定位?
两类错误最典型:一种是“永远等不到”,一种是
SemaphoreFullException。 “永远等不到”:大概率是
WaitAsync()没配
TimeSpan或
CancellationToken,上游调用方已超时放弃,而你还在死等 —— 始终给
WaitAsync(TimeSpan.FromSeconds(30))加超时
SemaphoreFullException:说明
Release()调用次数超过了
WaitAsync()成功次数,比如异常路径漏了
finally,或同一请求里多次
Release()—— 检查所有
Release()是否严格配对且只执行一次 别在
using里创建
SemaphoreSlim实例,它是长期存活的协调器,不是一次性资源
性能差一倍?看底层等待方式
Semaphore每次
WaitOne()都走内核,哪怕只等 1ms,也要付出上下文切换成本;
SemaphoreSlim默认先自旋几十纳秒,抢到就走,没抢到才退化为内核事件 —— 这就是它快的本质。 实测:在 4 核 CPU 上模拟 1000 次短临界区访问,
SemaphoreSlim耗时约 8ms,
Semaphore约 15ms,差距随竞争加剧而扩大 但若等待时间普遍 > 10ms(比如等外部 API),两者差异收敛,此时选型应由“是否跨进程”决定,而非性能
SemaphoreSlim的
WaitHandle属性是延迟初始化的,仅当你显式访问(如用于
WaitAny)才创建内核对象,平时零开销
真正容易被忽略的是:你根本不需要手动管理“谁该释放”。两种信号量都不绑定线程身份,
Release()可由任意线程调用 —— 这既是灵活性来源,也是 bug 温床。务必把
Release()放进
finally,而不是依赖“同一线程释放”的假设。
