3. 互斥锁(Mutex):std::mutex的基本使用、lock与unlock、std::lock_guard与std::unique_lock

互斥锁,说白了就是一把「门锁」。你进去干活,把门锁上,别人进不来。等你干完活,把锁打开,下一个人才能进去。

我刚开始写多线程代码时,总觉得这东西可有可无。直到有一次,两个线程同时修改一个全局计数器,结果跑出来的结果每次都不一样。嗯,从那以后,我再也不敢轻视互斥锁了。

3.1 std::mutex 的基本使用

C++11 标准库提供了 std::mutex,它是互斥锁的核心实现。用法很简单:

#include <mutex>
#include <iostream>
#include <thread>

std::mutex mtx;
int shared_data = 0;

void increment() {
    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;  // 输出 2
    return 0;
}

你看,核心就三步:lock()、干活、unlock()。但这里有个坑——如果 ++shared_data 这行代码抛异常了,unlock() 就永远不会执行。锁一直挂着,其他线程就永远进不来了。

⚠️ 注意: 直接使用 lock/unlock 容易导致死锁或资源泄漏。我建议你尽量别这么写,除非你能保证代码路径绝对安全。

3.2 std::lock_guard:自动解锁的「安全锁」

为了解决手动 unlock 容易遗漏的问题,C++11 提供了 std::lock_guard。它是个 RAII 包装器——构造时加锁,析构时自动解锁。

void increment_safe() {
    std::lock_guard<std::mutex> guard(mtx);
    ++shared_data;
    // 函数结束,guard 析构,自动调用 mtx.unlock()
}

我个人习惯在简单场景下优先用 lock_guard。它轻量、高效,没有额外开销。你想想看,代码里少写一个 unlock(),就少一个出 bug 的机会。

💡 小技巧: 尽量缩小锁的粒度。只把真正需要保护的代码放在锁内,不要锁住整个函数。比如:
void process() {
    // 不需要锁的操作
    int local = compute_local();
    
    // 只有这里需要保护
    {
        std::lock_guard<std::mutex> guard(mtx);
        shared_data += local;
    }
    
    // 继续其他操作
}

3.3 std::unique_lock:更灵活的锁

std::unique_locklock_guard 更灵活。它支持延迟加锁、尝试加锁、手动解锁、转移所有权等操作。当然,灵活是有代价的——它内部维护了更多状态,性能略低于 lock_guard

std::unique_lock<std::mutex> lock(mtx);           // 构造时加锁
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);  // 延迟加锁,稍后手动 lock
std::unique_lock<std::mutex> lock(mtx, std::try_to_lock); // 尝试加锁,失败不阻塞

我在项目中遇到过这样一个场景:一个线程需要等待某个条件满足后再继续执行。这时候 unique_lock 就派上用场了——它配合 std::condition_variable 使用,可以在等待时自动解锁,唤醒后重新加锁。

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void worker() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []{ return ready; });  // 等待期间自动解锁
    // 条件满足,重新加锁,继续执行
    do_work();
}

你想想看,如果用 lock_guard,它不支持手动解锁,就没法实现这种「等待时释放锁」的机制。

3.4 lock_guard vs unique_lock:怎么选?

特性 std::lock_guard std::unique_lock
加锁时机 构造时立即加锁 可延迟、可立即、可尝试
手动解锁 不支持 支持(lock.unlock())
所有权转移 不支持 支持(move 语义)
配合条件变量 不支持 支持
性能开销 极低(零额外开销) 略高(维护状态标志)
推荐场景 简单加锁,作用域明确 需要灵活控制锁的生命周期

我的建议是:能用 lock_guard 就别用 unique_lock。只有当你确实需要延迟加锁、条件变量或所有权转移时,才考虑 unique_lock。别为了「万一以后要用」而过度设计。

3.5 避坑指南

我曾经踩过一个坑:两个线程互相等待对方释放锁,结果双双卡死。这就是经典的死锁问题。

// 错误示例:可能死锁
std::mutex mtx1, mtx2;

void thread1() {
    std::lock_guard<std::mutex> lock1(mtx1);
    std::lock_guard<std::mutex> lock2(mtx2);  // 如果 thread2 先锁了 mtx2,这里就卡住了
}

void thread2() {
    std::lock_guard<std::mutex> lock2(mtx2);
    std::lock_guard<std::mutex> lock1(mtx1);  // 如果 thread1 先锁了 mtx1,这里也卡住了
}

解决方案有两个:

  1. 固定加锁顺序:所有线程都按同样的顺序加锁(比如先 mtx1 后 mtx2)。
  2. 使用 std::lock:一次性锁住多个互斥量,避免死锁。
void safe_thread1() {
    std::lock(mtx1, mtx2);  // 同时锁住两个
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
    // 安全操作
}

这里 std::adopt_lock 告诉 lock_guard:锁已经加上了,你只管析构时解锁就行。

3.6 知识体系总览

下面这张图帮你理清本章的核心逻辑:

互斥锁(Mutex)知识体系 std::mutex lock() / unlock() std::lock_guard std::unique_lock ⚠️ 手动管理 • 必须成对调用 • 异常不安全 • 容易遗漏 unlock • 不推荐直接使用 ✅ RAII 自动管理 • 构造加锁,析构解锁 • 异常安全 • 零额外开销 • 推荐简单场景 🔧 灵活控制 • 延迟/尝试加锁 • 支持手动 unlock • 配合条件变量 • 性能略低

3.7 总结

互斥锁是多线程编程的基石。记住三点:

  • 别裸用 lock/unlock——RAII 包装器是你的好朋友。
  • 简单场景用 lock_guard——轻量、安全、高效。
  • 复杂场景用 unique_lock——灵活、可控、配合条件变量。

我曾经在代码审查时看到有人用 lock_guard 锁住了整个循环,性能直接掉了一个数量级。嗯,锁的粒度一定要小,只保护真正需要保护的代码。

互斥锁不是银弹。它解决了数据竞争问题,但也带来了死锁、性能下降等新问题。后面我们会讲到读写锁、原子操作等更高级的同步机制,它们在某些场景下比互斥锁更合适。


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