C#多线程编程中,我们经常遇到这样的需求:不要求线程互斥,只要求同时访问某个资源的线程数量不能超过上限。比如下载器最多同时开5个连接、数据库连接池最多容纳10个连接。这时Lock和Monitor这类互斥手段就不合适了,Semaphore信号量才是对症的工具。它通过一个内部计数器来控制并发度,线程进入前计数减一,退出后计数加一,计数为零时后续线程只能等待。

Semaphore的基本原理与命名信号量
Semaphore的内部维护一个整数计数器,构造时需要传入两个参数:初始可用数量和最大并发数量。初始数量表示一开始有几个"许可证"可以发放,最大数量则限制了Release能恢复的上限。线程调用WaitOne时申请许可证,如果当前计数大于零则计数减一并立即返回,线程继续执行;如果计数已经为零,线程就进入阻塞队列等待,直到其他线程调用Release归还许可证。
值得注意的是,Semaphore的计数可以理解为"资源的剩余额度"而不是"已被占用的数量",这一点和SemaphoreSlim的使用没有区别,但初次接触时容易混淆方向。初始计数设为3,意味着最多同时允许3个线程通过,第4个线程必须等前面某个线程释放。
命名信号量是System.Threading.Semaphore的一个特殊能力,构造时传入名称后,信号量在整个操作系统范围内可见,可以跨进程协同工作。例如限制同一台机器上多个进程对某个文件的并发访问:
// 创建一个命名信号量,初始2个许可,最大2个,跨进程可见
using var sem = new Semaphore(2, 2, "Global\\MyFileSemaphore");
sem.WaitOne();
try
{
Console.WriteLine($"线程{Thread.CurrentThread.ManagedThreadId} 获得许可,开始处理");
Thread.Sleep(2000); // 模拟耗时操作
}
finally
{
sem.Release(); // 必须在finally中释放,防止异常导致许可泄漏
}名称前缀Global表示在全局命名空间创建,即使多个会话环境中的进程也能共享同一个信号量。如果不加前缀,信号量只在当前会话内可见。跨进程场景下要特别注意异常处理,因为Semaphore没有基于Process的自动释放机制,进程异常退出虽然会释放句柄,但已授予的许可在内核对象销毁前可能造成短暂的计数错乱,可借助构造时传入out createdNew参数做初始化保护。
Semaphore与SemaphoreSlim的区别及选型
.NET提供了两个信号量类型,很多初学者不清楚该用哪个。System.Threading.Semaphore是对Windows内核信号量对象的封装,功能强大但开销较高,每次WaitOne和Release都涉及内核态切换,同时它支持命名实例和跨进程同步。SemaphoreSlim则是.NET 4.0引入的轻量级托管实现,计数逻辑完全在用户态完成,只有在需要真正阻塞等待时才借助内核事件对象,性能明显更好。
选型的基本原则很清晰:绝大多数单进程内的并发控制场景都应该用SemaphoreSlim。只有在需要跨进程同步、或者需要信号量的WaitHandle参与WaitHandle.WaitAny等组合等待时,才考虑使用Semaphore。此外SemaphoreSlim提供了CancellationToken支持、WaitAsync异步等待方法以及AvailableWaitHandle属性,在async/await代码中它是唯一合理的选择,因为Semaphore的WaitOne是同步阻塞的,直接用在异步方法中会占用线程池线程,违背异步编程的初衷。
下面的例子演示SemaphoreSlim在异步限流中的典型用法,比如并发调用一个限流为3的外部接口:
var semaphore = new SemaphoreSlim(3, 3); // 最多3个并发
var tasks = Enumerable.Range(1, 10).Select(async i =>
{
Console.WriteLine($"请求{i} 等待进入");
await semaphore.WaitAsync(); // 异步等待,不阻塞线程
try
{
Console.WriteLine($"请求{i} 开始执行");
await Task.Delay(1000); // 模拟异步IO
Console.WriteLine($"请求{i} 执行完毕");
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
Console.WriteLine($"剩余可用许可: {semaphore.CurrentCount}");这段代码中10个异步请求争抢3个许可,输出会清晰地展示每次只有3个请求在同时执行。CurrentCount属性可以随时查看剩余许可数,方便做监控和日志。执行结果还体现出信号量不保证公平性,先等待的线程不一定先获得许可,这一点在编写对顺序有要求的逻辑时要留意。
常见误区与实战注意事项
第一类高频问题是Release调用次数超过最大上限。SemaphoreSlim的Release不接受超额释放,如果Release的次数使计数超过maximumCount,会直接抛出SemaphoreFullException。典型错误是在条件分支中只Release部分路径,或者循环里Wait一次却Release多次。规范做法是让Wait和Release严格成对出现,并始终把Release放在finally块中,这样即使try中抛出异常也能保证许可归还。
第二类问题是InitialCount和maximumCount的关系搞反。构造函数第一个参数是当前可用计数,第二个是最大值,初始值不能大于最大值。有人误以为第一个参数是最大并发数,写成了new Semaphore(5, 3),运行时立刻抛出ArgumentOutOfRangeException。建议写代码时始终用命名参数或注释明确含义,降低出错概率。
第三类问题出现在异步与同步混用上。SemaphoreSlim不能在持有许可的代码中再次调用WaitAsync去等待同一个信号量,若许可已耗尽会造成自我死锁。如果确实需要可重入语义,应该在业务层自己维护重入计数,而不是依赖信号量。另外SemaphoreSlim的WaitAsync虽然不阻塞线程,但等待的Task没有超时控制时可能长时间挂起,生产环境建议传入超时和取消令牌:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
if (await semaphore.WaitAsync(TimeSpan.FromSeconds(3), cts.Token))
{
try
{
// 获得许可后的业务逻辑
}
finally
{
semaphore.Release();
}
}
else
{
Console.WriteLine("等待超时,未获得许可,走降级逻辑");
}最后补充一个资源池的实战模式。信号量配合队列可以轻松实现对象池:信号量控制并发上限,ConcurrentQueue存放可用对象,获取时Wait成功后从队列取对象,用完归还时先把对象放回队列再Release。这个模式在数据库连接池、Socket连接池的实现中广泛使用。理解了信号量的计数本质和成对使用的纪律,它就能成为多线程限流与资源管理中最可靠的工具之一。