互斥锁与条件变量
面试回答
常见问法
- 互斥锁和条件变量分别解决什么问题?
- 为什么条件变量一定要和锁一起用?
wait为什么要用谓词,不能直接wait一次就继续吗?notify_one和notify_all怎么选?- 为什么很多代码会先解锁再
notify? condition_variable为什么要求std::unique_lock<std::mutex>,不能直接用lock_guard吗?- 条件变量会不会“丢通知”?
- 条件变量和信号量有什么区别?
回答
互斥锁和条件变量解决的是两个不同维度的问题:
-
**互斥锁(mutex)**解决的是:共享数据如何被安全访问。 它保证同一时刻只有一个线程进入临界区,避免数据竞争和状态破坏。
-
**条件变量(condition variable)**解决的是:线程什么时候可以继续执行。 当某个条件暂时不满足时,线程不应该忙等,而应该阻塞;等到条件满足后,再被唤醒继续执行。
它们经常一起使用,因为线程在“检查条件”和“进入等待”之间如果没有锁保护,就会出现竞态: 你刚检查完条件不满足,准备睡眠,另一个线程可能就在这一瞬间把条件改成满足并发出通知;如果没有锁,这个通知就可能被错过,导致线程一直睡下去。
所以正确模式一定是:
- 加锁检查共享状态
- 条件不满足时,调用
wait wait会原子地释放锁并阻塞- 被唤醒后重新拿锁,再次检查条件
- 条件满足后再继续执行
一句面试里很加分的话是:
互斥锁保护共享状态,条件变量等待状态变化;条件变量同步的是“条件”,不是“通知”本身。
下面用经典的生产者-消费者模型说明:
#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_one 和 notify_all 怎么选?
-
notify_one:只唤醒一个等待线程 适合“只需要一个线程去处理”的场景,比如队列里新来了一个任务。 -
notify_all:唤醒所有等待线程 适合“状态发生了全局变化”的场景,比如:- 程序要退出了,所有等待线程都该醒来
- 某个条件一旦成立,所有线程都可以继续
选择原则:
- 条件只允许一个线程推进,优先
notify_one - 条件变化可能让多个线程都满足,或无法确定是谁该醒,考虑
notify_all
notify_all 更安全,但可能带来“惊群效应”,即大家都醒来争锁,最后大多数线程又继续睡回去。
3. 为什么很多实现会“先 unlock,再 notify”?
常见原因是性能优化,不是绝对的正确性要求。
如果你持锁 notify:
- 被唤醒的线程会立刻尝试拿锁
- 但锁还在通知线程手里
- 它只能再次阻塞
这样会增加一次无谓的竞争和上下文切换。
所以很多代码会:
lock.unlock();
cv.notify_one();
但这里有两个边界要讲清楚:
- 不是说持锁
notify就错了,通常仍然是正确的 - 关键不是通知顺序本身,而是状态修改必须在锁保护下完成
高质量回答可以这样说:
一般推荐“修改共享状态后释放锁,再通知”,这样能减少唤醒线程立刻阻塞的概率;但正确性核心不在于先后顺序,而在于条件检查、状态修改、等待这几步必须围绕同一把锁建立一致性。
4. 为什么 wait 要用 unique_lock,不能用 lock_guard?
因为 wait 的内部行为是:
- 先检查条件
- 原子地释放锁并阻塞
- 被唤醒后重新加锁
- 返回给调用方
这要求锁对象支持显式 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 不能原子地完成“释放锁并休眠”,就会有经典竞态:
- 线程 A 拿锁检查条件,发现不满足
- 线程 A 准备睡眠,但还没真正睡下去
- 线程 B 改了状态并
notify - 线程 A 这时才开始睡
- 永久错过这次状态变化
条件变量的 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_one | notify_all |
|---|---|---|
| 唤醒数量 | 一个线程 | 所有等待线程 |
| 性能 | 更高 | 可能产生惊群 |
| 使用场景 | 只有一个线程能推进工作 | 多个线程都可能满足条件,或全局状态变化 |
| 风险 | 可能唤错线程,推进效率低 | 唤醒过多线程造成竞争 |
易错点
- 用
if而不是while/ 谓词等待条件,忽略虚假唤醒 - 只写
cv.wait(lock),却不维护共享状态变量 - 把条件变量当消息队列使用,以为
notify能被“记住” - 修改条件时没有持有同一把互斥锁,导致竞态或可见性问题
- 在持锁状态下执行耗时逻辑、IO、RPC、回调,导致锁粒度过大
notify_all滥用,造成惊群效应- 误以为“必须先
unlock再notify才正确” 实际上这更多是性能优化,非绝对正确性要求 - 忘记为什么
wait需要unique_lock,把lock_guard强行用于等待 - 只会背“生产者消费者”,但说不清楚核心原理是“状态 + 同一把锁 + 谓词等待”
- 以为“被唤醒”就等于“条件满足”,没有重检条件
记忆技巧
-
互斥锁管“别同时改”,条件变量管“现在能不能做” 一个解决安全,一个解决时机
-
记住一句话: mutex 保护状态,condition_variable 等待状态变化
-
记住
wait的三步: 放锁 → 睡眠 → 醒来再拿锁 -
记住条件变量不是消息队列: 它记不住通知,只能重新检查条件
-
面试口诀: “共享状态加锁保护,条件不满足就等待;被唤醒后重新拿锁,再次检查条件。”
面试速答版
互斥锁和条件变量解决的是两个不同问题。互斥锁负责保护共享数据,避免多个线程同时修改导致数据竞争;条件变量负责线程同步,也就是当条件不满足时让线程阻塞,等条件满足后再唤醒。条件变量通常要和互斥锁一起用,因为线程在检查条件和进入等待之间如果没有锁保护,就可能错过状态变化。正确写法一定是 wait(lock, predicate) 或 while + wait,因为条件变量可能虚假唤醒,而且被唤醒时条件也未必还成立。notify_one 适合只需要唤醒一个线程的场景,notify_all 适合全局状态变化,但可能有惊群问题。一般会先解锁再通知来减少竞争,不过这主要是性能优化,不是绝对的正确性要求。
面试加分版
互斥锁和条件变量在并发里分工很明确:互斥锁解决的是共享状态的互斥访问,条件变量解决的是线程在条件不满足时如何高效等待。比如生产者消费者模型里,锁可以保护队列不被并发写坏,但如果队列为空,消费者不能一直轮询,否则会浪费 CPU,这时候就需要条件变量让线程阻塞等待。
它们必须配合使用的根本原因是:线程在“检查条件”和“进入等待”之间必须保持一致性。假设消费者检查到队列为空,正准备睡眠,这一瞬间生产者插入了数据并发出通知;如果没有锁把这几个步骤串起来,这个通知就可能被错过,消费者会一直睡下去。条件变量的 wait 之所以重要,就是因为它能原子地完成“释放锁并阻塞”,被唤醒之后再重新拿锁继续检查。
这里最关键的原则有两个。第一,条件变量等待的是状态,不是通知本身,所以必须维护一个受同一把锁保护的共享状态,比如队列是否为空、任务是否就绪、资源是否可用。第二,wait 必须配合谓词或 while 循环使用,因为条件变量可能虚假唤醒,而且即使被通知了,条件也可能已经被其他线程抢先改变了,所以“醒来”不等于“条件成立”。
工程上再补一句会很加分:notify_one 和 notify_all 的选择取决于条件变化能推进多少线程;通常优先 notify_one 降低竞争,而像退出、广播配置更新这类全局状态变化更适合 notify_all。另外很多实现会在修改完共享状态后先释放锁再通知,这样能减少被唤醒线程立刻阻塞在锁上的概率,不过这主要是性能优化,真正决定正确性的仍然是共享状态始终由同一把锁保护,以及等待时必须反复检查条件。