18、线程安全的设计模式:单例模式的双重检查锁定、线程安全的观察者模式、Actor模型简介
各位同学,今天我们来聊聊并发编程里绕不开的几个设计模式。说实话,设计模式这东西,单线程下写起来挺顺手的,一旦上了多线程,很多经典套路就失效了。我早年在一个高并发网关项目里,就吃过单例模式的大亏——线上服务莫名其妙地创建了两个实例,排查了一整天才发现是双重检查锁定写错了。嗯,今天就把这些坑给大家讲透。
18.1 单例模式与双重检查锁定(DCL)
单例模式,说白了就是保证一个类只有一个实例。但在多线程环境下,简单的懒加载写法会出问题。你想想看,两个线程同时调用 getInstance(),可能各自创建了一个对象,这就违背了单例的初衷。
最直接的解决方案是加锁。但每次获取实例都加锁,性能太差了。于是就有了双重检查锁定(Double-Checked Locking,DCL)——先检查实例是否已创建,没创建才加锁,加锁后再检查一次,确保不会重复创建。
核心要点:双重检查锁定必须配合 std::atomic 或 volatile(C++中推荐 atomic)使用,否则可能因为指令重排序导致拿到未完全初始化的对象。
来看一个标准的 C++11 实现:
#include <atomic>
#include <mutex>
class Singleton {
private:
static std::atomic<Singleton*> m_instance;
static std::mutex m_mutex;
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
public:
static Singleton* getInstance() {
Singleton* tmp = m_instance.load(std::memory_order_acquire);
if (tmp == nullptr) {
std::lock_guard<std::mutex> lock(m_mutex);
tmp = m_instance.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton();
m_instance.store(tmp, std::memory_order_release);
}
}
return tmp;
}
};
std::atomic<Singleton*> Singleton::m_instance{nullptr};
std::mutex Singleton::m_mutex;
这里用了 memory_order_acquire 和 memory_order_release,确保读操作不会重排到写操作之前。我建议你直接用 std::memory_order_seq_cst 也行,性能损失不大,但代码更安全。
注意:在 C++11 之前,DCL 在 C++ 里是没法正确实现的,因为缺乏内存模型保证。如果你在维护老项目,建议直接用 std::call_once 或局部静态变量(C++11 保证线程安全的初始化)。
其实我个人更推荐用局部静态变量的方式,代码简洁得多:
class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance;
return instance;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
Singleton() = default;
};
这种方式在 C++11 之后是线程安全的,编译器会帮你处理好同步问题。不过要注意,如果你需要延迟销毁或控制生命周期,还是得用 DCL 方案。
18.2 线程安全的观察者模式
观察者模式,也叫发布-订阅模式。一个主题(Subject)维护一组观察者(Observer),状态变化时通知所有观察者。单线程下很好写,但多线程下就麻烦了——通知过程中,可能有新观察者注册,也可能有观察者被移除,甚至观察者自己就在另一个线程里。
我曾在做实时行情推送系统时遇到过这个问题。行情数据每秒上千次更新,通知列表还在动态变化。如果用一把大锁锁住整个通知过程,性能直接崩了。
常见的解决方案有两种:
- 方案一:使用读写锁。注册/注销观察者时加写锁,通知时加读锁。适合读多写少的场景。
- 方案二:使用不可变列表 + 原子指针。每次修改观察者列表时,复制一份新列表,然后用原子指针替换。通知时直接读取当前指针指向的列表,完全无锁。
来看一个基于读写锁的实现:
#include <shared_mutex>
#include <vector>
#include <functional>
class ThreadSafeSubject {
private:
mutable std::shared_mutex m_mutex;
std::vector<std::function<void(int)>> m_observers;
public:
void registerObserver(std::function<void(int)> obs) {
std::unique_lock<std::shared_mutex> lock(m_mutex);
m_observers.push_back(std::move(obs));
}
void notify(int data) {
std::shared_lock<std::shared_mutex> lock(m_mutex);
for (const auto& obs : m_observers) {
obs(data); // 注意:这里如果观察者耗时较长,会阻塞通知线程
}
}
};
小技巧:如果观察者的回调可能耗时较长,建议在通知线程里只做事件分发,把实际处理丢到线程池里。否则一个慢观察者会拖慢所有观察者的通知速度。
方案二的无锁版本稍微复杂一些,但性能更好。核心思路是用 std::atomic<std::shared_ptr<const std::vector<...>>> 来存储观察者列表。每次修改时创建新列表,原子替换。通知时直接读取原子指针,拿到列表后遍历。这样通知操作完全不需要加锁。
注意:无锁方案要处理好 ABA 问题和内存回收。C++ 的 std::shared_ptr 原子操作能保证安全,但要注意循环引用导致的内存泄漏。
18.3 Actor 模型简介
前面讲的两种模式,本质上还是共享内存 + 锁的思路。Actor 模型换了个玩法——一切皆 Actor,Actor 之间通过消息传递通信,不共享任何状态。每个 Actor 有自己的邮箱(Mailbox),顺序处理收到的消息。
为什么说 Actor 模型适合并发?因为每个 Actor 内部是单线程的,你不需要考虑锁的问题。Actor 之间只通过消息交互,天然避免了数据竞争。Erlang 语言就是靠 Actor 模型实现了 9 个 9 的可用性。
C++ 里没有内置的 Actor 框架,但我们可以用 std::thread + 消息队列自己实现一个简单的:
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <functional>
class Actor {
private:
std::thread m_thread;
std::queue<std::function<void()>> m_mailbox;
std::mutex m_mutex;
std::condition_variable m_cv;
bool m_stop{false};
void run() {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(m_mutex);
m_cv.wait(lock, [this]{
return !m_mailbox.empty() || m_stop;
});
if (m_stop && m_mailbox.empty()) break;
task = std::move(m_mailbox.front());
m_mailbox.pop();
}
task(); // 顺序处理消息
}
}
public:
Actor() : m_thread(&Actor::run, this) {}
~Actor() {
{
std::lock_guard<std::mutex> lock(m_mutex);
m_stop = true;
}
m_cv.notify_one();
m_thread.join();
}
void send(std::function<void()> msg) {
{
std::lock_guard<std::mutex> lock(m_mutex);
m_mailbox.push(std::move(msg));
}
m_cv.notify_one();
}
};
这个实现虽然简陋,但体现了 Actor 的核心思想:每个 Actor 跑在独立线程里,消息排队处理,没有共享数据。实际项目中可以用 boost::asio 或 libcppa 等成熟库。
我的经验:Actor 模型特别适合状态管理复杂的场景。比如游戏服务器里的玩家对象,每个玩家就是一个 Actor,收到移动、攻击等消息后更新自己的状态。这样你完全不用担心多个玩家同时修改同一个数据的问题。
不过 Actor 模型也有缺点:消息传递有开销,不适合高频小数据量的场景;而且容易形成消息循环导致死锁。我建议你在以下场景考虑 Actor:
- 系统中有大量独立状态实体(如玩家、设备、会话)
- 实体间交互以事件驱动为主
- 你希望避免显式加锁,降低心智负担
最后,用一张图总结这三种模式的核心思想:
好了,这三种模式在实际项目中都很常用。我个人建议:能用局部静态变量就别写 DCL;观察者模式优先考虑无锁方案;如果状态管理让你头疼,试试 Actor 模型。记住,没有银弹,选型要看具体场景。
最后提醒一句:设计模式是工具,不是目的。别为了用模式而用模式,简单直接的方案往往更可靠。
公众号:蓝海资料掘金营,微信deep3321