必须用std::atomic而不能用volatile,因为volatile仅抑制编译器优化,不提供原子性、不阻止CPU乱序执行、不建立happens-before关系;而std::atomic配合memory_order_acquire/release才能确保构造完成与指针发布的顺序一致性,防止其他线程访问到“半构造对象”。

双重检查锁里为什么必须用 std::atomic 而不能用 volatile
因为 volatile 不提供线程间同步语义,它只告诉编译器“这个变量可能被外部修改”,但不阻止指令重排,也不保证内存可见性。在多线程环境下,volatile 无法防止构造函数执行完但对象指针尚未写入的重排问题——这正是双重检查锁崩溃的根源。
正确做法是用 std::atomic<:shared_ptr>> 或 std::atomic,并配合 memory_order_acquire/memory_order_release 控制内存序:
-
std::atomic初始为m_instance nullptr,比shared_ptr更轻量,适合单例生命周期与程序一致的场景 - 读操作用
load(std::memory_order_acquire),确保后续读取看到完整构造的对象 - 写操作用
store(ptr, std::memory_order_release),确保构造完成后再更新指针 - 禁止使用
memory_order_relaxed—— 它不建立同步关系,会导致未定义行为
pthread_once 和 std::call_once 是更优解,但为什么还要手写双重检查锁
多数情况下,你应该直接用 std::call_once + static local variable(C++11 起 guaranteed thread-safe initialization),它由编译器和运行时保障,简洁且无误。
手写双重检查锁仅在以下真实场景必要:
- 需要延迟初始化(lazy-init)且单例构造开销极大,而
static local在首次调用函数时即触发,无法控制“真正第一次使用”的时机 - 目标平台不支持 C++11(如某些嵌入式环境),但支持 pthread 或自定义原子操作
- 需在单例构造失败时返回错误码(而非抛异常),而
static local构造异常会调用std::terminate - 调试或教学目的:理解原子操作、内存模型与竞态条件的底层交互
手写 DCL 单例的关键代码结构与易错点
核心是三段式:先读、再锁内二次判空、最后原子写入。漏掉任意一环都会导致重复构造或访问未初始化内存。
典型实现片段(省略析构与线程安全释放):
class Singleton {
public:
static Singleton* getInstance() {
Singleton* instance = m_instance.load(std::memory_order_acquire);
if (instance == nullptr) {
std::lock_guard<:mutex> lock(m_mutex);
instance = m_instance.load(std::memory_order_acquire);
if (instance == nullptr) {
instance = new Singleton(); // 构造必须在锁内完成
m_instance.store(instance, std::memory_order_release); // 写入必须在构造后
}
}
return instance;
}
<p>private:
Singleton() = default; // 防止外部构造
static std::atomic<singleton>> m_instance;
static std::mutex m_mutex;
};
std::atomic<singleton>> Singleton::m_instance{nullptr};
std::mutex Singleton::m_mutex;</singleton></singleton></p></:mutex>
常见错误:
- 第二次
load()忘加,导致锁内仍可能重复 new - 把
new Singleton()和store()拆成两步,中间被调度打断,其他线程读到空指针但实际对象未完全构造 - 用
memory_order_relaxed读/写,失去同步边界 - 忘记将构造函数设为
private,或未禁用拷贝/移动
为什么 C++11 后推荐用 static local variable 而非 DCL
因为标准明确保证:函数内 static 局部变量的初始化是“首次控制流经过声明处时”原子完成的,且带完整的内存屏障。编译器生成的代码等价于最严谨的手写 DCL,但无需手动管理原子变量、锁、内存序。
例如:
Singleton& getInstance() {
static Singleton instance; // 线程安全,自动完成双重检查+构造+发布
return instance;
}
它的隐含成本其实很低:GCC/Clang 用 __cxa_guard 实现,首次调用有少量原子判断开销,之后就是普通内存读取。除非你明确需要构造时机可控或失败可恢复,否则没必要绕开它。
真正难的是理解为什么它能安全——背后依赖的是编译器对静态初始化的特殊处理,不是靠用户写的原子操作。这点容易被忽略,但恰恰是现代 C++ 线程安全的基石。
C++免费学习笔记(深入):立即使用
在学习笔记中,你将探索 C++ 的入门与实战技巧!











