优先用 std::call_once + 静态局部变量;若需运行时参数,则用 std::call_once + std::unique_ptr 并配合双重检查,避免手写原子指针——因存在重排、异常不安全及标准未保证等问题。

直接结论:优先用 std::call_once + 静态局部变量,而不是手写原子指针检查;若必须延迟初始化且需控制构造时机(如依赖运行时参数),再考虑 std::call_once + std::unique_ptr,但务必配合双重检查。
为什么不用 std::atomic 手写双重检查锁定
很多人试图用 std::atomic<singleton></singleton> 实现无锁单例,典型写法是先读指针、为空则加锁再建实例。这看似高效,但存在两个硬伤:
- 即使
load()是原子的,store()也必须配对使用memory_order_release,而构造对象本身不是原子操作——编译器和 CPU 可能重排对象初始化与指针赋值顺序,导致其他线程看到未构造完成的对象 - 没有异常安全保证:若构造函数抛异常,
atomic_store不会回滚,后续调用仍会拿到一个 dangling 指针 - C++ 标准不保证
std::atomic<t></t>对非 trivially copyable 类型的完全安全,尤其在涉及虚表、动态内存等场景下行为不可靠
std::call_once 的实际调用开销在哪
std::call_once 并非“每次调用都进锁”。它的底层状态机(PENDING → EXECUTING → COMPLETED)由原子操作驱动,首次调用后,后续所有调用只需一次原子 load 就能跳过执行路径。实测在 x86-64 上,已初始化后的调用耗时约 1–2 ns,远低于 std::mutex::lock() 的 20–50 ns。
- 真正开销集中在第一次调用:要确保只有一个线程进入回调,其余线程需等待或自旋,此时依赖 OS 级同步原语(如 futex 或 Windows SRWLock)
- 若初始化函数耗时较长(比如打开数据库连接),所有等待线程会被阻塞,这不是
call_once的问题,而是业务逻辑本身的瓶颈 - 不要把
std::once_flag声明为自动变量——它必须是static,否则每次函数调用都新建一个 flag,彻底失去“一次性”语义
std::call_once + std::unique_ptr 的正确姿势
当单例需要延迟初始化(例如构造参数来自配置文件),且不能用 C++11 的静态局部变量(因无法传参),才启用该组合。关键点在于:必须做指针空值快速检查,避免高频调用反复进入 std::call_once 路径。
-
db_和db_init_flag_都必须是static成员,否则每个对象实例都会拥有独立 flag,多线程下失效 - getter 中先判断
if (db_) return *db_;,仅在空时才调用std::call_once,这是现代 C++ 中 Double-Checked Locking 的标准写法 - 返回引用而非指针:避免调用方做 null 检查,也防止误删或重复释放;若需明确表达“可能未初始化”,应在 lambda 内校验构造结果并抛异常,而不是让外部处理空指针
- 别用
std::shared_ptr替代std::unique_ptr——除非真有多个所有者,否则原子引用计数带来额外开销,且无必要
最容易被忽略的异常传播细节
std::call_once 对异常的处理是“失败即重试”,不是“失败即终止”。如果初始化 lambda 抛出未捕获异常,call_once 会清除 EXECUTING 状态,允许下一个线程再次尝试执行。这意味着:
- 若构造函数可能因配置错误、网络超时等失败,你得确保重试逻辑合理(比如带退避的重连,而不是无限循环)
- 不要在 lambda 中吞掉所有异常;更糟的是用
try/catch(...)后静默返回,这会让后续调用永远卡在等待状态 - 一旦初始化成功,
std::once_flag状态就永久变为COMPLETED,不可重置——哪怕程序 later reload 配置,也无法触发二次初始化
C++免费学习笔记(深入):立即使用
在学习笔记中,你将探索 C++ 的入门与实战技巧!











