18、线程安全的设计模式:单例模式的双重检查锁定、线程安全的观察者模式、Actor模型简介

各位同学,今天我们来聊聊并发编程里绕不开的几个设计模式。说实话,设计模式这东西,单线程下写起来挺顺手的,一旦上了多线程,很多经典套路就失效了。我早年在一个高并发网关项目里,就吃过单例模式的大亏——线上服务莫名其妙地创建了两个实例,排查了一整天才发现是双重检查锁定写错了。嗯,今天就把这些坑给大家讲透。

18.1 单例模式与双重检查锁定(DCL)

单例模式,说白了就是保证一个类只有一个实例。但在多线程环境下,简单的懒加载写法会出问题。你想想看,两个线程同时调用 getInstance(),可能各自创建了一个对象,这就违背了单例的初衷。

最直接的解决方案是加锁。但每次获取实例都加锁,性能太差了。于是就有了双重检查锁定(Double-Checked Locking,DCL)——先检查实例是否已创建,没创建才加锁,加锁后再检查一次,确保不会重复创建。

核心要点:双重检查锁定必须配合 std::atomicvolatile(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_acquirememory_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::asiolibcppa 等成熟库。

我的经验:Actor 模型特别适合状态管理复杂的场景。比如游戏服务器里的玩家对象,每个玩家就是一个 Actor,收到移动、攻击等消息后更新自己的状态。这样你完全不用担心多个玩家同时修改同一个数据的问题。

不过 Actor 模型也有缺点:消息传递有开销,不适合高频小数据量的场景;而且容易形成消息循环导致死锁。我建议你在以下场景考虑 Actor:

  • 系统中有大量独立状态实体(如玩家、设备、会话)
  • 实体间交互以事件驱动为主
  • 你希望避免显式加锁,降低心智负担

最后,用一张图总结这三种模式的核心思想:

线程安全设计模式对比 单例模式 (DCL) • 全局唯一实例 • 双重检查 + 原子操作 • 避免指令重排序 • 推荐局部静态变量 适用:配置管理、 日志实例、线程池 观察者模式 • 一对多通知机制 • 读写锁保护列表 • 无锁方案:原子指针 • 注意回调耗时 适用:事件驱动、 UI更新、状态推送 Actor 模型 • 一切皆 Actor • 消息传递不共享 • 顺序处理消息 • 天然避免数据竞争 适用:游戏服务器、 分布式系统、状态管理 核心思路:减少共享 → 减少锁竞争 → 提高并发性能

好了,这三种模式在实际项目中都很常用。我个人建议:能用局部静态变量就别写 DCL;观察者模式优先考虑无锁方案;如果状态管理让你头疼,试试 Actor 模型。记住,没有银弹,选型要看具体场景。

最后提醒一句:设计模式是工具,不是目的。别为了用模式而用模式,简单直接的方案往往更可靠。


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