C++:内存顺序(Memory Order)的概念以及使用

目录

1.前言

2.问题引入

3.有锁和std::atomic为什么还需要内存序?

4.内存序是什么?

5.简要介绍std::atomic

6.如何使用内存序?

7.内存序实战


前言

C++中的内存顺序(Memory Order)是原子操作中的关键概念,用于控制多线程环境下的内存可见性和操作顺序。它们解决了多线程编程中最核心的同步问题。但是在许多书籍上并没有深入讲解关于内存顺序的知识,而且根据博主自主查阅了许多博客后发现很多内容只是停留在概念,写法也没有很能令人理解。所以便有了这篇博客,希望能采取图文并茂的方式讲解内存顺序的概念和如何使用,以及能在项目上解决哪些实际问题。


问题引入

在讲解C++中的内存顺序(Memory Order)之前,我们先从实际项目中可能遇到的多线程场景入手,分析在不恰当的使用多线程不关心CPU缓冲的情况会存在哪些问题,再讲解什么是Memory Order,如何使用Memory Order,以及使用Memory Order解决当前小节所提到的这些问题。下面作者将从一下问题展开描述:
1.生产者-消费者模型中的幽灵数据

cpp 复制代码
// 共享普通变量
int shared_data = 0;
bool ready = false; // 注意:是普通 bool

void producer() {
    shared_data = 42;  // 步骤1:写数据
    ready = true;      // 步骤2:发信号
}

void consumer() {
    while (!ready) {   // 步骤3:等待信号
        // 忙等
    }
    std::cout << shared_data << std::endl; // 步骤4:读数据
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
    return 0;
}

当我们执行以上代码的预期是输出shared_data的值为42,但是实际运行过程中可能出现的结果有shared_data的值为0,或者程序卡在while循环中导致死循环。导致这些问题的主要原因可能为以下几点:
1.编译器优化导致指令重排:在producer()函数中,shared_data 和 ready 是毫无关联的独立变量。编译器为了提高流水线效率,可能会将ready = true的指令重排到shared_data = 42之前。此时消费者一旦发现ready == true,就去读shared_data,读到的是修改前的。

2.CPU存储缓冲区未刷新:现代CPU核有自己的写缓存。线程 A 在核1运行,写入shared_data=42可能滞留在核1的 Store Buffer 里尚未刷入三级缓存,而写入ready=true却因为某种原因被立即刷入。线程B在核2运行,能看见最新的ready=true,但看不见shared_data=42。

3.编译器优化寄存器变量导致值的状态没有正确被读取 **:**因为ready是普通变量,编译器可能将其优化为寄存器变量。while(!ready)在编译后可能变成了cmp eax, 0,而eax的值在循环里永远不会被重新从内存加载,导致死循环。

2.自旋锁失效

cpp 复制代码
int lock = 0; // 0为无锁, 1为上锁
int counter = 0;

void worker() {
    // 自旋获取锁
    while (lock == 1) {} 
    lock = 1; // 上锁
    
    // 临界区
    counter++;
    
    lock = 0; // 解锁
}

int main() {
    std::thread t1(worker);
    std::thread t2(worker);
    t1.join();
    t2.join();
    std::cout << counter << std::endl;
    return 0;
}

在执行上述代码的时候预期行为是输出counter的值为2,但是实际可能出现的结果是counter的值为1,或者程序崩溃/卡死。导致这些问题的主要原因可能为以下几点:

1.检测锁状态时都判断锁没有被使用,导致多个线程执行写操作:线程A和B同时执行while(lock == 1){},然后它们都发现lock== 0并跳出循环,此时它们又同时执行lock = 1,然后一起进入临界区。结果则是counter++被并发执行,丢失一次更新。

2.同一数据的读操作和写操作次序重排:假设只有一个线程A获取到锁,并且在worke函数执行结束时执行lock = 0。而线程B在while(lock == 1) 自旋,但是由于CPU允许存储和加载次序重排,线程A对counter的写入可能晚于对 lock=0 的写入被其他核看到。结果则是当线程B进入临界区后,看到的counter还是旧值。

3.双重检测锁状态

cpp 复制代码
// 经典的单例模式错误实现(不使用 atomic)
class Singleton {
public:
    static Singleton* getInstance() {
        if (instance == nullptr) {          // 1. 第一次检查(无锁)
            std::lock_guard<std::mutex> lock(mtx);
            if (instance == nullptr) {      // 2. 第二次检查(有锁)
                instance = new Singleton(); // 3. 构造对象
            }
        }
        return instance;
    }

private:
    Singleton() {}
    static Singleton* instance;
    static std::mutex mtx;
};

这是 C++ 史上最臭名昭著的并发陷阱。问题出在instance = new Singleton()这一行。这行代码在机器指令层面通常由三步组成:先分配内存,在分配的内存上调用构造函数,最后将内存地址赋值给instance指针。而为了性能编译器可能将步骤3提前到步骤2之前。

例如当线程A执行到getInstance函数,分配内存然后先赋值指针(此时 instance 不为空),但还没调用构造函数。而线程B此时正好调用getInstance,执行第一次检查if (instance == nullptr),然后线程B发现instance不是空指针,于是直接return instance。线程B拿到这个半成品指针去调用方法,而此时该对象的构造函数还未运行,虚表指针可能都是乱码,导致段错误崩溃。


有锁和std::atomic为什么还需要内存序?

当读者读完《问题引入》小节的时候可能大多数人都有一个疑问,那就是这些场景我使用锁使用std::atomic已经能完全解决这些问题,再学习一个内存序的话更像是杀鸡用牛刀多此一举。所以本小节就是讲解这个问题,在使用锁的时候我们知道锁其实在硬件指令层面上还是构建了一层或者两层的内存屏障,但是使用锁的同时还构建了控制流屏障(阻塞与唤醒),而内存序则是纯粹的只要执行顺序的正确,不需要使用阻塞和唤醒,这相比于使用锁的情况能节省更多的时间。而相比较于std::atomic类型,当我们使用std::atomic类型实例化变量时就相当于使用了内存序,只是把显示的内存序隐藏到对std::atomic对象的调用中,而std::atomic对象中默认调用的内存序对比自己编写的内存序会满3至10倍。具体的对比可以参考以下表格:

|---------------|------------------------|------------------------|-------------------------------------|
| 对比维度 | 互斥锁 (std::mutex) | std::atomic 默认模式 | 显式内存序 |
| 核心本质 | 操作系统管理的控制流同步(阻塞/唤醒) | 全局最强数据同步(顺序一致性) | 按需定制的数据同步(局部屏障) |
| 相对延迟开销 | 极高(微秒级) | 中等(百纳秒级,) | 极低(十纳秒级) |
| 并发读扩展性 | 差(读读互斥) | 较好(读读不互斥,但有全局屏障限制) | 极好(acquire 可并行读,relaxed 近似普通变量) |
| 编程心智负担 | 低(RAII 块保护) | 中(理解原子性边界) | 极高(需理解缓存、写缓冲区、依赖链) |
| 调试难度 | 低(死锁易检测) | 中(顺序问题较隐晦) | 灾难级(错误参数导致极难复现的随机崩溃) |
| 适用典型场景 | 业务逻辑、复杂数据结构、长耗时操作 | 无锁编程入门、简单全局标志、DCLP 单例 | 高频交易、无锁容器、引用计数、中断处理 |
| 是否涉及内核态切换 | 是(争用时) | 否 | 否 |

表1.互斥锁,std::atomic和内存序的对比


内存序是什么?

C++中的内存序(Memory Order)是多线程编程的核心概念,它定义了在多线程环境下,一个线程对内存的写入操作何时以及以何种顺序对另一个线程变得可见。而C++11标准定义了六种内存序,从最宽松到最严格如下表所示:

|--------------------------|---------------------------------------------|------------------------------|
| 内存序 | 核心作用 | 典型操作 |
| memory_order_relaxed | 最宽松,仅保证操作的原子性,无任何顺序或同步保证。 | load, store, fetch_add等 |
| memory_order_acquire | 获取语义。当前线程后续读写不得重排到该操作之前,且能看到其他线程的release写入。 | 仅用于load操作 |
| memory_order_release | 释放语义。当前线程先前读写不得重排到该操作之后,结果能被后续的acquire看到。 | 仅用于store操作 |
| memory_order_acq_rel | 兼具获取和释放语义,常用于读-修改-写(RMW)操作。 | fetch_add, compare_exchange等 |
| memory_order_seq_cst | 最严格、默认的内存序。提供全局统一的顺序一致性。 | 所有原子操作 |
| memory_order_consume | 消费语义,与acquire类似但更弱。不推荐使用,已在C++17中被弃用。 | load |

表2.C++中六种内存序

在C++17中内存序memory_order_consume由于编译器无法准确追踪所有"依赖关系",其实际实现均等同于memory_order_acquire,所以已经被弃用。所以在后续小节将不再对memory_order_consume进行讲解。


简要介绍std::atomic

由于内存序的使用还需要稍微了解一些std::atomic的内容,所以本小节将补充一些关于std::atomic所含的函数,具体如下:
1.std::atomic中的加载函数

cpp 复制代码
// 从原子变量读取值,支持指定内存序(默认 seq_cst)
T load(std::memory_order order = std::memory_order_seq_cst) const noexcept;

2.std::atomic中的存储函数

cpp 复制代码
// 向原子变量写入值,支持指定内存序(默认 seq_cst)
void store(T desired, std::memory_order order = std::memory_order_seq_cst) noexcept;

3.std::atomic中的交换函数

cpp 复制代码
// 将原子变量的值替换为desired,返回旧值
T exchange(T desired, std::memory_order order = std::memory_order_seq_cst) noexcept;

4.std::atomic中的比较和交换函数

cpp 复制代码
// 如果原子变量的值等于expected,则替换为desired,返回true;否则将当前值写入expected,返回 false
bool compare_exchange_weak(T& expected, T desired, std::memory_order success,
                           std::memory_order failure) noexcept;
bool compare_exchange_strong(T& expected, T desired, std::memory_order success,
                             std::memory_order failure) noexcept;
PS:需提供两个内存序,成功时的内存序和失败时的内存序。失败内存序不能比成功内存序更强

如何使用内存序?

正如小节《有锁和std::atomic为什么还需要内存序?》中所描述,为了更好的性能我们才需要去使用内存序,但是如果对内存序的理解不够则会引火烧身,本小节将教你如何使用C++中的内存序,具体如下:

1.memory_order_relaxed :仅保证原子性,无顺序约束。该内存序是单纯的计数器,当多个线程只对一个变量做纯粹的原子计数(如统计点击量、引用计数增减),且该变量与其他共享数据没有任何依赖关系时,用relaxed可以消除所有不必要的内存屏障开销

cpp 复制代码
std::atomic<int> counter{0};

void increment_many(int n) {
    for (int i = 0; i < n; ++i) {
        // 只保证递增操作是原子的,不保证与其他操作的顺序
        counter.fetch_add(1, std::memory_order_relaxed);
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i)
        threads.emplace_back(increment_many, 1000);
    for (auto& t : threads) t.join();
    std::cout << counter.load(std::memory_order_relaxed) << '\n'; // 总是10000
}

2.memory_order_acquire和memory_order_release :配对使用实现"发布-订阅"同步,这两个内存序主要是在生产消费者模型中使用。生产者先准备好一块数据,再"发布"一个标志。消费者看到标志后,才能安全地读取数据,这对内存序能保证数据可见性,同时避免昂贵的全局屏障

其主要原理是内存序release保证之前的所有写操作(包括对shared_payload的写操作)不会被重排到其后,而内存序acquire则保证之后的所有读操作不会被重排到其前,通过二者配对构成同步关系。

cpp 复制代码
std::atomic<bool> ready{ false };         // 同步用的原子标志
std::string shared_payload = "未发布信息"; // 非原子的共享数据

// 生产者
void producer() {
    shared_payload = "非常重要的消息";  
    ready.store(true, std::memory_order_release);
}

// 消费者
void consumer() {
    while (!ready.load(std::memory_order_acquire)) {
        std::cout << "等待发布";
    }
    // 一旦跳出循环,保证能看到shared_payload的最新值
    std::cout << shared_payload;
}

int main() {
    std::thread t1(producer), t2(consumer);
    t2.join();
    t1.join();
}

3.memory_order_acq_rel :当你用原子变量实现自旋锁、CAS 更新共享状态等"读-改-写"操作时,需要让该操作同时具备获取和释放语义,以确保前后代码的顺序不被破坏。

cpp 复制代码
struct Node { int value; Node* next; };
std::atomic<Node*> head{nullptr};

void push_front(int val) {
    Node* n = new Node{val, nullptr};
    // 读-改-写操作:获取当前 head,同时设置为 n
    // acq_rel 保证:成功写入时,之前的赋值对之后读取者可见
    n->next = head.load(std::memory_order_relaxed);
    while (!head.compare_exchange_weak(n->next, n,
               std::memory_order_acq_rel, std::memory_order_relaxed))
        ;
}

int main() {
    // 简单演示
    push_front(42);
    Node* front = head.load();
    assert(front->value == 42);
    delete front;
}

4.memory_order_seq_cst:全局统一顺序。该内存序提供最直观、最安全的保证,在所有线程中形成单一全序,最符合直觉,但开销最大 (尤其在 ARM 架构上)。

cpp 复制代码
std::atomic<bool> x{false}, y{false};
std::atomic<int> z{0};

void write_x() {
    x.store(true, std::memory_order_seq_cst);
}

void write_y() {
    y.store(true, std::memory_order_seq_cst);
}

void read_x_then_y() {
    while (!x.load(std::memory_order_seq_cst));
    if (y.load(std::memory_order_seq_cst))
        ++z;
}

void read_y_then_x() {
    while (!y.load(std::memory_order_seq_cst));
    if (x.load(std::memory_order_seq_cst))
        ++z;
}

int main() {
    std::thread a(write_x), b(write_y), c(read_x_then_y), d(read_y_then_x);
    a.join(); b.join(); c.join(); d.join();
    assert(z != 0); // z 至少为 1,在 seq_cst 下永远不会为 0
}

内存序实战

在本篇博客开头的小节------问题引入中,我们谈到了三个问题中能使用内存序来解决,分别是生产者-消费者模型中的幽灵数据,自旋锁失效和双重检测锁状态的问题。本小节就通过内存序来解决这些问题,具体如下:

1.生产者-消费者模型中的幽灵数据

cpp 复制代码
int shared_data = 0;               // 普通变量
std::atomic<bool> ready(false);    // 原子布尔变量

void producer() {
    shared_data = 42;                      // 步骤1:写数据
    ready.store(true, std::memory_order_release); // 步骤2:释放信号
}

void consumer() {
    while (!ready.load(std::memory_order_acquire)) { // 步骤3:获取信号
        // 忙等
    }
    std::cout << shared_data << std::endl; // 步骤4:读数据(保证读到 42)
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
    return 0;
}

2.自旋锁失效

cpp 复制代码
std::atomic_flag lock = ATOMIC_FLAG_INIT; // 初始化为 false(未上锁)
int counter = 0;                          // 被锁保护的普通变量

void worker() {
    // 自旋获取锁:用 test_and_set 原子地尝试设 flag 为 true,并返回旧值
    while (lock.test_and_set(std::memory_order_acquire)) {
        
    }
    
    // --- 临界区 ---
    // acquire 语义保证:此前所有内存读取不会重排到此之后,
    // 并且释放锁线程对 counter 的修改在此立即可见
    counter++;
    // --- 临界区结束 ---
    
    // 释放锁:clear 原子地将 flag 置为 false
    lock.clear(std::memory_order_release);
}

int main() {
    std::thread t1(worker);
    std::thread t2(worker);
    t1.join();
    t2.join();
    std::cout << counter << std::endl; // 必定输出 2
    return 0;
}

3.双重检测锁状态

cpp 复制代码
class Singleton {
public:
    static Singleton* getInstance() {
        // 第一次检查:使用 acquire 语义读取指针
        Singleton* tmp = instance.load(std::memory_order_acquire);
        if (tmp == nullptr) {
            std::lock_guard<std::mutex> lock(mtx);
            // 第二次检查:使用 relaxed 即可(锁已保证互斥)
            tmp = instance.load(std::memory_order_relaxed);
            if (tmp == nullptr) {
                tmp = new Singleton();
                // 发布指针:使用 release 语义,保证构造完成后再对其它线程可见
                instance.store(tmp, std::memory_order_release);
            }
        }
        return tmp;
    }

private:
    Singleton() {}
    static std::atomic<Singleton*> instance;
    static std::mutex mtx;
};
相关推荐
并不喜欢吃鱼2 小时前
从零开始C++----七.继承相关模型,解析多继承与菱形继承问题(下篇)
开发语言·c++
进击的荆棘2 小时前
递归、搜索与回溯——二叉树中的深搜
数据结构·c++·算法·leetcode·深度优先·dfs
进击的荆棘2 小时前
递归、搜索与回溯——回溯
数据结构·c++·算法·leetcode·dfs
郝学胜-神的一滴3 小时前
[简化版 Games 101] 计算机图形学 05:二维变换下
c++·unity·图形渲染·three.js·opengl·unreal
BestOrNothing_201511 小时前
C++零基础到工程实战(4.3.3):vector数组访问与遍历
c++·迭代器·stl·vector·动态数组
charlie11451419111 小时前
通用GUI编程技术——图形渲染实战(三十三)——Direct2D与Win32/GDI互操作:渐进迁移实战
c++·图形渲染·gui·win32
文祐11 小时前
C++类之虚函数表及其内存布局(一个子类继承一个父类)
开发语言·c++
墨尘笔尖13 小时前
最大最小值降采样算法的优化
c++·算法
YIN_尹15 小时前
【Linux系统编程】进程地址空间
linux·c++