3. 线程同步之互斥锁
多线程编程里,最让人头疼的问题是什么?
我个人觉得,就是「数据竞争」。两个线程同时读写同一个变量,结果不可预测。你想想看,一个线程刚把值写进去,另一个线程就把它覆盖了,程序跑起来跟抽风似的。
解决这个问题的核心工具,就是互斥锁。
3.1 互斥锁的原理
互斥锁,说白了就是一把「门锁」。
它的工作逻辑很简单:
- 线程A想进临界区,先拿钥匙开锁
- 如果锁是开的,A进去,把锁锁上
- 线程B也想进去,发现锁着,只能在外面等着
- 线程A出来,把锁打开,B才能进去
这个机制保证了「同一时刻只有一个线程在临界区里」。临界区就是那些需要保护的共享数据操作代码段。
核心要点:互斥锁的本质是「原子操作 + 等待队列」。锁的获取和释放必须是原子的,否则锁本身也会出现竞争。
我在项目中遇到过一个问题:有人用了一个普通的bool变量当锁,结果两个线程同时读到true,都认为自己拿到了锁。嗯,这就是没有原子操作的下场。
3.2 std::mutex 使用
C++11 标准库提供了 std::mutex,用起来非常直接。
#include <mutex>
#include <thread>
#include <iostream>
std::mutex mtx;
int shared_data = 0;
void worker() {
for (int i = 0; i < 100000; ++i) {
mtx.lock();
++shared_data;
mtx.unlock();
}
}
int main() {
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
std::cout << shared_data << std::endl; // 输出 200000
return 0;
}
不过,我建议你少用 lock() 和 unlock() 这种原始方式。为什么?因为容易忘掉 unlock。一旦中间抛出异常,锁就永远锁死了。
更好的做法是用 std::lock_guard:
void worker() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mtx);
++shared_data;
} // 离开作用域时自动解锁
}
lock_guard 利用了 RAII 机制。构造时加锁,析构时解锁。就算中间抛出异常,栈展开也会调用析构函数,锁一定会被释放。
我的习惯:只要需要加锁,一律用 lock_guard 或 unique_lock。手动 lock/unlock 只用在极少数特殊场景下。
3.3 死锁的产生与预防
死锁是个大坑。我曾经在一个线上服务里排查过整整两天,最后发现是死锁导致的。
死锁的典型场景:线程A持有锁1,等待锁2;线程B持有锁2,等待锁1。两个线程互相等,谁也动不了。
std::mutex mtx1, mtx2;
void threadA() {
std::lock_guard<std::mutex> lock1(mtx1);
// 做一些操作...
std::lock_guard<std::mutex> lock2(mtx2); // 可能死锁
}
void threadB() {
std::lock_guard<std::mutex> lock2(mtx2);
// 做一些操作...
std::lock_guard<std::mutex> lock1(mtx1); // 可能死锁
}
死锁产生的四个必要条件:
- 互斥:资源一次只能被一个线程占用
- 持有并等待:线程持有资源,同时等待其他资源
- 不可剥夺:资源不能被强制拿走
- 循环等待:存在一个等待环路
预防死锁,我总结了三个实用方法:
- 固定加锁顺序:所有线程都按同样的顺序加锁。比如先锁mtx1再锁mtx2,就不会出现循环等待。
- 使用 std::lock:一次性锁住多个互斥量,避免分步加锁。
void safe_operation() {
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::timed_mutex的try_lock_for,拿不到锁就放弃,避免死等。
注意:死锁不一定只在两个锁之间发生。一个线程对自己已经持有的锁再次加锁,也会死锁。这叫「递归死锁」。C++ 提供了 std::recursive_mutex 来解决这个问题,但我建议你尽量避免递归加锁的设计。
3.4 锁的粒度控制
锁的粒度,指的是锁保护的代码范围大小。这是个权衡问题。
粗粒度锁:锁的范围大,代码简单,但并发度低。所有线程串行执行,跟单线程没区别。
细粒度锁:锁的范围小,并发度高,但容易出错,而且锁本身有开销。
我举个例子。假设你有一个哈希表,需要支持并发读写。
- 粗粒度:整个哈希表一把锁。简单,但一次只能一个线程操作。
- 细粒度:每个桶一把锁。多个线程可以同时操作不同的桶,并发度大大提高。
class FineGrainedHashMap {
std::vector<std::mutex> bucket_locks;
std::vector<std::list<Entry>> buckets;
public:
void insert(const Key& k, const Value& v) {
size_t idx = hash(k) % buckets.size();
std::lock_guard<std::mutex> lock(bucket_locks[idx]);
// 只锁一个桶
buckets[idx].push_back({k, v});
}
};
这里有个经验值:锁的临界区代码执行时间,最好控制在微秒级别。如果临界区里有耗时的 I/O 操作,那锁的粒度就太粗了。
避坑指南:我曾经把一个日志写入操作放在锁里面,结果日志量大的时候,所有线程都在等锁。后来我把日志操作移到了锁外面,性能直接翻倍。记住:锁里只做必要的共享数据操作,其他事情都放到外面去。
锁的粒度控制,说白了就是「在正确性和性能之间找平衡」。太粗了性能差,太细了容易出 bug。我的建议是:
- 先写粗粒度锁,保证正确性
- 用性能分析工具找出热点
- 只在热点区域细化锁粒度
- 每次细化后都要做充分的并发测试
知识体系总览
下面这张图,把本章的核心逻辑串起来了:
这张图把互斥锁的四个核心维度串在了一起。从原理出发,到具体使用,再到死锁预防和粒度控制,每一步都是环环相扣的。
记住一句话:锁不是银弹。能用无锁数据结构解决的问题,尽量不用锁。但当你需要用锁的时候,一定要理解它的原理和陷阱。
本章小结:互斥锁是多线程编程的基石。掌握 std::mutex 的正确用法,理解死锁的产生条件,学会控制锁的粒度,你就能写出既正确又高效的多线程代码。
可可精品资料库