1. 介绍
Mutex 和 lock 就像只服务一个客人的咖啡师。但如果我们不是只有一台咖啡机,而是有三台——可以同时做三杯咖啡怎么办?
比如,你有一家咖啡馆,有三台咖啡机。顾客(线程)来,占用空闲的机器,做完咖啡就离开。如果三台都被占了,其他人就得等到有一台空出来。
问题: 怎么保证最多只有三个顾客同时在咖啡机上,其他人排队等候?
答案: 用 信号量!
什么是信号量?
信号量是经典的同步工具。如果 lock/Mutex 控制“一个进去——其他人等”,信号量则说:“我允许同时有 N 个!”。
信号量由 Edsger Dijkstra 在 1965 年提出。名字来自海上信号:就像旗帜传递可用信息,信号量在代码里告诉线程——可以进来还是得等着。
使用场景
- 限制同时访问某个资源的线程数量。
- 限制并发连接到数据库的数量、并行请求数、重任务数等。
2. 类概览: Semaphore 和 SemaphoreSlim
Semaphore
- 比较“重”,使用操作系统内核对象 (kernel objects)。
- 支持跨进程的线程同步。
- 可以指定名字并在进程间共享。
SemaphoreSlim
- 精简版本,只在单个进程内工作。
- 更快,资源消耗更少。
- 如果不需要跨进程同步,几乎总是优先使用。
类比:轻便背包(SemaphoreSlim)对比 大行李箱(Semaphore)。旅行轻装就背背包。
对比表
| 类 | 跨进程 | 性能 | 推荐场景 |
|---|---|---|---|
|
是 | 慢一些 | 需要进程间同步时使用 |
|
否 | 更快 | 99% 的情况,单进程内使用 |
信号量的主要方法和属性
主要参数
- InitialCount — 初始许可数。
- MaxCount — 最多同时发放的许可数。
关键方法
- Wait() 或 WaitAsync() — 请求访问(占用一个许可)。
- Release() — 释放一个许可。
工作原理
如果在调用 Wait() 时没有可用许可,线程会被阻塞并等待,直到有人调用 Release()。释放后,等待的某个线程会继续执行。
3. 第一个实践例子
在控制台程序里加一个“停车场”,只有 3 个车位,并尝试启动 10 个线程。
using System;
using System.Threading;
class Program
{
// 信号量,有 3 个许可(3 个停车位)
static SemaphoreSlim parking = new SemaphoreSlim(3);
static void Main()
{
for (int i = 1; i <= 10; i++)
{
int carNumber = i;
new Thread(() =>
{
Console.WriteLine($"机器车 #{carNumber} 尝试停车...");
parking.Wait(); // 等待空位
Console.WriteLine($"机器车 #{carNumber} 停进停车场了!");
Thread.Sleep(2000); // 在停车场停 2 秒
Console.WriteLine($"机器车 #{carNumber} 驶离停车场。");
parking.Release(); // 释放车位
}).Start();
}
}
}
- 一次最多只有三辆“车”能停车。
- 其他的会等到有空位为止。
- 输出可能交错——这是多线程的正常现象。
4. 信号量作为负载限制器
把同时运行的重任务(比如下载)限制到 5 个。
static SemaphoreSlim semaphore = new SemaphoreSlim(5); // 最多 5 个并发下载
static void DownloadFile(int fileId)
{
semaphore.Wait();
try
{
Console.WriteLine($"--> 开始下载文件 {fileId}");
Thread.Sleep(1000 + fileId * 100); // 下载(模拟)
Console.WriteLine($"<-- 文件 {fileId} 下载完成");
}
finally
{
semaphore.Release();
}
}
static void Main()
{
for (int i = 1; i <= 12; i++)
{
int localId = i;
new Thread(() => DownloadFile(localId)).Start();
}
}
重要点: 把 Wait() 放在 try 之前,把 Release() 放在 finally。这样即使发生异常,许可也一定会被释放。
5. Wait(int millisecondsTimeout) 和异步方法
可以只等待有限的时间:
if (semaphore.Wait(500))
{
// 成功在半秒内拿到许可!
}
else
{
// 500 ms 内没等到 — 放弃
}
在现代应用(比如 ASP.NET)里,应该使用异步版本:await semaphore.WaitAsync()。这不会在等待许可时阻塞执行线程。
注意:在异步代码里要用 SemaphoreSlim 和它的 WaitAsync,否则可能遇到不可预期的 deadlock。
6. 错误用法和正确用法示例
常见错误是忘记调用 Release():许可会“泄露”,程序会停住。
错误示例
static void SomeWork()
{
semaphore.Wait();
// ... 处理,但忘了 Release()!
}
正确示例
static void SomeWork()
{
semaphore.Wait();
try
{
// 处理
}
finally
{
semaphore.Release();
}
}
异步版本
static async Task SomeAsyncWork()
{
await semaphore.WaitAsync();
try
{
// 异步处理
}
finally
{
semaphore.Release();
}
}
7. 信号量的内部结构(通俗解释)
信号量就是一个计数器。Wait() 把它减 1。如果计数器 > 0 —— 线程通过;如果等于 0 —— 线程等待。Release() 增加计数并唤醒等待者。
+-------------------------------+
| 信号量 (计数 = 3) |
+-------------------------------+
| [ ] [ ] [ ] | <--- 许可
+----+----+----+----------------+
| | |
线程 线程 线程
8. 有用的细节
与其他原语的区别
- lock / Monitor / Mutex — 只允许一个线程(排他访问)。
- Semaphore/SemaphoreSlim — 允许有限的 N 个线程同时通过。
信号量不绑定“所有者”:任何线程都可以释放许可。这是特性,不是 bug。
现实世界的应用
- 限制到某个服务或数据库的并发连接数。
- 资源池:资源上最多有 N 个线程。
- 限制同时处理的 web 请求数。
- 读/写限制以防止过载。
- 限制对外部 API 的调用次数。
错误示例(Release 比 Wait 多)
var semaphore = new SemaphoreSlim(2);
semaphore.Release(); // 错误!计数变成 3,超过了 MaxCount — 会抛出 SemaphoreFullException.
这里会抛出 SemaphoreFullException:计数超过了最大值。
Semaphore 和 SemaphoreSlim 的区别
- SemaphoreSlim — 进程内、速度快、用法简单(几乎总是用它)。
- Semaphore — 在需要跨进程同步时才用(罕见场景)。
为什么要了解信号量?
面试中常见的问题:“怎么限制同时访问某个资源的线程数?”——正确答案就是:信号量。
- lock — 1 个线程。
- Semaphore/SemaphoreSlim — N 个线程。
9. 常见错误和使用注意事项
错误 №1: 忘记调用 Release(). 如果线程占用了许可(通过 Wait() 或 WaitAsync()),却没有释放,其他线程会无限等待——应用会“卡住”。
错误 №2: 调用 Release() 的次数多于 Wait() 的次数. 会出现“多余”的许可。对于 Semaphore 这会导致 SemaphoreFullException,破坏访问逻辑。
错误 №3: 混用不同的同步机制. 一处用 lock,另一处用信号量去保护同一资源,会增加死锁的风险。
错误 №4: 在异步代码里用 Semaphore. 经典的 Semaphore 不太适合 async/await。异步场景要用 SemaphoreSlim 和 WaitAsync()。
错误 №5: 错误设置 initialCount 和 maxCount. 值设置不当可能被绕过,导致比预期更多的线程访问资源。
GO TO FULL VERSION