☰
C++内存序的操作方法
2026/10/15 15:06:21 网站建设 项目流程

前言


只要涉及多线程,就绕不开一个问题:我在 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。原因有两层:



  1. 编译器重排。在没有同步关系的前提下,编译器完全可以认为data = 42与ready.store(true)互不影响,从而把 store 提到前面;也可能认为data在 consumer 的自旋循环里不会被改动,把它提升到循环外只读一次。

  2. 硬件重排。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 会发生什么":




写法原子性跨变量顺序是否 UB



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

2ready.store(true, std::memory_order_relaxed);后直接读普通变量data:数据竞争,UBready.store(true, std::memory_order_release);,读端ready.load(std::memory_order_acquire)

3int expected = 0;后直接counter.compare_exchange_weak(0, 1, ...)必须传左值:int expected = 0; counter.compare_exchange_weak(expected, 1, ...)

4flag.compare_exchange_weak(exp, des, std::memory_order_release, std::memory_order_acquire)(失败序强于成功序)失败序用relaxed:成功序acq_rel、失败序relaxed

5给store传acquire、给load传releasestore只接受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承接

8std::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)。


总结




需求该用的 order说明



只要一个计数不撕裂,别的不管relaxed无同步,读普通变量必 UB

生产者发布数据、消费者读取release/acquire最常见的组合,性能与正确性兼顾

用 CAS 改一个变量acq_rel(成功)/relaxed(失败)失败序不能强于成功序

多个原子变量之间需要全局一致seq_cst默认值,最保守也最慢

同步点不便于塞进单个原子操作atomic_thread_fencerelease/acquire 屏障各一个

想用volatile——不要用,它和线程同步无关



内存序的本质是"用一个原子变量把同步关系传递给你真正关心的那批普通数据"。正确顺序是:先用seq_cst保证正确性,用 ThreadSanitizer 之类的工具验证,再在清楚每次放宽牺牲了什么的前提下逐处放松。任何"我猜这里 relaxed 应该也行"的改动,都该先当作 UB 来怀疑。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询