12、C++11 并发编程:std::mutex与std::lock_guard,保护共享数据
多线程编程,说白了就是让程序同时干好几件事。听起来很爽对吧?但有个大坑——共享数据。两个线程同时读写同一个变量,结果往往不是你想要的。我早年做服务器开发时,就因为这个bug排查了整整三天,最后发现是计数器没加锁。嗯,从那以后我对互斥锁就格外上心了。
12.1 为什么需要互斥锁?
先看一个经典问题。两个线程同时对一个全局变量做 counter++。你猜结果是多少?
#include <iostream>
#include <thread>
int counter = 0;
void increment() {
for (int i = 0; i < 100000; ++i) {
++counter; // 这行不是原子操作!
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "counter = " << counter << std::endl;
return 0;
}
运行几次你会发现,结果每次都不一样,而且几乎永远不是200000。为什么会这样?因为 ++counter 在CPU层面其实是三步:读、改、写。两个线程可能同时读到同一个旧值,各自加1,再写回去——结果只加了1次。
核心问题:多个线程同时访问同一块内存,至少有一个线程在写,就会产生数据竞争(data race)。C++标准说这是未定义行为,程序可能崩溃、死循环、或者给你一个莫名其妙的结果。
12.2 std::mutex:最基本的互斥工具
std::mutex 是C++11提供的最基础的互斥锁。它的用法很简单:
lock():获取锁。如果锁已被其他线程持有,当前线程阻塞等待。unlock():释放锁。让其他等待的线程有机会获取。
用 std::mutex 修复上面的问题:
#include <iostream>
#include <thread>
#include <mutex>
int counter = 0;
std::mutex mtx; // 全局互斥锁
void increment() {
for (int i = 0; i < 100000; ++i) {
mtx.lock();
++counter;
mtx.unlock();
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "counter = " << counter << std::endl;
return 0;
}
现在结果稳定在200000。但这样写有个隐患——你忘了 unlock() 怎么办?或者中间抛出异常了怎么办?锁一旦没释放,其他线程就永远卡死了。
警告:直接使用 lock() 和 unlock() 是危险的。异常安全、提前返回、忘记解锁——任何一个场景都能让你头疼。我在项目中见过太多因为手动管理锁导致的死锁问题。
12.3 std::lock_guard:RAII风格的锁管理
C++的RAII(资源获取即初始化)思想在这里大放异彩。std::lock_guard 是一个包装类,构造时自动加锁,析构时自动解锁。你根本不需要手动调用 unlock()。
#include <iostream>
#include <thread>
#include <mutex>
int counter = 0;
std::mutex mtx;
void increment() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> guard(mtx); // 构造时加锁
++counter;
// 离开作用域时自动解锁
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "counter = " << counter << std::endl;
return 0;
}
你看,代码简洁多了。而且不管函数是正常返回、提前return、还是抛出异常,lock_guard 的析构函数都会被执行,锁一定会被释放。这就是RAII的魅力。
个人建议:只要涉及互斥锁,优先使用 std::lock_guard。我自己的代码里几乎从不直接调用 lock()/unlock()。除非你需要更精细的控制(比如手动提前解锁),否则 lock_guard 就是最佳选择。
12.4 知识体系图
下面这张图展示了本章的核心逻辑:
12.5 使用场景与最佳实践
不是所有地方都需要加锁。你想想看,如果每个线程只读不写,那根本不需要锁。只有「至少一个线程在写」的时候才需要。
| 场景 | 是否需要锁 | 推荐做法 |
|---|---|---|
| 多个线程只读 | 不需要 | 直接访问,无需保护 |
| 一个线程写,其他线程读 | 需要 | std::lock_guard + std::mutex |
| 多个线程写 | 需要 | std::lock_guard + std::mutex |
| 读多写少 | 需要 | std::shared_mutex (C++17) |
核心原则:锁的粒度要尽量小。只保护真正需要保护的代码段,不要在锁里做耗时操作(比如文件读写、网络请求)。否则其他线程会等得很痛苦。
12.6 避坑指南
我总结几个常见的坑,都是我在项目中踩过的:
- 死锁:两个线程各自持有一把锁,又去请求对方的锁。解决办法是保证加锁顺序一致,或者用
std::lock一次性锁住多个互斥量。 - 锁的粒度太大:把整个函数都锁住,导致并发性能急剧下降。应该只锁住临界区。
- 忘记解锁:用
lock_guard可以彻底避免这个问题。 - 锁了非共享数据:只保护那些真正被多个线程访问的变量,不要无脑加锁。
我曾经在一个项目中,把锁加在了循环外面,结果一个线程占着锁跑了很久,其他线程全部饿死。后来改成在循环内部加锁,虽然每次加解锁有开销,但整体吞吐量反而上去了。记住:锁的粒度要细,但也不能太细(比如每次加一个int都加锁,那性能就崩了)。
12.7 小结
多线程编程的核心就是管理好共享数据。std::mutex 提供了基础的互斥能力,而 std::lock_guard 用RAII思想让锁的管理变得安全又简单。我个人习惯是:只要看到需要保护共享数据,第一反应就是 std::lock_guard。它几乎能解决90%的并发保护问题。
记住一句话:能不用锁就不用锁,用锁就用 lock_guard。这样你的代码既安全又清晰。