4、同步原语(二):读写锁(RWLock)、条件变量(Condition Variable)、屏障(Barrier)的使用场景

上一章我们聊了互斥锁和信号量,那是并发编程的入门基本功。但实际项目中,你会发现光靠这两样东西,很多场景写起来特别别扭。要么性能上不去,要么逻辑绕来绕去把自己搞晕。

今天要讲的三个原语——读写锁、条件变量、屏障,就是专门解决这些「别扭」的。我个人习惯把它们叫做「进阶三件套」。你想想看,读多写少怎么优化?线程怎么等某个条件成立?一堆任务怎么对齐到同一个起跑线?嗯,就是它们仨的活儿。

核心观点:选对同步原语,比写出复杂的锁逻辑更重要。读写锁提升读多写少场景的并发度;条件变量解耦「等待条件」与「轮询」;屏障让并行任务在指定关卡同步。

同步原语(二):进阶三件套 进阶同步原语 读写锁(RWLock) 条件变量 屏障(Barrier) 读读不互斥 读写互斥 等待条件 避免忙等 N线程到达 同时放行 选对原语 = 性能 + 可读性 + 正确性,三者缺一不可

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,逻辑绕了三层,最后还出了竞态条件。后来换成屏障,代码量减少一半,正确性一目了然。选对工具,比写出复杂的「万能方案」重要得多。

最后说一句:这些原语不是互斥的。实际项目中,读写锁里可能嵌套条件变量,屏障内部可能用互斥锁保护共享状态。关键是理解每个原语的语义边界——它解决什么问题,不解决什么问题。边界清晰了,组合起来才不会出乱子。