C++ 锁
Abstract
锁是多线程编程中最常见的同步工具,用来保护共享数据,避免多个线程同时读写同一份资源时出现竞态条件。
- 锁解决的是共享数据访问中的竞态条件
std::mutex是最基础的互斥锁- 日常代码优先使用 RAII 工具,而不是手写
lock()/unlock() - 读多写少可以考虑
std::shared_mutex - 多锁场景优先考虑
std::scoped_lock - 条件变量负责等待条件成立,通常和
std::unique_lock搭配使用
为什么需要锁
锁要解决的核心问题很直接:多个线程并发访问共享数据时,如果没有同步机制,程序结果就可能不正确,甚至直接进入未定义行为。
典型风险包括:
- 多线程同时访问共享数据
- 竞态条件(race condition)
- 数据不一致
- 未定义行为
一个例子,展示了为什么需要锁
-
当不加锁时,
++counter不是一个原子操作,它通常会拆成:
如果两个线程交错执行,就会导致结果不稳定。graph LR A[读取 counter] --> B[执行加一] B --> C[写回结果] -
加锁后,
++counter虽然本身仍然不是原子操作,但它会在互斥保护下执行,因此不会发生数据竞争,结果稳定为200000。
-
未加锁: 结果不稳定
#include <iostream> #include <thread> int counter = 0; void work() { for (int i = 0; i < 100000; ++i) { ++counter; } } int main() { std::thread t1(work); std::thread t2(work); t1.join(); t2.join(); std::cout << counter << '\n'; }如果两个线程交错执行,就会发生“丢失更新”。
-
加锁后: 结果稳定为
200000
#include <iostream> #include <mutex> #include <thread> std::mutex mtx; int counter = 0; void work() { for (int i = 0; i < 100000; ++i) { std::lock_guard<std::mutex> lock(mtx); ++counter; } } int main() { std::thread t1(work); std::thread t2(work); t1.join(); t2.join(); std::cout << counter << '\n'; }
基本概念
-
临界区(critical section): 访问共享资源、且必须被互斥保护的代码区域。
-
互斥(mutual exclusion): 同一时刻只允许一个线程进入临界区。
- 同步(synchronization): 协调多个线程执行顺序和数据可见性的机制。锁是同步手段的一种,条件变量、原子变量也属于同步工具。
- 竞争(contention): 多个线程同时争抢同一把锁或同一资源,导致等待和性能下降。
- 死锁(deadlock): 两个或多个线程互相等待对方释放资源,导致谁都无法继续执行。
- 活锁(livelock): 线程没有阻塞,看起来一直在运行,但因为反复让步或重试,始终无法推进实际工作。
- 饥饿(starvation): 某些线程长期拿不到资源,一直得不到执行机会。
-
可重入 / 不可重入: 这里通常说的是“同一线程能否重复获取同一把锁”。
- 可重入: 同一线程可以多次加锁,例如
std::recursive_mutex - 不可重入: 同一线程重复加锁会出错或死锁,例如普通
std::mutex
- 可重入: 同一线程可以多次加锁,例如
-
阻塞锁 / 自旋锁:
- 阻塞锁: 拿不到锁时线程会挂起,等待调度器唤醒
- 自旋锁: 拿不到锁时线程持续循环重试,不主动睡眠
Note
C++ 标准库主要提供阻塞式互斥量;自旋锁更偏底层实现,通常用于锁持有时间极短的场景。
常见的锁类型
std::mutex
最基础的互斥锁,适合保护共享资源。常见接口:
lock(): 加锁unlock(): 解锁try_lock(): 尝试加锁,失败时立即返回
最简单示例
#include <mutex>
std::mutex mtx;
int value = 0;
void update() {
mtx.lock();
++value;
mtx.unlock();
}
注意事项
- 逻辑上能工作,但不推荐日常手写
lock()/unlock() - 一旦中间有异常、提前
return或分支跳转,就可能忘记unlock() - 实际开发更推荐
std::lock_guard或std::unique_lock
std::recursive_mutex
同一线程可以对同一把锁重复加锁,多次加锁后也需要对应次数地解锁。
示例场景
#include <mutex>
std::recursive_mutex mtx;
void inner() {
std::lock_guard<std::recursive_mutex> lock(mtx);
}
void outer() {
std::lock_guard<std::recursive_mutex> lock(mtx);
inner();
}
如果这里换成普通 std::mutex,outer() 持锁后调用 inner(),同一线程再次加锁会出问题。
为什么一般不推荐滥用
- 它容易掩盖调用层次设计不清的问题
- 看到“能重入”不代表逻辑一定正确
- 通常更好的做法是拆分职责,避免嵌套加锁
std::timed_mutex
支持带超时的加锁,适合不希望无限阻塞的场景。
常见接口:
try_lock_for(): 尝试加锁,失败时等待指定时间try_lock_until(): 尝试加锁,失败时等待直到指定时间点
示例
#include <chrono>
#include <mutex>
std::timed_mutex mtx;
int value = 0;
void work() {
if (mtx.try_lock_for(std::chrono::milliseconds(100))) {
++value;
mtx.unlock();
} else {
// 超时后走降级逻辑
}
}
适合场景
- 避免线程长期卡死在等待锁上
- 某些超时后允许降级或放弃的场景
std::recursive_timed_mutex
它是 recursive_mutex 和 timed_mutex 的组合版本,支持同一线程重复加锁,也支持超时等待。实际开发中不常见,了解即可。
std::shared_mutex
std::shared_mutex 是 C++17 引入的读写锁,适合“读多写少”的场景。
特点
- 多个线程可以同时持有读锁
- 写锁必须独占
- 写入时不能有读者或其他写者
适用场景
- 配置读取
- 缓存查询
- 索引结构访问
示例
#include <mutex>
#include <shared_mutex>
#include <string>
std::shared_mutex rw_mtx;
std::string config = "v1";
void read_config() {
std::shared_lock lock(rw_mtx);
// 多个读线程可以同时进入
}
void write_config() {
std::unique_lock lock(rw_mtx);
config = "v2";
}
std::shared_timed_mutex
它和 shared_mutex 类似,但支持定时等待。在一些较旧标准或兼容性场景里可能见到,现代代码里通常优先考虑 std::shared_mutex。
| 类型 | 标准版本 | 是否支持定时加锁接口 |
|---|---|---|
std::shared_timed_mutex |
C++14 | ✅ 支持 |
std::shared_mutex |
C++17 | ❌ 不支持 |
RAII 风格的加锁工具
实际开发里,直接操作 lock() / unlock() 容易出错,更推荐 RAII(Resource Acquisition Is Initialization) 风格的加锁工具。
std::lock_guard
最轻量、最常见。
std::lock_guard 的特点
- 构造时加锁
- 析构时解锁
- 不能手动解锁
- 最适合简单作用域保护
示例
#include <mutex>
std::mutex mtx;
int counter = 0;
void work() {
std::lock_guard<std::mutex> lock(mtx);
++counter;
}
std::unique_lock
比 lock_guard 更灵活,但也稍重一些。
std::unique_lock 的特点
- 可以延迟加锁
- 可以手动
unlock()/lock() - 可以转移所有权
- 可以和条件变量配合使用
示例
#include <mutex>
std::mutex mtx;
void work() {
std::unique_lock<std::mutex> lock(mtx);
// 临界区逻辑
lock.unlock();
// 后续逻辑不再持锁
}
std::scoped_lock
std::scoped_lock 是 C++17 提供的工具,适合同时锁住多个互斥量。
std::scoped_lock 的特点
- 一次锁多个 mutex
- 内部使用死锁规避策略
- 适合替代手写多重加锁
示例
#include <mutex>
std::mutex m1;
std::mutex m2;
void work() {
std::scoped_lock lock(m1, m2);
// 同时安全持有两把锁
}
std::shared_lock
std::shared_lock 一般和 std::shared_mutex 配合使用,表示共享读锁。
示例
#include <shared_mutex>
std::shared_mutex rw_mtx;
int data = 42;
int read_data() {
std::shared_lock lock(rw_mtx);
return data;
}
锁的典型用法
保护单个共享变量
这是最基础的用法,适合计数器、状态位、共享标志等简单共享数据。
示例
#include <mutex>
std::mutex mtx;
int counter = 0;
void increment() {
std::lock_guard<std::mutex> lock(mtx);
++counter;
}
保护一个容器
标准容器本身通常不是线程安全的,如果多个线程会读写同一个容器,就需要同步保护。
示例
#include <mutex>
#include <vector>
std::mutex mtx;
std::vector<int> values;
void add_value(int x) {
std::lock_guard<std::mutex> lock(mtx);
values.push_back(x);
}
读多写少场景
使用 shared_mutex 可以实现读多写少的场景。
示例
#include <mutex>
#include <shared_mutex>
#include <string>
std::shared_mutex rw_mtx;
std::string cache = "hello";
std::string read_cache() {
std::shared_lock lock(rw_mtx);
return cache;
}
void update_cache(const std::string& s) {
std::unique_lock lock(rw_mtx);
cache = s;
}
多个锁一起加锁
如果必须同时操作两份共享资源,应尽量一次性加锁,避免锁顺序不一致导致死锁。
#include <mutex>
std::mutex account_mtx1;
std::mutex account_mtx2;
int a = 100;
int b = 200;
void transfer(int amount) {
std::scoped_lock lock(account_mtx1, account_mtx2);
a -= amount;
b += amount;
}
非阻塞尝试
通常 try_lock() 适合“拿不到锁就先做别的”或“立即失败也可以接受”的场景。
示例
#include <mutex>
std::mutex mtx;
int value = 0;
void maybe_update() {
if (mtx.try_lock()) {
++value;
mtx.unlock();
} else {
// 这次放弃更新,或者稍后重试
}
}
条件变量配合
条件变量等待时会临时释放锁,唤醒后再重新获取锁,因此它需要和 std::unique_lock 配合。
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;
void producer() {
{
std::lock_guard<std::mutex> lock(mtx);
q.push(42);
}
cv.notify_one();
}
void consumer() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return !q.empty(); });
int value = q.front();
q.pop();
}
条件变量和锁的关系
锁负责“互斥访问共享数据”,条件变量负责“等待某个条件成立”,这两者通常一起使用。
std::condition_variable
std::condition_variable 允许线程在条件不满足时进入等待状态,避免一直忙等浪费 CPU。
wait 配合 std::unique_lock
为什么 wait 要配合 std::unique_lock
因为 wait() 的内部行为不是单纯睡眠,而是:
- 检查条件
- 释放互斥锁
- 挂起当前线程
- 被唤醒后重新获取锁
- 再次检查条件
这个过程要求锁对象支持“解锁后再重新加锁”,因此使用 std::unique_lock,而不是 std::lock_guard。
wait 的正确写法
必须使用谓词,防止虚假唤醒(spurious wakeup)。
错误示例
cv.wait(lock);
正确写法
bool ready = false;
cv.wait(lock, [] { return ready; });
或者显式写成循环:
bool ready = false;
while (!ready) {
cv.wait(lock);
}
notify_one / notify_all
notify_one():唤醒一个等待线程notify_all():唤醒所有等待线程
选择原则:
- 只有一个线程继续执行就够了,用
notify_one() - 多个线程都需要重新竞争条件,用
notify_all()
生产者-消费者示例
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;
bool done = false;
void producer() {
for (int i = 1; i <= 5; ++i) {
{
std::lock_guard<std::mutex> lock(mtx);
q.push(i);
}
cv.notify_one();
}
{
std::lock_guard<std::mutex> lock(mtx);
done = true;
}
cv.notify_all();
}
void consumer() {
std::unique_lock<std::mutex> lock(mtx);
while (!done || !q.empty()) {
cv.wait(lock, [] { return done || !q.empty(); });
while (!q.empty()) {
int value = q.front();
q.pop();
std::cout << value << '\n';
}
}
}