4、同步原语(二):读写锁(RWLock)、条件变量(Condition Variable)、屏障(Barrier)的使用场景
上一章我们聊了互斥锁和信号量,那是并发编程的入门基本功。但实际项目中,你会发现光靠这两样东西,很多场景写起来特别别扭。要么性能上不去,要么逻辑绕来绕去把自己搞晕。
今天要讲的三个原语——读写锁、条件变量、屏障,就是专门解决这些「别扭」的。我个人习惯把它们叫做「进阶三件套」。你想想看,读多写少怎么优化?线程怎么等某个条件成立?一堆任务怎么对齐到同一个起跑线?嗯,就是它们仨的活儿。
核心观点:选对同步原语,比写出复杂的锁逻辑更重要。读写锁提升读多写少场景的并发度;条件变量解耦「等待条件」与「轮询」;屏障让并行任务在指定关卡同步。
4.1 读写锁:读多写少场景的银弹
先问一个问题:你用互斥锁保护一个缓存,100个线程读,1个线程写。读操作之间其实不打架,但互斥锁硬是把所有读串行化了。这不浪费吗?
读写锁就是干这个的。它区分两种锁模式:共享锁(读锁)和独占锁(写锁)。多个线程可以同时持有读锁,但写锁只能被一个线程持有,而且写锁和读锁互斥。
说白了就是:读读不互斥,读写互斥,写写互斥。
我的经验:我曾经在一个配置中心项目里,用读写锁替换了原来的互斥锁。配置数据读的频率是写的几百倍。替换后,读操作的吞吐量直接翻了3倍。嗯,改动就几行代码,效果立竿见影。
来看一个典型的读写锁用法(C++ 伪代码):
// 共享缓存
std::shared_mutex rwlock;
std::map<std::string, std::string> cache;
// 读操作:多个线程可以同时进入
std::string read(const std::string& key) {
std::shared_lock<std::shared_mutex> lock(rwlock);
auto it = cache.find(key);
return (it != cache.end()) ? it->second : "";
}
// 写操作:必须独占
void write(const std::string& key, const std::string& val) {
std::unique_lock<std::shared_mutex> lock(rwlock);
cache[key] = val;
}
这里要注意一个坑:不要把读锁升级为写锁。比如你先拿了读锁,发现数据不存在,想升级成写锁去插入。很多实现不支持这种升级,强行做会导致死锁。我建议的做法是:先释放读锁,再拿写锁,然后重新检查一次数据是否存在(double-check)。
避坑指南:我曾经在一个项目中看到同事在持有读锁时尝试获取写锁,结果线程全部卡死。读写锁的「锁升级」操作在不同平台行为不一致,最安全的做法是:先解锁,再加写锁,并配合 double-check 模式。
4.2 条件变量:告别轮询的等待艺术
你想想看,一个生产者-消费者队列,消费者怎么知道队列里有数据了?
最笨的办法是:while(队列为空) { sleep(1); }。这叫轮询,浪费CPU,延迟还高。好一点的办法是用信号量,但信号量计数和队列长度之间的映射关系容易搞错。
条件变量就是专门解决「等待某个条件成立」这个问题的。它让线程在条件不满足时挂起(不占CPU),条件满足时被唤醒。
核心API就三个:wait、notify_one、notify_all。
看一个经典的生产者-消费者例子:
std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;
// 消费者线程
void consumer() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, []{ return !q.empty(); }); // 等待条件
int val = q.front();
q.pop();
// 处理 val...
}
// 生产者线程
void producer(int val) {
std::unique_lock<std::mutex> lock(mtx);
q.push(val);
cv.notify_one(); // 唤醒一个等待的消费者
}
这里有个关键点:wait 必须配合互斥锁使用。为什么?因为条件变量的检查(判断队列是否为空)和修改(pop)必须是原子的,否则会出现「虚假唤醒」——线程被唤醒了,但条件其实不成立。
重要:条件变量的 wait 一定要用带谓词的重载形式(lambda),或者自己写 while 循环检查条件。不要直接写 cv.wait(lock),否则虚假唤醒会让你怀疑人生。我见过线上事故就是因为这个。
虚假唤醒是什么?就是线程没收到 notify 也被唤醒了。操作系统层面可能因为信号等原因导致 wait 返回。所以标准做法是:wait 返回后,必须重新检查条件。
4.3 屏障:让并行任务对齐步调
屏障(Barrier)这个名字可能有点陌生,但它的场景很常见:你有N个线程并行处理数据,每个线程完成自己的阶段任务后,必须等所有线程都到达某个「关卡」,才能一起进入下一阶段。
举个例子:并行计算矩阵乘法。第一阶段每个线程计算一部分结果,第二阶段需要所有线程的结果才能做下一步聚合。这时候就需要屏障。
C++20 标准库提供了 std::barrier,用法很简洁:
#include <barrier>
#include <thread>
#include <vector>
const int NUM_THREADS = 4;
std::barrier sync_point(NUM_THREADS);
void worker(int id) {
// 阶段一:计算
do_phase1(id);
sync_point.arrive_and_wait(); // 等待所有线程完成阶段一
// 阶段二:聚合
do_phase2(id);
sync_point.arrive_and_wait(); // 等待所有线程完成阶段二
// 阶段三:输出
do_phase3(id);
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < NUM_THREADS; ++i)
threads.emplace_back(worker, i);
for (auto& t : threads) t.join();
}
屏障和条件变量有什么区别?屏障是一次性同步所有线程,而条件变量是一个线程通知另一个线程。屏障的语义是「大家都到了,一起走」,条件变量的语义是「条件变了,你醒醒」。
| 特性 | 屏障(Barrier) | 条件变量(CV) |
|---|---|---|
| 同步模式 | N对N,所有线程互相等待 | 1对1或1对N,生产者通知消费者 |
| 复用性 | 可重复使用(到达计数自动重置) | 需要手动管理状态 |
| 典型场景 | 并行计算的分阶段同步 | 生产者-消费者、事件驱动 |
| 是否需要互斥锁 | 不需要 | 必须配合互斥锁 |
| 虚假唤醒 | 不存在 | 存在,必须处理 |
我的建议:如果你用 C++17 及以下版本,没有标准屏障。可以用 std::latch(C++20 也有)或者自己用条件变量+计数器实现一个。但自己实现要注意边界条件,我踩过坑——计数器重置时机不对,导致屏障「漏人」。
4.4 三个原语的选择决策
好了,三个原语都讲完了。怎么选?我总结了一个简单的判断流程:
- 读多写少,且读操作不修改数据 → 读写锁。别犹豫,直接用。
- 一个线程等待另一个线程满足某个条件 → 条件变量。注意配合互斥锁和 while 循环。
- 多个线程需要在某个关卡对齐 → 屏障。并行计算、分阶段任务的首选。
- 如果只是简单的计数等待(一次性) → 用
std::latch,比屏障更轻量。
避坑指南:我曾经在一个项目中,把条件变量用在了屏障的场景。结果代码里全是计数器+notify_all,逻辑绕了三层,最后还出了竞态条件。后来换成屏障,代码量减少一半,正确性一目了然。选对工具,比写出复杂的「万能方案」重要得多。
最后说一句:这些原语不是互斥的。实际项目中,读写锁里可能嵌套条件变量,屏障内部可能用互斥锁保护共享状态。关键是理解每个原语的语义边界——它解决什么问题,不解决什么问题。边界清晰了,组合起来才不会出乱子。