双检查锁模式旨在解决多线程环境下单例初始化的性能瓶颈问题。其核心目标是在保证线程安全的前提下,尽量减少锁的使用次数。尽管现代 C++ 提供了 std::call_once 等便捷工具,但在高性能底层库开发中,手动实现双检查锁依然是必要的技能。
本文将指导你如何利用 C++11 的原子操作和内存序(std::memory_order)来实现一个标准、高效的双检查锁。
理解核心问题
在多线程程序中,如果两个线程同时尝试获取一个尚未初始化的单例对象,可能会发生竞态条件。最简单的方案是使用互斥锁保护整个初始化过程,但这会导致每次获取单例时都要加锁,带来不必要的性能开销。
双检查锁的逻辑如下:
- 第一次检查:判断 指针是否为空。如果非空,直接返回,无需 加锁。
- 加锁:如果为空,进入 临界区。
- 第二次检查:再次判断指针是否为空(防止在等待锁的过程中,其他线程已经初始化了它)。
- 初始化:如果依然为空,执行
new操作。 - 解锁并返回。
然而,在 C++11 之前,由于指令重排的存在,上述步骤是不安全的。编译器或 CPU 可能会调整 new 操作和“指针赋值”操作的顺序,导致其他线程拿到一个指向“半成品”对象的指针。C++11 引入了 std::atomic 和内存序,完美解决了这个问题。
掌握关键内存序
要实现正确的双检查锁,你不需要掌握所有 6 种内存序,只需理解以下两种配对使用的语义:
memory_order_acquire(获取语义):
通常用于读操作(load)。它保证在本线程中,任何后续的读写操作不会被重排到这条指令之前。memory_order_release(释放语义):
通常用于写操作(store)。它保证在本线程中,任何之前的读写操作不会被重排到这条指令之后。
当“写线程”使用 release 写入数据,而“读线程”使用 acquire 读取到该数据时,写线程在 release 之前的所有修改对读线程来说都是可见的。这就是我们需要的“同步”关系。
实施步骤:构建高效双检查锁
按照以下步骤编写代码。
1. 定义静态成员变量
声明 单例指针为 std::atomic 类型。必须使用原子类型,否则非原子的读写本身就是未定义行为(UB)。
class Singleton {
public:
static Singleton* getInstance();
private:
Singleton() {}
~Singleton() {}
// 禁止拷贝和赋值
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static std::atomic<Singleton*> instance;
static std::mutex mutex;
};
2. 实现获取函数
编写 getInstance 函数,严格按照 Acquire 和 Release 的规则进行加载和存储。
Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mutex;
Singleton* Singleton::getInstance() {
// 步骤 1:第一次检查
// 使用 memory_order_acquire
// 如果 instance 不为空,意味着它已经被完整初始化(因为 store 时用了 release)
Singleton* ptr = instance.load(std::memory_order_acquire);
if (ptr == nullptr) {
// 步骤 2:加锁
std::lock_guard<std::mutex> lock(mutex);
// 步骤 3:第二次检查
// 在锁内再次加载,防止其他线程在等待锁时完成了初始化
ptr = instance.load(std::memory_order_acquire);
if (ptr == nullptr) {
// 步骤 4:创建对象并存储
ptr = new Singleton;
// 步骤 5:发布指针
// 使用 memory_order_release
// 这保证了 "new Singleton" 的所有构造操作都在这步之前完成
// 其他线程如果 acquire 到了这个值,一定能看到构造好的对象
instance.store(ptr, std::memory_order_release);
}
}
return ptr;
}
验证逻辑与指令重排
为了更直观地理解为什么这行得通,我们来看一下指令重排是如何被阻止的。
如果没有 memory_order_release,编译器或 CPU 可能会将步骤 5 的 instance.store 排列到步骤 4 的 new 操作之前(即先分配地址并赋值给全局指针,再调用构造函数)。这会导致另一个线程在第一次检查时看到指针非空,于是拿去使用,但此时构造函数还没执行完,程序崩溃。
通过强制要求 store 使用 release,我们保证了:
new Singleton的内存分配和构造(写操作)。instance.store(..., release)。
操作 1 绝对不会被重排到操作 2 之后。配合读取端的 acquire,它们之间形成了一个“同步点”。
下面的流程图展示了两个线程如何通过这对内存序进行同步:
在图中,只有当线程 B 的 Acquire 读到了线程 A Release 写入的值时,线程 A 中所有的写操作(构造对象)对线程 B 来说才可见。
代码审查清单
在提交代码前,检查 以下关键点:
- 类型检查:确认单例指针使用了
std::atomic包装。 - 加载序:确认在
if判断时使用的是load(std::memory_order_acquire)。如果你不传参数,默认是memory_order_seq_cst,虽然也是安全的,但在 x86/ARM 架构上可能比acquire/release稍慢。 - 存储序:确认在
new之后写入指针时使用的是store(ptr, std::memory_order_release)。 - 双重检查:确认锁内还有一次
load检查。
常见陷阱与修正
许多老旧的 C++ 代码中包含“双重检查锁定”的反模式,例如直接在 if 判断里读取非原子指针,或者使用带有内存屏障的 volatile(这并非 C++ 标准行为)。下表总结了常见错误与正确做法的对比:
| 特性 | 错误做法 (非原子/C++98) | 正确做法 (C++11 Atomic) |
|---|---|---|
| 指针类型 | static Singleton* instance; (裸指针) |
static std::atomic<Singleton*> instance; |
| 读取方式 | if (instance == nullptr) (直接读) |
instance.load(std::memory_order_acquire) |
| 写入方式 | instance = new Singleton; (直接写) |
instance.store(ptr, std::memory_order_release) |
| 安全性 | 不安全,存在指令重排风险 | 安全,符合 C++ 内存模型标准 |
通过严格遵循上述步骤和规范,你可以在保证绝对线程安全的前提下,实现零性能损耗的单例访问路径(即只有在初始化那一刻需要锁,后续所有访问都仅仅是原子读操作,不涉及系统调用或锁竞争)。

暂无评论,快来抢沙发吧!