C++ STL之互斥锁与条件变量详解
C++ STL之互斥锁与条件变量详解
一、从数据竞争说起
多线程同时读写同一内存,结果不确定,这就是数据竞争。C++ 标准库提供了mutex系列锁和condition_variable来解决线程同步问题。从 C++11 到 C++17,同步原语逐步完善,每个都有明确的适用场景。
二、互斥锁四兄弟
std::mutex – 基础互斥锁
最基础的排他锁。lock()阻塞直到获得锁,unlock()释放。不可递归——同一线程二次调用lock()产生未定义行为。适合临界区极短的场景。
std::recursive_mutex – 可重入锁
同一线程允许重复加锁,内部维护计数,调用几次lock()就要调用几次unlock()。适用于递归函数中需要加锁的场景,但通常意味着设计可优化——多数时候可以把锁提到递归外。
std::timed_mutex – 超时锁
在mutex基础上增加了try_lock_for(duration)和try_lock_until(time_point),超时返回false而非死等。适合不能无限阻塞的 I/O 或网络操作。
std::shared_mutex(C++17)– 读写锁
这是最常用的高性能锁。读操作可以共享锁,写操作必须独占。C++14 的shared_timed_mutex增加了超时版本。
读写锁对比一览:
| 特性 | mutex | recursive_mutex | timed_mutex | shared_mutex |
|---|---|---|---|---|
| 排他性 | 独占 | 独占 | 独占 | 读共享/写独占 |
| 可重入 | 否 | 是 | 否 | 否 |
| 超时 | 否 | 否 | 是 | 是(C++14) |
| 性能 | 最快 | 略慢 | 同mutex | 读多写少最优 |
三、RAII 锁包装器
手动lock/unlock容易遗漏异常安全路径,RAII 包装器让锁随作用域自动释放。
lock_guard – 最简单的 RAII
构造时加锁,析构时解锁。不可复制,不可移动,不可手动解锁。适用简单临界区:
std::mutex mtx;{std::lock_guard<std::mutex>lock(mtx);// 临界区,自动管理}unique_lock – 灵活的 RAII
比lock_guard多了三个能力:延迟加锁(构造时不锁,稍后lock())、提前解锁(unlock()减少持有时间)、转移所有权(move语义)。常配合condition_variable:
std::mutex mtx;std::unique_lock<std::mutex>lock(mtx,std::defer_lock);// 不立即锁// ... 其他操作 ...lock.lock();// 需要时再加scoped_lock(C++17)– 防死锁的多锁方案
一次锁多个 mutex,内部使用std::lock的死锁避免算法(按固定顺序尝试加锁),是同时加锁多个互斥量的首选:
std::mutex m1,m2;{std::scoped_locklock(m1,m2);// 同时锁住,死锁安全// 操作两个临界区}C++17 之前只能用std::lock(m1, m2)+lock_guard配合,scoped_lock把这个模式包装成了一行。
四、条件变量与虚假唤醒
std::condition_variable配合unique_lock使用,线程可以等待某个条件成立再继续。
std::mutex mtx;std::condition_variable cv;boolready=false;// 等待线程std::unique_lock<std::mutex>lock(mtx);cv.wait(lock,[]{returnready;});// 等效于 while(!ready) cv.wait(lock);// 通知线程{std::lock_guard<std::mutex>lock(mtx);ready=true;}cv.notify_one();虚假唤醒(spurious wakeup)是条件变量的固有问题——wait可能在未被通知时返回。操作系统行为、信号处理等都可能导致。必须用 while 循环二次检查谓词,不能假设醒来就是条件满足。
带谓词的wait(lock, predicate)等价于while (!predicate()) { wait(lock); },是 C++ 标准的推荐写法。永远不要用无谓词的wait。
五、死锁四条件
死锁必须同时满足四个条件:
- 互斥——资源一次只能被一个线程占用
- 持有并等待——线程持有一个资源同时等待另一个
- 不可剥夺——资源只能由持有者主动释放
- 循环等待——存在线程间环形等待链
工程对策:
- 固定加锁顺序——所有线程按相同顺序加锁(先 A 后 B)
std::lock/scoped_lock——一次锁多个,内部避免死锁try_lock回退——加锁失败时释放已持有的锁
死锁最难排查——不崩溃、不报错,程序卡死。生产环境常配合std::lock_guard+ 严格代码审查来预防。
六、面试题
Q1:lock_guard和unique_lock的区别?
lock_guard不可解锁不可转移,极简 RAII。unique_lock可解锁、可转移、可延迟加锁,但多了虚函数调用(性能略低约 5%~10%)。需要配合condition_variable时必须用unique_lock。
Q2:什么是虚假唤醒?如何避免?
操作系统可能在无通知时唤醒wait返回。必须用带谓词的wait(lock, pred)或while (!pred) wait(lock)二次检查。
Q3:shared_mutex适用于什么场景?
读远多于写(如配置表、缓存),读操作可并发,写操作排他。典型如 DNS 缓存、配置中心。
Q4:scoped_lock如何避免死锁?
内部调用std::lock(...)使用算法如Try-Lock 排序或回退策略,确保多个锁的加锁操作整体是原子且无环的。C++17 起应优先使用。
Q5:recursive_mutex有什么问题?
掩盖了代码结构问题——递归加锁常意味着加锁粒度过大或职责不清。大多数场景应拆分子函数,让每个函数只锁需要的部分。
Q6:如果mutex.lock()抛出异常怎么办?
mutex.lock()本身不抛异常(无noexcept但在实践中不会因业务逻辑抛异常)。更常见的是临界区代码抛异常——此时必须用 RAII 包装器确保unlock,否则 mutex 被永远锁住。
Q7:try_lock_for的典型用法?
std::timed_mutex mtx;if(mtx.try_lock_for(std::chrono::milliseconds(100))){// 获得锁mtx.unlock();}else{// 超时处理}七、总结
| 锁 | 一句话适用场景 |
|---|---|
mutex | 短临界区,不需要重入/超时 |
recursive_mutex | 递归函数必须加锁(尽量重构) |
timed_mutex | 有超时容错需求 |
shared_mutex | 读极多写极少 |
scoped_lock | 多锁同时加,防死锁首选 |
C++ 同步原语链从mutex到shared_mutex,RAII 包装器从lock_guard到scoped_lock,逐层解决更复杂的并发问题。记住三点:RAII 保异常安全、while 防虚假唤醒、固定顺序防死锁。
