并发设计模式:生产者-消费者、读者-写者、工作窃取
并发编程里,有些问题反复出现。就像盖房子,总有几套标准图纸。今天聊的三个模式——生产者-消费者、读者-写者、工作窃取——就是并发世界里的经典图纸。我这些年做后端服务、游戏引擎,几乎每个项目都跟它们打过照面。
生产者-消费者模式:最经典的解耦方案
这个模式,说白了就是两类线程:一类负责生产数据,另一类负责消费数据。中间用个缓冲区隔开。生产者和消费者不直接打交道,各干各的。
我在做日志系统时遇到过这种情况。业务线程疯狂写日志,磁盘IO却跟不上。如果让业务线程直接写文件,整个系统都会被拖慢。用生产者-消费者模式,业务线程只管往内存队列里扔日志,后台一个专用线程慢慢往磁盘刷。完美解耦。
- 缓冲区要线程安全——通常用互斥锁或无锁队列
- 缓冲区满时生产者要等待——条件变量派上用场
- 缓冲区空时消费者要等待——同样用条件变量
来看一个简化版实现:
#include <queue>
#include <mutex>
#include <condition_variable>
template<typename T>
class ThreadSafeQueue {
std::queue<T> queue_;
mutable std::mutex mutex_;
std::condition_variable cond_;
size_t max_size_;
public:
explicit ThreadSafeQueue(size_t max_size = 1024)
: max_size_(max_size) {}
void push(T value) {
std::unique_lock<std::mutex> lock(mutex_);
cond_.wait(lock, [this]{
return queue_.size() < max_size_;
});
queue_.push(std::move(value));
cond_.notify_one(); // 通知消费者
}
T pop() {
std::unique_lock<std::mutex> lock(mutex_);
cond_.wait(lock, [this]{
return !queue_.empty();
});
T value = std::move(queue_.front());
queue_.pop();
cond_.notify_one(); // 通知生产者
return value;
}
};
读者-写者模式:读多写少的优化利器
你想想看,有些数据读操作远多于写操作。比如配置表、缓存数据。如果用普通互斥锁,读线程之间也要互相等待,这太浪费了。读者-写者模式允许同时多个线程读,但写的时候独占。
C++17提供了std::shared_mutex,专门干这个事。读线程用shared_lock,写线程用unique_lock。
#include <shared_mutex>
class ConfigManager {
std::map<std::string, std::string> config_;
mutable std::shared_mutex rw_lock_;
public:
std::string get(const std::string& key) const {
std::shared_lock lock(rw_lock_); // 共享锁,可多个读
auto it = config_.find(key);
return it != config_.end() ? it->second : "";
}
void set(const std::string& key, const std::string& value) {
std::unique_lock lock(rw_lock_); // 独占锁,写时不能读
config_[key] = value;
}
};
什么时候用?我个人的判断标准是:读操作占比超过80%,且写操作不频繁。比如游戏里的技能配置表,加载后几乎不变,但每帧都有几百个线程在读。这种场景,读者-写者模式能带来明显的性能提升。
工作窃取模式:负载均衡的艺术
这个模式稍微复杂点。每个线程有自己的任务队列,干完活后,如果发现别的线程队列还有任务,就偷偷拿过来做。这就是「窃取」的由来。
为什么要这么设计?因为任务分配很难做到绝对均匀。有的线程忙死,有的线程闲死。工作窃取让空闲线程主动去帮忙,自动实现负载均衡。
C++标准库没有直接提供工作窃取队列,但我们可以用std::deque模拟一个简化版:
#include <deque>
#include <mutex>
#include <atomic>
class WorkStealingQueue {
std::deque<std::function<void()>> tasks_;
mutable std::mutex mutex_;
std::atomic<size_t> size_{0};
public:
void push(std::function<void()> task) {
std::lock_guard<std::mutex> lock(mutex_);
tasks_.push_back(std::move(task));
size_.store(tasks_.size(), std::memory_order_release);
}
// 本线程从尾部取任务(LIFO,缓存友好)
bool try_pop(std::function<void()>& task) {
std::lock_guard<std::mutex> lock(mutex_);
if (tasks_.empty()) return false;
task = std::move(tasks_.back());
tasks_.pop_back();
size_.store(tasks_.size(), std::memory_order_release);
return true;
}
// 其他线程从头部窃取(FIFO,减少冲突)
bool try_steal(std::function<void()>& task) {
std::lock_guard<std::mutex> lock(mutex_);
if (tasks_.empty()) return false;
task = std::move(tasks_.front());
tasks_.pop_front();
size_.store(tasks_.size(), std::memory_order_release);
return true;
}
size_t size() const {
return size_.load(std::memory_order_acquire);
}
};
- 本线程从尾部取任务——利用缓存局部性
- 窃取线程从头部取任务——减少锁冲突
- 每个线程维护自己的队列——避免全局锁
我在做并行计算框架时用过这个模式。当时要处理几十万个独立计算任务,每个任务耗时差异很大。如果用全局队列,锁竞争能把性能吃掉一半。改用工作窃取后,每个线程的本地队列几乎无锁操作,只有窃取时才需要加锁。最终性能提升了3倍多。
三种模式对比
| 模式 | 适用场景 | 核心机制 | 典型问题 |
|---|---|---|---|
| 生产者-消费者 | 数据生产与消费速度不匹配 | 线程安全缓冲区 + 条件变量 | 缓冲区大小设置、死锁 |
| 读者-写者 | 读多写少的数据共享 | 共享锁 + 独占锁 | 写线程饥饿 |
| 工作窃取 | 任务负载不均衡 | 本地队列 + 窃取机制 | 窃取策略、缓存性能 |
这三种模式,说白了就是解决并发编程中三个最头疼的问题:数据流解耦、共享数据保护、任务负载均衡。我建议你在实际项目中,先画出数据流图,看看瓶颈在哪,再选择合适的模式。不要为了用模式而用模式,那反而会把简单问题复杂化。