如果你在多线程程序里用 std::atomic<int> 跑一段自增循环,再拿它跟 std::mutex 保护的版本做性能对比,很容易产生一种错觉:原子操作几乎不加锁。这个错觉恰恰是很多人把 C++ 原子操作用坏的起点。
原子操作不是没有锁,而是把锁下沉到了 CPU 指令和缓存一致性协议这一层。它相当于把钥匙交给硬件,让硬件替你在更短的时间里完成“锁门开门”的动作。作为一个写了十几年 C++ 的老程序员,我被 std::atomic 这种“貌似免费”的表象坑过不止一次。计数器在并发下少算、自旋锁死循环、无锁队列莫名其妙丢节点,这些问题最后都指向同一个根因:我并没有真正理解原子操作在底层是怎么实现的。
这篇文章要把 C++ 原子操作从头到尾剥开一次:从 i++ 为什么丢数据,到 MESI 缓存一致性协议,再到 x86 的 lock 前缀和 ARM 的 LL/SC 方案,最后落到 std::atomic 和 memory_order 的真实编译结果。适合那些已经会用 std::atomic,但还想知道“它凭什么能保证原子性”的人;也适合正被 ABA 问题、内存乱序问题折腾的排错选手。
1. 从被无数人写错的 i++ 说起
1.1 非原子读改写为什么不安全
先看一段最常见的“错误代码”:
cpp复制#include <atomic>
#include <thread>
#include <iostream>
int main() {
int counter = 0;
std::thread t1([&] { for (int i = 0; i < 100000; ++i) ++counter; });
std::thread t2([&] { for (int i = 0; i < 100000; ++i) ++counter; });
t1.join();
t2.join();
std::cout << counter << std::endl;
return 0;
}
理论上两个线程各加十万次,counter 应该是 200000。但实际跑起来,结果往往是一个小于 200000 的数,而且每次运行都可能不一样。
原因在于 ++counter 在机器层面不是一个动作,而是三条指令:
assembly复制mov eax, [counter] ; 把 counter 读入寄存器
add eax, 1 ; 寄存器加一
mov [counter], eax ; 把新值写回内存
线程 t1 执行完 mov eax, [counter] 之后,操作系统碰巧切换到了 t2;t2 同样把旧的 counter 值读到自己的寄存器里加一写回;等 t1 再恢复执行时,它仍然拿着旧值加一写回。两次自增操作合并成了一次,计数器就少了。
这个场景里,++counter 是一个典型的“读-改-写”区间,它不是不可分割的。要保证正确性,就得让这个区间在并发环境下对外表现为一个原子步骤。
1.2 原子操作锁定的真正对象是缓存行
很多人第一次接触原子指令时,以为它在锁总线。早期 x86 确实会在原子指令执行期间拉高 LOCK# 信号,阻止其他核访问内存。后来 CPU 性能越来越强,锁总线太粗暴了,因为它会让所有核心都停摆,哪怕它们操作的完全是不同内存地址。
现代 CPU 的做法是:锁缓存行。
CPU 和内存之间隔着多级缓存,数据在缓存里按“缓存行”存放,一个缓存行通常是 64 字节。原子指令执行时,CPU 拿到包含目标数据的缓存行的独占权,在指令执行期间禁止其他核心对该缓存行做任何修改,操作完成后再释放。这样既保证原子性,又不会影响其他核心访问无关的缓存行。
这个思路和数据库的行锁很像:不是把整张表锁住,而是只锁你正在改的那一行。只不过 CPU 的“行锁”粒度是 64 字节的缓存行,不是某条数据。理解了这一点,你对“原子操作为什么这么快”就有了第一层认知:它没有调用系统调用,没有进入内核,只是让硬件在极短的时间内替你管理了一个缓存行的所有权。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 真正在底层保证原子性的是硬件机制
2.1 MESI 协议如何在多核之间协调缓存行
要让“锁缓存行”成立,CPU 之间必须有一套统一的规则知道谁持有缓存行、谁能改缓存行,这就是缓存一致性协议。用得最广的是 MESI 协议,名字来自缓存行的四种状态:
| 状态 | 含义 | 说明 |
|---|---|---|
| M(Modified) | 已被本核修改,且和内存不一致 | 本核拥有最新数据 |
| E(Exclusive) | 仅本核持有,和内存一致 | 写之前不用通知别人 |
| S(Shared) | 多个核都持有副本,和内存一致 | 写之前需要先发失效请求 |
| I(Invalid) | 数据失效,不能直接使用 | 读到之前必须重新加载 |
当一个线程执行原子自增时,CPU 会走这样一条链路:
- 请求读取目标地址对应的缓存行。
- 如果本核缓存里没有,就通过片上互联网络向其他核发起请求。
- 如果其他核持有该缓存行,状态会变成 I;本核拿到缓存行副本后,可以是 E 或 S 状态。
- 在执行原子写之前,CPU 要把缓存行状态转为 M,这
