文章目录

C++原子操作std::memory_order在双检查锁中的应用

发布于 2026-04-19 21:16:12 · 浏览 8 次 · 评论 0 条

双检查锁模式旨在解决多线程环境下单例初始化的性能瓶颈问题。其核心目标是在保证线程安全的前提下,尽量减少锁的使用次数。尽管现代 C++ 提供了 std::call_once 等便捷工具,但在高性能底层库开发中,手动实现双检查锁依然是必要的技能。

本文将指导你如何利用 C++11 的原子操作和内存序(std::memory_order)来实现一个标准、高效的双检查锁。


理解核心问题

在多线程程序中,如果两个线程同时尝试获取一个尚未初始化的单例对象,可能会发生竞态条件。最简单的方案是使用互斥锁保护整个初始化过程,但这会导致每次获取单例时都要加锁,带来不必要的性能开销。

双检查锁的逻辑如下:

  1. 第一次检查:判断 指针是否为空。如果非空,直接返回,无需 加锁。
  2. 加锁:如果为空,进入 临界区。
  3. 第二次检查:再次判断指针是否为空(防止在等待锁的过程中,其他线程已经初始化了它)。
  4. 初始化:如果依然为空,执行 new 操作。
  5. 解锁并返回。

然而,在 C++11 之前,由于指令重排的存在,上述步骤是不安全的。编译器或 CPU 可能会调整 new 操作和“指针赋值”操作的顺序,导致其他线程拿到一个指向“半成品”对象的指针。C++11 引入了 std::atomic 和内存序,完美解决了这个问题。


掌握关键内存序

要实现正确的双检查锁,你不需要掌握所有 6 种内存序,只需理解以下两种配对使用的语义:

  1. memory_order_acquire(获取语义)
    通常用于读操作(load)。它保证在本线程中,任何后续的读写操作不会被重排到这条指令之前
  2. 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 函数,严格按照 AcquireRelease 的规则进行加载和存储。

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,我们保证了:

  1. new Singleton 的内存分配和构造(写操作)。
  2. instance.store(..., release)

操作 1 绝对不会被重排到操作 2 之后。配合读取端的 acquire,它们之间形成了一个“同步点”。

下面的流程图展示了两个线程如何通过这对内存序进行同步:

graph LR subgraph Thread_A["Thread A (Writer)"] A1["Construct Object"] --> A2["Store Ptr (Release)"] end subgraph Thread_B["Thread B (Reader)"] B1["Check Ptr (Acquire)"] --> B2["Use Object"] end A2 -.->|Happens-Before Synchronization| B1

在图中,只有当线程 B 的 Acquire 读到了线程 A Release 写入的值时,线程 A 中所有的写操作(构造对象)对线程 B 来说才可见。


代码审查清单

在提交代码前,检查 以下关键点:

  1. 类型检查:确认单例指针使用了 std::atomic 包装。
  2. 加载序:确认在 if 判断时使用的是 load(std::memory_order_acquire)。如果你不传参数,默认是 memory_order_seq_cst,虽然也是安全的,但在 x86/ARM 架构上可能比 acquire/release 稍慢。
  3. 存储序:确认在 new 之后写入指针时使用的是 store(ptr, std::memory_order_release)
  4. 双重检查:确认锁内还有一次 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++ 内存模型标准

通过严格遵循上述步骤和规范,你可以在保证绝对线程安全的前提下,实现零性能损耗的单例访问路径(即只有在初始化那一刻需要锁,后续所有访问都仅仅是原子读操作,不涉及系统调用或锁竞争)。

评论 (0)

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

扫一扫,手机查看

扫描上方二维码,在手机上查看本文