12、并发设计模式:生产者-消费者模式、读者-写者模式、线程安全单例模式、屏障(barrier)模式
并发编程里,有些问题反复出现。比如数据怎么安全地传递?多个线程读一个资源,写的时候怎么互斥?全局对象怎么只初始化一次?线程之间怎么同步到同一个步调?
这些问题,前辈们早就总结出了套路。这就是并发设计模式。今天我把四个最常用的模式掰开揉碎讲清楚。每个模式我都会结合项目里的真实场景,告诉你什么时候用,怎么用,坑在哪里。
核心观点:设计模式不是教条,是解决问题的工具箱。理解模式背后的“为什么”,比记住代码模板重要一百倍。
12.1 生产者-消费者模式
这个模式太经典了。说白了,就是两个角色:一个负责生产数据,一个负责消费数据。中间用个缓冲区隔开。
我在项目中遇到过最典型的场景是日志系统。业务线程是生产者,拼命往队列里扔日志。后台线程是消费者,一条一条写到磁盘。这样业务线程不会被IO卡住。
我的习惯:用 std::queue 配合 std::condition_variable 实现。队列空时消费者等待,队列满时生产者等待(如果有限制)。
#include <queue>
#include <mutex>
#include <condition_variable>
#include <thread>
template<typename T>
class ThreadSafeQueue {
public:
void push(T item) {
std::lock_guard<std::mutex> lock(m_mutex);
m_queue.push(std::move(item));
m_cv.notify_one(); // 通知一个消费者
}
T pop() {
std::unique_lock<std::mutex> lock(m_mutex);
m_cv.wait(lock, [this] { return !m_queue.empty(); });
T item = std::move(m_queue.front());
m_queue.pop();
return item;
}
private:
std::queue<T> m_queue;
std::mutex m_mutex;
std::condition_variable m_cv;
};
注意:我曾经犯过一个错——在 push 里用了 notify_all。结果多个消费者被唤醒,只有一个能拿到数据,其他白忙活。这叫“惊群效应”。除非你有多个消费者等着处理不同类型的数据,否则用 notify_one 就够了。
12.2 读者-写者模式
这个模式解决的是“读多写少”的场景。比如一个配置中心,大部分时间都在读配置,偶尔才更新一次。如果每次读都加互斥锁,性能就浪费了。
你想想看,多个线程同时读一个变量,根本不会出问题。只有写的时候才需要独占。读者-写者锁就是干这个的。
#include <shared_mutex>
class ConfigManager {
public:
std::string get(const std::string& key) {
std::shared_lock<std::shared_mutex> lock(m_rwMutex);
auto it = m_config.find(key);
return (it != m_config.end()) ? it->second : "";
}
void set(const std::string& key, const std::string& value) {
std::unique_lock<std::shared_mutex> lock(m_rwMutex);
m_config[key] = value;
}
private:
std::unordered_map<std::string, std::string> m_config;
std::shared_mutex m_rwMutex;
};
关键点:读锁是共享的,写锁是独占的。写锁会阻塞所有读锁,读锁也会阻塞写锁。但多个读锁可以同时持有。
我记得有一次优化一个游戏服务器。玩家信息查询接口QPS很高,但更新频率很低。用 std::shared_mutex 替换 std::mutex 后,吞吐量直接翻了三倍。嗯,这就是选对模式的威力。
12.3 线程安全单例模式
单例模式本身不复杂,复杂的是“线程安全”。你想想,如果两个线程同时第一次访问单例,会不会创建出两个对象?
C++11之后,这个问题有了最优雅的解法——Meyer's Singleton。利用局部静态变量的线程安全初始化特性。
class Singleton {
public:
static Singleton& instance() {
static Singleton inst; // C++11保证:线程安全的首次初始化
return inst;
}
void doSomething() {
// 业务逻辑
}
private:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
我的建议:能用Meyer's Singleton就别用双重检查锁定。后者容易写错,而且需要内存屏障。C++11的静态局部变量初始化,编译器已经帮你搞定了所有同步问题。
不过要注意一点:如果你需要在程序启动时控制单例的初始化顺序(比如依赖其他全局对象),那Meyer's Singleton就不够用了。这时候我一般会用 std::call_once 配合 std::once_flag。
class SingletonWithDependency {
public:
static SingletonWithDependency& instance() {
std::call_once(m_onceFlag, [] {
// 这里可以控制初始化顺序
m_instance.reset(new SingletonWithDependency());
});
return *m_instance;
}
private:
static std::once_flag m_onceFlag;
static std::unique_ptr<SingletonWithDependency> m_instance;
};
12.4 屏障(Barrier)模式
屏障模式,也叫栅栏。它的作用是让一组线程在某个点上“对齐”。所有线程都到达屏障后,才能继续往下走。
这个模式在并行计算里特别常见。比如你要并行处理一个大数组,每个线程处理一块。处理完之后,需要等所有线程都完成,才能进入下一阶段。
C++20 标准库直接提供了 std::barrier。如果你还在用C++17,可以用 std::latch(一次性屏障)或者自己用条件变量实现。
#include <barrier>
#include <thread>
#include <vector>
#include <iostream>
void parallel_work() {
const int num_threads = 4;
std::barrier sync_point(num_threads, [] {
std::cout << "所有线程到达屏障,进入下一阶段\n";
});
auto worker = [&](int id) {
// 第一阶段
std::cout << "线程 " << id << " 完成阶段1\n";
sync_point.arrive_and_wait();
// 第二阶段
std::cout << "线程 " << id << " 完成阶段2\n";
sync_point.arrive_and_wait();
// 第三阶段
std::cout << "线程 " << id << " 完成阶段3\n";
};
std::vector<std::thread> threads;
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back(worker, i);
}
for (auto& t : threads) {
t.join();
}
}
避坑指南:我曾经在屏障回调函数里做了耗时操作,结果所有线程都在等回调完成。屏障回调应该轻量,只做日志或状态更新。别在里面加锁或IO。
如果你用的是C++17,可以用 std::latch 实现一次性屏障。或者自己用 std::atomic<int> 加条件变量手搓一个。但说实话,能升C++20就升吧,标准库的东西比自己写的稳。
12.5 模式对比与选型
| 模式 | 核心机制 | 适用场景 | C++实现关键 |
|---|---|---|---|
| 生产者-消费者 | 队列 + 条件变量 | 任务调度、日志、数据流处理 | std::queue + std::condition_variable |
| 读者-写者 | 读写锁 | 配置中心、缓存、读多写少 | std::shared_mutex |
| 线程安全单例 | 静态局部变量 / call_once | 全局资源、日志器、线程池 | Meyer's Singleton / std::call_once |
| 屏障 | 计数 + 等待 | 并行计算、分阶段任务 | std::barrier (C++20) / std::latch |
选型其实不难。数据流用生产者-消费者。读多写少用读者-写者。全局唯一用单例。并行同步用屏障。记住这四个口诀,大部分场景都能覆盖。
最后说一句:模式是工具,不是目的。我见过有人为了用模式而用模式,把简单问题搞复杂了。先想清楚你要解决什么问题,再选模式。如果一把锁就能搞定,别硬上读写锁。如果全局变量就能满足,别硬造单例。