DCLP因指令重排和内存可见性问题可能导致未初始化对象被访问,c++11前难以正确实现;使用std::atomic配合acquire-release内存序可修复,但推荐更简洁安全的Meyers单例,即局部静态变量方式,由标准保证线程安全,避免复杂同步逻辑。

双重检查锁定模式(double-Checked Locking Pattern, DCLP)在C++中常被用于实现线程安全的单例模式,初衷是避免每次调用都加锁带来的性能损耗。然而,在早期C++标准(尤其是C++11之前)中,DCLP存在严重的内存可见性和指令重排问题,导致其在多线程环境下可能失效。
为什么DCLP会出问题?
DCLP的核心逻辑是在返回单例实例前进行两次判空:一次无锁判断,一次加锁后判断。看似合理,但问题出在对象构造和指针赋值的顺序上。
考虑以下典型错误实现:
Singleton* Singleton::getInstance() { if (instance == nullptr) { // 第一次检查 std::lock_guard lock(mutex_); if (instance == nullptr) { // 第二次检查 instance = new Singleton(); // 危险! } } return instance; }
这段代码的问题在于:new Singleton() 实际包含三个步骤:
立即学习“C++免费学习笔记(深入)”;
- 分配内存
- 调用构造函数初始化对象
- 将指针赋给 instance
由于编译器或处理器可能进行指令重排,第3步可能早于第2步完成。此时若另一个线程恰好执行第一次检查,会看到非空的 instance 指针,但该对象尚未完成构造,访问它将导致未定义行为。
C++11之后的解决方案
C++11引入了内存模型和原子操作,使得我们能正确实现DCLP。使用 std::atomic 可以防止数据竞争,并通过内存序控制可见性。
std::atomic Singleton::instance{nullptr}; std::mutex Singleton::mutex_; Singleton Singleton::getInstance() { Singleton tmp = instance.load(std::memory_order_acquire); if (tmp == nullptr) { std::lockguard lock(mutex ); tmp = instance.load(std::memory_order_relaxed); if (tmp == nullptr) { tmp = new Singleton(); instance.store(tmp, std::memory_order_release); } } return tmp; }
这里的关键是使用 acquire-release 内存序来保证:在释放锁之前完成的所有写操作(包括对象构造),对后续以 acquire 方式读取 instance 的线程可见。
更推荐的方式:Meyers 单例
其实,对于大多数场景,更简洁且线程安全的方法是使用局部静态变量——Meyers 单例:
Singleton& Singleton::getInstance() { static Singleton instance; return instance; }
C++11标准规定:局部静态变量的初始化是线程安全的,由编译器自动保证。首次调用时才创建,且只创建一次。这种方式既免除了显式加锁,又避免了DCLP的所有陷阱,代码更短,语义清晰。
总结
DCLP是一个典型的“看似正确却极易出错”的模式。在C++11之前,因缺乏标准化的内存模型,其实现几乎不可能跨平台正确。如今虽然可用原子操作修复,但没必要自找麻烦。除非有特殊需求(如延迟加载+频繁访问+极致性能要求),否则应优先选择 Meyers 单例。
基本上就这些。简单、安全、高效才是好设计。DCLP可以作为学习内存模型的案例,但不建议在实际项目中手动实现。