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_locklock_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);  // 可能死锁
}

我曾经在一个支付系统中遇到过这种问题。两个线程分别处理订单和库存,互相等待对方的锁,导致整个支付链路卡死。那次事故让我深刻理解了死锁的可怕。

怎么避免?有几个常用方法:

  1. 固定加锁顺序:所有线程都按同样的顺序加锁。比如先锁mtx1再锁mtx2,就不会死锁。
  2. 使用 std::lock():一次性锁住多个互斥量,内部会避免死锁。
  3. 使用 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);  // 同时锁住两个
    // 安全访问
}
⚠️ 死锁检测: 死锁很难在测试中复现,因为它依赖于线程调度时机。我建议你在代码中加一些超时机制,或者使用工具如 ThreadSanitizer 来检测潜在死锁。

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。它意味着你的代码设计可能有问题——为什么同一个线程需要多次获取同一把锁?是不是可以重构一下,把加锁逻辑提到外面?

💡 我的经验: 能用普通 mutex 就别用 recursive_mutex。后者有额外的性能开销,而且容易掩盖设计缺陷。我只有在实现一些遗留接口时才会用它,比如回调函数里需要加锁,而回调本身又在锁内被调用。

知识体系总览

下面这张图总结了本章的核心内容,你可以对照着回顾一下:

互斥量与锁 知识体系 互斥量与锁 std::mutex lock_guard unique_lock recursive_mutex lock/unlock 异常不安全 RAII 自动管理 轻量高效 延迟/手动加锁 try_lock / 移动 可重入加锁 谨慎使用 死锁:固定顺序 / std::lock / try_lock 避免方法

这张图把四种锁和死锁的避免方法串在了一起。你可以看到,lock_guardunique_lock 都是对 std::mutex 的封装,而 recursive_mutex 是特殊情况。底部是死锁的三种避免策略。

好了,这一章的内容就到这里。记住一句话:能用 lock_guard 就别用裸 lock,能用 unique_lock 就别用 recursive_mutex。这是我在无数个加班夜里总结出来的经验。


公众号:蓝海资料掘金营,微信deep3321