xiedeacc
发布于 2025-06-27 / 7 阅读
0
0

现代C++中的内存序

内存顺序对比表

内存顺序

适用操作

同步保证

典型用途

seq_cst

任意

全局顺序一致性

默认选项,需要强保证时

acquire

加载(load)

确保在该加载操作之后才读取非原子数据,以保证看到生产者线程Release操作之前的所有写入

读取发布数据(配合release)

release

存储(store)

保之前的所有内存操作(如数据写入)在该原子操作前完成

发布数据(配合acquire)

acq_rel

读-修改-写

兼具acquire+release

原子RMW操作

consume

加载(load)

依赖顺序(理论)

不推荐使用

relaxed

任意

仅原子性

计数器等无同步需求

1. std::memory_order_relaxed

最弱的内存序,仅保证原子性和修改顺序一致性,不提供同步和顺序约束。

示例1: 计数器

std::atomic<int> counter{0};

// 多个线程执行
void increment_counter() {
    for (int i = 0; i < 1000; ++i) {
        counter.fetch_add(1, std::memory_order_relaxed);
    }
}

// 读取计数器,可能读到中间值,但最终结果是准确的
int read_counter() {
    return counter.load(std::memory_order_relaxed);
}

注意:​​ 这个读取操作可能在任何时候返回计数器当前的值,但不与其他操作有顺序约束。

2. std::memory_order_consume (已弃用,不推荐使用)

本意是建立数据依赖关系,但实际中编译器几乎总是将其提升为memory_order_acquire。因此,我们跳过其示例,直接使用memory_order_acquire

3. std::memory_order_acquirestd::memory_order_release

这对内存序用于建立同步关系:一个线程的释放操作与另一个线程的获取操作配对,使得释放操作之前的所有写操作(包括非原子写)对获取操作之后的读操作可见。

示例2: 通过原子标志位传递非原子数据

std::atomic<int*> data{nullptr};
std::atomic<bool> ready{false};

// 线程1:生产者
void producer() {
    int* p = new int(42);
    // 先初始化非原子数据
    *p = 42; 
    // 释放操作:确保上面的写操作在释放之前完成,并且对获得这个标志为true的线程可见
    data.store(p, std::memory_order_relaxed); // 可以用relaxed,因为下面有release
    ready.store(true, std::memory_order_release); // 释放:之前的写入对获取的线程可见
}

// 线程2:消费者
void consumer() {
    // 循环直到数据就绪
    while (!ready.load(std::memory_order_acquire)) { 
        // 等待
    }
    // 到这里,ready的获取操作成功,线程1中release之前的所有写操作在此处都可见
    int* p = data.load(std::memory_order_relaxed);
    assert(*p == 42); // 一定会成功
    delete p;
}

3.1 std::memory_order_acquire 和 std::memory_order_release的例子中,假如线程2 ready.load(std::memory_order_acquire)改成ready.load(std::memory_order_relax)会有问题吗?

假设重排:

consumer 中:

  1. 读取 ready 的值为 true(但用的是 relaxed,所以没有任何屏障阻止之后的读操作重排到之前)。

  2. 读取 data 得到指针 p

  3. 读取 *p(即 p 指向的内存)。

然而,由于编译器和处理器的优化,步骤1(relaxed 加载)不能阻止下面的乱序:

  • 编译器和处理器可能把步骤3(*p)的重排到步骤1(ready.load(relaxed))之前执行(只要在单线程内语义不变)。这意味着可能在 ready 还没有为 true 的时候就去读 *p(但此时 p 还没有被设置? 注意我们还有 data 的读取,这里还需要进一步分析)。

更准确的乱序可能性:

  • producer 中的写操作可能被重排(但 release 会阻止重排到 store(true) 之后)。

  • consumer 中,由于 ready.load(relaxed) 没有获得语义,它之后的任何读操作(包括 data.load(relaxed)*p)都可能被重排到它之前。但是,即使重排到它之前,在循环等待时我们只关心 ready 的值,所以不影响等待逻辑。但是最关键的是:​即使读到了 readytrue,却不能保证看到 producer 线程在 release 之前写入的数据(因为缺少 acquire 操作带来的可见性保证)​。

另一种错误:延迟的写入可见性

  • consumer 线程执行 ready.load(relaxed) 并读到了 true,但由于没有 acquire 语义,其他核心可能还没有看到 producer 线程在 release 之前对 *p 的写入(42)。

  • 所以,consumer 读取 *p 时可能读到旧值(比如内存中该地址尚未更新为42)或者初始值(比如0或垃圾值),导致断言失败。

4. std::memory_order_acq_rel

用于读-修改-写操作,同时具有获取和释放语义。即,它既同步之前的释放操作,又同步之后的获取操作。

示例3: 自旋锁实现

class SpinLock {
    std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
    void lock() {
        while (flag.test_and_set(std::memory_order_acq_rel)) {
            // 自旋
        }
    }
    void unlock() {
        flag.clear(std::memory_order_release);
    }
};

注意:test_and_set是读-修改-写操作,使用memory_order_acq_rel可以保证锁的获取操作(lock)具有获取语义,即在锁内操作之前,不能将锁内操作重排到加锁之前;而解锁操作(unlock)使用释放语义,锁内操作不能重排到解锁之后。同时,使用acq_rel可以保证多个线程对锁的竞争是公平的(修改顺序一致)。

但实际上,锁的获取通常需要获取语义,释放需要释放语义,所以这里memory_order_acquirememory_order_release也可以,但acq_rel更准确地表达了读-修改-写同时具有两种语义。

5. std::memory_order_seq_cst

顺序一致性:默认选项,所有线程看到相同的操作顺序,且所有操作(包括非原子操作)不允许重排越过原子操作。

示例4: 顺序一致性的典型例子

std::atomic<bool> x = {false};
std::atomic<bool> y = {false};
std::atomic<int> z = {0};

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

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

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

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

int main() {
    std::thread a(write_x);
    std::thread b(write_y);
    std::thread c(read_x_then_y);
    std::thread d(read_y_then_x);
    a.join(); b.join(); c.join(); d.join();
    assert(z.load() != 0); // 可能为0吗?
}

在顺序一致性下,所有操作(#1到#6)都按一个单一的全局顺序执行,且每个线程的操作顺序在全局顺序中保持不变。因此,在read_x_then_y中,看到x为真后,y一定为真(因为#1在#2之前发生)?但实际并非如此,因为线程a和b是并行的,两个写操作顺序未定。

然而,由于是顺序一致性,全局顺序中要么#1在#2之前,要么#2在#1之前。如果是#1在#2之前,那么read_y_then_x在循环退出(即看到#2为真)后,检查#1(x)也必定为真,因为#1在#2之前发生,所以#1已经被执行。反之亦然。因此,两个线程read_x_then_yread_y_then_x中至少有一个会执行++z。所以assert(z.load() != 0)永远不会触发。

但是,如果使用更弱的内存序(例如release/acquire),则上述断言可能触发。因为弱内存序下,不同的线程可能看到不同的操作顺序,可能导致一个线程看到x为真而y仍为假,另一个线程看到y为真而x仍为假,那么两个if条件都不成立,z将保持0。

总结

  • relaxed:仅原子,无同步。

  • release/acquire:配对使用,在两个线程之间建立同步关系。

  • acq_rel:用于读-修改-写,同时具有获取和释放语义。

  • seq_cst:全局顺序一致,最强约束,默认选项。

注意:实际编程中,应尽量使用默认的seq_cst,除非性能测试表明需要更弱的内存序。更弱的内存序虽然能提升性能,但正确性难以保证。

void incorrect_producer_relaxed() {
    data = 42;
    // 危险:使用relaxed存储,没有同步语义
    ready.store(true, std::memory_order_relaxed);
}

void correct_consumer() {
    while (!ready.load(std::memory_order_acquire)) ; // 使用acquire
    std::cout << data << std::endl; // 可能输出0,而不是42!
}

#include <atomic>
#include <thread>
#include <iostream>

std::atomic<bool> x{false};
std::atomic<bool> 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);
    std::thread b(write_y);
    std::thread c(read_x_then_y);
    std::thread d(read_y_then_x);
    a.join(); b.join(); c.join(); d.join();
    std::cout << "z = " << z << std::endl;
}

请注意在上述例子中,还是有可能因为cache之间的同步延迟导致结果为0,即运行read_x_then_y线程cpu本地cache有y的旧值,运行read_y_then_x的线程cpu本地cache有x的旧值。seq_cst情况下,不会发生指令重排


评论