12、并发设计模式:生产者-消费者模式、读者-写者模式、线程安全单例模式、屏障(barrier)模式

并发编程里,有些问题反复出现。比如数据怎么安全地传递?多个线程读一个资源,写的时候怎么互斥?全局对象怎么只初始化一次?线程之间怎么同步到同一个步调?

这些问题,前辈们早就总结出了套路。这就是并发设计模式。今天我把四个最常用的模式掰开揉碎讲清楚。每个模式我都会结合项目里的真实场景,告诉你什么时候用,怎么用,坑在哪里。

核心观点:设计模式不是教条,是解决问题的工具箱。理解模式背后的“为什么”,比记住代码模板重要一百倍。

并发设计模式知识体系 生产者-消费者 读者-写者 线程安全单例 屏障(Barrier) 队列 + 条件变量 读写锁 双重检查锁定 std::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

选型其实不难。数据流用生产者-消费者。读多写少用读者-写者。全局唯一用单例。并行同步用屏障。记住这四个口诀,大部分场景都能覆盖。

最后说一句:模式是工具,不是目的。我见过有人为了用模式而用模式,把简单问题搞复杂了。先想清楚你要解决什么问题,再选模式。如果一把锁就能搞定,别硬上读写锁。如果全局变量就能满足,别硬造单例。

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