Appearance
同步与互斥:信号量、管程与经典问题
考频 ⭐⭐⭐⭐⭐。并发编程的理论核心,也是后端 / 系统岗必问。 主线:为什么需要同步 → 各种锁的取舍 → 信号量 PV → 管程与条件变量 → 三个经典问题 → 和 C++ 的对应。 一句话:同步就是让多个并发执行体「保持一定的相对关系」,互斥只是它最常用的一种形态。
一、基本概念
Q1. 临界区、竞态条件、原子性分别是什么?
答案
- 临界区(Critical Section):访问共享资源的那段代码。同一时刻至多一个执行体进入,才安全。
- 竞态条件(Race Condition):程序的执行结果依赖不受控的时序/交错。即使没有数据竞争,也可能因为「检查后使用(TOCTOU)」出错。
- 数据竞争(Data Race):两个及以上线程访问同一内存,至少一个在写,且没有同步 → 未定义行为(UB)。比竞态条件更严格。
- 原子性:一个操作要么全做、要么全不做,中间不会被观察/打断。单条机器指令通常原子,多步操作需要锁或 CAS 保证。
c
// 竞态:a++ 在机器层面是 读-改-写 三步,非原子
balance++; // 实际: load, add, store —— 多线程下会丢更新同步三要素:互斥(Mutual Exclusion)+ 同步(先后顺序)+ 原子性。
Q2. 忙等待 vs 阻塞?
答案
| 方式 | 行为 | 优缺点 | 适用 |
|---|---|---|---|
| 忙等待(busy waiting) | 循环反复检查条件,不让出 CPU | 无切换开销,响应快;但烧 CPU,单核上可能白等 | 临界区极短、可确定等待时间的场景 |
| 阻塞(blocking) | 让出 CPU,进入睡眠,条件满足后由内核唤醒 | 不占 CPU;但有两次上下文切换开销 | 临界区可能较长、等待不确定 |
自旋锁是忙等待,互斥锁/信号量是阻塞。二者的选择本质是:「等待时间 vs 上下文切换耗时」谁更小。
混合方案:先自旋一小会儿(适应型自旋),还没拿到再阻塞,兼顾两者。
二、常见锁的类型与取舍
Q3. 互斥锁 / 自旋锁 / 读写锁 / 可重入锁各自特点?
答案
| 锁 | 特点 | 适用场景 | 注意点 |
|---|---|---|---|
| 互斥锁 mutex | 独占,拿不到就阻塞 | 通用 | 不可重入则二次加锁会死锁 |
| 自旋锁 spinlock | 独占,拿不到忙等 | 临界区极短、多核、内核 | 单核/持锁被抢占会死等 |
| 读写锁 rwlock | 读共享、写独占 | 读多写少 | 写者可能饥饿;比 mutex 略重 |
| 可重入锁 recursive_mutex | 同一线程可重复加锁(计数) | 递归调用、回调 | 加锁次数需与解锁匹配 |
补充:
- 共享互斥锁
std::shared_mutex(C++17) = 读写锁,shared_lock读、unique_lock写。 - 可重入 ≠ 线程安全:可重入解决「同一线程重复加锁」,不解决跨线程竞态。
- 公平性:普通锁不保证公平,可能出现饥饿;可换成公平锁/排队锁(ticket lock、MCS)。
cpp
std::recursive_mutex rm;
void f() {
std::lock_guard<std::recursive_mutex> lk(rm); // 可重复加锁
f2();
}三、信号量(Semaphore)
Q4. 信号量的 P/V 语义是什么?
答案
信号量是一个整型计数器 + 一条等待队列,只允许三个原子操作:
- P 操作(wait / down / 减):
S = S - 1;若S < 0,则调用者阻塞,进入等待队列。语义 = 申请一个资源。 - V 操作(signal / up / 加):
S = S + 1;若有等待者,则唤醒一个。语义 = 释放一个资源。 - 初始化:设定初值。
c
// 语义化实现(真实实现用原子/关中断保证 P、V 本身互斥)
void P(sem_t *s) {
s->value--;
if (s->value < 0) { // 无资源
block(cur); // 阻塞当前进程,入队
}
}
void V(sem_t *s) {
s->value++;
if (s->value <= 0) { // 有进程在等
wakeup(one_waiter(s)); // 唤醒一个
}
}注意 P/V 内部(改计数、判断、入队)必须原子,否则信号量自身也会产生竞态。
Q5. 计数信号量 vs 二值信号量?
答案
- 二值信号量(binary):取值只有 0/1,等价于一把互斥锁,实现互斥。
- 计数信号量(counting):取值 0..N,表示 N 个可用资源,可用于资源计数与同步。
| 用途 | 初值设置 | 说明 |
|---|---|---|
| 实现互斥 | 1 | P 进入临界区、V 离开,保证只有一个执行体在临界区 |
| 资源计数 | N(资源数) | 如连接池、缓冲区槽位数 |
| 条件同步(先后) | 0 | A 完成后 V,B 在 P 上等待;实现「B 必须等 A 做完」 |
c
sem_t mutex; sem_init(&mutex, 0, 1); // 互斥
P(&mutex);
/* 临界区 */
V(&mutex);Q6. P 操作的顺序为什么重要?
答案
这在生产者-消费者里最关键:P(资源信号量)必须在 P(mutex)之前,否则可能死锁。
- 正确顺序:先
P(empty)/P(full)判断「有没有空位/数据」,再P(mutex)进临界区。 - 错误顺序:先
P(mutex)拿锁,再P(empty)等空位。若缓冲区已满,生产者持锁睡觉;消费者想消费却拿不到mutex→ 互相等待 → 死锁。
c
// ❌ 错误:先锁后判资源
P(&mutex); P(&empty); /* 放数据 */ V(&full); V(&mutex);
// ✅ 正确:先判资源再锁
P(&empty); P(&mutex); /* 放数据 */ V(&full); V(&mutex);V 的顺序不敏感(都是加,不会阻塞),但 P 的顺序是死锁与否的分水岭。
四、管程(Monitor)与条件变量
Q7. 什么是管程?条件变量的 wait/signal 语义?
答案
管程(Monitor):把「共享数据 + 操作这些数据的过程 + 同步机制」封装成一个模块,任一时刻只允许一个进程/线程在管程内活动(由编译器/运行时自动加锁,程序员不用手动 P/V)。Java 的 synchronized 方法、C++ 里「mutex + condition_variable 的组合」就是管程思想。
条件变量(condition variable) 是管程内用于「等待某条件成立」的机制:
wait(cv, lock):原子地释放锁并阻塞,被唤醒后重新获取锁再返回。signal/notify(cv):唤醒一个在该 cv 上等待的线程(notify_one)或全部(notify_all)。- 为什么必须配锁:判断「条件是否成立」需要读取共享状态,检查和等待必须原子,否则会丢失唤醒(见 Q9)。
cpp
// 典型用法:等待队列非空
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&]{ return !queue.empty(); }); // 谓词版:等价 while(!pred) waitQ8. 条件变量为什么要用 while 而不是 if?(虚假唤醒)
答案
- 虚假唤醒(spurious wakeup):
wait可能在没有 signal 的情况下自行返回(操作系统/硬件允许)。此外notify_all醒来后条件也可能被别的线程先抢走。 - 所以被唤醒后必须重新检查条件:写成
while (!pred) cv.wait(lk);,而不是if (!pred) cv.wait(lk);。 - C++ 提供了谓词版
wait(lk, pred),内部就是while(!pred()) wait(lk);,推荐直接用。
cpp
// ❌ if 版:虚假唤醒 / 被抢占后条件又不成立 → 逻辑错误
if (queue.empty()) cv.wait(lk);
auto x = queue.front(); // 可能已空 → UB
// ✅ while 版
while (queue.empty()) cv.wait(lk);Q9. 什么是「丢失唤醒」?notify 要不要持锁?
答案
- 丢失唤醒(lost wakeup):如果「检查条件」和「进入等待」不是原子的——线程 A 检查到条件不满足,还没睡下,线程 B 就把条件改好并
notify了;A 随后才睡下,于是永远等不到通知。管程/条件变量把「检查 + 睡眠」做成原子,正是为了消除它。 - notify 是否持锁:逻辑上不必持锁就能保证正确(共享状态由锁保护)。持锁 notify 的坏处:被唤醒者要等 notify 方释放锁才能跑,可能多一次唤醒延迟。常见推荐做法是改完状态、释放锁之后再 notify(如
lock_guard作用域结束后 notify);但这要求「改状态」本身在锁内。
cpp
{
std::lock_guard<std::mutex> lk(m);
data.push(x);
} // 先解锁
cv.notify_one(); // 再通知 —— 减少唤醒后的等待五、三个经典同步问题
Q10. 生产者-消费者(三信号量)怎么写?
答案
三信号量:empty(空槽位,初值 N)、full(满槽位,初值 0)、mutex(互斥访问缓冲,初值 1)。
c
#define N 8
int buffer[N], in = 0, out = 0;
sem_t empty, full, mutex;
void init() {
sem_init(&empty, 0, N); // N 个空位
sem_init(&full, 0, 0); // 0 个数据
sem_init(&mutex, 0, 1); // 互斥
}
void producer() {
while (1) {
int item = produce();
P(&empty); // ① 先判有无空位(顺序关键!)
P(&mutex); // ② 再进临界区
buffer[in] = item;
in = (in + 1) % N;
V(&mutex); // ③ 离开
V(&full); // ④ 通知有新数据
}
}
void consumer() {
while (1) {
P(&full); // 先判有无数据
P(&mutex);
int item = buffer[out];
out = (out + 1) % N;
V(&mutex);
V(&empty); // 通知有空位
consume(item);
}
}要点:先 P 资源信号量,再 P mutex(见 Q6);V 顺序可交换。
Q11. 读者-写者问题?读者优先 vs 写者优先?
答案
约束:允许多个读者同时读;写者独占(写时不能有读者或别的写者)。
读者优先(读者来得多时写者可能饿死):
c
sem_t rw_mutex; // 读者与写者/写者之间互斥,初值 1
sem_t mutex; // 保护 read_count,初值 1
int read_count = 0;
void reader() {
P(&mutex);
if (++read_count == 1) P(&rw_mutex); // 第一个读者锁住写者
V(&mutex);
/* 读 */
P(&mutex);
if (--read_count == 0) V(&rw_mutex); // 最后一个读者放行写者
V(&mutex);
}
void writer() {
P(&rw_mutex);
/* 写 */
V(&rw_mutex);
}写者优先:再加一个信号量,让写者在等待时阻断后续读者,避免写者饥饿。核心取舍:读者优先→写者可能饿死;写者优先→读者可能饿死;公平方案需额外排队。
Q12. 哲学家进餐问题?怎么避免死锁?
答案
5 个哲学家围坐,两人之间一只筷子,需要同时拿起左右两只才能吃。若每人「先拿左、再拿右」,5 人同时拿起左边的筷子 → 每人都等右边 → 死锁。
解法:
- 破坏循环等待:每人按固定顺序拿筷子(如都先拿编号小的那只)——最常用。
- 限制并发人数:用信号量
room(初值 4),最多 4 人同时抢筷 → 必有一人能拿到两只。 - 一次拿两只:把「两只筷子」作为一个原子资源(用 mutex 保护取放,或和性检查)。
- 奇数/偶数策略:奇数号先左后右、偶数号先右后左,打破对称。
c
sem_t room; // 初值 4,最多 4 人入座
sem_t chopstick[5]; // 每只筷子一个互斥量,初值 1
void philosopher(int i) {
while (1) {
think();
P(&room); // 限制人数,破坏循环等待
P(&chopstick[i]);
P(&chopstick[(i + 1) % 5]);
eat();
V(&chopstick[i]);
V(&chopstick[(i + 1) % 5]);
V(&room);
}
}记忆:哲学家进餐 = 死锁问题的标准范例,解法对应「破坏循环等待 / 限制并发 / 原子获取 / 打破对称」。
六、与 C++ 的对应
Q13. std::mutex、condition_variable、semaphore 怎么对应这些概念?
答案
| 概念 | C++ 对应 | 备注 |
|---|---|---|
| 互斥锁 | std::mutex + lock_guard / unique_lock / scoped_lock(C++17) | RAII 自动解锁 |
| 读写锁 | std::shared_mutex(C++17) + shared_lock | 读共享、写独占 |
| 可重入锁 | std::recursive_mutex | 计数式 |
| 条件变量 | std::condition_variable | 配 unique_lock,用谓词版 wait |
| 信号量 | std::counting_semaphore / binary_semaphore(C++20) | acquire=P、release=V |
| 一次性初始化 | std::once_flag + call_once | 线程安全单例 |
| 原子 | std::atomic<T> | 无锁、内存序 |
cpp
#include <semaphore>
std::counting_semaphore<8> empty{8}; // 计数信号量(模板参数为上限)
std::binary_semaphore ready{0}; // 二值信号量
empty.acquire(); // P
empty.release(); // VC++20 前没有标准信号量,可用「mutex + condition_variable + 计数器」手写,或 POSIX sem_t。详见并发专题。
Q14. std::lock_guard vs unique_lock vs scoped_lock?
答案
lock_guard:构造加锁、析构解锁,不可手动解锁,最轻量。unique_lock:可延迟加锁、手动unlock/lock、可移动、能配条件变量,略重。scoped_lock(C++17):同时锁多把锁,内部用防死锁算法(相当于对多个mutex调std::lock)。
cpp
std::scoped_lock lk(m1, m2); // 一次锁两把,防死锁七、高频 Q&A(快问快答)
Q15. 信号量和互斥锁的区别?
答案
互斥锁只用于互斥,且谁加锁谁解锁;信号量是计数器,可实现互斥 + 同步 + 资源计数,且 P/V 可以由不同线程执行(常用于「A 生产、B 消费」的同步)。二值信号量在功能上≈互斥锁。
Q16. 条件变量和信号量的区别?
答案
条件变量没有记忆,signal 若无人在等则丢失,且必须配锁、要防虚假唤醒;信号量有计数,V 操作即使当时无人等待也会累加,后续 P 能立刻通过。所以「一次性事件用信号量不怕丢通知,条件等待复杂条件用条件变量」。
Q17. 自旋锁和互斥锁怎么选?
答案
临界区极短且多核 → 自旋(省切换);临界区可能长 / 单核 / 需睡眠 → 互斥。经验:先自旋一小段再阻塞的混合锁适合大多数情况。
Q18. 无锁一定比加锁快吗?
答案
不一定。低竞争下无锁的原子操作 + 内存屏障可能比轻量锁更慢;高竞争下无锁的 CAS 失败重试也会放大。选择取决于竞争程度、临界区大小、数据是否可 CAS 表达。有竞争时分区(sharding)减争用常比换无锁更有效。