29. 设计模式与多线程编程:线程安全的单例,生产者-消费者模式,线程池设计

多线程编程,说白了就是让程序同时干好几件事。但事情一多,就容易打架——争资源、抢数据、死锁……这时候设计模式就派上用场了。我这些年做后端服务,几乎每个高并发项目都离不开这三个模式:线程安全的单例、生产者-消费者、线程池。今天咱们就一个一个掰开揉碎了聊。

一、线程安全的单例模式

单例模式本身不复杂,就是保证一个类只有一个实例。但放到多线程环境里,问题就来了——两个线程同时调用 getInstance(),会不会创建出两个对象?

1. 懒汉式 vs 饿汉式

饿汉式:类加载时就创建实例。简单粗暴,线程安全,但如果你压根不用这个类,它就白占内存了。

class Singleton {
private:
    static Singleton instance;
public:
    static Singleton& getInstance() { return instance; }
};
Singleton Singleton::instance;

懒汉式:第一次调用时才创建。节省资源,但需要处理线程安全。

2. 双检锁(DCLP)——我踩过的坑

双检锁是懒汉式的经典方案。先检查实例是否为空,如果为空才加锁,加锁后再检查一次。听起来完美,对吧?

class Singleton {
private:
    static std::atomic<Singleton*> instance;
    static std::mutex mtx;
public:
    static Singleton* getInstance() {
        Singleton* p = instance.load(std::memory_order_acquire);
        if (!p) {
            std::lock_guard<std::mutex> lock(mtx);
            p = instance.load(std::memory_order_relaxed);
            if (!p) {
                p = new Singleton();
                instance.store(p, std::memory_order_release);
            }
        }
        return p;
    }
};

⚠️ 我曾经在这里栽过跟头

早期我写双检锁时没用 std::atomic,结果在 ARM 架构上频繁崩溃。原因是 CPU 乱序执行导致 instance 被赋值了,但 Singleton 的构造函数还没跑完。另一个线程拿到的就是个半成品对象。所以记住:双检锁必须配合内存屏障或原子操作

3. C++11 的终极方案——Meyer's Singleton

C++11 标准规定:局部静态变量的初始化是线程安全的。所以最简单的写法反而最可靠:

class Singleton {
public:
    static Singleton& getInstance() {
        static Singleton instance;
        return instance;
    }
private:
    Singleton() = default;
    ~Singleton() = default;
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
};

💡 我个人习惯用这个方案

代码量最少,性能最好(只有第一次调用有锁开销),而且编译器帮你保证了线程安全。除非你用的是 C++03 之前的古董编译器,否则别折腾双检锁了。

二、生产者-消费者模式

这个模式太常见了。一个线程生产数据,另一个线程消费数据。中间需要一个缓冲区——也就是队列。我做过一个日志系统,主线程是生产者,后台线程是消费者,把日志异步写入磁盘。

1. 基础实现

template<typename T>
class ThreadSafeQueue {
private:
    std::queue<T> queue_;
    mutable std::mutex mtx_;
    std::condition_variable cv_;
public:
    void push(T value) {
        std::lock_guard<std::mutex> lock(mtx_);
        queue_.push(std::move(value));
        cv_.notify_one();
    }

    T pop() {
        std::unique_lock<std::mutex> lock(mtx_);
        cv_.wait(lock, [this] { return !queue_.empty(); });
        T value = std::move(queue_.front());
        queue_.pop();
        return value;
    }
};

2. 避坑指南

  • 虚假唤醒wait() 可能在没有被 notify 的情况下返回。所以一定要用带谓词的版本,或者用 while 循环检查条件。
  • 死锁:如果消费者在持有锁的情况下又去获取另一个锁,就可能死锁。我建议尽量保持锁的粒度小,别在锁里调用外部回调函数。
  • 性能:如果生产速度远大于消费速度,队列会无限增长。可以设置最大容量,满了就阻塞生产者。

我曾经遇到过一个线上事故

生产者线程因为网络延迟偶尔会批量生产大量数据,消费者来不及处理,队列撑爆了内存。后来我加了容量限制和背压机制——队列满时生产者直接丢弃旧数据或阻塞等待。这才稳住。

三、线程池设计

线程池说白了就是提前创建一堆线程,有任务就扔进去执行。避免了频繁创建/销毁线程的开销。我参与过一个图片处理服务,每张图片的处理耗时约 50ms,用线程池把吞吐量提升了 5 倍。

1. 核心组件

组件 职责
任务队列 存放待执行的任务(通常是 std::function)
工作线程 从队列取任务并执行
线程管理器 控制线程的创建、销毁、动态调整
同步机制 mutex + condition_variable 保证线程安全

2. 简易实现

class ThreadPool {
private:
    std::vector<std::thread> workers_;
    std::queue<std::function<void()>> tasks_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool stop_ = false;
public:
    explicit ThreadPool(size_t threads) {
        for (size_t i = 0; i < threads; ++i) {
            workers_.emplace_back([this] {
                while (true) {
                    std::function<void()> task;
                    {
                        std::unique_lock<std::mutex> lock(mtx_);
                        cv_.wait(lock, [this] {
                            return stop_ || !tasks_.empty();
                        });
                        if (stop_ && tasks_.empty()) return;
                        task = std::move(tasks_.front());
                        tasks_.pop();
                    }
                    task();
                }
            });
        }
    }

    template<typename F>
    void enqueue(F f) {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            tasks_.emplace(std::forward<F>(f));
        }
        cv_.notify_one();
    }

    ~ThreadPool() {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            stop_ = true;
        }
        cv_.notify_all();
        for (auto& worker : workers_) {
            worker.join();
        }
    }
};

3. 线程数怎么定?

这个问题面试常问。我的经验是:

  • CPU 密集型任务:线程数 = CPU 核心数 + 1(多一个防止缺页中断等阻塞)
  • IO 密集型任务:线程数可以多一些,比如 2 * CPU 核心数,甚至更高
  • 混合型:用公式 线程数 = CPU核心数 * (1 + 等待时间/计算时间)

💡 我个人建议

别迷信公式。最好的办法是压测。我一般先设一个初始值,然后用工具模拟真实负载,观察 CPU 利用率和响应时间,再调整。线程池的好处就是可以动态调整线程数,别浪费这个灵活性。

四、知识体系总览

下面这张图把这三个模式的关系和核心要点串起来了。你想想看,单例负责全局唯一资源,生产者-消费者负责解耦生产与消费,线程池负责管理并发执行——三者配合,基本能覆盖大部分多线程场景。

多线程设计模式知识体系 线程安全单例 全局唯一实例 生产者-消费者 解耦生产与消费 线程池 复用线程执行任务 实现方案 • 饿汉式(类加载时创建) • 双检锁(DCLP + atomic) • Meyer's Singleton(C++11) 核心组件 • 线程安全队列 • mutex + condition_variable • 容量控制与背压 设计要点 • 任务队列 + 工作线程 • 线程数动态调整 • 优雅关闭(stop flag) 核心原则 最小化锁粒度 · 避免死锁 · 优先使用无锁或原子操作 · 压测验证

五、总结

这三个模式是并发编程的基石。单例管资源,生产者-消费者管数据流,线程池管执行效率。把它们吃透了,大部分多线程场景你都能应付。

嗯,最后提醒一句:别为了用模式而用模式。我见过有人把简单的计数器也套上单例,结果引入了一堆不必要的复杂度。模式是工具,不是目的。用之前先问问自己:这里真的需要吗?