前言
只要涉及多线程,就绕不开一个问题:我在 A 线程里写下的这句赋值,B 线程到底什么时候能看到、按什么顺序看到。这个问题在单线程里根本不存在,因为单线程下语言保证的是顺序一致(sequential consistency)的假象;一旦跨线程,编译器指令重排、CPU 乱序执行、store buffer、缓存一致性协议都会参与进来。
关于内存序(memory order),流传最广的两个误解是:
- "用
volatile就能保证线程间可见性。" 错。volatile只约束编译器不要优化掉对该对象的访问,它既不提供原子性(atomicity),也不建立 happens-before 关系。两个线程同时读写同一个volatile变量,依然是数据竞争(data race),标准规定这是未定义行为(UB)。
- "用了
std::atomic就万事大吉。" 只对了一半。原子性和顺序性是两码事:默认的std::memory_order_seq_cst同时给你两者,可一旦你手写了std::memory_order_relaxed,就只保留了"单个变量的读改写不会被撕裂",所有跨变量的顺序保证全部放弃。
本文按"为什么需要内存序 → 六种 memory_order 各自保证什么 → 工程里怎么写"这条线展开。代码以 C++17 为基准,在 GCC 13 / Clang 17 / MSVC 19.3x 上均可编译(GCC/Clang 需要加-pthread)。
一、普通变量的访问为什么会"看不见"
先看一段几乎所有人第一次写并发时都会写出的代码:
// ❌ 有数据竞争,是 UB
#include <atomic>
#include <iostream>
#include <thread>
std::atomic<bool> ready{false};
int data = 0;
void producer() {
data = 42; // 普通写
ready.store(true, std::memory_order_relaxed); // 原子写,但只保证原子性
}
void consumer() {
while (!ready.load(std::memory_order_relaxed)) {
}
std::cout << data << '\n'; // UB:读 data 与写 data 构成数据竞争
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
}
这段代码能编过,跑起来大概率也打印 42,但它仍然是 UB。原因有两层:
- 编译器重排。在没有同步关系的前提下,编译器完全可以认为
data = 42与ready.store(true)互不影响,从而把 store 提到前面;也可能认为data在 consumer 的自旋循环里不会被改动,把它提升到循环外只读一次。
- 硬件重排。x86 的 store buffer 会让写操作"看起来"延迟提交,ARM/PowerPC 的弱内存模型允许的乱序范围更大。
要让data的写入对 consumer 可见,就必须在两个线程之间建立synchronizes-with(同步于)关系,进而得到happens-before(先于发生)。release写和acquire读就是干这个的:
// ✅ 正确:release/acquire 建立同步关系
#include <atomic>
#include <iostream>
#include <string>
#include <thread>
std::atomic<bool> ready{false};
std::string payload; // 普通(非原子)对象
void producer() {
payload = "hello from producer"; // ① 普通写
ready.store(true, std::memory_order_release); // ② release 写
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) { // ③ acquire 读
std::this_thread::yield();
}
std::cout << payload << '\n'; // ④ 一定看到 "hello from producer"
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
}
编译命令(GCC/Clang):
g++ -std=c++17 -O2 -pthread release_acquire.cpp -o demo
这里的关键不是"原子变量本身的值对不对",而是:
- release 语义保证 ① 的所有写操作,不会被重排到 ② 之后;
- acquire 语义保证 ④ 的所有读操作,不会被重排到 ③ 之前;
- ③ 读到了 ② 写入的值,于是 ② 与 ③ 同步,进而 ① happens-before ④,
payload的写入对 consumer 可见。
注意payload是普通的std::string,一行原子代码都没用上。这正是内存序的价值:你不需要把共享数据本身变成原子类型,只需要用一个原子变量把同步关系"传递"过去。
二、六种 memory_order 的语义
std::memory_order是定义在<atomic>里的一个枚举(有_t后缀的别名std::memory_order本身就是枚举名,不带_t)。取值和语义如下:
relaxed | 读、写、读改写 | 仅保证本次操作本身是原子的,不建立任何同步关系 | 计数器、统计量、只需"不撕裂"的场景 |
consume | 读 | 按数据依赖(dependency chain)排序;C++17 起标准明确建议不要使用,实现一般直接按acquire处理 | 实际工程中不要用 |
acquire | 读 | 之后的读写不能重排到它之前;与 release 写配对时读到其值即建立同步 | 读端,配合 release |
release | 写 | 之前的读写不能重排到它之后 | 写端,配合 acquire |
acq_rel | 读改写(RMW) | 同时具备 acquire 与 release 语义 | fetch_add、compare_exchange等 |
seq_cst | 全部 | 在 acquire/release 之上,还保证所有 seq_cst 操作构成一个全局一致的全序 | 默认值,需要"全局顺序"时使用 |
几个必须记住的边界:
relaxed不是"没有同步",而是"只有原子性"。用relaxed保护普通变量(上面第一段代码)会得到数据竞争,是 UB,不是"结果不确定但还能跑"。
acquire只能用于读操作,release只能用于写操作。把一个release传给load,或者把acquire传给store,属于"order 参数与该操作允许的取值不符",标准规定行为未定义——编译器通常不会报错,这是最阴的一类坑。
memory_order_consume在 C++17 起被标准明确列为不推荐使用(因为无法在现有编译器上有效实现),实现把它当acquire处理即可。写新代码不要用它。
seq_cst是默认值。不给第二个参数时,load/store/fetch_add全都用seq_cst。它最贵的地方在于需要在写端插入全屏障(x86 上是mfence或lock前缀指令),读端也要阻止与其它 seq_cst 读的重排。
下表对比"同一段代码用不同 order 会发生什么":
ready.store(true, relaxed)+ 普通data = 42 | 有 | 无 | ❌ UB(数据竞争) |
ready.store(true, release)+ 普通data = 42 | 有 | release 之前的写不后移 | ✅ 配合 acquire 读 |
ready.load(acquire)自旋后再读普通data | 有 | 同步建立后可见 | ✅ |
volatile bool ready+ 普通data = 42 | 无 | 无 | ❌ UB,且原子性也没有 |
ready.store(true, seq_cst)+ready.load(seq_cst) | 有 | 全序 + 同步 | ✅ 最保守 |
再补充两个 C++17 起才有的便利设施:
std::atomic<T>::is_always_lock_free是 C++17 新增的static constexpr bool成员,在编译期就能判断该特化是否始终无锁;运行期的is_lock_free()是 C++11 就有的。
std::atomic<T>既不可拷贝也不可移动(拷贝构造和拷贝赋值都被删除)。想放进容器就用std::vector<std::atomic<int>>配合resize是不行的(atomic不满足MoveInsertable),只能std::vector<std::unique_ptr<std::atomic<int>>>或std::deque之类。
- C++20 给原子类型加了
wait/notify_one/notify_all,可以替代"自旋 +yield";std::atomic<std::shared_ptr<T>>也是 C++20 才有的。
三、工程里的四种典型写法
3.1 读改写(RMW)与 CAS 循环
compare_exchange_weak是唯一"可能伪失败(spurious failure)"的操作,因此必须放在循环里:
#include <atomic>
#include <iostream>
int main() {
std::atomic<int> counter{0};
int expected = counter.load(std::memory_order_relaxed);
int desired = 0;
do {
desired = expected + 1;
} while (!counter.compare_exchange_weak(expected, desired,
std::memory_order_acq_rel,
std::memory_order_relaxed));
std::cout << counter.load(std::memory_order_relaxed) << '\n'; // 输出 1
}
逐行核对几个容易写错的点:
expected必须是左值,类型与counter的值类型一致(这里是int)。传字面量会编译失败。
compare_exchange_weak失败时会把"当前值"写回expected,所以循环下一轮用的expected是自动更新的,不需要手动重新load。
- 四参数版本里,成功序是
acq_rel,失败序是relaxed。标准要求失败序不能是release或acq_rel,也不能比成功序更强,否则也是 UB。
compare_exchange_strong不会伪失败,但在某些架构上需要额外的循环,反而更慢;"无所谓快慢、只是不想写循环"时用 strong。
3.2 用std::atomic_flag写自旋锁
std::atomic_flag是唯一被标准保证永远无锁的原子类型:
#include <atomic>
#include <thread>
class SpinLock {
public:
void lock() noexcept {
while (flag_.test_and_set(std::memory_order_acquire)) {
std::this_thread::yield(); // 让出时间片,降低缓存行争抢
}
}
void unlock() noexcept {
flag_.clear(std::memory_order_release);
}
private:
std::atomic_flag flag_ = ATOMIC_FLAG_INIT; // C++17 的写法
};
test_and_set的签名是bool test_and_set(std::memory_order order = std::memory_order_seq_cst) noexcept;
clear的签名是void clear(std::memory_order order = std::memory_order_seq_cst) noexcept;
- 加锁用
acquire、解锁用release是唯一正确的组合:解锁(临界区内的写)必须 happens-before 下一个加锁成功的线程。
- C++20 起
std::atomic_flag可以值初始化得到 clear 状态,ATOMIC_FLAG_INIT被弃用;C++17 必须用宏。
- 这个锁不公平(没有排队机制),且只在临界区极短时才有意义;临界区里一旦有阻塞调用,就会白白烧 CPU。
3.3 用 fence 达到相同效果
如果同步点不好塞进某一次原子操作(比如循环里统一判断),可以用std::atomic_thread_fence:
#include <atomic>
#include <string>
std::string payload;
std::atomic<bool> ready{false};
void producer() {
payload = "data";
std::atomic_thread_fence(std::memory_order_release); // 屏障:之前的写不后移
ready.store(true, std::memory_order_relaxed); // 这里 relaxed 就够
}
void consumer() {
while (!ready.load(std::memory_order_relaxed)) {
}
std::atomic_thread_fence(std::memory_order_acquire); // 屏障:之后的读不前移
// 此处读 payload 是安全的
}
fence 的语义是"屏障",它本身不操作任何变量,只约束重排。上面这段之所以正确,是因为 release fence 与 acquire fence 之间夹着同一个原子对象的 relaxed 写 / relaxed 读,标准明确规定了这种情况下的 synchronizes-with。另外还有一个std::atomic_signal_fence,它只约束编译器、不生成 CPU 屏障,用于和信号处理函数配合,不能用来做线程间同步。
3.4 什么时候真的需要seq_cst
判断标准很简单:你需要"不可能出现两个线程都认为自己是第一个"这种全局一致性吗?
Dekker 风格的互斥标志、需要多线程对多个原子变量的修改顺序达成一致的场景,才必须用seq_cst。单纯的生产者—消费者传递数据,release/acquire 已经完全够用。libstdc++ 在 AArch64 上会把seq_cst的 store 编译成stlr加屏障,而release只需要stlr,这正是"能放宽就放宽"的收益来源。
反过来,如果拿不准,先写默认的seq_cst,确认正确性之后再逐处放宽——顺序是"先对,再快",不是反过来。
常见坑点
| 1 | 用volatile bool当线程间标志位:volatile bool ready = false; | 用std::atomic<bool> ready{false};,volatile既不原子也不建立 happens-before |
| 2 | ready.store(true, std::memory_order_relaxed);后直接读普通变量data:数据竞争,UB | ready.store(true, std::memory_order_release);,读端ready.load(std::memory_order_acquire) |
| 3 | int expected = 0;后直接counter.compare_exchange_weak(0, 1, ...) | 必须传左值:int expected = 0; counter.compare_exchange_weak(expected, 1, ...) |
| 4 | flag.compare_exchange_weak(exp, des, std::memory_order_release, std::memory_order_acquire)(失败序强于成功序) | 失败序用relaxed:成功序acq_rel、失败序relaxed |
| 5 | 给store传acquire、给load传release | store只接受relaxed/release/seq_cst,load只接受relaxed/acquire/seq_cst,传错是 UB |
| 6 | 用compare_exchange_weak但不写循环:伪失败时把值丢了 | 一定包在do { ... } while (!cas(...));里 |
| 7 | 把std::atomic<int>塞进std::vector直接push_back:atomic不可拷贝/移动,编译失败 | 用std::vector<std::unique_ptr<std::atomic<int>>>或std::deque承接 |
| 8 | std::atomic<double> d; d.fetch_add(x, ...)想当然地写(C++17 只支持 load/store/exchange/CAS) | 浮点fetch_add是 C++20 才有的;C++17 下写 CAS 循环,或改用std::atomic<int>定点数 |
第 8 条展开一下,C++17 下的写法是:
#include <atomic>
#include <cmath>
std::atomic<double> balance{0.0};
void add(double delta) {
double cur = balance.load(std::memory_order_relaxed);
double next = cur + delta;
while (!balance.compare_exchange_weak(cur, next,
std::memory_order_release,
std::memory_order_relaxed)) {
next = cur + delta; // 失败时 cur 已被更新,必须重算 next
}
}
再补两条不算"写法错误"但会显著影响正确性和性能的:
- 自旋等待时不写
std::this_thread::yield(),在单核或超售环境里就是纯粹的时间片浪费;正确做法是退避(backoff)或直接用 C++20 的atomic::wait。
- 两个频繁被不同线程写、又恰好落在同一个缓存行(cache line)上的原子变量会产生伪共享(false sharing),可以用
alignas(std::hardware_destructive_interference_size)把它们分开——注意这个常量是 C++17 的,具体取值是实现定义的(GCC/Clang 上通常是 64)。
总结
| 只要一个计数不撕裂,别的不管 | relaxed | 无同步,读普通变量必 UB |
| 生产者发布数据、消费者读取 | release/acquire | 最常见的组合,性能与正确性兼顾 |
| 用 CAS 改一个变量 | acq_rel(成功)/relaxed(失败) | 失败序不能强于成功序 |
| 多个原子变量之间需要全局一致 | seq_cst | 默认值,最保守也最慢 |
| 同步点不便于塞进单个原子操作 | atomic_thread_fence | release/acquire 屏障各一个 |
内存序的本质是"用一个原子变量把同步关系传递给你真正关心的那批普通数据"。正确顺序是:先用seq_cst保证正确性,用 ThreadSanitizer 之类的工具验证,再在清楚每次放宽牺牲了什么的前提下逐处放松。任何"我猜这里 relaxed 应该也行"的改动,都该先当作 UB 来怀疑。