3、互斥量与锁:std::mutex基础、lock_guard与unique_lock、死锁的产生与避免、std::recursive_mutex
多线程编程里,最让人头疼的问题是什么?
我个人觉得,不是线程怎么创建,也不是任务怎么分配。而是——数据打架。
两个线程同时写一个变量,结果谁先谁后完全看运气。程序跑十次,八次对,两次错。这种bug最难查。所以,我们需要互斥量,也就是锁。
std::mutex:最基础的锁
std::mutex 是C++11引入的最基本的互斥量。它的用法很简单:lock() 加锁,unlock() 解锁。
#include <mutex>
#include <thread>
#include <iostream>
std::mutex mtx;
int shared_data = 0;
void increment() {
for (int i = 0; i < 100000; ++i) {
mtx.lock();
++shared_data;
mtx.unlock();
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << shared_data << std::endl; // 输出 200000
return 0;
}
嗯,这里要注意:lock() 和 unlock() 必须成对出现。如果中间抛了异常,unlock() 没执行,锁就永远解不开了。其他线程会一直卡在 lock() 那里。
lock() / unlock() 是危险的。异常安全无法保证。我在项目中见过有人这么写,结果线上服务偶尔死锁,排查了三天才发现是某个分支提前 return 了,没走到 unlock。
lock_guard:RAII 风格的自动锁
为了解决上面的问题,C++ 提供了 std::lock_guard。它在构造时加锁,析构时自动解锁。说白了,就是利用 RAII 机制,让锁的生命周期和代码块绑定。
void increment() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mtx);
++shared_data;
} // 离开作用域时自动解锁
}
你看,代码简洁多了。而且不管中间是 return 还是抛异常,lock_guard 的析构函数都会执行,锁一定会被释放。
lock_guard。除非你需要更灵活的控制,否则别碰裸的 lock() / unlock()。
unique_lock:更灵活的锁
std::unique_lock 比 lock_guard 更强大。它支持延迟加锁、尝试加锁、手动解锁、移动语义等。
std::unique_lock<std::mutex> lock(mtx, std::defer_lock); // 先不锁
// 做一些准备工作...
lock.lock(); // 手动加锁
// 访问共享数据
lock.unlock(); // 手动解锁
// 做一些不需要锁的事情
lock.lock(); // 再次加锁
为什么需要手动解锁?举个例子:你有一段代码,前半部分需要保护共享数据,后半部分只是本地计算。如果一直持有锁,其他线程就得等着。这时候手动解锁能提高并发度。
另外,unique_lock 还支持 try_lock(),尝试加锁,如果锁被占用就立即返回 false,不会阻塞。这在实现一些非阻塞算法时很有用。
lock_guard:轻量,固定加锁策略,性能略好unique_lock:重量,支持延迟/尝试/移动,功能更全
如果你不需要特殊操作,用 lock_guard 就够了。需要灵活控制时再换 unique_lock。
死锁的产生与避免
死锁是什么?说白了就是两个线程互相等对方释放锁,结果谁也等不到。
举个例子:线程A持有锁1,想获取锁2;线程B持有锁2,想获取锁1。两个线程都在等对方释放,程序就卡死了。
std::mutex mtx1, mtx2;
void threadA() {
std::lock_guard<std::mutex> lock1(mtx1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock2(mtx2); // 可能死锁
}
void threadB() {
std::lock_guard<std::mutex> lock2(mtx2);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock1(mtx1); // 可能死锁
}
我曾经在一个支付系统中遇到过这种问题。两个线程分别处理订单和库存,互相等待对方的锁,导致整个支付链路卡死。那次事故让我深刻理解了死锁的可怕。
怎么避免?有几个常用方法:
- 固定加锁顺序:所有线程都按同样的顺序加锁。比如先锁mtx1再锁mtx2,就不会死锁。
- 使用 std::lock():一次性锁住多个互斥量,内部会避免死锁。
- 使用 try_lock():加锁失败时释放已持有的锁,重试。
// 方法2:使用 std::lock()
void safeThread() {
std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
std::lock(lock1, lock2); // 同时锁住两个
// 安全访问
}
std::recursive_mutex:可重入锁
有时候,同一个线程需要多次获取同一个锁。比如一个递归函数,或者一个函数调用了另一个也需要加锁的函数。
普通 std::mutex 不允许同一个线程重复加锁,否则会死锁。这时候就需要 std::recursive_mutex。
std::recursive_mutex rmtx;
void recursive_func(int depth) {
std::lock_guard<std::recursive_mutex> lock(rmtx);
if (depth > 0) {
recursive_func(depth - 1); // 再次加锁,没问题
}
}
不过,我建议你谨慎使用 recursive_mutex。它意味着你的代码设计可能有问题——为什么同一个线程需要多次获取同一把锁?是不是可以重构一下,把加锁逻辑提到外面?
知识体系总览
下面这张图总结了本章的核心内容,你可以对照着回顾一下:
这张图把四种锁和死锁的避免方法串在了一起。你可以看到,lock_guard 和 unique_lock 都是对 std::mutex 的封装,而 recursive_mutex 是特殊情况。底部是死锁的三种避免策略。
好了,这一章的内容就到这里。记住一句话:能用 lock_guard 就别用裸 lock,能用 unique_lock 就别用 recursive_mutex。这是我在无数个加班夜里总结出来的经验。