线程同步原语:互斥锁、读写锁、条件变量与信号量
多线程编程里,最让人头疼的是什么?
我个人觉得,不是线程怎么创建,也不是怎么销毁。而是——数据竞争。两个线程同时写一个变量,程序就崩了。这种问题我遇到过太多次了。
今天咱们就把四种最常用的同步原语讲透。互斥锁、读写锁、条件变量、信号量。它们各有各的脾气,用对了是神器,用错了是灾难。
一、互斥锁(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;
// 离开作用域时自动解锁
}
互斥锁的使用场景很明确:
- 保护共享变量的读写
- 保护临界区代码的原子执行
- 任何需要「一次只有一个线程访问」的地方
二、读写锁(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;
}
三、条件变量(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 的标准。
可可精品资料库