c++并发--原子操作,内存顺序,屏障

1.三个原子类型

1.1.std::atomic_flag

最简单、保证无锁(is_lock_free() 恒为 true)的原子布尔标志。

c 复制代码
std::atomic_flag f = ATOMIC_FLAG_INIT;   // 唯一合法初始化方式(C++20 前)
// C++20 起可用 std::atomic_flag f;  默认初始化为 clear
  • 只有两个操作:test()(C++20,读)、clear()(写,参数为 memory_order,不允许 release/acq_rel/seq_cst 以外?------准确说 clear 只允许 relaxed/release/seq_cst)、test_and_set()RMW,读-改-写)。
  • C++20 前没有 test(),所以它是一个纯"测试并设置"的标志位,用于实现自旋锁:
c 复制代码
class spinlock {
    std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
    void lock()   { while (flag.test_and_set(std::memory_order_acquire)) {} }
    void unlock() { flag.clear(std::memory_order_release); }
};

acquire限制了lock后,临界区的内容不会重排序到lock前,release限制了unlock前临界区的内容不会重排到unlock后。

  • 不能拷贝、赋值、交换。

1.2.std::atomic(T 为整型/指针/浮点等 trivially copyable 类型)

通用原子封装,操作:load / store / exchange / compare_exchange_strong / weak / fetch_add / fetch_sub / fetch_and / fetch_or / fetch_xor / operator++ 等。

  • 不可拷贝、不可赋值(拷贝构造/拷贝赋值被删除),但有一个非原子的 operator=(T)(等价于 store(r, seq_cst))。
  • 可能不是无锁的(用锁实现),可用 is_lock_free() 查询。

1.3.std::atomic<T*>(特化)

指针特化,支持所有通用操作,外加指针算术:

c 复制代码
std::atomic<int*> p;
p.fetch_add(3);      // 前进 3 * sizeof(int)
p.fetch_sub(1);      // 后退
p += 2; p -= 1;      // 等价形式
++p; --p;            // 自增自减
  • fetch_add/fetch_subRMW 操作,返回修改前的旧指针。
  • 注意内存序约束:指针运算只保证对指针值本身的原子性,不保证指针所指对象的生命周期安全(ABA 问题的根源之一)。

2.五种基本操作与 memory_order 矩阵

六种 memory_order

顺序 归类
relaxed 无同步
consume load 类(C++26 已弃用/移除提案,实践中等价 acquire)
acquire load
release store
acq_rel RMW 专用
seq_cst 全能,默认且最强

约束规则(编译期强制):

  • store 只允许:relaxed / release / seq_cst
  • load 只允许:relaxed / consume / acquire / seq_cst
  • RMW 允许全部六种
  • 传错顺序是 UB(运行期断言/编译期检查实现定义,但标准上违规即未定义行为)

2.1.load 四种顺序

c 复制代码
T load(memory_order order = seq_cst) const noexcept;
顺序 语义
relaxed 仅保证"读到某个时刻的真实值"(原子性),无任何顺序保证。可读到任意时刻的值,不与其他操作排序。
consume 保证对该原子变量依赖该读值 的后续操作不被重排到读之前(data dependency ordering)。允许硬件上比 acquire 更便宜的优化(如 ARM/PowerPC 上不需要内存屏障)。注意:编译器常把 consume 提升为 acquire(std::kill_dependency 也几乎无用),C++26 起 consume 被移除/弃用,实践中直接用 acquire。
acquire 建立"获取"语义:本线程中所有 后续(程序顺序在后)的读写不能被重排到本次 load 之前。典型用途:读取标志位后安全读取共享数据。
seq_cst acquire 全部语义 + 全局单一总顺序(single total order)。所有线程看到的 seq_cst 操作序列一致。最慢(x86loadseq_cst 通常免费,store 需要 xchg/mfence)。

2.2.store 三种顺序

c 复制代码
void store(T desired, memory_order order = seq_cst) noexcept;
顺序 语义
relaxed 原子地写入,其他线程不会读到撕裂值,但无发布语义。前后代码可自由穿越这个 store 重排。
release 建立"释放"语义:本线程中所有 之前的读写不能被重排到本次 store 之后。与另一个线程的 acquire-load 形成 happens-before(配对使用)。
seq_cst release 语义 + 参与全局总顺序。保证"所有 seq_cst store 按同一顺序被所有线程观察到"。

释放-获取配对(Dekker/Peterson 式同步的基石):

c 复制代码
// 线程 1                          // 线程 2
data = 42;                         if (flag.load(acquire)) {
flag.store(true, release);    ──▶      // data 必为 42(happens-before)
                                   }

2.3.exchange(RMW)

c 复制代码
T exchange(T desired, memory_order order = seq_cst) noexcept;

原子地"写入新值并返回旧值",一步到位。任何顺序都允许,因为它同时是读和写:

  • 只用 relaxed:当只需原子交换、不需要发布数据时;
  • acq_rel:若既要从旧值"获取"、又要向新值"释放";
  • seq_cst:参与全局总顺序(默认)。

2.4.compare_exchange_strong / weak(CAS)

c 复制代码
bool compare_exchange_strong(T& expected, T desired, memory_order success, memory_order failure) noexcept;
bool compare_exchange_strong(T& expected, T desired, memory_order order = seq_cst) noexcept; // failure = order 但不得强于 success
bool compare_exchange_weak  (同上);

语义:原子地执行 if (current == expected) { current = desired; return true; } else { expected = current; return false; }

  • 失败时 expected 会被改写为当前值------这是用 CAS 循环更新状态的机制。
  • 失败序必须是 load 类顺序(relaxed/consume/acquire/seq_cst),且不允许强于成功序。

strong vs weak 的区别:

strong weak
假失败(spurious failure) 不允许 允许 (在某些平台如 ARM/LL-SC 架构上,即使 *this == expected 也可能返回 false)
开销 可能更重(需要内部循环保证) 更贴近硬件,单条 LL/SC 指令
使用场景 只调用一次、不能容忍假失败 写循环时 (必须包在 while 里)
c 复制代码
// weak 必须这样用:
int old = head.load(relaxed);
while (!head.compare_exchange_weak(old, new_node,
                                   memory_order_release,
                                   memory_order_relaxed)) {
    // old 已被刷新为最新值,循环继续
}

无锁栈的 push 就依赖这个模式。

3.RMW 六种顺序逐一解析

RMWread-modify-write,如 fetch_add、exchange、CAS、++x)的语义 = "读的顺序" + "写的顺序"的组合:

顺序 读侧语义 写侧语义 典型场景
relaxed 纯计数器(统计调用次数),只需结果不丢更新
consume 数据依赖序 ---(C++ 标准对 RMW 的 consume 写侧无意义) 罕见,一般直接用 acquire
acquire 获取语义 读旧值以判断状态,但不发布新数据
release 释放语义 发布新数据,但不关心读到什么旧值
acq_rel 获取 + 释放 同左 同时拥有:读到旧值后开始临界区(acquire),写入新值结束临界区(release)。无锁引用计数增减的经典选择
seq_cst 获取 + 全局总序 释放 + 全局总序 需要跨线程严格一致顺序时(如 Dekker 算法互斥双方的两个 RMW)
c 复制代码
// 引用计数 release 场景
void unref() {
    if (count.fetch_sub(1, std::memory_order_acq_rel) == 1)
        delete this;   // 我是最后一个:之前的 release 操作(其他线程的 store)
}                      // 已同步,可以安全析构

关键点:fetch_add(1, memory_order_release) 的含义是"本次加法结果对其他做过 acquire 的线程可见",而不是"加法操作之前的代码对任何线程有序"------它是单边的。

4.std::atomic 对用户自定义类型的要求(C++20 前 vs C++20 起)

C++11/14/17POD 限制

std::atomic<T> 对自定义 T 的(C++17 及之前)要求是:

  • is_trivially_copyable_v<T>true(即可以用 memcpy 复制、无自定义拷贝构造/虚函数等);
  • 可复制构造、可移动赋值(std::memcpy 语义);
  • 所有基类子对象和数据成员都满足上述条件;
  • 不能是数组(指针可以);
  • compare_exchangebitwise 比较(memcmp 语义),而不是 operator==------因此不能有 padding 中的不确定值,否则 CAS 可能永远失败(padding 字节每次拷贝不同)。
  • T 必须平凡默认构造(C++17 起要求 is_default_constructible 的平凡默认构造)。
  • 需要 atomic<T> 特化可用,否则操作 = 全局 std::atomic<T> 模板的默认实现(用内部锁),is_lock_free() 返回 false。

C++20 起:要求放宽

std::atomic<T> 的合法 T 变为:任何 is_trivially_copyable_v<T> 且可平凡默认构造的类型------即 union、含位域的结构体(bit-field)等也变得可用,并且 volatile 限定语义整理。

C++20 新增 std::atomic_ref<T>:对已存在对象的原子引用(不要求对象本身是 atomic),要求同样是 trivially copyable

实践中自定义原子类型的正确姿势

c 复制代码
struct Config {
    int a;
    double b;
    char name[16];
    // 不能有 virtual、不能有自定义拷贝/析构
    // 注意对齐问题:
} __attribute__((aligned(8)));   // 让 sizeof 对齐到 8 的倍数,减少 padding 差异

static_assert(std::is_trivially_copyable<Config>::value, "");
std::atomic<Config> cfg;

padding 陷阱:

c 复制代码
struct Bad {
    char c;   // 1 字节 + 7 字节 padding
    int  i;
};
std::atomic<Bad> x;
Bad expected = x.load();
while (!x.compare_exchange_weak(expected, new_val)) {} // 可能死循环!padding 每次不同

解决方案:手工填充(char pad[7])、alignas、或改用成员级原子变量。

C++26 视角(当前时间点):

std::atomic_flagtest() 已加入;memory_order_consumestd::kill_dependency 已被弃用(实践中编译器把 consume 一律升级为 acquire,标准委员会放弃了修复它的努力);

atomic<T> 的自定义类型要求保持不变(trivially copyable)。

5.一张总表:每种操作允许的顺序

操作 relaxed consume acquire release acq_rel seq_cst
store
load
exchange
compare_exchange_*
fetch_add/sub/and/or/xor

默认全是 seq_cst------不写顺序就是最强的,写无锁代码时这是最常见的性能 bug 来源(x86storeseq_cstrelease 多一条 mfence/lock xchgARM 上差距更大)。

6.选择建议(实战)

  • 计数器(只统计,不发布数据):fetch_add(1, relaxed)。
  • 一次性初始化标志位:store(true, release) + load(acquire)(比 seq_cstcall_once 更便宜)。
  • 无锁队列/栈的 push(发布节点):CASreleaseacq_rel,失败侧 relaxed
  • 自旋锁:test_and_set(acquire) / clear(release)
  • 只有当你需要"所有线程看到完全一致的交错顺序"(如实现 Dekker 互斥)才用 seq_cst
  • 永远不要手动写 consume,直接 acquire

7.内存顺序语义详细解释

7.1.relaxed ------ 唯一的规则是"不撕裂"

c 复制代码
前面的代码  ┆  这个原子操作  ┆  后面的代码
           (随便穿越,随便换序)
  • 只保证一件事:这个读/写本身是原子的,别的线程不会读到"写了一半的值"(对 int 不会出现 0x000012340x56780000 拼出来的怪值)。
  • 前后代码可以任意穿越它重排。比如 x.store(1, relaxed) 前后算好的普通变量,别的线程读到 1 时完全可能还没算好。

7.2.acquire ------ "后面的不许跑到我前面"(只约束后半边)

c 复制代码
前面的代码  →  这个原子读  ║←屏障→║  后面的代码
     (可以穿越下去)              (不许穿越上来)
  • 规则:本操作中之后的所有读写,禁止重排到本操作之前。
  • 含义:我"获取"到了某个信号(读到了某个值),那么读信号之后做的事,逻辑上必须真的发生在读信号之后。

7.3.release ------ "前面的不许跑到我后面"(只约束前半边)

c 复制代码
前面的代码  ║←屏障→║  这个原子写  →  后面的代码
     (不许穿越下去)             (可以穿越上来)
  • 规则:本操作之前的所有读写,禁止重排到本操作之后。
  • 含义:我把数据都算好、摆好了(前面的代码),然后"释放"出一个信号(写入标志位)。别人看到信号时,数据必须已经摆好了。

7.4.acq_rel ------ 同时是 acquire 又是 release(两头都堵)

c 复制代码
前面的代码  ║←屏障→║  读+写  ║←屏障→║  后面的代码
     (不许下去)                  (不许上来)
  • 前面不许穿下去 + 后面不许穿上来。
  • 关键:它只约束本线程内的重排,不约束"所有线程看到的全局顺序"------这是它和 seq_cst 的本质区别。

7.5.seq_cst ------ acq_rel 再加强制"全球统一剧本"

  • 拥有 acq_rel 的全部约束;
  • 额外加一条:所有线程的所有 seq_cst 操作,在所有线程眼中都按同一个顺序发生。
c 复制代码
// 线程1                    // 线程2
a.store(1, seq_cst);       b.store(1, seq_cst);
r1 = b.load(seq_cst);      r2 = a.load(seq_cst);

7.6.结合硬件底层解释seq_cst与acq_rel

每个核心里有一个 store buffer(写缓冲)------一个几十项的小队列。执行 x = 1 时:

  • 核把 x=1 放进自己的 store buffer,指令立刻"退休",流水线继续跑下一条;
  • 缓冲里的写稍后才异步发给缓存系统,真正变成"全局可见"可能滞后几十到几百个周期;

也就是说:"指令执行了" ≠ "别的核能看见"。写有一个"到达全球"的时差。

seq_cststore 在硬件上等于"带全屏障的写":

  • x86lock 前缀指令(如 lock xchg)或 mfence。作用:排空本核整个写缓冲,等所有先前的写真正进入缓存系统、拿到 coherence point 的确认,store 才退休。
  • ARMstlr 之外还需 dmb ish 级全屏障(RMW 形式的 seq_cst 常在指令后再补一道)。

效果体现在写的一端:执行 x.store(1, seq_cst) 的核必须站在原地等 x=1 全球落地,才能执行下一条指令。cststore操作既修改所在核的store buffer,还需同步执行发送修改给缓存系统,达成缓存一致性,才继续下一条指令。

这里还有几点需要注意:

  • T上的写操作执行了异步发送,但此异步发送被其他各个核所感知到的时间点也会因为核间距离不同而有所差异。
  • 但相同的源和目标下,先发出的异步通知,相比后发出的异步通知必然会被目标核先感知到。
  • 对写操作,逻辑上通过release来担保过去。作为对比,对读,逻辑上通过acquire来担保未来。分别担保一半。

8.操作原子语义

操作 读? 写? 为什么
store(x) W 只写入,不管旧值是什么
load() R 只读取,不改变值
exchange(x) R W 先读旧值,再写入新值,一步完成
compare_exchange(e, d) R W 读当前值来比较,相等则写入
fetch_add(1) R W 读出旧值、写入旧值+1

为什么 store 行的空白格是 acquire / consume / acq_rel

  • store 没有读部分。acquire 的语义是"我读到信号了,后面的事不许提前"------store 什么都没读,"获取"谁呢?这个约束没有任何意义,标准直接禁止(用了就是 UB)。
  • release 有意义:"写之前的准备工作不许重排到写之后"。✓
  • acq_rel = acquire + release,acquire 半边无意义 → 禁止。
  • seq_cst 有意义:它含 release 语义 + 全球总序。✓

为什么 load 行的空白格是 release / acq_rel

  • load 没有写部分。release 的语义是"我写出一个信号,之前的准备必须已就位"------load 什么都没发布,release 无对象。
  • acquire 有意义:"我读到信号了,之后的消费不许提前"。✓
  • seq_cst 有意义:含 acquire + 全球总序。✓

为什么 RMW 行(exchange / CAS / fetch_*)六个全 ✓?

组合 读侧约束 写侧约束 场景
fetch_add(1, relaxed) 纯计数,只求不丢更新
fetch_add(1, acquire) 后面的不许提前 读到旧值后才开始干活,但不对外发布什么
fetch_add(1, release) 前面准备不许滞后 算好东西,写入新值即发布
fetch_add(1, acq_rel) 后面的不许提前 前面准备不许滞后 一次操作既"进场"又"出场",如引用计数减到 1 后析构
fetch_add(1, seq_cst) 以上全部 + 全球总序 同上 需要所有线程一致剧本

8.std::atomic_thread_fence

8.1.它是什么

c 复制代码
void atomic_thread_fence(std::memory_order order) noexcept;   // <atomic>

一句话:它只施加"顺序约束",本身不是任何读/写/RMW

  • 编译器视角:一道编译屏障------屏障两侧的内存操作不许互相跨越;
  • 硬件视角:一道 CPU 屏障------视顺序强度插入 mfence / dmb 等指令;
  • 它不操作任何变量,必须和(通常 relaxed 的)原子变量配合使用才有跨线程意义。

它和"给原子操作传 memory_order"的本质区别:

操作自带的顺序 独立栅栏
作用对象 只约束那一个原子操作 约束它两侧的所有内存操作
位置 焊死在操作上 自由摆放------可以在循环外、函数边界、热路径之外
典型用途 常规同步 把昂贵的序从热路径挪走,做精细优化

8.2.六种顺序的栅栏语义

栅栏 语义
fence(relaxed) 空操作(只 compiler barrier 级别都算不上,无跨线程效果)
fence(consume) 数据依赖序(已弃用,同 acquire)
fence(acquire) 之后的所有读写,不许重排到栅栏之前
fence(release) 之前的所有读写,不许重排到栅栏之后
fence(acq_rel) 两者兼有
fence(seq_cst) acq_rel + 参与全局总序 S

栅栏是单边约束,配对规则才是灵魂(见下)。

8.3.核心配对规则:栅栏怎么"借力"relaxed 原子操作

栅栏自己不是原子操作,无法被"读到",所以它需要找一个 relaxed 原子操作当载体:

c 复制代码
线程 A                                    线程 B
普通写 x = 42;                            while (y.load(relaxed) != 1) {}
fence(release);                   			  fence(acquire);         
y.store(1, relaxed);                     普通读 r = x;  // 保证 42

和标准教科书例子的对照:

c 复制代码
// 等价于 ready.store(1, release) + ready.load(acquire)
x = 42;                                            // 数据准备
std::atomic_thread_fence(std::memory_order_release);
ready.store(true, std::memory_order_relaxed);      // 信号(载体)

// 对端
while (!ready.load(std::memory_order_relaxed)) {}
std::atomic_thread_fence(std::memory_order_acquire);
assert(x == 42);   // 保证成立

效果与 release/acquire 版完全等价,区别只在约束的落点:release 版本把顺序焊在 ready 这一次操作上;栅栏版本把顺序摊给了栅栏两侧的一切操作。

8.4.一句话总结

  • 原子操作的 memory_order 把顺序"焊在操作上";
  • atomic_thread_fence 把顺序"撒在两操作之间"。
  • 前者够用、好读;后者让你在多变量、热路径、批量同步的场景里,以更低的成本精确铺设 happens-before 的边界。
相关推荐
张小姐的猫1 小时前
【AI大模型接入SDK】 —— SQLite上手
linux·开发语言·c++·人工智能·python·log4j
hansang_IR1 小时前
【题解】[JSOI2016] 最佳团体(分数规划)
c++·算法
代码中介商1 小时前
C++ 预约系统实战(二):TCP + JSON 自定义协议设计
c++·网络协议
渡我白衣2 小时前
Util工具类功能设计与类设计
linux·服务器·网络·c++·人工智能·目标检测·机器学习
小王的创意工坊2 小时前
C++ 在线判题平台(OJ)测试报告
c++·可用性测试
sdm0704272 小时前
仿muduo库实现高并发服务器-下
开发语言·网络·c++·多路转接
爱和冰阔落2 小时前
【Linux】手写日志与固定线程池:任务队列、工作线程和安全退出
linux·运维·c++·redis·安卓
All for pursuit.2 小时前
【矩阵-2】240.搜索二维矩阵 II
数据结构·c++·算法·leetcode
程序猿阿森2 小时前
C语言预处理完全指南:宏定义、条件编译与头文件规范,一篇搞懂工程化编程
c语言·c++·编译