Skip to content

C++ 线程安全单例:DCLP、Meyers Singleton 与 inline static ​

单例模式常用于管理编译器、执行器、配置中心这类全局唯一的重量级资源。多线程环境下,单例的核心风险集中在初始化阶段:两个线程同时首次调用 getInstance() 时,如何保证实例只被构造一次、且其他线程不会拿到"构造了一半"的对象。本文按时间线整理 C++ 线程安全单例的四种实现,说明各自的问题与适用场景,给出选型结论。

多线程下的两个核心问题 ​

  1. 竞态条件:两个线程同时通过 if (instance == nullptr) 检查,构造函数被调用两次。当初始化涉及文件句柄、硬件设备等资源时是灾难性的。
  2. 未同步的读写:一个线程在写(初始化)实例成员,另一个线程在读。没有正确的内存屏障时,读线程可能看到部分构造的对象——内存已分配但内部指针尚未初始化,属于未定义行为。

方案一:双检锁(DCLP)——传统方案,不推荐 ​

cpp
Singleton* Singleton::getInstance() {
    if (pInstance == nullptr) {            // 第一次检查:避免每次调用都加锁
        std::lock_guard<std::mutex> lock(mutex);
        if (pInstance == nullptr) {        // 第二次检查:锁内只创建一次
            pInstance = new Singleton();   // 问题所在
        }
    }
    return pInstance;
}

问题出在 pInstance = new Singleton(); 不是原子操作,它分三步:

  1. 分配内存;
  2. 在内存上构造对象;
  3. 将地址赋值给 pInstance。

编译器优化和 CPU 指令重排可能使步骤 2、3 互换。如果线程 A 执行完步骤 1、3 后(pInstance 已非空)被挂起,线程 B 调用 getInstance() 时看到非空指针,直接返回一个未构造完成的对象。

修复需要手动插入内存屏障:把 pInstance 声明为 std::atomic<Singleton*>,写侧用 memory_order_release、读侧用 memory_order_acquire。写法正确但代码复杂、易错、难以审查,现代 C++ 中没有理由再手写。

方案二:C++11 静态局部变量(Meyers Singleton)——首选 ​

cpp
Singleton& Singleton::getInstance() {
    static Singleton instance;  // 线程安全的初始化("magic statics")
    return instance;
}

C++11 标准规定:如果控制流并发地进入某个局部静态变量的初始化,其他线程应当阻塞等待初始化完成([stmt.dcl]/4)。编译器据此在底层插入互斥锁或等价的原子同步,保证对象只构造一次。

这个写法的优点:

  • 极简:代码量最少,意图清晰;
  • 懒加载:第一次调用才创建;多个单例有依赖关系时可以自然处理,避免静态初始化顺序问题;
  • 异常安全:构造抛出异常视为未完成初始化,下次调用会重新尝试(guard 变量只在成功完成时置位)。

一个边界情况要注意:递归重入初始化是未定义行为——如果构造函数内部又(直接或间接)调用了 getInstance(),控制流在初始化未完成时重入声明,实践中通常表现为死锁或崩溃。单例构造函数里不要回调自身。

配合删除拷贝构造/赋值、私有化构造函数,就是完整的实现:

cpp
class Compiler {
public:
    Compiler(const Compiler&) = delete;
    Compiler& operator=(const Compiler&) = delete;

    static Compiler& getInstance() {
        static Compiler instance;
        return instance;
    }

private:
    Compiler() = default;
};

方案三:std::call_once + std::once_flag ​

cpp
Singleton& Singleton::getInstance() {
    std::call_once(initFlag, []() { pInstance.reset(new Singleton); });
    return *pInstance;
}

同样是标准保证线程安全,明确表达"只执行一次"的意图;相比 Meyers 写法需要定义静态成员并在类外初始化,代码更冗长。适合初始化逻辑复杂、不适合塞进一个构造表达式的场景。

方案四:C++17 inline 静态成员变量 ​

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

private:
    Singleton() = default;
    inline static Singleton instance{};  // 类内直接定义并初始化
};
  • 线程安全:在进入 main 之前完成初始化,由运行时保证;
  • 非懒加载:程序启动即创建,可能增加启动开销;
  • inline 变量(C++17)解决了跨编译单元重复定义问题,不再需要类外定义。

适合启动时必须就绪、不能容忍首次调用延迟的资源。

选型总结 ​

时代 方案 特点 推荐度
C++11 之前 双检锁 复杂、易错、需手动处理内存序 不推荐
C++11 起 静态局部变量 简洁、安全、懒加载 首选
C++11 起 std::call_once 明确、安全、稍冗长 备选
C++17 起 inline 静态成员 简单、安全、非懒加载 启动期必须初始化的资源

工程原则:单例初始化通常只发生一次,"更快的单例"没有意义,正确性和可读性优先。只有实测证明初始化是系统瓶颈时才考虑其他方案,而这种情况极罕见。

另一个常被忽略的维度是析构:跨编译单元的静态析构顺序同样是未指定的,Meyers Singleton 只保证初始化安全、不保证销毁时序,退出期 use-after-free 的成因与四种应对方式,是一个独立的话题。

最近更新

基于 VitePress 构建