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() 就永远不会执行。锁一直挂着,其他线程就永远进不来了。
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_lock 比 lock_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,这里也卡住了
}
解决方案有两个:
- 固定加锁顺序:所有线程都按同样的顺序加锁(比如先 mtx1 后 mtx2)。
- 使用 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 知识体系总览
下面这张图帮你理清本章的核心逻辑:
3.7 总结
互斥锁是多线程编程的基石。记住三点:
- 别裸用 lock/unlock——RAII 包装器是你的好朋友。
- 简单场景用 lock_guard——轻量、安全、高效。
- 复杂场景用 unique_lock——灵活、可控、配合条件变量。
我曾经在代码审查时看到有人用 lock_guard 锁住了整个循环,性能直接掉了一个数量级。嗯,锁的粒度一定要小,只保护真正需要保护的代码。
互斥锁不是银弹。它解决了数据竞争问题,但也带来了死锁、性能下降等新问题。后面我们会讲到读写锁、原子操作等更高级的同步机制,它们在某些场景下比互斥锁更合适。