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 利用率和响应时间,再调整。线程池的好处就是可以动态调整线程数,别浪费这个灵活性。
四、知识体系总览
下面这张图把这三个模式的关系和核心要点串起来了。你想想看,单例负责全局唯一资源,生产者-消费者负责解耦生产与消费,线程池负责管理并发执行——三者配合,基本能覆盖大部分多线程场景。
五、总结
这三个模式是并发编程的基石。单例管资源,生产者-消费者管数据流,线程池管执行效率。把它们吃透了,大部分多线程场景你都能应付。
嗯,最后提醒一句:别为了用模式而用模式。我见过有人把简单的计数器也套上单例,结果引入了一堆不必要的复杂度。模式是工具,不是目的。用之前先问问自己:这里真的需要吗?