详解C++实现线程安全的单例模式 前言单例模式singleton pattern保证一个类只有一个实例并提供全局访问点。 它常被用在配置管理、日志器、连接池这类「天生就该只有一份」的对象上。单例的问题不在于「怎么写」而在于并发下的初始化instance()第一次被多个线程 同时调用时怎样保证构造函数只执行一次而且其它线程拿到的一定是已经完全构造好的对象。 这篇文章要纠正三个流传很广的说法「双检锁double-checked locking加上volatile就是线程安全的」——这是 Java 里的结论。在 C 里volatile不提供原子性、也不建立 happens-before 关系 用它修饰指针解决不了任何同步问题。「饿汉式程序启动时就构造最好天然线程安全」——它确实避开了初始化竞争但也把构造开销挪到了启动阶段还会引入跨编译单元的静态初始化顺序问题。「只要构造函数里没有竞争就行」——还要保证对象构造完成这一事实对其它线程可见这就是内存序的作用。从 C11 起标准直接给出了最简单也最正确的答案函数内的静态局部变量 也就是所谓的「magic static」。本文先讲它再讲std::call_once和原子双检锁 最后对比几种方案的取舍。示例按C17标准编写 目标编译器为 GCC 13 / Clang 17 / MSVC 19.3x。一、首选的写法函数内静态局部变量#include string class Config { public: static Config instance() { static Config inst; // C11 起初始化过程保证线程安全 return inst; } const std::string name() const { return name_; } Config(const Config) delete; Config operator(const Config) delete; private: Config() : name_(default) {} ~Config() default; std::string name_; };这段代码之所以成立是因为 C11 给静态局部变量的初始化加了明确保证 [stmt.dcl]语义为如果多个线程并发地进入同一个静态局部变量的声明 那么其中一个线程执行初始化其余线程必须等待初始化完成 并且初始化完成与后续读取之间建立了 happens-before 关系。 换句话说其它线程要么看到「还没开始初始化」并自己去做要么等到初始化完成后 拿到一个完整可用的对象不可能拿到一个构造了一半的对象。还有一条容易被忽略、但在做「懒加载 失败重试」时非常有用的规定如果初始化过程中抛出了异常这次初始化被视作没有完成 异常向外传播下一次再调用instance()时会重新尝试初始化。 所以在构造函数可能抛异常的场景比如读配置文件失败表现是「下次再来」 而不是「永远坏掉」。几个实现层面的补充属于实现细节不是标准要求GCC / Clang 用__cxa_guard_acquire/__cxa_guard_release这一对函数加上一个 guard 变量来实现快速路径通常只是一次带 acquire 语义的读。MSVC 使用线程安全的初始化标记同样在快速路径上避免加锁。C11 之前的编译器不保证这一点。老代码里static局部变量的「懒汉式」是不安全的需要用call_once或者启动阶段显式初始化来替代。另外注意Config这个返回值类型返回引用而不是指针 使用者就不可能写出「拿到空指针」的分支也就少了一整类判空 bug。二、std::call_once 与 std::once_flag当要初始化的不是一个静态对象而是一个堆上对象、或者需要传入参数时std::call_once更合适#include memory #include mutex class Logger { public: static Logger instance() { std::call_once(init_flag_, Logger::create); // 恰好执行一次 return *instance_; } void write(const char* msg) const { (void)msg; } Logger(const Logger) delete; Logger operator(const Logger) delete; private: static void create() { // 注意不能用 std::make_uniqueLogger()——它不是 Logger 的友元 // 访问不到私有的构造函数这里必须直接 new instance_.reset(new Logger()); } Logger() default; static std::once_flag init_flag_; static std::unique_ptrLogger instance_; }; std::once_flag Logger::init_flag_; std::unique_ptrLogger Logger::instance_;std::call_once的语义必须讲清楚否则很容易踩坑恰好执行一次。并发调用中只有一个线程真正执行传入的可调用对象标准把这次执行称为 active execution其余线程会阻塞等待它结束 passive execution然后直接返回。异常会传播而且会重试。如果被调用的函数抛了异常异常会传播给call_once的调用者并且once_flag不会被置为「已完成」 之后任何线程再次调用call_once用同一个 flag都会重新尝试执行那个函数。 这就是所谓的「异常重试」语义。实践含义被调用的函数必须是可重试的。如果它先改了全局状态再抛异常那么第二次执行就会看到一个半成品状态。正确做法是「先把新对象完整构造好 最后一步才发布」中间失败就直接抛出不要留下中间态。换句话说call_once保证的是「成功执行恰好一次」不是「尝试恰好一次」。它不可重入。在被调用的函数内部不要再用同一个once_flag去调用call_once直接或间接都不行。标准没有为这种用法提供任何保证 各家实现以及底层的pthread_once在这种情形下会自锁实务上应当视为非法用法。 对不同的once_flag做嵌套调用是允许的但要注意多个锁的获取顺序 否则可能和别的代码构成死锁环。once_flag本身不可拷贝、不可移动必须作为静态存储期对象或者更长生命周期的对象存在。三、原子双检锁DCLP在 C11 之前双检锁是著名的「看起来对、实际会坏」的写法 指针的写入和对象构造的写入可能被重排其它线程可能看到一个非空指针 却访问到尚未构造完成的对象。C11 之后只要用原子类型 正确的内存序 这个模式是可以写对的#include atomic #include mutex class Registry { public: static Registry* instance() { Registry* p instance_.load(std::memory_order_acquire); // ① 无锁快路径 if (p nullptr) { std::lock_guardstd::mutex lk(mtx_); p instance_.load(std::memory_order_relaxed); // ② 锁内复查 if (p nullptr) { p new Registry(); // ③ 构造 instance_.store(p, std::memory_order_release); // ④ 发布 } } return p; // ⑤ 返回 } Registry(const Registry) delete; Registry operator(const Registry) delete; private: Registry() default; ~Registry() default; static std::atomicRegistry* instance_; static std::mutex mtx_; }; std::atomicRegistry* Registry::instance_{nullptr}; std::mutex Registry::mtx_;为什么 ①②④ 的内存序不能省④ 的release保证③ 这个 new 表达式的所有写操作构造对象不会排到 ④ 之后① 的acquire保证读到 ④ 写入的指针之后③ 中写的所有内容都可见两者配对构成 synchronizes-with 关系于是「看到非空指针」和「对象已构造完成」变成同一件事。如果把两边都换成relaxed其他线程完全可能先看到指针、后看到对象内容这时访问的就是一个生命周期尚未开始的对象的成员——这是 UB 标准不保证任何行为不是「偶尔读到垃圾值」。② 在锁内用的是relaxed也没问题持锁本身已经提供了必要的同步。这个写法的代价是instance_指向的对象用new分配不会被自动销毁 需要在退出时手动释放或者接受这点「有意的泄漏」。相比之下 第一种「函数内静态局部变量」的方案由运行时在main返回后统一销毁省心得多。 所以除非有明确理由例如需要延迟到某个特定时机、或者要与 C 接口共享句柄优先用静态局部变量不要手写双检锁。四、四种方案的对比方案线程安全首次调用开销异常后行为销毁时机适用场景函数内静态局部变量✅ C11 起由标准保证首次需同步之后近似一次原子读下次调用自动重试初始化程序退出时销毁顺序见坑点首选std::call_onceonce_flag✅首次需同步抛出后 flag 不置位下次重试由你自己管理的对象决定需要参数、要初始化多个对象、或要跨类型复用原子双检锁 new✅内存序写对的前提下首次需加锁需自己保证可重试不会自动销毁需要精确定制初始化时机饿汉式全局对象 / 静态成员✅初始化在单线程阶段启动时就付抛出即std::terminate程序退出时初始化很快、且不依赖其它全局对象每次访问都加锁的懒汉式✅每次都要加锁—视实现不推荐白白付出同步代价顺便澄清一个常见错误说法「静态局部变量 vs 双检锁性能差很多」并不成立。 两者的快路径都只需要一次带 acquire 语义的原子读具体实现细节各库不同 差异主要在可读性和销毁语义上而不是在速度上。真正会显著变慢的是 「每次调用都加一次互斥锁」的懒汉式写法。常见坑点场景❌ 错误写法✅ 正确写法双检锁的同步原语用volatile static T* p;做双检锁std::atomicT*acquire/release或直接用静态局部变量调用方使用方式返回T*调用方每次判空返回T从接口上排除空指针拷贝语义不删除拷贝构造/赋值单例被复制出多份显式 delete拷贝构造与拷贝赋值老编译器假设 C11 之前的编译器也保证静态局部变量初始化线程安全加-stdc11或更高老代码改用call_oncecall_once的异常在被调用函数里改了一半全局状态后抛异常下次重试时状态已脏先完整构造、最后一步发布保证函数可重试call_once的可重入在被调用函数内部用同一个once_flag再调call_once自锁拆成两个不同的once_flag或改成一次调用完成静态初始化顺序一个全局单例的构造函数里访问另一个翻译单元里的全局对象改成函数内静态局部变量局部静态在首次使用时才构造析构顺序某个单例的析构函数里访问另一个已经被销毁的单例UB不在析构函数里跨单例调用或干脆不做销毁可选性判断用if (instance_)之类的判空分支掩盖初始化失败初始化失败就让构造函数抛异常调用方感知最值得单独强调的还是静态初始化顺序问题static initialization order fiasco 不同翻译单元里的全局对象初始化顺序是未指定的。 如果 A 的构造函数里用了 B而 B 恰好还没构造你访问到的是一块全零内存 随后调用它的成员函数就是 UB。这个问题的经典解法正是本篇文章的主角—— 把全局对象换成函数内的静态局部变量因为局部静态是在首次执行到那一行时才构造的 此时它依赖的对象必然已经可用。另外单例的析构同样有顺序问题静态存储期对象的销毁顺序是构造顺序的逆序 跨翻译单元依然没有保证。所以不要在一个单例的析构函数里去用另一个单例。总结要点结论最简方案函数内静态局部变量C11 起标准保证线程安全失败可重试需要参数或堆对象std::call_oncestd::once_flag成功后恰好一次抛异常后下次重试手写双检锁必须用std::atomicrelease/acquirevolatile帮不上忙接口设计返回引用、禁用拷贝顺序问题避免跨编译单元的全局对象依赖改用局部静态性能别为了「快」手写双检锁收益不明确风险却不小写单例的正确顺序是先问「真的需要全局唯一吗」再问「用哪种实现」。 单例本质上是受控的全局状态它会让单元测试变难、让依赖关系变隐蔽。 如果确实需要请用「函数内静态局部变量 返回引用 禁用拷贝」这十三行代码解决问题—— 它比任何手写的双检锁都更短、更快也更容易让下一位维护者看懂。