线程同步原语:互斥锁、读写锁、条件变量与信号量

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

我个人觉得,不是线程怎么创建,也不是怎么销毁。而是——数据竞争。两个线程同时写一个变量,程序就崩了。这种问题我遇到过太多次了。

今天咱们就把四种最常用的同步原语讲透。互斥锁、读写锁、条件变量、信号量。它们各有各的脾气,用对了是神器,用错了是灾难。

一、互斥锁(Mutex)——最基础的守护者

互斥锁,说白了就是一把锁。谁拿到锁,谁就能进临界区。其他人只能在门外等着。

它的原理其实很简单:

  • 线程A尝试加锁,成功,进入临界区
  • 线程B也尝试加锁,发现锁被占着,阻塞等待
  • 线程A解锁,线程B被唤醒,拿到锁

我习惯用 std::mutex 配合 std::lock_guard 来用。为什么?因为 lock_guard 在构造时加锁,析构时自动解锁。就算中间抛异常了,锁也会被释放。这比手动 lock/unlock 安全得多。

#include <mutex>
#include <thread>

std::mutex mtx;
int shared_data = 0;

void safe_increment() {
    std::lock_guard<std::mutex> lock(mtx);
    ++shared_data;
    // 离开作用域时自动解锁
}
注意:千万别在加锁的代码里做耗时操作。比如网络请求、文件读写。我曾经见过一个项目,有人在锁里调了 sleep(100ms),结果整个系统的吞吐量直接腰斩。

互斥锁的使用场景很明确:

  • 保护共享变量的读写
  • 保护临界区代码的原子执行
  • 任何需要「一次只有一个线程访问」的地方

二、读写锁(RWLock)——读多写少的利器

你想想看,如果大部分时候都是读操作,偶尔才写一次。用互斥锁是不是太浪费了?

读写锁就是解决这个问题的。它允许多个线程同时读,但写的时候只能有一个线程,而且读也不能进行。

我做过一个配置管理系统。配置数据几乎不变,但几百个线程都在读。用互斥锁的话,读操作都要排队,性能惨不忍睹。换成读写锁后,读操作几乎不阻塞,只有更新配置时才短暂锁一下。

#include <shared_mutex>
#include <unordered_map>

std::shared_mutex rw_mutex;
std::unordered_map<std::string, std::string> config;

std::string read_config(const std::string& key) {
    std::shared_lock<std::shared_mutex> lock(rw_mutex);
    auto it = config.find(key);
    return it != config.end() ? it->second : "";
}

void update_config(const std::string& key, const std::string& value) {
    std::unique_lock<std::shared_mutex> lock(rw_mutex);
    config[key] = value;
}
经验之谈:读写锁不是万能的。如果写操作很频繁,读写锁反而比互斥锁慢。因为写锁要等所有读锁释放,读锁又要等写锁释放,容易造成「写者饥饿」。我建议写操作占比超过20%时,老老实实用互斥锁。

三、条件变量(Condition Variable)——线程间的信号灯

条件变量是什么?它让一个线程等待某个条件成立,另一个线程在条件满足时通知它。

典型的场景:生产者-消费者模型。生产者往队列里放数据,消费者从队列里取数据。队列为空时,消费者就得等着。

这里有个坑,我踩过好几次:虚假唤醒。条件变量可能在没有被通知的情况下被唤醒。所以一定要在循环里检查条件。

#include <condition_variable>
#include <queue>

std::mutex cv_mtx;
std::condition_variable cv;
std::queue<int> data_queue;

void consumer() {
    std::unique_lock<std::mutex> lock(cv_mtx);
    cv.wait(lock, []{ return !data_queue.empty(); });
    // 这里队列一定非空
    int value = data_queue.front();
    data_queue.pop();
}

void producer(int value) {
    {
        std::lock_guard<std::mutex> lock(cv_mtx);
        data_queue.push(value);
    }
    cv.notify_one();  // 通知一个等待的线程
}

核心要点:

  • wait() 必须在 unique_lock 上调用,不能是 lock_guard
  • 一定要用带谓词的 wait 重载,或者自己写 while 循环
  • notify_one() 唤醒一个线程,notify_all() 唤醒所有

条件变量的使用场景:

  • 生产者-消费者队列
  • 线程池的任务分发
  • 任何需要「等待某个条件成立」的场景

四、信号量(Semaphore)——资源计数的管家

信号量跟互斥锁有点像,但更灵活。它维护一个计数器,表示可用资源的数量。

  • acquire():计数器减1,如果为0则阻塞
  • release():计数器加1,唤醒一个等待的线程

C++20 终于把信号量加进标准库了。以前我们得用 POSIX 的信号量,或者自己用条件变量模拟。

#include <semaphore>
#include <thread>

std::counting_semaphore<5> sem(3);  // 最多5个,初始3个资源

void worker(int id) {
    sem.acquire();  // 拿一个资源
    // 执行任务...
    sem.release();  // 归还资源
}

我做过一个数据库连接池。连接数是有限的,比如10个。每个线程要查询时,先 acquire() 拿一个连接,用完 release() 还回去。这样既不会创建过多连接,也不会让线程空等。

注意:信号量的初始值要设对。设大了资源浪费,设小了线程会频繁阻塞。我一般根据实际负载做压测,找到最优值。

信号量的典型场景:

  • 资源池(连接池、线程池)
  • 控制并发访问数量
  • 实现「最多N个线程同时执行」的限制

五、四种原语的对比与选择

说了这么多,到底该用哪个?我整理了一张表,方便你对照:

原语 核心特性 最佳场景 注意事项
互斥锁 独占访问 写操作多,临界区小 避免在锁内做耗时操作
读写锁 读共享,写独占 读多写少 写频繁时性能反而差
条件变量 等待/通知机制 生产者-消费者 小心虚假唤醒
信号量 资源计数 资源池、限流 初始值要合理

选型其实不难。记住一句话:能用简单就别用复杂。大部分场景下,互斥锁就够了。只有当你明确遇到性能瓶颈,或者需要等待/通知机制时,才考虑其他原语。

六、知识体系结构图

下面这张图,把四种同步原语的关系和适用场景串起来了。我画的时候特意把「数据竞争」放在中间,因为所有同步原语都是为了解决它。

数据竞争 互斥锁 读写锁 条件变量 信号量 独占访问 写操作多 读共享 读多写少 等待/通知 生产者-消费者 资源计数 连接池/限流 选择原则 能用简单就别用复杂,互斥锁是默认选择

我的建议:刚开始学多线程时,先死磕互斥锁和条件变量。这两个用熟了,读写锁和信号量自然就懂了。别一上来就想用最复杂的,容易把自己绕进去。

嗯,四种同步原语就讲到这里。代码示例都是 C++ 标准库的,跨平台直接用。你在 Windows、Linux、macOS 上都能跑通。如果遇到编译问题,检查一下是不是用了 C++17 或 C++20 的标准。

可可精品资料库