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);  // 可能死锁
}

死锁产生的四个必要条件:

  • 互斥:资源一次只能被一个线程占用
  • 持有并等待:线程持有资源,同时等待其他资源
  • 不可剥夺:资源不能被强制拿走
  • 循环等待:存在一个等待环路

预防死锁,我总结了三个实用方法:

  1. 固定加锁顺序:所有线程都按同样的顺序加锁。比如先锁mtx1再锁mtx2,就不会出现循环等待。
  2. 使用 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);
    // 安全操作...
}
  1. 使用超时锁:用 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 使用 lock/unlock lock_guard / unique_lock 死锁 四个必要条件 固定顺序 / std::lock 锁的粒度控制 粗粒度:简单但并发低 细粒度:复杂但并发高 核心:正确性 <-> 性能 的平衡

这张图把互斥锁的四个核心维度串在了一起。从原理出发,到具体使用,再到死锁预防和粒度控制,每一步都是环环相扣的。

记住一句话:锁不是银弹。能用无锁数据结构解决的问题,尽量不用锁。但当你需要用锁的时候,一定要理解它的原理和陷阱。

本章小结:互斥锁是多线程编程的基石。掌握 std::mutex 的正确用法,理解死锁的产生条件,学会控制锁的粒度,你就能写出既正确又高效的多线程代码。

可可精品资料库