Lecture 5: 锁与条件变量(Locks and Condition Variables)
Lecture 5: 锁与条件变量(Locks and Condition Variables)
概述
买牛奶问题证明:我们需要高层同步原语。本讲引入互斥锁(mutex/lock)提供互斥、条件变量(condition variable)提供阻塞等待,并通过”生产者-消费者管道”这一经典问题,逐步演示从错误版本到正确版本(monitor 模式)的完整推导过程。
核心概念与系统机制图解
锁(Lock / std::mutex)
- 定义:提供互斥的同步对象。
lock():阻塞直到锁空闲,然后标记为当前线程持有;unlock():把锁标记为空闲并唤醒一个等待线程;try_lock():不阻塞,拿不到就返回错误。 - 直观解释:锁是”厕所门闩”——一个人进去后把门闩上,其他人只能在门外等待;出来时开门让下一个进。
- 买牛奶问题的锁解法:
std::mutex mutex; mutex.lock(); if (milk == 0) { buy_milk(); milk = 1; } mutex.unlock();
条件变量(Condition Variable, std::condition_variable)
- 定义:一种让线程在持锁状态下原子地”释放锁 + 睡眠等待某个条件”的原语。
- 直观解释:条件变量是”候诊室的叫号”——患者(等待线程)在候诊室睡觉,护士(通知线程)叫到号时唤醒他;被唤醒后要重新排队(重新拿锁)才能继续。
- API:
wait(lock):原子地释放锁并把调用线程置为睡眠;被唤醒后重新获取锁再返回。notify_one():唤醒一个正在睡眠的线程(如果有)。notify_all():唤醒所有正在睡眠的线程。
- 重要警示:被通知的线程不一定立刻拿到锁,唤醒时条件可能已不成立 → 必须用
while重新检查条件,而不是if。
生产者-消费者问题(Producer/Consumer,一个有界缓冲的”管道”)
需求:生产者放字符进缓冲区,消费者取字符;缓冲空时消费者等待,缓冲满时生产者等待。
错误版本 v1:只用锁,get() 在空缓冲时 count-- 变成 -1、读到未定义字符。 错误版本 v2/v2.5:空转等待(while (count == 0) {})——忙等浪费 CPU,或在锁外检查条件导致竞态。 错误版本 v2.9:while (count == 0) { mutex.unlock(); mutex.lock(); }——正确但过度忙等(惊群式空转)。 正确版本 v3:条件变量 + while 循环(见代码示例)。
Monitor 模式(Assign2 的核心)
- 定义:一种组织共享数据与同步的编程模式,包含:
- 一个共享数据结构;
- 一组操作该数据的方法;
- 一把锁(每个方法进入时加锁、返回前解锁);
- 一个或多个条件变量用于等待。
- 直观解释:把”资源 + 门卫 + 排队区”打包成一个对象;外界只能通过带锁的方法访问资源,从结构上杜绝”忘了加锁”。
锁的粒度(Lock Granularity)
- 粗粒度(coarse-grained):一把大锁保护所有数据——简单、开销低,但并发度低(锁竞争激烈)。
- 细粒度(fine-grained):多把小锁保护不同数据——并发度高,但复杂、易出竞态与死锁(下下讲)。
- 最佳实践:在可接受的竞争水平下尽量少用锁;把一把锁与”一组相关的变量”绑定(即 monitor 风格)。例:Linux 内核、Python 全局解释器锁(GIL)。
代码示例与系统调用解说
示例:生产者/消费者 Pipe(monitor 模式,正确版 v4)
#include <condition_variable>
#include <mutex>
const int SIZE = 8;
class Pipe {
public:
Pipe() : count(0), nextPut(0), nextGet(0) {}
void put(char c) {
std::unique_lock<std::mutex> lock(mutex); // 进入临界区(RAII 加锁)
while (count == SIZE) { // 缓冲满: 等待, 必须用 while!
charRemoved.wait(lock); // 原子地释放锁并睡眠
}
count++;
buffer[nextPut] = c;
nextPut = (nextPut + 1) % SIZE;
charAdded.notify_one(); // 唤醒一个等待的消费者
} // 出作用域自动解锁
char get() {
std::unique_lock<std::mutex> lock(mutex);
while (count == 0) { // 缓冲空: 等待
charAdded.wait(lock);
}
count--;
char c = buffer[nextGet];
nextGet = (nextGet + 1) % SIZE;
charRemoved.notify_one(); // 唤醒一个等待的生产者
return c;
}
private:
std::mutex mutex;
std::condition_variable charAdded, charRemoved;
char buffer[SIZE];
int count;
int nextPut;
int nextGet;
};
【代码做了什么?】
put:加锁 → 若缓冲满则charRemoved.wait(lock)睡眠 → 放入字符 →charAdded.notify_one()→ 自动解锁。get:加锁 → 若缓冲空则charAdded.wait(lock)睡眠 → 取字符 →charRemoved.notify_one()→ 返回。std::unique_lock是 RAII 锁包装:构造时加锁、析构时解锁,且支持传给wait()(wait需要能原子释放/重获锁)。
【系统机制透视】
wait()的原子性为什么关键:如果”释放锁”和”睡眠”不是原子的,那么消费者释放锁后、入睡前,生产者可能执行完notify_one()——通知落在消费者入睡之前,消费者将永远错过唤醒(丢失唤醒)。wait(lock)把”释放锁+睡眠”合并为一步,杜绝该窗口。- 为什么必须
while而不是if:被唤醒的线程要重新竞争锁;在它拿到锁之前,其它线程可能又消费/生产,使条件再次不成立(如 T1、T2 两个消费者同时被唤醒,只有一个字符)。while循环保证唤醒后重新检查条件。 notify_onevsnotify_all:本问题中任一时刻只需一个消费者/生产者被唤醒,notify_one足够且开销小;但若被唤醒的线程发现条件仍不成立(例如用notify_all唤醒多个等待者),while会让多余者继续睡眠——正确性由while兜底。
关键要点
- 锁解决互斥(一次一个线程进临界区);条件变量解决等待(在持锁时阻塞直到某事件发生)。
- 条件变量
wait必须与锁配合,且必须用while重新检查条件(防丢失唤醒与假唤醒)。 - Monitor 模式 = 共享数据 + 方法 + 锁 + 条件变量,是编写线程安全类的推荐结构。
- 锁的粒度权衡:并发度 vs 复杂度/开销——尽量少用锁、按”相关变量组”绑定。
- 被
notify唤醒 ≠ 条件成立:唤醒只是”可能有机会了”。
常见陷阱与注意事项
- 用
if代替while检查条件 → 竞态(消费未定义字符、覆盖未读字符)。 - 忘记在等待前加锁,或在等待时持锁不放 → 死锁或未定义行为。
notify时机错误:必须在修改共享状态之后、释放锁之前(或之后)通知;通知太早会丢失唤醒。- 锁粒度不当:全局一把大锁(性能差)vs 每变量一把小锁(易死锁)。
- 忘记解锁:用 RAII(
std::lock_guard/std::unique_lock)避免。
思考题
- 问题:
pthread_mutex_lock()/std::mutex::lock()在锁被占用时,线程进入什么状态?- 答案:线程被放入该锁的等待队列,状态变为 BLOCKED(阻塞),不再占用 CPU;当持锁线程
unlock()时,内核/运行库把一个等待线程移回就绪队列(READY),它重新竞争锁。
- 答案:线程被放入该锁的等待队列,状态变为 BLOCKED(阻塞),不再占用 CPU;当持锁线程
- 问题:为什么条件变量的
wait要”原子地释放锁”?如果先释放锁、再睡眠会怎样?- 答案:先释放锁会留下一个窗口:在释放锁与睡眠之间,另一个线程可能已经修改条件并发出了 notify,而等待者还没入睡,于是永远错过唤醒(丢失唤醒)。
wait原子完成”释放+睡眠”以消除该窗口。
- 答案:先释放锁会留下一个窗口:在释放锁与睡眠之间,另一个线程可能已经修改条件并发出了 notify,而等待者还没入睡,于是永远错过唤醒(丢失唤醒)。
- 问题:monitor 模式为什么能减少同步错误?
- 答案:它把共享数据封装在对象内,所有访问都必须经由”先加锁的方法”,从结构上杜绝了”忘记加锁/解锁”;条件变量与共享状态放在一起,使等待条件与状态修改的对应关系一目了然。
