并发设计模式:生产者-消费者、读者-写者、工作窃取

并发编程里,有些问题反复出现。就像盖房子,总有几套标准图纸。今天聊的三个模式——生产者-消费者、读者-写者、工作窃取——就是并发世界里的经典图纸。我这些年做后端服务、游戏引擎,几乎每个项目都跟它们打过照面。

生产者-消费者模式:最经典的解耦方案

这个模式,说白了就是两类线程:一类负责生产数据,另一类负责消费数据。中间用个缓冲区隔开。生产者和消费者不直接打交道,各干各的。

我在做日志系统时遇到过这种情况。业务线程疯狂写日志,磁盘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;
    }
};
我的经验:条件变量的wait一定要用带谓词的版本。我曾经图省事,直接写cond_.wait(lock),结果线程被虚假唤醒后,队列还是空的,直接崩了。带谓词的版本会自动检查条件,安全得多。

读者-写者模式:读多写少的优化利器

你想想看,有些数据读操作远多于写操作。比如配置表、缓存数据。如果用普通互斥锁,读线程之间也要互相等待,这太浪费了。读者-写者模式允许同时多个线程读,但写的时候独占。

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;
    }
};
注意:读者-写者模式不是万能的。如果写操作很频繁,写线程会被读线程活活饿死。我见过一个系统,读线程太多,写线程等了整整3秒才拿到锁。这种情况下,普通互斥锁反而更好。

什么时候用?我个人的判断标准是:读操作占比超过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倍多。

避坑指南:我曾经把窃取策略搞反了——本线程从头部取,窃取从尾部取。结果本线程频繁触发缓存缺失,性能反而下降了。记住:本线程用LIFO(后进先出),窃取用FIFO(先进先出),这是经过大量实践验证的最佳组合。

三种模式对比

模式 适用场景 核心机制 典型问题
生产者-消费者 数据生产与消费速度不匹配 线程安全缓冲区 + 条件变量 缓冲区大小设置、死锁
读者-写者 读多写少的数据共享 共享锁 + 独占锁 写线程饥饿
工作窃取 任务负载不均衡 本地队列 + 窃取机制 窃取策略、缓存性能
三种并发设计模式知识结构图 生产者-消费者 生产者线程 → 缓冲区 → 消费者线程 • 解耦生产与消费 • 条件变量控制等待 • 缓冲区大小需权衡 • 典型:日志系统、任务队列 读者-写者 多个读者 + 一个写者 • 共享锁允许多读 • 独占锁保证写安全 • 读多写少场景适用 • 注意写线程饥饿 工作窃取 每个线程有自己的队列 • 本线程从尾部取任务 • 空闲线程窃取头部 • 自动负载均衡 • 典型:并行计算框架 选择建议 • 数据流解耦 → 生产者-消费者 • 数据共享读多写少 → 读者-写者 • 任务负载不均衡 → 工作窃取 实际项目中,这三种模式经常组合使用。比如: 生产者-消费者 + 工作窃取 = 高性能任务调度系统

这三种模式,说白了就是解决并发编程中三个最头疼的问题:数据流解耦、共享数据保护、任务负载均衡。我建议你在实际项目中,先画出数据流图,看看瓶颈在哪,再选择合适的模式。不要为了用模式而用模式,那反而会把简单问题复杂化。

公众号:蓝海资料掘金营,微信deep3321