C++ 内存顺序 (Memory Order)
六月 01, 2022 [c++] #c++ #内存模型 #atomic #并发内存顺序问题有两个来源:编译器优化可能改变代码中读写指令的顺序;CPU 在运行时也可能重排指令(如 Tomasulo 算法)。单线程或使用 Mutex/Semaphore 时,编译器和 CPU 保证语义一致,开发者无需关心内存顺序;但当使用原子操作进行无锁编程时,内存模型的抽象被打破,开发者必须显式规约。
硬件内存模型
不同 CPU 架构的内存模型不同:
- x86_64 (TSO):强顺序模型,只有 Store-Load 可以被重排,因此天然具有 acquire/release 语义。程序员在 x86 上调不出的并发 bug,在 ARM 上可能必现。
- ARM / PowerPC / MIPS:弱内存模型 (WMO),允许各种重排,程序员必须主动插入内存屏障。
内存顺序的四种场景
CPU 对内存的访问只有 Load(读)和 Store(写),跨线程的顺序问题分为四种:
| 先后 | 读-读 | 读-写 | 写-读 | 写-写 |
|---|---|---|---|---|
| 读在先 | ✓ | ✓ | — | — |
| 写在先 | — | ✓ | ✓ | ✓ |
- Release 语义:写操作之前的读写不能重排到写之后(即约束 write-* 和 read-before-write)
- Acquire 语义:读操作之后的读写不能重排到读之前(即约束 *-read 之后)
原子操作
原子操作要么执行成功,要么尚未开始,不存在中间态,靠硬件指令(如 CAS)保证。C++11 通过 std::atomic 和 memory_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 和 release | CAS 中转线程 |
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 以外的重排都被硬件禁止,因此:
- x86 天然提供 acquire/release 语义
- 在 x86 上
memory_order_relaxed的行为几乎等同于seq_cst - relaxed 带来的问题在 x86 上很难复现,必须到 ARM 手机上才能测出来
一句话总结:基于锁的同步是在代码里静态约定行事顺序,基于原子操作和内存序是在运行时动态发现行事顺序。
参考链接: