⚡C++ 并发编程

互斥锁与条件变量

面试回答

常见问法

  • 互斥锁和条件变量分别解决什么问题?
  • 为什么条件变量一定要和锁一起用?
  • wait 为什么要用谓词,不能直接 wait 一次就继续吗?
  • notify_onenotify_all 怎么选?
  • 为什么很多代码会先解锁再 notify
  • condition_variable 为什么要求 std::unique_lock<std::mutex>,不能直接用 lock_guard 吗?
  • 条件变量会不会“丢通知”?
  • 条件变量和信号量有什么区别?

回答

互斥锁和条件变量解决的是两个不同维度的问题

  • **互斥锁(mutex)**解决的是:共享数据如何被安全访问。 它保证同一时刻只有一个线程进入临界区,避免数据竞争和状态破坏。

  • **条件变量(condition variable)**解决的是:线程什么时候可以继续执行。 当某个条件暂时不满足时,线程不应该忙等,而应该阻塞;等到条件满足后,再被唤醒继续执行。

它们经常一起使用,因为线程在“检查条件”和“进入等待”之间如果没有锁保护,就会出现竞态: 你刚检查完条件不满足,准备睡眠,另一个线程可能就在这一瞬间把条件改成满足并发出通知;如果没有锁,这个通知就可能被错过,导致线程一直睡下去。

所以正确模式一定是:

  1. 加锁检查共享状态
  2. 条件不满足时,调用 wait
  3. wait原子地释放锁并阻塞
  4. 被唤醒后重新拿锁,再次检查条件
  5. 条件满足后再继续执行

一句面试里很加分的话是:

互斥锁保护共享状态,条件变量等待状态变化;条件变量同步的是“条件”,不是“通知”本身。


下面用经典的生产者-消费者模型说明:

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

class BoundedBuffer {
    std::queue<int> queue_;
    size_t capacity_;
    std::mutex mutex_;
    std::condition_variable not_empty_;
    std::condition_variable not_full_;

public:
    explicit BoundedBuffer(size_t capacity) : capacity_(capacity) {}

    void put(int value) {
        std::unique_lock<std::mutex> lock(mutex_);

        // 队列满了就等待
        not_full_.wait(lock, [this] {
            return queue_.size() < capacity_;
        });

        queue_.push(value);

        lock.unlock();              // 先释放锁,再通知,减少无效竞争
        not_empty_.notify_one();    // 通知“现在非空了”
    }

    int get() {
        std::unique_lock<std::mutex> lock(mutex_);

        // 队列空了就等待
        not_empty_.wait(lock, [this] {
            return !queue_.empty();
        });

        int value = queue_.front();
        queue_.pop();

        lock.unlock();
        not_full_.notify_one();     // 通知“现在不满了”
        return value;
    }
};

这个例子里:

  • mutex_ 保护的是 queue_
  • not_empty_ 表示“消费者等待队列非空”
  • not_full_ 表示“生产者等待队列未满”

如果只有锁,没有条件变量,消费者发现队列空了只能不停轮询,会浪费 CPU。 如果只有条件变量,没有锁,又无法安全判断和修改队列状态。

追问

1. 为什么 wait 要使用谓词?

因为条件变量可能发生虚假唤醒(spurious wakeup),也可能出现“被唤醒了,但条件又被别的线程抢先改回去了”的情况。 所以唤醒不等于条件一定成立,必须重新检查条件。

正确写法:

cv.wait(lock, [] { return ready; });

等价于:

while (!ready) {
    cv.wait(lock);
}

错误写法:

if (!ready) {
    cv.wait(lock); // 醒来后不再检查,可能出错
}

2. notify_onenotify_all 怎么选?

  • notify_one:只唤醒一个等待线程 适合“只需要一个线程去处理”的场景,比如队列里新来了一个任务。

  • notify_all:唤醒所有等待线程 适合“状态发生了全局变化”的场景,比如:

    • 程序要退出了,所有等待线程都该醒来
    • 某个条件一旦成立,所有线程都可以继续

选择原则:

  • 条件只允许一个线程推进,优先 notify_one
  • 条件变化可能让多个线程都满足,或无法确定是谁该醒,考虑 notify_all

notify_all 更安全,但可能带来“惊群效应”,即大家都醒来争锁,最后大多数线程又继续睡回去。


3. 为什么很多实现会“先 unlock,再 notify”?

常见原因是性能优化,不是绝对的正确性要求。

如果你持锁 notify

  1. 被唤醒的线程会立刻尝试拿锁
  2. 但锁还在通知线程手里
  3. 它只能再次阻塞

这样会增加一次无谓的竞争和上下文切换。

所以很多代码会:

lock.unlock();
cv.notify_one();

但这里有两个边界要讲清楚:

  • 不是说持锁 notify 就错了,通常仍然是正确的
  • 关键不是通知顺序本身,而是状态修改必须在锁保护下完成

高质量回答可以这样说:

一般推荐“修改共享状态后释放锁,再通知”,这样能减少唤醒线程立刻阻塞的概率;但正确性核心不在于先后顺序,而在于条件检查、状态修改、等待这几步必须围绕同一把锁建立一致性。


4. 为什么 wait 要用 unique_lock,不能用 lock_guard

因为 wait 的内部行为是:

  1. 先检查条件
  2. 原子地释放锁并阻塞
  3. 被唤醒后重新加锁
  4. 返回给调用方

这要求锁对象支持显式 unlock() / lock()std::lock_guard 只负责简单的作用域加解锁,没有这个能力; std::unique_lock 是可移动、可解锁、可重锁的,所以 condition_variable::wait 要求它。


5. 条件变量会不会“丢通知”?

会,但更准确地说,条件变量本来就不是“消息存储器”

如果一个线程先 notify_one(),另一个线程之后才开始 wait(),那么这个通知不会被记住。 所以正确模型不是“等通知”,而是“等条件”。

例如:

  • 错误理解:notify 发过了,所以以后来的线程也该自动通过
  • 正确理解:线程醒来与否不重要,只要条件已经成立,就不该再等待

因此一定要把条件写成共享状态,例如:

bool ready = false;

然后:

  • 生产者:改 ready = true,再 notify
  • 消费者:检查 ready,不满足才等待

6. 条件变量和互斥锁有没有内存可见性语义?

有。 典型模式下,一个线程在持有同一把互斥锁时修改共享状态,另一个线程在 wait 被唤醒并重新拿到这把锁后,就能看到这个状态更新。 这也是为什么“条件变量必须和同一把锁保护的共享状态配合使用”。


原理展开

1. 互斥锁解决的是“安全修改”,条件变量解决的是“等待时机”

只用互斥锁,可以保证共享数据不被并发写坏,但无法优雅地表达“现在没条件干活,那我先睡”。

例如消费者线程:

std::mutex mtx;
std::queue<int> q;

void consumer_bad() {
    while (true) {
        std::lock_guard<std::mutex> lock(mtx);
        if (!q.empty()) {
            int x = q.front();
            q.pop();
            // ...
        }
        // 队列为空时,线程仍然不停循环,占用 CPU
    }
}

这个问题不叫“线程安全”,而叫“同步效率差”。 条件变量的价值在于:把忙等变成阻塞等待


2. wait 的本质是“释放锁 + 休眠 + 被唤醒后重新抢锁”

这是条件变量最关键的机制。

如果 wait 不能原子地完成“释放锁并休眠”,就会有经典竞态:

  1. 线程 A 拿锁检查条件,发现不满足
  2. 线程 A 准备睡眠,但还没真正睡下去
  3. 线程 B 改了状态并 notify
  4. 线程 A 这时才开始睡
  5. 永久错过这次状态变化

条件变量的 wait 就是为了解决这个窗口问题。 所以你可以把它理解成:

“我现在条件不满足,请你把锁放下并睡眠;等有人通知我时,再帮我把锁拿回来,让我重新检查状态。”

这也是为什么条件变量几乎总是围绕“共享状态 + 同一把锁”使用,而不是单独使用。


3. 条件变量等待的是状态,不是事件;因此必须用“谓词/循环”建模

很多初学者误以为:

  • notify_one() = 发了一条消息
  • wait() = 收这条消息

这不对。 条件变量更接近于“有人提醒你状态可能变了,你醒来自己检查”。

所以 wait 的逻辑不是:

收到通知 -> 继续执行

而是:

状态不满足 -> 睡眠
被唤醒 -> 重新检查状态
状态满足 -> 继续执行
否则 -> 继续等待

典型错误示例:

#include <condition_variable>
#include <mutex>

std::condition_variable cv;
std::mutex mtx;
bool ready = false;

void consumer_wrong() {
    std::unique_lock<std::mutex> lock(mtx);
    if (!ready) {
        cv.wait(lock);   // 错:醒来后 ready 可能仍然是 false
    }
    // 这里假设 ready 一定成立,是不安全的
}

正确写法:

void consumer_right() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return ready; });
}

或者:

void consumer_right2() {
    std::unique_lock<std::mutex> lock(mtx);
    while (!ready) {
        cv.wait(lock);
    }
}

4. 为什么“状态变量”必须受同一把锁保护

这是很多面试追问的重点。

例如:

bool ready = false;
std::mutex mtx;
std::condition_variable cv;

如果你:

  • 等待时拿的是 mtx
  • 修改 ready 时却没拿 mtx

那条件检查与状态修改就不再是同一同步域里的操作,会产生数据竞争或可见性问题。 这时就算你写了 wait / notify,程序也可能偶发失效。

正确原则:

  • 检查条件时拿哪把锁
  • 修改条件时也必须拿同一把锁

5. 工程实践里,锁的作用域要尽量小,但状态修改必须完整受保护

锁不是拿得越久越安全,而是临界区要小而完整

例如:

#include <mutex>
#include <vector>

class DataProcessor {
    std::vector<int> data_;
    std::mutex data_mutex_;

public:
    void add_data(int value) {
        {
            std::lock_guard<std::mutex> lock(data_mutex_);
            data_.push_back(value);
        } // 这里就释放锁

        process_data(); // 耗时逻辑放到锁外
    }

private:
    void process_data() {
        // 复杂计算 / IO / 日志 / RPC ...
    }
};

实践原则:

  • 共享状态修改:必须在锁内
  • 耗时逻辑 / IO / 回调 / 外部调用:尽量在锁外
  • 不要在持锁时调用用户回调或不受控代码,否则容易导致长时间阻塞甚至死锁

对比总结

概念解决的问题是否保护共享数据是否负责等待/唤醒典型场景优点缺点/风险
std::mutex互斥访问保护共享容器、计数器、对象状态简单直接,线程安全基础设施只能“防并发写坏”,不能高效等待条件
std::condition_variable条件等待与线程协作否(本身不保护)生产者-消费者、资源池、任务依赖能让线程阻塞等待,避免忙等必须配合锁和谓词,使用不当容易丢条件/虚假唤醒
std::lock_guard简单 RAII 加锁短小临界区语义简单,不易误用不能手动解锁/重锁,不能直接配合 wait
std::unique_lock可控的 RAII 加锁需要 wait、延迟加锁、手动解锁灵活,支持 wait 所需操作略有额外开销,语义比 lock_guard 更复杂
信号量(如 std::counting_semaphore计数型资源控制连接池、限流、固定资源数量更适合表示“可用资源个数”不直接保护复杂共享状态,表达条件关系不如条件变量直观
自旋锁(概念上)极短临界区互斥内核/极低延迟短临界区避免线程睡眠切换忙等耗 CPU,不适合长临界区或普通业务代码

条件变量 vs 信号量

维度条件变量信号量
核心语义等待“条件成立”等待“计数资源可用”
通知是否持久否,通知本身不存储是,计数会累积
是否必须配合锁通常是不一定
适合场景复杂状态判断、多个条件组合限流、资源个数管理
面试判断一句话状态驱动资源计数驱动

notify_one vs notify_all

维度notify_onenotify_all
唤醒数量一个线程所有等待线程
性能更高可能产生惊群
使用场景只有一个线程能推进工作多个线程都可能满足条件,或全局状态变化
风险可能唤错线程,推进效率低唤醒过多线程造成竞争

易错点

  • if 而不是 while / 谓词等待条件,忽略虚假唤醒
  • 只写 cv.wait(lock),却不维护共享状态变量
  • 把条件变量当消息队列使用,以为 notify 能被“记住”
  • 修改条件时没有持有同一把互斥锁,导致竞态或可见性问题
  • 在持锁状态下执行耗时逻辑、IO、RPC、回调,导致锁粒度过大
  • notify_all 滥用,造成惊群效应
  • 误以为“必须先 unlocknotify 才正确” 实际上这更多是性能优化,非绝对正确性要求
  • 忘记为什么 wait 需要 unique_lock,把 lock_guard 强行用于等待
  • 只会背“生产者消费者”,但说不清楚核心原理是“状态 + 同一把锁 + 谓词等待”
  • 以为“被唤醒”就等于“条件满足”,没有重检条件

记忆技巧

  • 互斥锁管“别同时改”,条件变量管“现在能不能做” 一个解决安全,一个解决时机

  • 记住一句话: mutex 保护状态,condition_variable 等待状态变化

  • 记住 wait 的三步: 放锁 → 睡眠 → 醒来再拿锁

  • 记住条件变量不是消息队列: 它记不住通知,只能重新检查条件

  • 面试口诀: “共享状态加锁保护,条件不满足就等待;被唤醒后重新拿锁,再次检查条件。”


面试速答版

互斥锁和条件变量解决的是两个不同问题。互斥锁负责保护共享数据,避免多个线程同时修改导致数据竞争;条件变量负责线程同步,也就是当条件不满足时让线程阻塞,等条件满足后再唤醒。条件变量通常要和互斥锁一起用,因为线程在检查条件和进入等待之间如果没有锁保护,就可能错过状态变化。正确写法一定是 wait(lock, predicate)while + wait,因为条件变量可能虚假唤醒,而且被唤醒时条件也未必还成立。notify_one 适合只需要唤醒一个线程的场景,notify_all 适合全局状态变化,但可能有惊群问题。一般会先解锁再通知来减少竞争,不过这主要是性能优化,不是绝对的正确性要求。


面试加分版

互斥锁和条件变量在并发里分工很明确:互斥锁解决的是共享状态的互斥访问,条件变量解决的是线程在条件不满足时如何高效等待。比如生产者消费者模型里,锁可以保护队列不被并发写坏,但如果队列为空,消费者不能一直轮询,否则会浪费 CPU,这时候就需要条件变量让线程阻塞等待。

它们必须配合使用的根本原因是:线程在“检查条件”和“进入等待”之间必须保持一致性。假设消费者检查到队列为空,正准备睡眠,这一瞬间生产者插入了数据并发出通知;如果没有锁把这几个步骤串起来,这个通知就可能被错过,消费者会一直睡下去。条件变量的 wait 之所以重要,就是因为它能原子地完成“释放锁并阻塞”,被唤醒之后再重新拿锁继续检查。

这里最关键的原则有两个。第一,条件变量等待的是状态,不是通知本身,所以必须维护一个受同一把锁保护的共享状态,比如队列是否为空、任务是否就绪、资源是否可用。第二,wait 必须配合谓词或 while 循环使用,因为条件变量可能虚假唤醒,而且即使被通知了,条件也可能已经被其他线程抢先改变了,所以“醒来”不等于“条件成立”。

工程上再补一句会很加分:notify_onenotify_all 的选择取决于条件变化能推进多少线程;通常优先 notify_one 降低竞争,而像退出、广播配置更新这类全局状态变化更适合 notify_all。另外很多实现会在修改完共享状态后先释放锁再通知,这样能减少被唤醒线程立刻阻塞在锁上的概率,不过这主要是性能优化,真正决定正确性的仍然是共享状态始终由同一把锁保护,以及等待时必须反复检查条件。