C++ 条件变量为什么要配合锁使用:原理与设计深度解析



一、引言:条件变量与锁不可分割的关系


std::condition_variable 是 C++ 多线程编程中用于线程间等待/通知的核心机制。一个常见的问题是:为什么条件变量必须配合 std::mutex 使用? wait() 方法为什么必须传入一个已经锁定的 std::unique_lock


简短的回答是:为了防止“丢失唤醒”(Lost Wakeup)和保证“条件检查”的原子性。但要真正理解这个设计,需要深入条件变量的使用模式、竞态条件的产生,以及 wait() 内部的原子操作。本文将逐层深入,彻底讲清楚这一关键设计。



二、核心概念速览



| 概念 | 说明 |

| --- | --- |

| 条件变量 | 允许线程等待某个条件成立,由其他线程通知 |

| 丢失唤醒 | 通知发生在等待之前,导致等待线程永远不醒 |

| 条件检查 | 判断是否可以继续执行(如队列是否非空) |

| 原子性窗口 | 从“检查条件”到“开始等待”之间存在竞态 |

| 锁的作用 | 保护条件变量关联的共享数据,并消除原子性窗口 |



三、问题根源:条件检查的竞态条件



3.1 如果条件变量不需要锁——会发生什么


cpp复制下载

#include <mutex>
#include <condition_variable>
#include <queue>
#include <thread>
#include <iostream>

// ❌ 假设的条件变量(不需要锁)——这只是演示问题
struct HypotheticalBadCV {
    bool ready = false;
    
    void notify() { ready = true; }
    
    void wait() {
        while (!ready) {
            // 空转等待
        }
    }
};

std::queue<int> dataQueue;
HypotheticalBadCV cv;  // 假设的条件变量

// 生产者
void producer() {
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    dataQueue.push(42);  // 产生数据
    cv.notify();          // 通知消费者
}

// 消费者
void consumer() {
    // 问题:检查条件与等待之间存在窗口
    if (dataQueue.empty()) {     // 1. 检查条件:队列为空
        // ← 2. 窗口期!生产者可能在这里 push 并 notify
        cv.wait();                // 3. 等待通知
        // 但如果 notify 发生在第 2 步,wait 就会错过它!
    }
    int value = dataQueue.front();
    dataQueue.pop();
    std::cout << "Got: " << value << std::endl;
}

// 这就是“丢失唤醒”问题


图表代码下载全屏

四、解决方案:锁 + 条件变量



4.1 正确使用模式


cpp复制下载

#include <mutex>
#include <condition_variable>
#include <queue>

std::queue<int> dataQueue;
std::mutex mtx;
std::condition_variable cv;

// 生产者
void producer() {
    int value = 42;
    
    {
        std::lock_guard<std::mutex> lock(mtx);
        dataQueue.push(value);  // 修改共享数据(持有锁)
    }
    // 通知可以在锁外进行(推荐)
    cv.notify_one();
}

// 消费者
void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    
    // wait 内部原子地执行:解锁 + 等待 + 重新加锁
    cv.wait(lock, []() { return !dataQueue.empty(); });
    // wait 返回时,锁已被重新持有
    
    int value = dataQueue.front();
    dataQueue.pop();
    lock.unlock();  // 或依赖析构自动解锁
    
    std::cout << "Got: " << value << std::endl;
}



4.2 wait() 内部到底做了什么


cpp复制下载

// wait() 的简化实现(展示核心逻辑)
template<typename Predicate>
void wait(std::unique_lock<std::mutex>& lock, Predicate pred) {
    // 1. 先检查条件(持有锁,安全)
    while (!pred()) {
        // 2. 原子地:释放锁 + 进入等待
        //    这两个操作在操作系统层面是原子的!
        //    (通过 futex 或类似机制)
        atomic_unlock_and_wait(lock);
        
        // 3. 被 notify 唤醒后,原子地重新获取锁
        //    (同样是操作系统保证的原子操作)
        lock.lock();
        
        // 4. 重新检查条件(持有锁,安全)
        //    因为可能有多个消费者被唤醒(虚假唤醒)
        //    或者被另一个消费者抢走了数据
    }
}


图表代码下载全屏

五、锁的三大核心作用



5.1 作用一:保护共享数据


条件变量总是与某个共享状态关联(如队列是否为空、某个标志是否设置)。这个共享状态本身就是临界资源,需要锁来保护。


cpp复制下载

// 没有锁保护的共享数据——数据竞争!
std::queue<int> queue;
std::condition_variable cv;

void badProducer() {
    queue.push(42);  // ❌ 数据竞争!
    cv.notify_one();
}

void badConsumer() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock);  // 这里持有锁,但生产者没有
    int v = queue.front();  // 如果生产者也没加锁,数据竞争
    queue.pop();
}



5.2 作用二:消除“检查-等待”的原子性窗口


这是条件变量必须配合锁的核心原因。wait() 在内部必须原子地执行“释放锁 + 开始等待”,否则就会产生丢失唤醒问题。


cpp复制下载

// 如果 wait 不是原子的——丢失唤醒的详细分析
void hypotheticalBadWait(std::unique_lock<std::mutex>& lock) {
    // 假设的实现:先解锁,再等待
    lock.unlock();  // ← 窗口打开!
    // 此时生产者可能修改共享数据并 notify
    // 但消费者尚未进入等待状态 → 通知丢失
    
    wait_for_notification();  // 现在才等待 → 错过通知!
    lock.lock();  // 重新获取锁
}
// 这就是为什么 wait 必须是原子的!
// 操作系统提供的 futex 等机制保证了 unlock + wait 的原子性



5.3 作用三:保证条件检查的一致性


wait() 返回后,锁被重新持有,线程可以在锁保护下安全地检查条件并使用共享数据。


cpp复制下载

std::unique_lock<std::mutex> lock(mtx);

// wait 返回时,以下保证成立:
// 1. 条件已被重新检查(如果使用带谓词的版本)
// 2. 锁已被重新持有
// 3. 共享数据处于一致状态
cv.wait(lock, []() { return !queue.empty(); });

// 此时可以安全地操作共享数据
int value = queue.front();  // 安全
queue.pop();                // 安全



六、为什么使用 unique_lock 而不是 lock_guard



| 特性 | lock_guard | unique_lock |

| --- | --- | --- |

| 手动解锁 | 不支持 | 支持 unlock() |

| 延迟锁定 | 不支持 | 支持 defer_lock |

| 移动语义 | 不支持 | 支持 |

| 可配合条件变量 | 不能 | 可以 |


条件变量的 wait() 需要在等待期间临时解锁,等待结束后重新加锁。lock_guard 的生命周期内锁始终被持有,无法满足这个需求。unique_lock 提供了 lock()/unlock() 的灵活控制。


cpp复制下载

// unique_lock 允许 wait 内部临时解锁
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, condition);  // wait 内部会临时 unlock
// 返回时 lock 已被重新 locked

// 而 lock_guard 无法做到
// std::lock_guard<std::mutex> guard(mtx);
// cv.wait(guard, condition);  // 编译错误!



七、通知的时机:锁内还是锁外?


cpp复制下载

// 方式一:在锁内通知
{
    std::lock_guard<std::mutex> lock(mtx);
    queue.push(value);
    cv.notify_one();  // 在锁内通知
}  // 锁在这里释放

// 方式二:在锁外通知(推荐)
{
    std::lock_guard<std::mutex> lock(mtx);
    queue.push(value);
    // 修改完共享数据后尽快释放锁
}
cv.notify_one();  // 在锁外通知

// 区别:
// 锁内通知:被唤醒的线程立即尝试获取锁,但锁还被通知者持有 → 被唤醒后立刻阻塞
// 锁外通知:被唤醒的线程可以直接获取锁 → 避免额外的上下文切换
// 实际项目中两者都可接受,锁外通知效率略高



八、虚假唤醒(Spurious Wakeup)


条件变量可能出现虚假唤醒——即使没有线程调用 notifywait 也可能返回。因此条件检查必须使用循环(或在谓词中):


cpp复制下载

// ❌ 错误:没有循环检查条件
cv.wait(lock);  // 可能虚假唤醒
// 直接假设条件成立,可能出错

// ✓ 正确方式一:使用带谓词的 wait(推荐)
cv.wait(lock, []() { return !queue.empty(); });
// wait 内部使用 while 循环检查谓词

// ✓ 正确方式二:手动 while 循环
while (queue.empty()) {
    cv.wait(lock);
}
// 每次唤醒都重新检查条件



九、典型场景:生产者-消费者完整实现


cpp复制下载

#include <mutex>
#include <condition_variable>
#include <queue>
#include <thread>
#include <iostream>
#include <optional>

template<typename T>
class BlockingQueue {
    std::queue<T> queue_;
    mutable std::mutex mtx_;
    std::condition_variable notEmpty_;
    std::condition_variable notFull_;
    size_t maxSize_;
    
public:
    explicit BlockingQueue(size_t maxSize = 100) : maxSize_(maxSize) { }
    
    void push(T value) {
        std::unique_lock<std::mutex> lock(mtx_);
        
        // 等待队列有空间
        notFull_.wait(lock, [this]() { return queue_.size() < maxSize_; });
        
        queue_.push(std::move(value));
        
        // 解锁后通知消费者
        lock.unlock();
        notEmpty_.notify_one();
    }
    
    T pop() {
        std::unique_lock<std::mutex> lock(mtx_);
        
        // 等待队列非空
        notEmpty_.wait(lock, [this]() { return !queue_.empty(); });
        
        T value = std::move(queue_.front());
        queue_.pop();
        
        // 解锁后通知生产者
        lock.unlock();
        notFull_.notify_one();
        
        return value;
    }
    
    std::optional<T> tryPop() {
        std::lock_guard<std::mutex> lock(mtx_);
        if (queue_.empty()) return std::nullopt;
        T value = std::move(queue_.front());
        queue_.pop();
        notFull_.notify_one();
        return value;
    }
};

int main() {
    BlockingQueue<int> queue(5);
    
    // 生产者
    std::thread producer([&queue]() {
        for (int i = 0; i < 100; ++i) {
            queue.push(i);
            std::cout << "Produced: " << i << std::endl;
        }
    });
    
    // 消费者
    std::thread consumer([&queue]() {
        for (int i = 0; i < 100; ++i) {
            int value = queue.pop();
            std::cout << "Consumed: " << value << std::endl;
            std::this_thread::sleep_for(std::chrono::milliseconds(10));
        }
    });
    
    producer.join();
    consumer.join();
}



十、总结


条件变量必须配合锁使用的原因,归根结底是三个相互关联的需求:



  1. 保护共享状态:条件变量总是与某个共享状态关联(队列、标志、计数器等)。这个共享状态本身就是临界资源,必须用锁保护,避免数据竞争。
  2. 消除“检查-等待”的原子性窗口:这是条件变量设计的核心。从检查条件到开始等待之间存在一个危险的窗口——如果在这个窗口中生产者修改了条件并发出通知,等待者就会错过这个通知,永远睡眠(丢失唤醒)。wait() 内部的“释放锁 + 进入等待”操作由操作系统保证是原子的,消除了这个窗口。这就是为什么 wait() 必须传入一个锁——它需要在原子操作中释放这个锁。
  3. 保证唤醒后的一致性wait() 返回时重新获取锁,保证了线程可以安全地检查条件和访问共享数据。这也为处理虚假唤醒提供了基础——每次唤醒都重新检查条件。


选择 unique_lock 而非 lock_guard 的原因wait() 需要在内部临时释放锁和重新获取锁,lock_guard 不支持这种灵活控制,unique_lock 提供了 lock()/unlock() 能力。


理解这个设计,就理解了为什么条件变量 API 的设计是 cv.wait(lock, predicate) 而非 cv.wait() + 手动管理锁。这种设计保证了正确性,并让并发编程中的生产者-消费者、读写同步等模式变得安全且相对容易使用。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐