std::call_once + std::once_flag 是首选方案,因其是c++11标准提供的零成本、线程安全的单例构造机制,无需手动加锁或内存序控制,且保证回调仅执行一次并同步完成。

为什么 std::call_once + std::once_flag 是首选方案
因为它是 C++11 标准提供的、零成本抽象的线程安全单例构造机制,无需手动加锁、不依赖双重检查锁定(DCLP)的手动内存序控制,也规避了静态局部变量在早期编译器中可能存在的竞态问题。
常见错误是试图用 pthread_once 或自旋锁模拟,既增加依赖又易出错;更隐蔽的坑是误以为“静态局部变量天生线程安全”就高枕无忧——它虽在 C++11 起被标准保证初始化线程安全,但仅限于**首次构造**,不保护后续对单例对象内部状态的并发访问。
-
std::call_once保证回调函数全局只执行一次,且同步完成:所有等待线程会阻塞直到初始化结束 - 配合
static指针或static std::unique_ptr使用,避免栈上临时对象析构干扰 - 不要在
call_once回调里抛异常:一旦抛出,once_flag状态变为“已调用但失败”,后续调用直接 rethrow,无法重试
静态局部变量方式的适用边界与陷阱
它写起来最简洁,但只适用于“构造过程本身无复杂依赖、不需捕获异常并重试、且不需要延迟初始化控制权”的场景。
典型误用:在 DLL/so 中导出单例函数,且该单例依赖另一个跨模块的静态对象——此时初始化顺序不可控,可能触发未定义行为。
立即学习“C++免费学习笔记(深入)”;
- 语法:
static T& instance() { static T obj; return obj; } - C++11 起,编译器必须保证该初始化是线程安全的(通过隐式
call_once类机制) - 但对象析构顺序由声明顺序决定,跨编译单元时不可预测;若单例持有资源(如文件句柄、网络连接),析构时机可能早于使用者预期
- 无法在构造失败时返回 nullptr 或抛出自定义异常(只能让构造函数 throw,引发程序终止或未捕获异常)
双重检查锁定(DCLP)为何仍有人用,以及怎么写才不出错
主要出现在需要兼容老标准(C++03)、或必须精确控制内存布局/构造时机(如嵌入式、游戏引擎核心模块)的场景。但它极易因内存序错误导致未定义行为。
关键不是“加锁”,而是“如何让其他线程看到完全构造好的对象”。漏掉 std::atomic_thread_fence 或用错 memory_order,会导致读线程看到部分初始化的对象。
- 必须用
std::atomic<t></t>存储指针,不能用裸指针 +volatile - 第一次检查用
memory_order_acquire,第二次(锁内)用memory_order_relaxed,写入用memory_order_release - 构造必须在临界区内完成,且不能让编译器把 new 表达式优化到锁外(可用
std::atomic_signal_fence隔离,但更推荐直接用new+ placement new + 显式构造) - 别忘了显式 delete 拷贝/移动构造函数和赋值操作符,否则单例语义被破坏
单例的生命周期管理比创建更难:析构期竞态真实存在
几乎所有教程只讲“怎么安全创建”,却忽略“怎么安全销毁”。当多个线程在程序退出前同时调用 instance(),而单例又在某个静态析构阶段被释放,后续访问就会 crash。
根本矛盾在于:C++ 不定义静态对象的析构顺序,也无法阻止用户在 main 返回后、全局析构开始前继续调用单例接口。
- 解决方案一:放弃自动析构,改用
std::shared_ptr+ 自定义 deleter,将析构推迟到明确调用reset()时 - 解决方案二:使用
atexit()注册清理函数,但要注意它不支持带参数的函数,且无法捕获异常 - 最务实的做法:接受“进程退出时不保证单例安全析构”,在设计上确保单例不持有需显式释放的 OS 资源(如 socket、mutex),或改用 RAII 容器封装资源
真正棘手的从来不是“怎么让第一个线程建好它”,而是“怎么让最后一个线程知道它已经没了”。











