atomic原子操作原理
# 42.atomic原子操作原理
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. atomic 的底层实现原理
- 4. lock-free 与 wait-free 的精确区别
- 5. CAS 与 ABA 问题
- 6. atomic<T> 对 T 的严格要求
- 7. atomic_ref —— 给非 atomic 变量套上原子外壳
- 8. 常用原子操作的全览与选型
- 9. 汇编全景与性能剖析
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 无锁栈的 ABA 幽灵
某高频交易系统的无锁栈——用 CAS 实现 push/pop。跑了一个月后出现订单重复处理——同一个订单被两个工作线程同时弹出:
// ====== 事故代码 V1:无锁栈的 ABA 问题 ======
struct Node { int value; Node* next; };
std::atomic<Node*> head = nullptr;
void push(int v) {
Node* n = new Node{v, nullptr};
do { n->next = head.load(); }
while (!head.compare_exchange_weak(n->next, n));
}
int pop() {
Node* n;
do { n = head.load(); }
while (n && !head.compare_exchange_weak(n, n->next));
int val = n->value;
delete n; // ① 释放节点
return val;
}
// pop 的竞态时序——ABA:
// 时刻 T1:线程 1 读到 head=A
// 时刻 T2:线程 2 弹出 A → delete A → push(B) → push(C) → C.next = 以及 B
// 时刻 T3:线程 2 把 A 的地址分配给新的 Node —— A 的地址被复用!
// 时刻 T4:线程 1 的 CAS head==A → 成功!但 A 现在是另一个对象了
// head = A(新) → A.next = B → 原来 head=C → C 丢失了!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
根因:CAS 只比较地址值——地址 A 在 T1 和 T4 之间经历了「释放→重新分配」——地址值相同但对象变了。CAS 成功但语义已错。
# 1.2 atomic 的编译错误
同一个团队试图把 std::string 放进 std::atomic:
// ====== 事故代码 V2:atomic 不接受 string ======
std::atomic<std::string> name("config"); // ❌ 编译错误!
// GCC 输出:
// error: static assertion failed: std::atomic requires a trivially copyable type
2
3
4
5
std::string 不是 trivially copyable——它内部有堆分配的指针。std::atomic 要求类型可以用一条 CPU 指令(如 lock cmpxchg)原子地读写——std::string 不够简单。
# 1.3 七个待解疑问
① std::atomic 在 x86 和 ARM 上分别怎么实现? LOCK 前缀到底锁了什么? → 第 3 章
② lock-free 和 wait-free 有什么区别? 怎么判断一个 atomic 是不是 lock-free? → 第 4 章
③ CAS 是什么? strong vs weak 有什么区别? ABA 问题怎么解决? → 第 5 章
④ atomic<T> 对 T 有什么要求? 为什么 string/vector 不行? → 第 6 章
⑤ atomic_ref 是什么? 给已有的非 atomic 变量加原子外壳? → 第 7 章
⑥ 原子操作有哪几种? 什么时候用 store/load、什么时候用 CAS? → 第 8 章
⑦ atomic 和 volatile 有什么区别? volatile 是原子的吗? → 第 8.3 / 第 10 章
2
3
4
5
6
7
# 2. 架构概览
# 2.1 atomic 的三层实现模型
┌────────────────────────────────────────────┐
│ std::atomic 实现架构 │
├────────────────────────────────────────────┤
│ ① 类型层:trivially copyable 约束 │
│ → sizeof(T) ≤ 平台最大值才可能 lock-free│
├────────────────────────────────────────────┤
│ ② 指令层:x86(LOCK前缀) / ARM(ldrex/strex)│
│ → 单条原子指令 or LL/SC 循环 │
├────────────────────────────────────────────┤
│ ③ 内存序层:memory_order 屏障 │
│ → 编译器屏障 + CPU fence │
└────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
# 2.2 为何这么切
疑惑:为什么需要三层——不能直接一条 lock cmpxchg 搞定吗?
论证:
- 类型层是编译期的安全网——如果 T 不是 trivially copyable,
memcpy(T)不是安全操作(拷贝构造有副作用)。atomic<T>必须能直接用lock cmpxchg复制 T 的字节——不需要调构造函数。 - 指令层是硬件依赖的最小化——x86 用 LOCK 前缀(总线锁或缓存锁),ARM 用 LDREX/STREX 的乐观并发。C++ 标准把这些差异封装在
std::atomic的单一接口下。 - 内存序层是正确性的主角——即使有了原子指令,没有正确的 memory_order,并发操作仍然可能错误(第 41 篇)。
结论:atomic 不是「一条汇编指令」——是三层协作:类型系统过滤不安全的使用、硬件提供原子性、内存序提供可见性。 三层缺一不可。
# 3. atomic 的底层实现原理
# 3.1 x86 上的 LOCK 前缀——总线锁与缓存锁
; fetch_add(3, relaxed) →
lock xadd [rax], 3 ; 原子地 *rax += 3
2
LOCK 前缀的两种模式:
① 总线锁(Bus Lock)——Intel 486/Pentium 时代
LOCK# 信号拉低——物理总线被独占
→ 所有的内存访问被暂停——包括其他核和 DMA
→ 极其昂贵——整个系统暂停
② 缓存锁(Cache Lock)——现代 CPU(P6 及以后)
如果操作数在 cache line 内——只锁该 cache line
→ 通过 MESI 协议:
1. 发出 RFO(Read For Ownership)→ 独占该 cache line
2. 执行原子操作
3. 其他核看到该 cache line 为 M 状态——等待 ACK
→ 只影响一个 cache line——不阻塞其他核的内存访问
2
3
4
5
6
7
8
9
10
11
12
关键优化:现代 CPU 对同一 cache line 内的对齐访问使用缓存锁——快 10-50× 于总线锁。
# 3.2 ARM 上的 LDREX/STREX——乐观锁
ARM 没有 LOCK 前缀——用加载独占/存储独占对(Load-Linked / Store-Conditional):
; ARMv8 的 fetch_add:
.Lretry:
ldaxr w0, [x1] ; ① Load-Exclusive-Acquire——读且标记独占
add w0, w0, #3 ; ② 本地加 3
stlxr w2, w0, [x1] ; ③ Store-Exclusive-Release——写且检查独占标记
cbnz w2, .Lretry ; ④ 如果 w2≠0 → 独占被打破 → 重试
2
3
4
5
6
LL/SC 的乐观并发语义:
ldaxr 标记地址 [x1] 为「独占监控」
stlxr 检查:
├─ 独占标记还在 → 写成功, 返回 0
└─ 独占标记被破坏 → 写失败, 返回 1(其他核在 ldaxr 和 stlxr 之间访问了 [x1])
2
3
4
ARMv8.1 新增了原子指令(如 ldadd)——不再需要 LL/SC 循环——性能更高。
# 3.3 编译器屏障与 CPU 屏障的组合
// acquire load = 编译器屏障 + CPU acquire fence
// 编译器层面:asm volatile("" ::: "memory") —— 阻止编译器重排
// CPU 层面:x86 的 mov 自带 acquire ——不需要额外 fence
// release store
// CPU 层面:ARM dmb ish; str ——需要显式屏障
2
3
4
5
6
# 3.4 sizeof(atomic) 什么时候等于 sizeof(T)
static_assert(sizeof(std::atomic<int>) == sizeof(int)); // ✅
static_assert(sizeof(std::atomic<void*>) == sizeof(void*)); // ✅
// 这些类型 <= 8 字节 —— 一条 lock cmpxchg 搞定
2
3
疑惑:为什么 atomic<T> 有时候比 T 大?内部的隐藏锁在哪?
论证——两种实现模式:
模式 A:lock-free(T ≤ 16 字节,且平台支持)
atomic<T> 内部只有一个 T 成员——sizeof 完全等于 T
x86: lock cmpxchg16b 原子操作
模式 B:有锁(T > 16 字节 或 平台不支持)
atomic<T> 内部有 1 个 T + 1 个 mutex(或 spinlock)
sizeof(atomic<T>) = sizeof(T) + sizeof(mutex) ≈ sizeof(T) + 40
每次 load/store = lock(mutex) + 读写 + unlock(mutex)
2
3
4
5
6
7
8
关键规则:如果 T 的大小 > 16 字节(多数平台)——atomic<T> 内部必然有隐藏的互斥锁——sizeof 会增加。
# 3.5 原子操作的三层正确性保证
层级 1:编译器层(volatile 替代品——不是!)
编译器不能把 atomic load 优化掉(即使值「已知」)
编译器不能把连续的 atomic store 合并成一个
层级 2:CPU 层(LOCK 前缀 / LL-SC)
保证操作在硬件层面的不可分割性
层级 3:缓存一致性层(MESI)
保证多核看到的内存是一致的——没有「stale cache」
2
3
4
5
6
7
8
9
# 4. lock-free 与 wait-free 的精确区别
# 4.1 定义与区别
| 属性 | lock-free | wait-free |
|---|---|---|
| 定义 | 至少一个线程在有限步完成 | 每个线程在有限步完成 |
| 饥饿 | 可能——CAS 循环可能被其他线程反复干扰 | 不可能——每个线程有操作上限 |
| 实现复杂度 | 中等 | 高 |
| C++ 标准支持 | ✅ is_lock_free() | ❌ 无标准检测 |
# 4.2 is_lock_free() 与 is_always_lock_free 的差异
// is_lock_free() —— 运行时检测(因为某些平台的某些类型在运行时才知道)
std::atomic<int> x;
bool b = x.is_lock_free(); // 运行时
// is_always_lock_free —— 编译期常量(C++17)
constexpr bool always = std::atomic<int>::is_always_lock_free; // ✅ true
2
3
4
5
6
# 4.3 各平台各类型的 lock-free 状态表
| 类型 | x86 | ARMv7 | ARMv8 | ARMv8.1+ |
|---|---|---|---|---|
| int | ✅ | ✅ | ✅ | ✅ |
| void* | ✅ | ✅ | ✅ | ✅ |
| double | ✅ | ✅ (8B) | ✅ | ✅ |
| pair<int,int> (8B) | ✅ | ✅ | ✅ | ✅ |
| pair<int,int> (16B) | ✅ (cmpxchg16b) | ❌ | ✅ (LSE) | ✅ |
# 4.4 当 atomic 不是 lock-free 时发生了什么
std::atomic<BigStruct> b; // 内部有隐式的互斥锁
// fetch_add 等价于:
BigStruct atomic_fetch_add(std::atomic<BigStruct>* a, BigStruct v) {
std::lock_guard<std::mutex> g(a->__internal_lock);
BigStruct old = a->__value;
a->__value += v;
return old;
}
2
3
4
5
6
7
8
9
信号处理函数中用这种 atomic = 死锁——因为同一线程无法重新获取已经持有的互斥锁。
# 5. CAS 与 ABA 问题
# 5.1 compare_exchange_strong vs compare_exchange_weak
// strong —— 只有值不匹配时才失败
while (!head.compare_exchange_strong(old, new)) { old = head.load(); }
// weak —— 可能「伪失败」(spurious failure——即使值匹配也返回 false)
// 原因:ARM 的 LDREX/STREX 可能因中断被打破
// weak 在循环中通常配合 load 使用——伪失败多一次重试,但省一次 compare
while (!head.compare_exchange_weak(old, new)) {}
// old 在失败后自动更新为最新值——不需要重新 load
2
3
4
5
6
7
8
性能:weak 在 x86 和 strong 等价(lock cmpxchg 没有伪失败)。ARM 上 weak 少一条 cmp+mov——在 CAS 循环中更优。
# 5.2 ABA 问题的精确复现
案例 1.1 的完整时序:
初始状态:head → A(1) → B(2) → C(3)
线程 1: pop() | 线程 2: pop() + push()
─────────────────────────────────|─────────────────────────────────
n = head → A | pop → A → delete A
CAS(head,A,A.next) → 暂停 | pop → B → delete B
| push(42) → Node D{42,next=C}
| 分配器复用 A 的地址!→ D 在 A 的地址
| push(99) → Node E{99,next=D}
现在 head → E(99) → D(42) → C(3)
|
CAS(head,A,A.next) |
→ read head → E |
→ 比较 head==A → false → 重试 | ← 还好!因为 head 不是 A 了
→ 但如果是更精确的时序——也许是:
CAS(head,A,A.next)
→ head 此刻恰好是 D(A的地址复用)
→ head==A → true (地址匹配!) → CAS 成功
→ head = A.next = B → 但 B 已经 delete 了!
→ E 和 C 丢失——head 指向已释放内存
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 5.3 三种解决方案的对比
| 方案 | 原理 | 代价 | 实现 |
|---|---|---|---|
| 双宽 CAS | CAS 同时检查指针+tag | 需要 128bit CAS (x86 cmpxchg16b) | 指针高位存 tag |
| 引用计数 | 释放前检查是否有人持有引用 | 每次读写都要原子增减 | RCU / hazard pointer |
| 内存回收 | 延迟释放——等 safe epoch | 内存占用更高 | epoch-based reclamation |
# 5.4 双字 CAS 的使用场景
struct TaggedPtr { Node* ptr; uint64_t tag; }; // 16 字节
std::atomic<TaggedPtr> head; // cmpxchg16b 保证 lock-free
// tag 解决 ABA——每次修改 tag+1——即使地址复用,tag 不同 → CAS 失败
2
3
# 6. atomic 对 T 的严格要求
# 6.1 trivially copyable 是硬门槛
// ✅ trivially copyable:
int, void*, double, pair<int,int>, struct { int a; double b; }
// ❌ 不是 trivially copyable:
std::string // 有堆指针、非平凡析构
std::vector // 同上
std::map // 同上
2
3
4
5
6
7
标准条款:[atomics.types.generic]/1——T 必须是 trivially copyable。
# 6.2 为什么 string 不能 atomic
std::atomic<std::string> 不能工作——因为 string 的拷贝涉及堆分配。lock cmpxchg 只能复制 16 字节的原始位——如果 string 有 SSO 缓冲区在对象内且 ≤16 字节——在特定平台上技术上可能工作——但标准不保证。依赖这种未定义行为是自找麻烦。
# 6.3 平台大小的上限
| 平台 | 最大 lock-free 大小 | 相关指令 |
|---|---|---|
| x86-64 | 16 字节 | lock cmpxchg16b |
| ARMv8.0 | 8 字节 | ldxp/stxp (多字 LL/SC) |
| ARMv8.1 | 16 字节 | caspl (LSE 扩展) |
| x86 (32位) | 8 字节 | lock cmpxchg8b |
# 6.4 is_always_lock_free 的编译期判断
template <typename T>
void safe_atomic_use() {
if constexpr (std::atomic<T>::is_always_lock_free) {
// 可以安全地在信号处理函数中使用
// 可以安全地用于共享内存
} else {
// 不能用于信号处理函数(内部有互斥锁)
// 可以用,但有锁的开销
}
}
2
3
4
5
6
7
8
9
10
# 7. atomic_ref —— 给非 atomic 变量套上原子外壳
# 7.1 atomic_ref 的设计动机
C++20 引入 std::atomic_ref<T>——包装已有的非原子对象,提供原子操作:
int shared_data = 0; // 普通变量——不是 atomic
void thread_fn() {
std::atomic_ref<int> ref(shared_data); // 临时套上原子外壳
ref.fetch_add(1); // 原子的!
} // ref 析构——shared_data 恢复为普通变量
2
3
4
5
6
# 7.2 使用约束与陷阱
- 对象必须对齐到
required_alignment——通常是sizeof(T) - 同一对象的所有
atomic_ref访问必须是原子的——不能混用普通访问和 atomic_ref 访问 atomic_ref本身不是线程安全的——不要在多个线程中同时修改同一个atomic_ref对象
# 7.3 与 atomic 的对比
| 维度 | atomic | atomic_ref |
|---|---|---|
| 对象所有权 | 拥有 T | 引用已有 T |
| 内存布局 | 可能和 T 不同 | T 保持原样 |
| 需要修改原类型 | ✅ | ❌ |
# 8. 常用原子操作的全览与选型
# 8.1 load / store / exchange / fetch_add / CAS 的完整目录
| 操作 | 语义 | 返回 | 典型用途 |
|---|---|---|---|
load() | 原子读 | 当前值 | 读标志位 |
store(v) | 原子写 | — | 写标志位 |
exchange(v) | 原子交换 | 旧值 | 获取+设新值 |
fetch_add(v) | 原子加 | 旧值 | 计数器、索引 |
fetch_sub(v) | 原子减 | 旧值 | 引用计数 |
compare_exchange_weak/strong(exp, v) | CAS | bool+exp更新 | 无锁结构 |
# 8.2 操作选型的决策树
需要做什么?
├─ 只读当前值 → load()
├─ 只写新值 (不关心旧) → store()
├─ 需要旧值来做决策 → exchange() 或 CAS
├─ 原子加减 → fetch_add / fetch_sub
├─ 复杂的条件更新 → CAS (weak in loop)
│ └─ 如: if (old == X) set to Y
└─ 需要同时更新两个变量 → 考虑双宽CAS 或 重新设计
2
3
4
5
6
7
8
# 8.3 原子操作与 volatile 的本质区别
volatile int x; // ❌ 不是原子的!
x++; // 多线程下 data race!
std::atomic<int> x; // ✅ 原子操作
x.fetch_add(1); // 线程安全
// volatile 的含义:防止编译器优化掉读写(如 MMIO)
// atomic 的含义:多线程安全 + 内存序保证
// 两者完全不相关——不要混用
2
3
4
5
6
7
8
9
# 9. 汇编全景与性能剖析
# 9.1 各操作的 x86/ARM 指令对照
| 操作 | x86 | ARMv8.0 | ARMv8.1+ |
|---|---|---|---|
load(relaxed) | mov | ldr | ldr |
store(relaxed) | mov | str | str |
exchange(seq_cst) | xchg | swpal | swpal |
fetch_add(relaxed) | lock xadd | LL/SC loop | ldadd |
| CAS | lock cmpxchg | LL/SC loop | casal |
# 9.2 benchmark——单核 vs 多核争抢的开销
100 万次 fetch_add:
| 场景 | x86 time | ARMv8 time | 说明 |
|---|---|---|---|
| 单核(无竞争) | 14 ms | 18 ms | 只锁缓存行——快 |
| 2 核争抢 | 32 ms | 48 ms | 缓存行在两个核间跳动 |
| 8 核争抢 | 120 ms | 210 ms | 缓存行在 8 个核间连续跳(ping-pong) |
# 10. 综合案例串讲
# 10.1 案例真相揭晓
| # | 疑问 | 答案 |
|---|---|---|
| ① | x86/ARM 实现? | 第 3 章:x86 LOCK 前缀(缓存锁/总线锁),ARM LDREX/STREX 乐观锁 |
| ② | lock-free vs wait-free? | 第 4 章:lock-free 有人最终完成、wait-free 每个人都有限步完成 |
| ③ | CAS/ABA? | 第 5 章:ABA 地址复用→弱于 strong→双宽 CAS/引用计数/延迟释放解决 |
| ④ | T 的要求? | 第 6 章:trivially copyable、大小≤平台上限、对齐要求 |
| ⑤ | atomic_ref? | 第 7 章:非 atomic 变量的临时原子外壳——不能混用普通访问 |
| ⑥ | 操作选型? | 第 8 章:load/store→基本/CAS→复杂条件/exchange→获取 |
| ⑦ | vs volatile? | 第 8.3:volatile 不是原子的——防止编译器优化≠多线程安全 |
案例①修复(ABA):用双宽 CAS(指针+tag)——每次修改 tag+1——地址复用被 tag 检测到。
案例②修复(atomicstd::atomic<std::string*> 或者自设计 trivially copyable 的字符串替代品(如固定 size 的 char 数组)。
# 10.2 原子操作的选型心智模型
三条原则:
- 类型先决:T 必须 trivially copyable,且
sizeof(T)≤ 平台上限 - 操作选序:只读→load,只写→store,加减→fetch,条件→CAS
- 内存序后定:先
seq_cst写对,再降级为 acquire/release
# 10.3 设计哲学回扣
哲学 1:原子性只是一半——可见性是另一半
std::atomic 保证了「读写的不可分割」。但如果没有正确的 memory_order——隔壁线程可能永远看不到你的修改。原子性 + 内存序 = 完整的线程安全。只写 atomic 不管 memory_order = 只做了一半的工作。
哲学 2:lock-free 不是免费的——它在缓存一致性协议上收费
lock-free 避免了互斥锁的系统调用——但代价是「缓存行在多核之间跳动」。8 核争抢同一个 atomic<int>——lock add 的延迟从 14ns 涨到 120ns。lock-free 的「free」指的是没有 OS 锁——不是没有代价。
哲学 3:CAS 是乐观锁——失败就要重试
CAS 的核心思想:先假设没有冲突、试试看、失败了再重试。 这和数据库的乐观锁、HTTP 的 conditional PUT——同一套哲学。乐观锁在高竞争下退化为「反复失败+重试」——这就是为什么高竞争场景下互斥锁反而更快。
# 10.4 速查表合集
atomic 关键 API:
| API | 调用方 |
|---|---|
is_lock_free() | 运行期检测 |
is_always_lock_free | 编译期常量 (C++17) |
wait / notify_one / notify_all | C++20 原子等待 |
fetch_add / fetch_sub | 原子加减 |
compare_exchange_weak / strong | CAS |
下一篇:原子操作的指令说清了。下一篇进入 43.mutex 与条件变量——mutex 的底层 futex 实现原理、自旋锁 vs 阻塞锁的切换阈值、recursive_mutex 和 shared_mutex 的适用场景、condition_variable 的 spurious wakeup 根因与正确用法。