C++ 内存顺序 (Memory Order)

六月 01, 2022 [c++] #c++ #内存模型 #atomic #并发

内存顺序问题有两个来源:编译器优化可能改变代码中读写指令的顺序;CPU 在运行时也可能重排指令(如 Tomasulo 算法)。单线程或使用 Mutex/Semaphore 时,编译器和 CPU 保证语义一致,开发者无需关心内存顺序;但当使用原子操作进行无锁编程时,内存模型的抽象被打破,开发者必须显式规约。

硬件内存模型

不同 CPU 架构的内存模型不同:

内存顺序的四种场景

CPU 对内存的访问只有 Load(读)和 Store(写),跨线程的顺序问题分为四种:

先后读-读读-写写-读写-写
读在先
写在先

原子操作

原子操作要么执行成功,要么尚未开始,不存在中间态,靠硬件指令(如 CAS)保证。C++11 通过 std::atomicmemory_order 枚举暴露给开发者。需要注意:对齐的整数读写不一定是原子的(除非明确用原子类型)。

基础术语

sequenced-before

同一线程内,语句 A 在语句 B 之前执行,且 A 的结果对 B 可见:

r2 = x.load(std::memory_order_relaxed); // A
y.store(42, std::memory_order_relaxed); // B  —  A sequenced-before B

happens-before

跨线程操作之间的先后顺序。如果 A happens-before B,则 A 的内存状态在 B 执行前对 B 可见。满足传递性。

synchronizes-with

变量被修改后的传播关系。一个线程修改某变量后,另一个线程能看到该修改,则两个操作满足 synchronizes-with。本质是跨线程的 happens-before。

carries dependency

同一线程内,表达式 B 的值依赖于表达式 A,则是 carries dependency。例如 c = *a + *b 中,c 依赖于 *a*b

C++11 六种内存序

memory_order_relaxed

最宽松:仅保证原子性,不提供任何跨线程同步。典型应用场景:多线程引用计数。

std::atomic<int> cnt = {0};
void f() {
    for (int n = 0; n < 1000; ++n)
        cnt.fetch_add(1, std::memory_order_relaxed);
}
// 10 个线程并发执行,最终 cnt == 10000 总是成立

memory_order_consume + release

仅同步有依赖关系的操作,比 release-acquire 开销更小。但编译器支持不完整,实际中多被提升为 acquire。

std::atomic<std::string*> ptr;
int data;

// 生产者
data = 42;
ptr.store(new std::string("Hello"), std::memory_order_release);

// 消费者
std::string* p2;
while (!(p2 = ptr.load(std::memory_order_consume)))
    ;
assert(*p2 == "Hello");   // ✅ *p2 依赖于 ptr,一定可见
assert(data == 42);        // ❌ data 无依赖,可能为 0

memory_order_acquire + release

最重要的搭配。release 线程中,该 store 之前的所有写,对 acquire 线程中该 load 之后的所有读都可见。

std::atomic<bool> ready{false};
int data = 0;

// 线程 A
data = 42;
ready.store(true, std::memory_order_release);    // release

// 线程 B
while (!ready.load(std::memory_order_acquire))   // acquire
    ;
assert(data == 42);  // ✅ 永不失败

规则:对原子写用 release,对原子读用 acquire。release/acquire 定义的是运行时的同步通知关系,比锁和信号量的静态规约更弱——不阻塞线程,只在运行时动态检查。

memory_order_acq_rel

acq_rel 是 acquire + release 的叠加,用于需要同时读旧值并写新值的原子操作(如 compare_exchange_strong):

std::atomic<int> flag = {0};
std::vector<int> data;

// 线程 1:写数据
data.push_back(42);
flag.store(1, std::memory_order_release);

// 线程 2:读旧值,写新值(充当"中间人")
int expected = 1;
while (!flag.compare_exchange_strong(expected, 2, std::memory_order_acq_rel))
    expected = 1;

// 线程 3:等待线程 2 完成
while (flag.load(std::memory_order_acquire) < 2)
    ;
assert(data.at(0) == 42);  // ✅

memory_order_seq_cst

顺序一致性,约束最强。同一线程内执行结果与程序顺序一致,且所有线程之间看到的全局操作顺序也一致。这是 C++ 原子操作的默认模型,代价最高。

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

// 两个写入线程 + 两个读取线程,
// 无论怎么调度,z 不可能同时为 0 —— 否则出现了非顺序一致的执行

速查表

内存序保证适用场景
relaxed仅原子性引用计数
consume + release带依赖的同步(不推荐)有明确数据依赖的场景
acquire + release写前的所有状态对读后的所有操作可见最常用
acq_rel同时具备 acquire 和 releaseCAS 中转线程
seq_cst全局顺序一致(默认)需要严格顺序时

volatile vs atomic

volatile 仅保证数据在内存中读写(防编译器优化),但不提供原子性,不提供跨线程同步。多线程并发读写 volatile 变量是数据竞争。

volatile int count = 0;
// 两个线程分别 ++ 和 -- 各 100 万次,最终 count 几乎不可能为 0

实战:用 release-acquire 取代锁的通知机制

假设线程 A 处理完数据后需要通知线程 B 消费,普通写法存在重排问题:

// ❌ 错误:编译器可能把 if(flag) 提到 data 读之前
int data = 0;
int flag = 0;

void thread_A() { data = 42; flag = 1; }
void thread_B() { if (flag == 1) printf("%d\n", data); }
// ✅ 正确:用 release-acquire 保证顺序
#include <atomic>
std::atomic_int flag(0);
int data = 0;

void thread_A() {
    data = 42;
    flag.store(1, std::memory_order_release);
}
void thread_B() {
    if (flag.load(std::memory_order_acquire) == 1)
        printf("%d\n", data);   // 一定能看到 42
}

注意:这段代码只能保证线程 A 的写在线程 B 读到 flag==1 时可见,但不保证线程 B 一定读到 flag==1(如果线程 A 还没来得及写)。如需确定性同步,用信号量或忙等待。

x86 特别说明

x86 体系下 store-load 以外的重排都被硬件禁止,因此:

一句话总结:基于锁的同步是在代码里静态约定行事顺序,基于原子操作和内存序是在运行时动态发现行事顺序。


参考链接: