C++ 单例模式中的双重检查锁定是什么?(如何在多线程下安全实现)

1次阅读

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

C++ 单例模式中的双重检查锁定是什么?(如何在多线程下安全实现)

双重检查锁定为什么需要 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 看似可控,但每个原子操作的序、每个指针的生命周期、每个异常分支,都得同时盯住——稍一松懈就埋下偶发崩溃的种子。

text=ZqhQzanResources