1. 先搞清楚:原子操作到底在“原子”什么
1.1 从一次多线程事故说起
我之前维护过一个多线程统计服务,逻辑很简单:多个线程各自处理数据,最后往一个共享计数器里累加。最初是用 int count 直接加,结果跑到一半发现总数对不上,有时候少几千,有时候少几万。当时第一反应是“加锁不就行了”,于是上了 std::mutex,问题确实没了,但性能掉得厉害,因为每个线程每处理一条数据都要抢锁,锁的竞争开销比实际累加还高。
后来把计数器换成 std::atomic<int>,用 fetch_add 累加,性能和正确性都解决了。但真正让我停下来的问题是:为什么换成原子变量就好了?它底层到底做了什么? 如果你只是把原子操作当成“不用加锁的线程安全变量”,那遇到复杂场景(比如自旋锁、无锁队列、引用计数)一定会踩坑。这篇我把从比指令层再往下的那套机制拆开讲清楚。
1.2 “原子”的本质:不可分割的读-改-写
先从最朴素的角度理解“原子”。一个操作被称为原子,指的是它在执行过程中不可被中断,其他线程要么看到它执行之前的状态,要么看到它执行之后的状态,不存在中间状态。
问题在于,现代 CPU 上最常见的操作并不是一条指令就能完成读改写。比如 count++ 在指令层面通常被拆成三步:
- 从内存把数据加载到寄存器;
- 在寄存器里执行加 1;
- 把寄存器结果写回内存。
如果两个线程同时走到第 2 步,两个线程都读取到相同的旧值,各自加 1,再写回。结果就是明明执行了两次自增,最终却只增加了 1。这种经典问题叫“丢失更新”,多线程并发下你防不住它,纯粹靠写代码时“注意顺序”是不行的。
原子操作要解决的就是这件事:把“读-改-写”这三步在逻辑上打包成一个无法分割的单元,要么整段执行完,要么干脆不执行。但这里有个现实问题,CPU 和内存之间隔了很多层缓存和总线,光靠软件是管不住硬件的,编译器得配合 CPU 提供的特殊指令才能做到这一点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 硬件层:CPU 到底是靠什么实现原子性的
2.1 总线锁:最朴素的思路
要理解原子指令,得先看看 CPU 是怎么和内存交互的。最早期的处理器实现原子操作,思路很直接:在读改写期间,把总线锁住,让其他 CPU 内核无法访问内存。
具体做法是,处理器在执行一条需要原子性的指令前,向总线发送一个 LOCK# 信号,持有这个信号期间,其他处理器对内存的读写请求都会被阻塞。等到指令执行完,释放总线锁,其他处理器才能继续访问内存。这就是“总线锁”。
优点是实现简单,CPU 只要在指令级别加一个锁信号就行。缺点是代价太大:锁总线期间,所有处理器都在等,这等于把并行执行降级成了串行执行。而且如果每次自增都要锁整个总线,后果就是性能断崖式下跌。
所以在现代 CPU 里,总线锁已经很少是首选方案了,它更多作为一种兜底机制存在,真正高频使用的是下面的缓存锁。
2.2 缓存锁与 MESI 协议
现代 CPU 每个核心都有自己的缓存,数据先进入 L1/L2/L3,最后才写回内存。如果原子操作要修改的数据已经存在某个核心的私有缓存里,那其实没有必要锁整条总线,只需要保证其他核心不会读到过期的缓存数据就行。
这就是缓存一致性协议的用武之地。以最常见的 MESI 协议为例,每个缓存行有四种状态:
- M(Modified):缓存行被本核心修改,与内存不一致,本核心持有最新数据;
- E(Exclusive):缓存行只存在于本核心,内容与内存一致;
- S(Shared):缓存行在多个核心中存在,内容与内存一致;
- I(Invalid):缓存行无效,访问时需要重新从内存或其他核心加载。
当一个核心想独占修改某个缓存行时,它会先发送“读独占”请求,让其他核心将对应缓存行置为无效。一旦该缓存行处在 M 或 E 状态,这个核心后续对这个缓存行的修改就可以放心地做,因为其他核心已经知道自己手里的副本失效了。
基于这个机制,CPU 在设计原子指令时就不一定非要锁总线了:只要能在原子操作期间保证目标缓存行不被其他核心改动,并且操作结束后其他核心能看到最新值,就可以达到原子性。这被称为“缓存锁”。x86 指令中的 LOCK 前缀在现代 CPU 上往往就没有真正锁总线,而是锁住缓存行,效率高很多。
2.3 x86 和 ARM 的原子指令家族
不过,光有缓存一致性协议还不够,CPU 得提供具体指令给程序员用。不同架构的处理方式完全不同,这也是理解原子操作底层实现的关键分水岭。
x86 系列的思路是:给普通指令加一个 LOCK 前缀,或者使用本身就带原子性质的交换指令。常见的有:
LOCK ADD或LOCK INC:原子自增;LOCK XADD:原子交换并累加,fetch_add就映射到这类指令;LOCK CMPXCHG:原子比较并交换,compare_exchange映射到它;XCHG:交换两个寄存器和内存的值,这条指令即使不加LOCK前缀也是原子的。
ARM 系列走的是另一条路线:Load-Exclusive / Store-Exclusive,简称 LL/SC。它不是一次性完成读改写,而是先把内存读到寄存器,同时打上一个独占标记;等寄存器计算完,再执行条件写入(Store-Exclusive),如果写的时候发现独占标记还在,说明中间没有别人动过这块内存,写入成功;如果标记丢了,说明其他核心改过这个地址,写入失败,需要重新从头来一遍。
cpp复制// 伪代码,ARM 上实现原子自增的循环
retry:
ldrex r0, [r1] // 独占加载
add r0, r0, #1 // 加 1
strex r2, r0, [r1] // 条件存储,r2=0 表示成功
cmp r2, #0
bne retry // 失败则重试
后来 ARM 架构引入了 LSE(Large System Extension),新增了 LDADD、CAS、SWP 这类指令,才慢慢向“一条指令完成原子操作”靠拢。但即使到了 ARMv8 的很多实现里,LL/SC 依然是基础,LSE 只是可选的加速扩展。
提示:同样是“原子自增”,x86 上用一条
LOCK ADD,ARM 上可能是个循环。所以写 C++ 原子代码时,不能臆断“我这段代码在所有平台指令数都一样”,性能表现也可能完全不同。
3. C++ 层:std::atomic 是怎么包装底层能力的
3.1 编译器在中间扮演什么角色
CPU 提供原子指令之后,接下来轮到编译器上场。C++11 引入 std::atomic 之前,大家要么用平台相关的内建函数,比如 GCC 的 __sync_fetch_and_add,要么直接用汇编。现在有了标准库之后,std::atomic 相当于在“C++ 源码”和“CPU 指令”之间建立了一套统一的映射关系。
这套映射关系非常重要,因为不是每个操作都必须变成一条原子指令才能保证正确性,有些原子操作在特定架构下可以退化成普通指令。例如 x86-64 下,atomic.load() 的 memory_order_relaxed 和 memory_order_acquire 实际上就是一条普通 mov,因为 Intel 的内存模型本来就不允许加载操作被乱序到其他加载前面,软件层面不需要额外指令。
但这不代表你可以自己写 volatile int 来代替。volatile 只是告诉编译器“这个变量可能会在外部被修改,不要优化掉”,它不保证读改写整体是原子的,更不保证多核之间的顺序一致性。std::atomic 则明确告诉编译器:这个操作需要具备内存序语义,编译时必须生成对应的指令或屏障。
3.2 内存序是给谁看的
说到原子操作,绕不开的就是 memory_order。很多初学者把内存序理解为“CPU 内部指令重排”,其实不全对。memory_order 至少约束两层:
- 编译器层:编译器在生成汇编之前会做大量优化,比如把无关的读写指令交换顺序、合并操作、循环展开等。如果不告诉编译器哪些操作不能重排,它可能会把普通变量访问和原子操作搅在一起,结果就是你在源码里写的顺序,到汇编里就变了。
- CPU 层:CPU 执行时还会做乱序执行、写缓冲合并等优化,多个核看到的操作顺序可能不一致。某些架构(比如 ARM)默认的内存序较弱,需要显式加屏障指令
DMB或使用带 acquire/release 语义的加载/存储指令来约束。
换句话说,内存序是“你和编译器、CPU 三方之间的一份协议”。你声明了希望以什么顺序被其他线程观察到,编译器负责把它变成指令,CPU 负责按指令语义执行。
比如我用 memory_order_release 做 store:
cpp复制std::atomic<bool> ready{false};
// ... 写入一些数据 ...
ready.store(true, std::memory_order_release);
这句话告诉编译器:放在 store 之前的那些普通写操作,不允许被移动到 store 之后。在 ARM 上,这通常会生成 STLR 指令,它自带 release 语义;在 x86 上,编译器多半依靠自己的指令调度保证顺序,因为 x86 的普通 store 本身就带一定的顺序性。
再用 memory_order_acquire 做 load:
cpp复制if (ready.load(std::memory_order_acquire)) {
// 这里读取的数据,保证能看到上面 store 之前写的所有内容
}
同样,ARM 会生成 LDAR,x86 则通常只需要 mov + 编译器屏障。
内存序一共有六种,常用的是 relaxed、consume、acquire、release、acq_rel、seq_cst。其中 seq_cst 是最强的,保证所有线程对原子操作有一个全序,但代价也最大。默认情况下 std::atomic 所有操作都使用 seq_cst,如果对性能敏感,能准确判断场景时再降级到 acquire/release 或 relaxed。
3.3 不同架构下的指令映射
把概念落回实际,我用一个最简单不过的例子展示同一段 C++ 代码在不同平台下的生成结果。
cpp复制#include <atomic>
std::atomic<int> counter{0};
void add() {
counter.fetch_add(1, std::memory_order_relaxed);
}
void store_value() {
counter.store(42, std::memory_order_seq_cst);
}
x86-64 环境(clang/GCC 默认面向通用 x86-64) 下的生成结果大致如下:
asm复制add():
lock addl $1, counter(%rip)
ret
store_value():
movl $42, %eax
xchg %eax, counter(%rip)
ret
这里有几个值得注意的细节:
fetch_add用的是lock addl,不是先读后写,CPU 保证LOCK前缀在指令执行期间锁定缓存行;store_value的 seq_cst store 没有用普通mov,而是用了xchg。因为顺序一致性要求“任何后续的读操作都不能被重排到该 store 之前”,而 xchg 自带全屏障语义。如果用普通 mov,x86 的 store 虽然大概率顺序正确,但也可能会影响后面的 load 顺序,编译器必须保守处理;load如果是 seq_cst,则通常直接mov,因为 x86 的加载本身不会与后面的操作乱序太多,加上编译器屏障就够了。
ARM64 环境 下的生成结果则是另一套:
asm复制add():
add x0, x1, #1 ; 先加载到寄存器再加
ldadd w0, w0, [x1] ; LSE 指令:原子加
ret
store_value():
stlr w0, [x1] ; 带 release 语义的 store
ret
注意,ARM64 上如果没有 LSE 扩展,fetch_add 会退化成 ldrex/strex 的循环。所以“同样的 C++ 代码,性能在不同平台差距很大”这句话真不是夸张,尤其是对 fetch_add、compare_exchange 这类读改写操作来说。
4. 实操:用工具观察原子操作的真正面目
4.1 编译并反汇编一个最小示例
学习底层实现最有效的方式,不是看文档,而是自己生成的汇编。这里我演示一个简单流程,用 x86-64 上的 GCC 来观察。
先准备一个源文件:
cpp复制// atomic_demo.cpp
#include <atomic>
std::atomic<unsigned long long> counter{0};
void relaxed_add() {
counter.fetch_add(1, std::memory_order_relaxed);
}
void acq_rel_add() {
counter.fetch_add(1, std::memory_order_acq_rel);
}
void seq_cst_add() {
counter.fetch_add(1, std::memory_order_seq_cst);
}
然后编译并生成汇编:
bash复制g++ -O2 -std=c++17 -S atomic_demo.cpp -o atomic_demo.s
在生成的 .s 文件里搜 relaxed_add,你会看到 lock addq;搜 seq_cst_add,仍然是 lock addq。因为对于 fetch_add 本身,无论哪种内存序,CPU 都必须用原子指令,区别主要在于是否会额外插入屏障指令来约束其他读写。
如果你的平台是 ARM,建议用交叉编译器或直接在树莓派上执行同样命令,看到 ldadd 或 ldrex/strex 循环时,成就感会强很多。
4.2 为什么 seq_cst 是默认的,以及代价
std::atomic 默认内存序为 seq_cst,这是一个非常安全但也非常保守的默认值。安全在哪里?它能保证所有线程对原子操作的观察顺序一致,不存在“我改完你还没看到”这种因果倒挂现象。
代价在哪里?看一个典型场景。两个线程各写一个原子变量,另一个线程按顺序读这两个变量,如果用 seq_cst,编译器可能要在每个原子 store/load 后面插入全屏障;如果只是某个线程需要发布一个标志位告诉别人“数据准备好了”,那就只要 release/acquire 就够了。
一个常见的经验是:能用 relaxed 的计数器,绝不用 seq_cst。比如统计请求次数、埋点计数这类不参与控制流的场景,顺序无关紧要,memory_order_relaxed 是最优选择。但发布一个指针给消费者线程、或者实现一个简易自旋锁时,release/acquire 是必要的。
4.3 一个完整的自旋锁实现示例
原子操作底层实现最常见的应用之一就是自旋锁。看完前面的内容,你已经能自己写一个最小版本:
cpp复制#include <atomic>
class SpinLock {
public:
void lock() {
bool expected = false;
// 不断尝试把 true 写进去,直到原来状态是 false
while (!flag_.compare_exchange_weak(
expected, true,
std::memory_order_acquire,
std::memory_order_relaxed)) {
expected = false;
}
}
void unlock() {
flag_.store(false, std::memory_order_release);
}
private:
std::atomic<bool> flag_{false};
};
这段代码的底层逻辑:
compare_exchange_weak在 x86 上对应lock cmpxchg,在 ARM 上可能对应cas指令或 LL/SC 循环;- 失败时继续循环,本质是在忙等,这会消耗 CPU,所以自旋锁只适合临界区极短的场景;
acquire和release的配对保证:当线程 A 获得锁后,它能看见线程 B 在unlock()之前写入的所有数据。
用的时候要克制,自旋锁不适合长时间持有锁的场景,也不适合单核 CPU 的系统。单核上自旋等于白白空转,遇到这种情况应该直接 sched_yield() 让出 CPU。
5. 常见问题与排查技巧实录
5.1 std::atomic 一定无锁吗
不是。std::atomic<T> 是否无锁,取决于目标平台能不能为这个类型提供无锁原子指令。比如在 32 位平台上,std::atomic<double> 或 std::atomic<uint64_t> 可能没有对应的单条原子指令,库内部就会用一个全局锁来模拟原子性。
C++17 提供了 is_always_lock_free 常量,C++11 提供 is_lock_free() 成员函数。用它们可以在运行时判断原子类型是否无锁:
cpp复制#include <atomic>
#include <cstdint>
#include <iostream>
struct Foo {
char data[128];
};
int main() {
std::cout << "atomic_int: "
<< std::atomic<int>::is_always_lock_free << '\n';
std::cout << "atomic<Foo>: "
<< std::atomic<Foo>::is_always_lock_free << '\n';
return 0;
}
这里要注意,std::atomic<Foo> 如果被判定为“有锁”,内部会用某种互斥锁保证正确性。这不是说代码是错的,而是说某些场景下可能达不到你期望的无锁性能。
5.2 伪共享:原子操作最大的隐形性能杀手
原子操作本身很快,但如果多个原子变量不小心落在同一个缓存行里,性能会断崖式下降。
假设有两个线程,分别频繁更新 atomic<int> a 和 atomic<int> b。它们在内存里恰好相邻,放在同一个 64 字节缓存行中。线程 1 更新 a 时,会独占这一整行缓存,导致线程 2 持有的缓存行失效;线程 2 更新 b 时,又反过来让线程 1 的缓存行失效。两个线程就这样互相踢皮球,引发大量的缓存行同步流量,性能可能比加锁还慢。
解决办法很简单:让相互独立的热点变量对齐到不同缓存行。
cpp复制#include <atomic>
struct alignas(64) HotData {
std::atomic<int> a;
};
struct alignas(64) HotData2 {
std::atomic<int> b;
};
HotData d1;
HotData2 d2;
或者用 alignas(64) 修饰数组里的每个元素。这是我做性能压测时踩过最深的坑,表面上看是“原子操作用了锁导致性能差”,实际是伪共享在作祟。
5.3 ABA 问题与 compare_exchange
CAS(比较并交换)是实现无锁数据结构的基础,但它有个经典坑叫 ABA 问题:线程 1 读到值为 A,准备改成 C;线程 2 先把它改成 B,又改回 A;线程 1 的 CAS 发现值还是 A,于是判定“没人改过”,继续执行。可实际上数据已经发生过变化,如果这段数据是内存地址,那么这个地址可能已经被释放再复用,就会引发严重错误。
解决 ABA 问题的常见思路是“版本号”。在指针场景里,可以在指针的高位或低位额外保存一个标记:
cpp复制#include <atomic>
#include <cstdint>
class ConcurrentStack {
public:
void push(uintptr_t value) {
Node* node = new Node{value};
Node* old_head = head_.load(std::memory_order_relaxed);
do {
node->next = old_head;
} while (!head_.compare_exchange_weak(
old_head, node,
std::memory_order_release,
std::memory_order_relaxed));
}
private:
struct Node {
uintptr_t value;
Node* next;
};
std::atomic<Node*> head_{nullptr};
};
严格来说上面的实现只有一个 compare_exchange_weak,没有打印 ABA。如果要完全避免 ABA,可以让原子变量存放“指针+版本号”的组合,比如把指针和计数器按位打包进一个 uintptr_t,或者用 std::atomic<std::uintptr_t> 把版本位放在低比特位。
不过,大多数业务场景不会立刻遇到 ABA,它更像是无锁数据结构设计者必须考虑的质量关卡。新手阶段能识别出“CAS 不等于稳妥”就已经值回票价了。
6. 我在实际项目里的几点经验
玩原子操作这些年,最直观的体会有两条:
第一,能不用锁就不用锁,但不代表所有锁都能用原子替换。原子操作适合的是单一变量、单一计数、单一标志位的场景,一旦涉及多个变量的一致性更新,比如转账要同时扣两个账户的余额,原子操作解决不了,还是老老实实加锁或者改用数据库事务。原子操作只是“让单个操作在多核环境下安全”,不是万能灵药。
第二,一定要查看生成的汇编,尤其是在不同平台切换时。我试过同一段 fetch_add 在 x86 上秒回,在 ARM 低端开发板上慢一个数量级,反汇编一看才发现它退化成 LL/SC 循环了。想优化时,先确认底层指令是什么,再决定改内存序还是改数据结构布局。
第三,用内存序时让代码充分表达你的意图。比如计数器用 relaxed、发布数据用 release/acquire、涉及多个原子变量协同改选用 seq_cst。不要一上来就全部 seq_cst,也不用盲目套用别人的经验,每种选择都要有依据。
原子操作底层实现的内容到这里差不多说透了,从硬件指令、缓存一致性到 C++ 标准库的映射,再到常见的性能陷阱,全链路都过了一遍。理解到这一层之后,再去看无锁队列、读写锁、引用计数之类的代码,你会发现自己不再是被动接受 API,而是能预判出它底层大概长什么样,排查并发问题时的思路也会清楚很多。
