线程安全单例模式:从混乱到优雅

单例模式,说白了就是一个类只能有一个实例。听起来简单,但在多线程环境下,这事就变得微妙了。我最早接触这个模式是在一个游戏服务器项目里,当时负责日志模块——整个系统只需要一个日志实例,谁都能写,但不能写乱。嗯,那时候我还没意识到,后面会有这么多坑等着我。

为什么单例需要线程安全?

你想想看,两个线程同时调用 getInstance(),如果实例还没创建,它们可能各自 new 出一个对象。这就违反了单例的初衷。更糟糕的是,如果 A 线程刚分配了内存但还没构造完,B 线程就拿到了这个半成品指针——直接崩溃。

所以,线程安全的单例,核心就两个问题:

  • 唯一性:只有一个实例被创建
  • 可见性:所有线程看到的实例状态是一致的

经典方案一:双重检查锁定(DCLP)

这是很多 C++ 老手的第一反应。先检查一次,如果实例为空,再加锁,再检查一次。听起来很合理,对吧?

// 双重检查锁定(有问题的版本)
class Singleton {
private:
    static Singleton* instance;
    static std::mutex mtx;
public:
    static Singleton* getInstance() {
        if (instance == nullptr) {          // 第一次检查
            std::lock_guard<std::mutex> lock(mtx);
            if (instance == nullptr) {      // 第二次检查
                instance = new Singleton();
            }
        }
        return instance;
    }
};
⚠️ 注意: 这段代码在 C++03 下是有问题的!instance = new Singleton() 不是原子操作,它包含三步:分配内存、调用构造函数、赋值指针。编译器或 CPU 可能重排为:分配内存、赋值指针、调用构造函数。另一个线程看到指针非空,直接返回,但对象还没构造完。

我在一个高并发服务里就踩过这个坑。线上偶发崩溃,查了两天才定位到是 DCLP 的问题。后来加了内存屏障才解决。

修复版:加上内存屏障

// 使用 std::atomic 和内存序
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 == nullptr) {
            std::lock_guard<std::mutex> lock(mtx);
            p = instance.load(std::memory_order_relaxed);
            if (p == nullptr) {
                p = new Singleton();
                instance.store(p, std::memory_order_release);
            }
        }
        return p;
    }
};

这里的关键是 memory_order_acquirememory_order_release。简单说:release 保证之前的写操作不会被重排到后面,acquire 保证之后的读操作不会被重排到前面。这样,其他线程看到指针非空时,一定能看到完整的构造结果。

💡 我的建议: 除非你非常熟悉内存模型,否则别手写 DCLP。C++11 提供了更好的选择。

方案二:Meyer's Singleton(局部静态变量)

Scott Meyer 在《Effective C++》里提过一个优雅的方案:利用函数内的静态变量。

// Meyer's Singleton
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++11 之前也是不安全的——多个线程同时首次进入函数,可能构造多次。但 C++11 标准规定:局部静态变量的初始化是线程安全的。编译器会自动加锁,保证只初始化一次。

我个人非常喜欢这个方案。代码简洁,没有显式加锁,没有内存屏障,读起来就像普通代码。我在好几个项目里都用它,从来没出过问题。

✅ 优点:
  • 代码极简,只有几行
  • 线程安全由标准保证
  • 延迟初始化,按需创建
  • 没有动态内存分配,不会内存泄漏
⚠️ 注意: 如果你需要精确控制单例的销毁时机(比如在程序退出前手动释放资源),Meyer's Singleton 就不太合适了。静态变量的析构顺序是未定义的。

方案三:C++11 的线程安全初始化

C++11 引入了 std::call_oncestd::once_flag,专门解决「只执行一次」的问题。

// 使用 std::call_once
class Singleton {
private:
    static Singleton* instance;
    static std::once_flag flag;
public:
    static Singleton* getInstance() {
        std::call_once(flag, []() {
            instance = new Singleton();
        });
        return instance;
    }
};

std::call_once 保证传入的函数只被执行一次,即使多个线程同时调用。它的内部实现通常比手写 DCLP 更高效,而且不容易出错。

我记得有一次代码审查,看到新人手写了一个复杂的 DCLP,我直接建议他换成 call_once。他一开始还担心性能,后来 benchmark 发现 call_once 在无竞争时几乎零开销,有竞争时也比手写版本稳定。

三种方案对比

方案 代码复杂度 线程安全 延迟初始化 控制销毁 推荐场景
DCLP + 内存屏障 需小心实现 对性能极致要求,且熟悉内存模型
Meyer's Singleton 极低 C++11 保证 大多数场景,首选
std::call_once 标准保证 需要手动管理生命周期时

核心知识体系

下面这张图总结了单例模式线程安全的核心脉络:

线程安全单例模式核心脉络 问题:多线程下唯一性 + 可见性 方案一:DCLP + 内存屏障 手动控制,性能极致 方案二:Meyer's Singleton 局部静态变量,最简洁 方案三:std::call_once 标准保证,灵活可控 关键:acquire/release 语义 关键:C++11 标准保证 关键:once_flag 内部同步 结论:优先使用 Meyer's Singleton

避坑指南

我曾经在代码里看到过这样的「骚操作」:

// 错误示例:不要这样做!
static Singleton* instance = nullptr;
if (!instance) {
    instance = new Singleton();  // 没有锁,线程不安全
}

这种代码在单线程下没问题,一上多线程就崩。还有的人用 volatile 来修饰指针,以为能解决可见性问题——其实 volatile 在 C++ 里跟线程安全半毛钱关系都没有,它只是告诉编译器不要优化这个变量的读写。

💡 我的经验: 如果你不确定用哪个方案,就用 Meyer's Singleton。它简单、安全、性能好。只有当你需要精确控制销毁时机,或者单例的构造需要传入参数时,才考虑 std::call_once。至于手写 DCLP——除非你在写标准库,否则真的没必要。

嗯,单例模式就聊到这里。记住一句话:能用简单的,就别用复杂的。线程安全不是靠堆代码堆出来的,而是靠对并发模型的理解。

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