目录
[C++ 代码实现](#C++ 代码实现)
[信号量 vs. 互斥锁](#信号量 vs. 互斥锁)
[它的本质:只有 0 和 1 的计数器](#它的本质:只有 0 和 1 的计数器)
[为什么它比 Mutex 更适合"同步"?](#为什么它比 Mutex 更适合“同步”?)
[在 Foo 题中的具体应用细节](#在 Foo 题中的具体应用细节)
题目描述
给你一个类:
cpp
class Foo {
public:
Foo() {
}
void first(function<void()> printFirst) {
// printFirst() outputs "first". Do not change or remove this line.
printFirst();
}
void second(function<void()> printSecond) {
// printSecond() outputs "second". Do not change or remove this line.
printSecond();
}
void third(function<void()> printThird) {
// printThird() outputs "third". Do not change or remove this line.
printThird();
}
};
三个不同的线程 A、B、C 将会共用一个 Foo 实例。
-
线程 A 会调用 first() 方法;
-
线程 B 会调用 second() 方法;
-
线程 C 会调用 third() 方法;
请设计修改程序,以确保 second() 方法在 first() 方法之后被执行,third() 方法在 second() 方法之后被执行。
示例 1:
输入:nums = [1,2,3]
输出:"firstsecondthird"
解释:
有三个线程会被异步启动。输入 [1,2,3] 表示线程 A 将会调用 first() 方法,线程 B 将会调用 second() 方法,线程 C 将会调用 third() 方法。正确的输出是 "firstsecondthird"。
示例 2:
输入:nums = [1,3,2]
输出:"firstsecondthird"
解释:
输入 [1,3,2] 表示线程 A 将会调用 first() 方法,线程 B 将会调用 third() 方法,线程 C 将会调用 second() 方法。正确的输出是 "firstsecondthird"。
(来源:Leecode)
如果说互斥锁(Mutex)的本质是"排他性"(保护资源不被同时访问), 那么信号量(Semaphore)的本质就是"许可证管理"。
让我们再次运用第一性原理,从零推导信号量的解法。
回归本质------什么是信号量?
从第一性原理出发,信号量其实就是一个简单的计数器,它只有两个核心原子操作:
- 等待 (Wait / P 操作):
-
如果计数器 > 0,就把计数器减 1,然后通过。
-
如果计数器 == 0,就停下来等,直到计数器变成大于 0。
- 释放 (Signal / Post / V 操作):
-
把计数器加 1。
-
如果有线程在等,就叫醒它。
推论: 如果我们把信号量的初始值设为 0,那么任何试图进行"等待"操作的线程都会立刻被卡住。只有当另一个线程执行了"释放"操作,把 0 变成 1 时,被卡住的线程才能拿到这张"许可证"继续前进。
构建依赖链条
我们要实现:first -> second -> third。
-
分析
second:它需要一张来自first的许可证。 -
分析
third:它需要一张来自second的许可证。 -
分析初始状态:
-
second对应的许可证初始应该是 0 (没完成first前不准进)。 -
third对应的许可证初始应该是 0 (没完成second前不准进)。
C++ 代码实现
在 C++20 中,标准库终于引入了 <semaphore>。我们使用 std::binary_semaphore(二进位信号量),它的值只有 0 和 1,非常适合这种"点对点"通知。
1. 定义工具
我们需要两个信号量来充当"接力棒"。
cpp
#include <semaphore>
class Foo {
private:
// 初始值都设为 0
std::binary_semaphore sem1to2{0};
std::binary_semaphore sem2to3{0};
public:
Foo() {}
2. first 的逻辑:我是发令枪
first 不需要等任何人,它执行完后负责"投递"第一张许可证。
cpp
void first(function<void()> printFirst) {
// printFirst() outputs "first".
printFirst();
// 完成后,给 sem1to2 加 1,释放许可证
sem1to2.release();
}
3. second 的逻辑:承上启下
second 必须先拿到 first 投递的许可证,执行完后再给 third 投递一张。
cpp
void second(function<void()> printSecond) {
// 试图拿证,如果 first 没写完,这里就是 0,会阻塞等待
sem1to2.acquire();
printSecond();
// 完成后,释放下一张证给 third
sem2to3.release();
}
4. third 的逻辑:终点站
它只管盯着 second 的许可证。
cpp
void third(function<void()> printThird) {
// 等待来自 second 的许可证
sem2to3.acquire();
printThird();
}
};
信号量 vs. 互斥锁
为什么这道题用信号量看起来比 std::condition_variable 简洁这么多?
- 无状态化:
-
在使用互斥锁时,我们需要额外维护一个
int step变量来记录进度。 -
信号量本身就自带状态(计数器的值),它把"锁"和"条件判断"合二为一了。
- 解耦:
-
互斥锁通常要求"谁上锁谁解锁"。
-
信号量没有"所有权"概念。线程 A 可以
release(增加),而线程 B 执行acquire(减少)。这种跨线程的信号传递是信号量的天职。
补充:std::binary_semaphore
既然我们已经用"许可证"的逻辑推导出了信号量的解法,那么 std::binary_semaphore 就是 C++20 正式引入的、专门为了解决这种"二元通知"问题的利器。
从第一性原理来看,binary_semaphore 是对"状态同步"最纯粹的抽象。
它的本质:只有 0 和 1 的计数器
在 C++ 中,std::binary_semaphore 实际上是 std::counting_semaphore<1> 的一个别名。
-
核心约束: 它的计数器值只能是 0 或 1。
-
物理类比: 它就像一个一次只能放一个球的漏斗。如果里面已经有一个球了(值为 1),你再放也放不进去;如果里面没球(值为 0),想拿球的人就得在那等。
两个核心操作
在 C++ 里,它不再叫 P/V 操作,也不叫 Lock/Unlock,而是叫:
acquire() (获取/等待)
-
逻辑: "我要拿走那个唯一的许可证。"
-
行为: * 如果当前值是 1,把它变成 0,线程继续执行。
- 如果当前值是 0,线程阻塞(挂起),直到有人把它变成 1。
release() (释放/增加)
-
逻辑: "我把许可证还回去(或者新发一张证)。"
-
行为: * 把值从 0 变成 1。
- 如果此时有线程在
acquire()那里等着,那个线程会被唤醒。
- 如果此时有线程在
为什么它比 Mutex 更适合"同步"?
这是一个非常关键的底层逻辑差异:
| 特性 | std::mutex | std::binary_semaphore |
| 所有权 | 有。谁上锁,必须由谁解锁。 | 无。线程 A 可以在这里等,线程 B 可以在那边发证。 |
| 初始状态 | 默认是开着的(Unlocked)。 | 你可以自由指定。通常设为 0 来拦截线程。 |
| 语义 | 保护一段资源不被同时访问。 | 协调两个动作之间的先后顺序。 |
|---|
解决"虚假唤醒"的天生优势
还记得我们在讲 condition_variable 时提到的"虚假唤醒"吗?我们需要写一个 while 循环或传递一个 lambda 表达式来反复检查条件。
信号量不需要。
信号量的操作是由操作系统内核保证原子性的。如果 acquire() 返回了,那一定是因为计数器确实变成了 1 且被你成功减到了 0。它内部自带了"记忆",不需要你额外写 step == 2 这样的逻辑判断。
在 Foo 题中的具体应用细节
回到我们的代码片段:
cpp
// 初始值为 0,意味着"门关着"
std::binary_semaphore sem1to2{0};
void first(...) {
printFirst();
sem1to2.release(); // 门开了,信号量变 1
}
void second(...) {
sem1to2.acquire(); // 拿走信号量,变回 0。如果此时它还是 0,我就在这儿等。
printSecond();
}
这里有一个第一性原理的微妙之处:它是如何处理"抢跑"的?
-
如果
second抢跑了:它执行acquire(),发现是 0,乖乖睡觉。 -
如果
first先跑完了:它执行release(),信号量变 1。稍后second跑过来执行acquire(),发现是 1,瞬间通过,完全不会被阻塞。
这种"状态被保留"的特性,让它在处理这类异步时序问题时,比条件变量更直观。
std::binary_semaphore 是 C++ 给我们的一种轻量级、无所有权的信号传递工具。
-
如果你想保护一个变量不被同时改写,用 Mutex。
-
如果你想让线程 A 等着线程 B 完成某个任务,用 Semaphore。