在 C++ 中,信号量(Semaphore) 提供了一种基于“计数器”的同步机制。
需要注意的是,标准的信号量 std::counting_semaphore 和 std::binary_semaphore 是在 C++20 才正式引入的。如果你严格限制在 C++11 环境,通常需要自己封装 POSIX 信号量(sem_t)或 Windows 信号量,或者使用 Boost 库。
为了方便讲解原理,下面的代码使用 C++20 标准 的写法(逻辑在 C++11 下是一样的,只是 API 不同)。
1. 信号量的核心逻辑
信号量本质上是一个整数计数器,包含两个原子操作:
- **
acquire()(P 操作 / Wait)**:计数器减 1。如果计数器为 0,线程阻塞,直到大于 0。 - **
release()(V 操作 / Signal / Post)**:计数器加 1。如果有线程在阻塞,唤醒其中一个。
与条件变量最大的区别:
- 条件变量是无状态的:如果没有线程在
wait,你调用notify,这个信号就丢了。 - 信号量是有状态的:如果没有线程在
acquire,你调用release,计数器会累加,信号被保存下来,下一个线程来acquire时会直接通过,不用等待。
2. 代码实现:使用信号量的生产者-消费者
在这个方案中,我们使用两个核心组件:
-
std::mutex:仅用于保护std::queue的数据完整性(防止 push/pop 冲突)。 -
std::counting_semaphore:用来代表“队列中可用数据的数量”。
1 |
|
3. 条件变量 vs. 信号量:详细对比
| 特性 | 条件变量 (Condition Variable) | 信号量 (Semaphore) |
|---|---|---|
| 核心逻辑 | 基于状态检查。等待某个复杂的条件(谓词)成立。 | 基于计数。等待资源的数量 > 0。 |
| 是否有记忆 | 无记忆。如果在 wait 之前 notify,信号丢失。 |
有记忆。release 会增加计数,后续的 acquire 会立即成功。 |
| 互斥锁依赖 | 必须配合 unique_lock<mutex>。等待时自动释放锁,醒来自动加锁。 |
独立。信号量本身是原子的,不需要外部锁。但在操作共享容器时仍需锁。 |
| 灵活性 | 极高。条件可以是 `!queue.empty() && x > 5 | |
| 广播能力 | notify_all() 可以轻松唤醒所有线程。 |
没有直接的 release_all。通常需要循环调用 release 或者由被唤醒的线程级联唤醒。 |
| 典型场景 | 复杂的业务逻辑同步、状态机变化、一次性通知。 | 资源池限制(如连接池)、有界缓冲区的空槽/满槽计数。 |
4. 为什么 C++11 只有条件变量没有信号量?
这是一个有趣的设计哲学问题。C++ 标准委员会当时认为:
- 条件变量是更底层的原语:你可以用“互斥锁 + 条件变量 + 计数器整数”轻松实现一个信号量。
- 表达能力:条件变量能表达任意复杂的条件,而信号量只能表达计数。
- 易错性:信号量在复杂的逻辑中容易导致死锁(比如忘记 release),或者是编程意图不清晰(只有数字,不知道代表什么)。
如何用 C++11 的条件变量实现一个信号量?
(这也是面试常考题)
1 |
|
5. 什么时候该选哪一个?
选条件变量:
- 你需要检查的条件不仅仅是计数(例如:队列不为空 且 生产未结束)。
- 你需要广播(通知所有线程,例如“程序退出”信号)。
- 你需要更严格的临界区控制(醒来时必须持有锁)。
选信号量:
- 主要为了限制并发数量(例如:限制最多 5 个线程同时访问数据库连接池)。
- 有界缓冲区(Bounded Buffer):需要两个信号量,一个表示“空槽数量”,一个表示“数据数量”。这种场景下信号量代码非常优雅,不需要手动检查
queue.size() < MAX。 - 轻量级的通知机制,且发送通知时并不持有锁。
总结
如果不使用条件变量,使用信号量实现生产者-消费者模型是完全可行的,甚至在“纯粹计数”的场景下代码更少。
但信号量的局限性在于处理“非计数类”的状态变化(比如程序停止标志 finished)时比较麻烦,通常需要特殊的“毒药丸”策略来唤醒阻塞的线程,而条件变量只需要修改判断谓词即可。