Skip to content

同步与互斥:信号量、管程与经典问题 ​

考频 ⭐⭐⭐⭐⭐。并发编程的理论核心,也是后端 / 系统岗必问。 主线:为什么需要同步 → 各种锁的取舍 → 信号量 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 个可用资源,可用于资源计数与同步。
用途初值设置说明
实现互斥1P 进入临界区、V 离开,保证只有一个执行体在临界区
资源计数N(资源数)如连接池、缓冲区槽位数
条件同步(先后)0A 完成后 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) wait

Q8. 条件变量为什么要用 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 人同时拿起左边的筷子 → 每人都等右边 → 死锁。

解法:

  1. 破坏循环等待:每人按固定顺序拿筷子(如都先拿编号小的那只)——最常用。
  2. 限制并发人数:用信号量 room(初值 4),最多 4 人同时抢筷 → 必有一人能拿到两只。
  3. 一次拿两只:把「两只筷子」作为一个原子资源(用 mutex 保护取放,或和性检查)。
  4. 奇数/偶数策略:奇数号先左后右、偶数号先右后左,打破对称。
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();                       // V

C++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)减争用常比换无锁更有效。

相关 ​

  • 死锁:四条件、悲观锁/乐观锁、加锁顺序。
  • 进程与线程:线程模型、上下文切换。
  • 虚拟内存:共享内存与同步。
  • /concurrency/threads-basics.md:std::thread、mutex、条件变量、生产者-消费者。
  • /concurrency/lock-optimize.md:自旋锁 vs 互斥锁、读写锁、假共享。
  • /concurrency/lockfree.md:CAS、ABA、无锁队列。
  • /concurrency/atomic-memoryorder.md:内存序与 happens-before。
  • /concurrency/deadlock-singleton.md:死锁与线程安全单例。

糌粑的面经笔记 · VitePress 版