Lecture 5: 锁与条件变量(Locks and Condition Variables)

目录 · ← l3 · l5 →

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.9while (count == 0) { mutex.unlock(); mutex.lock(); }——正确但过度忙等(惊群式空转)。 正确版本 v3:条件变量 + while 循环(见代码示例)。

Monitor 模式(Assign2 的核心)

  • 定义:一种组织共享数据与同步的编程模式,包含:
    1. 一个共享数据结构;
    2. 一组操作该数据的方法;
    3. 一把锁(每个方法进入时加锁、返回前解锁);
    4. 一个或多个条件变量用于等待。
  • 直观解释:把”资源 + 门卫 + 排队区”打包成一个对象;外界只能通过带锁的方法访问资源,从结构上杜绝”忘了加锁”。

锁的粒度(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_one vs notify_all:本问题中任一时刻只需一个消费者/生产者被唤醒,notify_one 足够且开销小;但若被唤醒的线程发现条件仍不成立(例如用 notify_all 唤醒多个等待者),while 会让多余者继续睡眠——正确性由 while 兜底。

关键要点

  1. 锁解决互斥(一次一个线程进临界区);条件变量解决等待(在持锁时阻塞直到某事件发生)。
  2. 条件变量 wait 必须与锁配合,且必须用 while 重新检查条件(防丢失唤醒与假唤醒)。
  3. Monitor 模式 = 共享数据 + 方法 + 锁 + 条件变量,是编写线程安全类的推荐结构。
  4. 锁的粒度权衡:并发度 vs 复杂度/开销——尽量少用锁、按”相关变量组”绑定。
  5. notify 唤醒 ≠ 条件成立:唤醒只是”可能有机会了”。

常见陷阱与注意事项

  • if 代替 while 检查条件 → 竞态(消费未定义字符、覆盖未读字符)。
  • 忘记在等待前加锁,或在等待时持锁不放 → 死锁或未定义行为。
  • notify 时机错误:必须在修改共享状态之后、释放锁之前(或之后)通知;通知太早会丢失唤醒。
  • 锁粒度不当:全局一把大锁(性能差)vs 每变量一把小锁(易死锁)。
  • 忘记解锁:用 RAII(std::lock_guard/std::unique_lock)避免。

思考题

  1. 问题pthread_mutex_lock()/std::mutex::lock() 在锁被占用时,线程进入什么状态?
    • 答案:线程被放入该锁的等待队列,状态变为 BLOCKED(阻塞),不再占用 CPU;当持锁线程 unlock() 时,内核/运行库把一个等待线程移回就绪队列(READY),它重新竞争锁。
  2. 问题:为什么条件变量的 wait 要”原子地释放锁”?如果先释放锁、再睡眠会怎样?
    • 答案:先释放锁会留下一个窗口:在释放锁与睡眠之间,另一个线程可能已经修改条件并发出了 notify,而等待者还没入睡,于是永远错过唤醒(丢失唤醒)。wait 原子完成”释放+睡眠”以消除该窗口。
  3. 问题:monitor 模式为什么能减少同步错误?
    • 答案:它把共享数据封装在对象内,所有访问都必须经由”先加锁的方法”,从结构上杜绝了”忘记加锁/解锁”;条件变量与共享状态放在一起,使等待条件与状态修改的对应关系一目了然。