推荐用 std::call_once + static 局部变量替代手写双重检查锁定,因其线程安全、无需手动管理内存序、异常安全且编译器优化为无锁路径;手写 dcl 易因内存序错误或类型失配导致偶发崩溃。

双重检查锁定为什么需要 std::atomic 和内存序
不加内存序的双重检查锁定在多线程下可能返回未初始化的对象,根本原因是编译器重排和 CPU 乱序执行。比如 instance = new Singleton() 实际包含三步:分配内存、调用构造函数、将地址写入静态指针——后两步可能被交换,导致其他线程看到非空但未构造完成的指针。
必须用 std::atomic 包裹指针,并指定 memory_order_acquire(读)和 memory_order_release(写),否则无法阻止重排。C++11 之前靠 volatile 是无效的,它不提供跨线程同步语义。
-
std::atomic<singleton> instance{nullptr}</singleton>是底线,不能用裸指针 - 第一次检查用
load(std::memory_order_acquire),第二次写入用store(ptr, std::memory_order_release) - 构造函数内不能有耗时操作或抛异常,否则
store不会执行,下次调用仍会重复尝试构造
为什么推荐用 std::call_once + static 局部变量
手写双重检查锁定容易漏掉内存序、忘记原子操作、或在异常路径中留下竞态。而 C++11 起,static 局部变量的首次初始化本身就是线程安全的,背后由 std::call_once 保证,且无需手动管理内存序。
它比手写 DCL 更简洁、更难出错,且主流编译器(GCC/Clang/MSVC)都已优化为无锁路径(首次之后不进锁)。
立即学习“C++免费学习笔记(深入)”;
- 写法就是:
static Singleton instance;放在函数里,直接return instance; - 构造函数抛异常也没问题,标准保证:若初始化失败,下次调用仍会重试
- 不适用于需要延迟构造参数、或需控制析构时机的场景(比如单例依赖全局资源释放顺序)
手写 DCL 时最容易踩的坑
90% 的错误不是逻辑错,而是类型和内存序失配。比如把 std::atomic<singleton></singleton> 写成 std::atomic<singleton></singleton>,或者用 relaxed 序代替 acquire/release。
另一个高频问题是“假成功”:代码能编译、能跑通,但在某些 CPU 架构(如 ARM)或高并发压测下才暴露崩溃,因为 relaxed 序在 x86 上看似没问题,但 ARM 不保证 StoreLoad 顺序。
- 别用
volatile Singleton*替代std::atomic<singleton></singleton> - 两次
load()必须都带memory_order_acquire,不能第一次用relaxed - 别在构造函数里调用虚函数或访问其他未初始化的单例——此时对象尚未完全构造
什么时候不该用单例 + DCL
如果单例对象需要按特定顺序初始化或销毁(比如 A 依赖 B,B 又依赖 A),DCL 无法控制初始化顺序,static 局部变量也只保证本函数内首次调用时初始化,跨 TU 的顺序仍是未定义的。
还有生命周期问题:进程退出时,静态对象析构顺序与构造顺序相反,但 DCL 创建的对象是堆上分配的,不会自动析构——得额外加 atexit 或手动管理,反而增加复杂度。
- 优先考虑依赖注入,而不是全局可访问的单例
- 若必须全局状态,用
thread_local替代,避免锁和内存序问题 - 日志、配置等看似适合单例的场景,其实更适合传参或通过 context 对象流转
static 局部变量那行最短,也最不容易错;手写 DCL 看似可控,但每个原子操作的序、每个指针的生命周期、每个异常分支,都得同时盯住——稍一松懈就埋下偶发崩溃的种子。









