4. 互斥锁基础:std::mutex的使用、lock与unlock、std::lock_guard与RAII

多线程编程里,最让人头疼的问题是什么?

我个人觉得,就是数据竞争。两个线程同时读写同一个变量,结果就像两个人同时抢一支笔写字——写出来的东西鬼知道是谁的。嗯,这时候就需要互斥锁登场了。

4.1 为什么需要互斥锁?

先看一个经典的反面教材。假设我们有一个银行账户,两个线程同时往里存钱:

int balance = 0;

void deposit() {
    for (int i = 0; i < 100000; ++i) {
        balance = balance + 1;  // 这里有问题!
    }
}

// 主线程创建两个线程分别执行 deposit()
// 你猜最终 balance 是多少?

按理说,每个线程加10万次,结果应该是20万。但我告诉你,实际跑出来可能只有12万、15万,每次还不一样。为什么会这样?

因为 balance = balance + 1 这行代码,在CPU眼里其实是三步操作:

  1. 从内存读取 balance 到寄存器
  2. 在寄存器里加1
  3. 把结果写回内存

两个线程同时执行这三步,就会发生「你读的时候我还没写回去」的情况。结果就是,明明加了两次,实际只加了一次。这就是数据竞争。

核心结论: 多个线程同时访问同一块共享数据,只要其中至少有一个是写操作,就必须同步。

4.2 std::mutex 的基本用法

C++11 提供了 std::mutex,它就是一把锁。用法很简单:

#include <mutex>

std::mutex mtx;
int balance = 0;

void deposit() {
    for (int i = 0; i < 100000; ++i) {
        mtx.lock();          // 加锁
        balance = balance + 1;
        mtx.unlock();        // 解锁
    }
}

这样改完之后,每次只有一个线程能执行 balance = balance + 1,另一个线程必须等着。结果就稳定在20万了。

注意: 一定要记得 unlock!如果某个线程 lock 之后忘记 unlock,其他所有线程都会永远卡住。我在项目中就见过这种 bug,排查了一下午才发现是某个异常分支里忘了 unlock。

4.3 lock/unlock 的坑

手动 lock 和 unlock 有几个明显的痛点:

  • 忘记 unlock:函数中间 return 了,或者抛出异常了,unlock 就没执行到
  • 代码复杂:每个分支都要写 unlock,代码变得又臭又长
  • 死锁风险:多个锁互相等待时,手动管理很容易出错

我曾经在一个网络服务模块里,因为一个提前 return 忘记 unlock,导致整个服务线程池全部卡死。线上报警响了,我还在纳闷为什么所有请求都超时了……嗯,从那以后我再也不敢裸用 lock/unlock 了。

4.4 std::lock_guard:RAII 的救星

C++ 的 RAII(资源获取即初始化)思想,完美解决了这个问题。std::lock_guard 就是 RAII 在互斥锁上的应用:

void deposit() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> guard(mtx);  // 构造时 lock
        balance = balance + 1;
        // 出作用域时自动 unlock
    }
}

你看,代码简洁多了。guard 对象在构造时调用 mtx.lock(),在析构时调用 mtx.unlock()。不管函数是正常返回、中途 return、还是抛出异常,只要 guard 对象被销毁,锁就一定会释放。

我的习惯: 只要涉及互斥锁,一律用 lock_guard 或 unique_lock。裸 lock/unlock 只出现在极少数性能敏感且逻辑极其简单的场景里。你想想看,多写几个字符就能避免一个线上事故,这笔账怎么算都划算。

4.5 lock_guard 的局限性

当然,lock_guard 也不是万能的。它有几个限制:

  • 不能手动解锁:锁的持有时间完全由作用域决定,不能提前释放
  • 不能转移所有权:lock_guard 不可拷贝,也不可移动
  • 不能用于条件变量:条件变量需要更灵活的锁操作

如果你需要更灵活的控制,可以用 std::unique_lock。它支持手动 lock/unlock,也支持移动语义。不过那是后面章节的内容了,这里先不展开。

4.6 知识体系总览

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

互斥锁基础:知识体系 问题:数据竞争 解决方案:互斥锁 裸 lock/unlock 手动管理,容易出错 std::lock_guard RAII,自动管理 std::unique_lock 更灵活,可手动控制 推荐使用

4.7 实战建议与避坑指南

最后,分享几个我在实际项目中积累的经验:

场景 推荐做法 原因
保护一个简单变量 std::lock_guard 代码简洁,不易出错
需要提前解锁 std::unique_lock 支持手动 lock/unlock
配合条件变量 std::unique_lock 条件变量要求 unique_lock
性能极致优化 裸 lock/unlock 减少构造析构开销(极少用)
我曾经踩过的坑: 有一次我写了一个函数,里面用 lock_guard 保护了一段代码。后来重构时,我在 lock_guard 的作用域里调用了另一个函数,那个函数里又对同一个 mutex 加锁——死锁了。记住,std::mutex 是不可重入的,同一个线程不能对同一个 mutex 加锁两次。如果需要重入,请用 std::recursive_mutex。

好了,关于互斥锁的基础就聊到这里。说白了,记住两件事:第一,共享数据一定要加锁;第二,用 lock_guard 代替裸 lock/unlock。做到这两点,你的多线程代码就能避免大部分数据竞争的问题。


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