double-checked locking在C++11前不安全,因编译器与CPU重排序可能导致instance指针提前赋值而对象未构造完;C++11后需用std::atomic配合正确内存序才能安全使用。

为什么 double-checked locking 在 C++11 之前不安全
因为编译器重排和 CPU 指令重排可能导致 instance 指针被提前赋值,而对象构造尚未完成。线程 B 可能拿到一个未初始化完毕的指针,进而触发未定义行为。C++11 引入了 std::atomic 和内存序模型才让该模式真正可行——但前提是必须显式控制构造时机与可见性。
常见错误是直接套用 Java 写法:用普通指针 + volatile(C++ 中 volatile 不提供线程同步语义),或漏写 memory_order_acquire/release。
- 必须用
std::atomic<t></t>包装静态指针,不能用T*+volatile - 首次读取要用
memory_order_acquire,写入要用memory_order_release - 构造必须在临界区内完成,且不能在锁外暴露指针
如何用 std::call_once + std::once_flag 实现最简可靠单例
这是 C++11 起推荐的方式,无需手动管理锁、无双重检查开销、天然线程安全,且由标准库保证只执行一次初始化逻辑。
它底层可能使用 futex 或类似机制,在多数平台下比手写 double-checked locking 更轻量,也彻底规避了内存序误配风险。
立即学习“C++免费学习笔记(深入)”;
-
std::once_flag是静态存储期变量,必须声明为static -
std::call_once的函数参数不能抛异常,否则程序会调用std::terminate - 若需延迟构造(如依赖运行时参数),可将初始化逻辑封装进 lambda,并捕获必要变量
class Singleton {
public:
static Singleton& instance() {
static std::once_flag flag;
static Singleton* inst = nullptr;
std::call_once(flag, []{ inst = new Singleton(); });
return *inst;
}
private:
Singleton() = default;
};如果坚持用互斥锁 + double-checked locking,关键三步不能错
仅当需要兼容旧标准(如 C++03)或对初始化时机有精细控制(如需在构造中处理异常并重试)时才考虑此方案。它性能略逊于 std::call_once,但可控性更强。
核心陷阱在于:锁内二次检查、原子读写顺序、以及确保构造完成后再发布指针。
- 第一次检查用
load(memory_order_acquire),避免后续读写被重排到检查前 - 加锁后必须再次检查(防止多线程同时通过第一层检查)
- 构造完成后,用
store(ptr, memory_order_release)发布指针,确保构造写入对其他线程可见 - 不要在锁外调用
new再传入——分配和构造必须在锁保护下完成
std::mutex vs std::shared_mutex 对单例的影响
单例初始化是典型的“一次写、多次读”场景,但 std::shared_mutex 并不适用——因为初始化期间必须完全排他,且后续所有访问都不需要并发读保护(单例对象本身是否线程安全,和单例获取过程无关)。
用 std::shared_mutex 反而增加开销,且容易误以为“读操作可以并发”,从而忽略对单例内部状态的同步需求。
-
std::mutex是唯一合理选择:初始化用lock(),其余所有instance()调用走无锁路径 - 若单例内部方法需线程安全,那是类自身职责,应在其成员函数中加锁或用原子变量,与单例获取机制解耦
- 别试图用
std::shared_mutex优化instance()的调用路径——它没有读共享收益点
实际中最容易被忽略的是:单例对象的析构时机不可控,若在 main() 返回后仍有线程访问 instance(),会导致访问已销毁对象。这个问题和实现方式无关,但常在线上环境暴露——尤其涉及 atexit、动态库卸载或信号处理时。



















