原子操作
需要预先学习:
1. 为什么要原子操作
回顾《数据共享问题与锁》中的计数器例子:两个线程各执行 100 万次 ++counter,结果不是 200 万。原因是 ++counter 在底层是"读 → 加 → 写"三步,两个线程的三步交错执行,更新互相覆盖。
用互斥锁可以解决,但代价不小:lock() 通常涉及系统调用(竞争时线程睡眠/唤醒)、缓存行失效、上下文切换。如果只是"把一个变量加 1"这种简单操作,锁是杀鸡用牛刀。
原子操作(atomic operation)就是答案:不可分割的操作——要么完整执行,要么完全不执行,中间不可能被其他线程插入。x = 1 这类单条机器指令天然是原子的;而"读-改-写"这类多步操作,需要硬件提供特殊指令(如 x86 的 lock 前缀指令)保证原子性。
std::atomic<T> 把这种能力封装成标准库类型。用它改写计数器:
#include <atomic>
#include <iostream>
#include <thread>
std::atomic<int> counter{0}; // 原子变量, 必须显式初始化
void add()
{
for (int i = 0; i < 1000000; ++i)
{
++counter; // 原子自增: 读-改-写不可分割
}
}
int main()
{
std::thread t1(add);
std::thread t2(add);
t1.join();
t2.join();
std::cout << "counter = " << counter << std::endl; // 稳定输出 2000000
return 0;
}
不需要锁,结果稳定是 2000000。这就是原子操作的核心价值:对简单数据的并发访问,比锁更轻量、更快。
注:
std::atomic的构造函数不是原子的(C++20 起才保证atomic的初始化本身并发安全),所以原子变量通常用常量初始化(= {0}或{0}),并在任何线程使用之前完成初始化。
2. 原子变量的约束
std::atomic<T> 只支持"平凡可拷贝"(trivially copyable)的类型:int、double、指针、bool、枚举,以及只含平凡成员的结构体。std::string、std::vector 这种带资源管理的类型不能直接做成 atomic(标准库提供了 std::atomic<std::shared_ptr<T>> 特化等少数例外)。
std::atomic<int> a{0}; // 正确
std::atomic<int*> ptr{nullptr}; // 正确: 原子指针
std::atomic<std::string> s; // 错误! string 不是平凡可拷贝
对不能原子化的类型,要么用锁保护,要么用无锁数据结构(std::atomic 的 is_lock_free() 可以查询底层是否真的无锁)。
3. 基本操作
3.1 load 与 store
load() 原子地读取,store(v) 原子地写入。它们是原子变量最基本的操作,比 ++ 更常用:
#include <atomic>
#include <iostream>
int main()
{
std::atomic<int> flag{0};
flag.store(1); // 原子写入 1
int value = flag.load(); // 原子读取
std::cout << "value = " << value << std::endl; // 1
return 0;
}
flag = 1 等价于 store(1),int v = flag 等价于 load(),但显式写 load/store 更清晰,也更方便指定内存序(下文)。
3.2 exchange:原子交换
exchange(v) 原子地把变量设为 v 并返回旧值。典型用途:一次性标记(“抢到就返回旧值,别人抢不到”):
std::atomic<int> ticket{0};
int old = ticket.exchange(1); // 把 ticket 设为 1, 返回旧值
if (old == 0)
{
std::cout << "我抢到了第一个" << std::endl;
}
3.3 fetch_add / fetch_sub:原子算术
fetch_add(n) 原子地执行"加 n"并返回旧值;fetch_sub 同理。它们支持整数类型和指针(对指针按元素大小偏移):
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
int main()
{
std::atomic<int> counter{0};
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i)
{
threads.emplace_back([&counter] {
for (int j = 0; j < 1000; ++j)
{
counter.fetch_add(1); // 原子 +1
}
});
}
for (auto& t : threads)
{
t.join();
}
std::cout << "counter = " << counter.load() << std::endl; // 10000
return 0;
}
"返回旧值"这一点非常有用——它是实现无锁数据结构的基石。比如无锁地"给每个任务分配递增编号":
std::atomic<int> next_id{0};
int get_id()
{
return next_id.fetch_add(1); // 每个线程拿到的 id 都不重复
}
注:
++counter等价于counter.fetch_add(1) + 1(返回的是新值)。fetch_add系列还支持fetch_and、fetch_or、fetch_xor位运算。
3.4 compare_exchange:CAS
比较并交换(CAS,Compare-And-Swap)是最重要的原子操作:如果当前值等于期望值,就替换为新值;否则不替换。它有两个版本:
compare_exchange_strong(expected, desired):强版本,比较失败时把expected更新为当前值compare_exchange_weak(expected, desired):弱版本,可能"假失败"(值明明相等却返回false,发生在无锁平台上);通常放在循环里重试,循环版效率更高
#include <atomic>
#include <iostream>
int main()
{
std::atomic<int> value{5};
int expected = 5;
bool success = value.compare_exchange_strong(expected, 10);
// 当前值 5 == expected 5, 替换成功, success = true, value = 10
expected = 99;
success = value.compare_exchange_strong(expected, 20);
// 当前值 10 != 99, 替换失败, success = false, expected 被更新为 10
std::cout << "value = " << value.load() << ", expected = " << expected << std::endl;
return 0;
}
value = 10, expected = 10
CAS 的经典用途:无锁更新。比如"多线程并发地把 x 更新为 max(x, new_value)":
std::atomic<int> max_value{0};
void update_max(int new_value)
{
int old = max_value.load();
while (new_value > old && !max_value.compare_exchange_weak(old, new_value))
{
// 失败说明别的线程抢先更新了, old 已被更新为最新值, 重新比较
}
}
原理:compare_exchange_weak 失败时把 old 自动刷新为当前值,循环重试直到"要么我的值不再更大,要么我成功写入"。"读-算-CAS 循环"是无锁编程的基本模式。
注意:CAS 循环在竞争激烈时可能反复失败重试(活锁),性能下降。高竞争场景仍然需要锁或更高级的无锁算法。
4. 内存序:为什么需要
原子操作保证"不可分割",但不自动保证"顺序"。现代 CPU 为了性能会乱序执行指令、写缓冲会延迟写回内存、多核缓存不一致——即使每个原子操作本身正确,多个原子操作的相对顺序在另一个线程看来可能颠倒。
考虑经典的"发布-订阅"模式:
std::atomic<bool> ready{false};
int data = 0; // 普通变量
// 线程 A: 生产
void producer()
{
data = 42; // 1. 写普通变量
ready.store(true); // 2. 发布标志
}
// 线程 B: 消费
void consumer()
{
while (!ready.load()) {} // 3. 等待标志
std::cout << data << std::endl; // 4. 读普通变量
}
直觉上,B 看到 ready == true 时,data 一定已经是 42。但在默认内存序下不一定:编译器/CPU 可能把 data = 42 重排到 ready.store(true) 之后,或者 B 的 data 读取被提前。结果 B 可能读到 data == 0。
这就是内存序(memory order)要解决的问题:指定原子操作与其他内存访问之间的顺序约束。std::atomic 的每个操作都可以传一个内存序参数,C++ 提供六种:
| 内存序 | 含义 |
|---|---|
memory_order_relaxed |
只保证原子性,不提供任何顺序保证 |
memory_order_consume |
发布-消费,依赖链上的顺序(实践中几乎不用) |
memory_order_acquire |
读操作:其后的内存访问不能重排到它之前 |
memory_order_release |
写操作:其前的内存访问不能重排到它之后 |
memory_order_acq_rel |
读-改-写操作:acquire + release 的组合 |
memory_order_seq_cst |
顺序一致(默认):所有线程看到同一个全局顺序 |
4.1 acquire / release:发布-订阅
release 写与 acquire 读配对使用:线程 A 的 store(..., release) 发布数据,线程 B 的 load(..., acquire) 订阅数据。规则是:
- A 在 release 写之前的所有写(包括普通变量的写),对 B 在 acquire 读之后的读可见
- 即"release 之前的内容不会跑到 release 之后",“acquire 之后的内容不会跑到 acquire 之前”
修正上面的例子:
std::atomic<bool> ready{false};
int data = 0;
void producer()
{
data = 42;
ready.store(true, std::memory_order_release); // 发布: data 的写在标志之前可见
}
void consumer()
{
while (!ready.load(std::memory_order_acquire)) {} // 订阅
std::cout << data << std::endl; // 保证读到 42
}
release/acquire 是"单向屏障":它不阻止其他无关指令的重排,只约束"配对读写之间的依赖关系",所以比 seq_cst 快。
4.2 relaxed:只保证原子性
memory_order_relaxed 只保证操作本身不可分割,不保证任何顺序。适合"只需要计数正确、不关心顺序"的场景——比如统计次数、分配递增 id:
std::atomic<int> counter{0};
void worker()
{
counter.fetch_add(1, std::memory_order_relaxed); // 只关心总数, 顺序无所谓
}
4.3 seq_cst:顺序一致(默认)
memory_order_seq_cst 是默认内存序,最强也最直观:所有线程对所有原子操作看到同一个全局顺序,就像所有操作按某个确定的先后顺序依次执行。程序行为最容易推理,但同步开销最大(通常需要在每个操作处加内存屏障)。
flag.store(true); // 默认就是 seq_cst
bool v = flag.load();
何时用哪种?
| 场景 | 推荐内存序 |
|---|---|
| 计数器、统计、id 分配 | relaxed |
| 发布-订阅、传递数据所有权 | release / acquire |
| 需要多个原子变量的全局一致顺序 | seq_cst(默认) |
| 不确定、正确性优先 | seq_cst(默认) |
注意:内存序是 C++ 多线程里最难的角落。默认
seq_cst不会错,只是可能慢;用relaxed/acquire/release优化之前,先确保你真的理解它们的保证。过早优化内存序是 bug 的温床。
5. 实现原理:硬件层面
std::atomic 的底层是什么?以 x86-64 为例,拆解几个关键机制:
5.1 1. 对齐的单次访问天然原子
如果变量对齐(地址是自身大小的倍数)且访问宽度不超过总线宽度(x86 上 8 字节以内),单次读或写就是原子的——内存总线的传输本身不可分割。所以 std::atomic<int> 的 load/store 在 x86 上就是一条普通 mov 指令,零额外开销。
5.2 2. 读-改-写需要 lock 前缀
++x(读-改-写)在 x86 上对应 lock inc [addr]。lock 前缀让指令在执行期间锁住缓存行/总线,其他核心无法同时访问该地址:
lock inc DWORD PTR [counter] ; 原子自增
lock xchg DWORD PTR [flag], eax ; 原子交换
lock cmpxchg DWORD PTR [value], edx ; CAS
lock 前缀的成本:它会阻止其他核心访问该缓存行,并强制缓存行写回、同步到其他核心(缓存一致性流量),所以原子读-改-写比普通读写慢几十倍。
5.3 3. 缓存一致性协议(MESI)
多核 CPU 的每个核心有自己的 L1/L2 缓存,通过缓存一致性协议(x86 用 MESI 变种)保证"所有核心看到同一份数据"。每条缓存行有状态:M(Modified,已修改)、E(Exclusive,独占)、S(Shared,共享)、I(Invalid,失效)。
当核心 A 修改了一个变量,协议会让其他核心中持有该缓存行的副本失效(Invalid),下次读取必须重新从内存/其他核心同步。lock 指令正是借助这套机制实现"跨核心的原子性"。
核心 A: 修改 x → 缓存行变 M → 广播"失效"消息
核心 B: 收到消息 → 自己的 x 副本变 I → 下次读 x 必须重新加载
5.4 4. ARM 等平台:LL/SC
ARM、RISC-V 没有 lock 前缀,改用 LL/SC(Load-Linked / Store-Conditional,链接加载/条件存储)指令对:ldxr 读取并标记地址,stxr 写入时检查该地址是否被其他核心改过,改过则写入失败。所以 ARM 上 CAS 天然可能"假失败"——这正是 compare_exchange_weak 存在的原因。
5.5 5. 内存屏障
acquire/release/seq_cst 在硬件上对应内存屏障(memory barrier/fence)指令:x86 的 mfence、lock 前缀附带的全屏障等。它们阻止 CPU 乱序执行越过屏障。x86 的硬件内存模型较强(写后读不重排),所以 acquire/release 在 x86 上几乎是免费的(只影响编译器优化);ARM/PowerPC 上则需要真实指令,开销更大。
5.6 6. 无锁(lock-free)
当 std::atomic<T> 底层只用原子指令实现(没有隐藏的锁),称该类型是 lock-free 的,可用 is_lock_free() / is_always_lock_free() 查询:
std::atomic<int> a{0};
std::cout << std::boolalpha << a.is_lock_free() << std::endl; // true
只有 lock-free 的原子变量才有性能优势。atomic 允许内部用锁兜底实现(标准不禁止),此时它等价于"带锁的普通变量",性能没有意义。
6. 性能开销
原子操作到底比锁快多少?量级参考(现代 x86,单次操作):
| 操作 | 典型耗时 |
|---|---|
| 普通变量读写 | 约 0.5 ns(命中 L1 缓存) |
| 原子 load/store(无竞争) | 约 1 ns(对齐后就是普通 mov) |
| 原子 fetch_add / CAS | 约 10-20 ns(lock 指令 + 缓存同步) |
| 互斥锁加解锁(无竞争) | 约 20-50 ns(内部也是原子操作) |
| 互斥锁加解锁(有竞争) | 微秒到毫秒级(线程睡眠/唤醒、系统调用) |
结论:
- 无竞争的锁并不比原子操作慢太多(锁内部也是原子操作)
- 有竞争的锁代价高昂——线程睡眠、上下文切换、唤醒,原子操作则只是忙等重试(由硬件保证),不会让线程睡眠
- 原子操作的优势:无系统调用、无线程状态切换,适合高频、短小的临界区
何时用原子,何时用锁?
| 场景 | 选择 |
|---|---|
| 单个变量(计数、标志、id) | 原子操作 |
| 短临界区、高频访问 | 原子操作(CAS 循环) |
| 多个变量的一致性修改 | 锁(原子操作无法原子地改多个变量) |
| 长临界区(IO、复杂计算) | 锁(原子操作的忙等会浪费 CPU) |
| 复杂数据结构(容器、树) | 锁(或专门的无锁数据结构) |
注:原子操作不睡眠,竞争激烈时多个线程反复 CAS 重试会空转烧 CPU。临界区一旦变长,锁(让出 CPU)反而更合适。
7. 性能陷阱:伪共享
伪共享(false sharing)与原子操作/缓存密切相关:缓存以缓存行(通常 64 字节)为单位管理。如果两个线程频繁写不同的变量,但两个变量恰好落在同一条缓存行里,缓存一致性协议会让两个核心互相失效对方的缓存行——每次写都要重新同步整条缓存行,性能急剧下降:
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
// 两个原子变量紧挨着, 大概率落在同一条缓存行里
struct Shared
{
std::atomic<int> a{0};
std::atomic<int> b{0};
};
int main()
{
Shared shared;
auto worker = [&shared](std::atomic<int>& x) {
for (int i = 0; i < 10000000; ++i)
{
x.fetch_add(1, std::memory_order_relaxed);
}
};
auto start = std::chrono::steady_clock::now();
std::thread t1(worker, std::ref(shared.a));
std::thread t2(worker, std::ref(shared.b));
t1.join();
t2.join();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
std::cout << "伪共享耗时: " << ms << " ms" << std::endl;
return 0;
}
修复方法:把热点变量隔开,保证各自独占缓存行。常见手法是给变量填充 alignas(64)(对齐到缓存行大小)或补齐到 64 字节:
struct alignas(64) Padded
{
std::atomic<int> a{0}; // a 独占一条缓存行
};
struct Padded2
{
std::atomic<int> a{0};
char padding[60]; // 手动填充到 64 字节
std::atomic<int> b{0};
};
伪共享的判定:两个线程访问的地址差小于缓存行大小(64 字节),且都是写热点。它在多线程性能优化中极其隐蔽——perf 等工具可以统计 cache-miss 帮助定位。
8. atomic_flag 与自旋锁
std::atomic_flag 是最简单的原子类型:只有 clear()(置 false)和 test_and_set()(置 true 并返回旧值)两个操作,保证 lock-free。用它可以实现一个自旋锁(spinlock):
#include <atomic>
class SpinLock
{
public:
void lock()
{
// 不断尝试"抢到"标志; 抢到前忙等
while (flag_.test_and_set(std::memory_order_acquire))
{
// 抢不到: 让出 CPU, 避免空转烧核
}
}
void unlock()
{
flag_.clear(std::memory_order_release);
}
private:
std::atomic_flag flag_ = ATOMIC_FLAG_INIT; // 必须用宏初始化
};
用法和 std::mutex 一致(配合 lock_guard):
SpinLock spin;
{
std::lock_guard<SpinLock> guard(spin);
// 临界区
}
自旋锁的特点:不睡眠,一直忙等。适合"临界区极短、竞争不激烈"的场景(等待时间 < 线程睡眠唤醒的开销);临界区长或竞争激烈时,自旋会浪费大量 CPU,此时应改用 std::mutex。
注意:C++20 起
std::atomic_flag初始化可以用std::atomic_flag flag{false};,ATOMIC_FLAG_INIT宏是 C++11/14/17 的写法。
9. 原子操作与锁的选型总结
| 维度 | 原子操作 | 互斥锁 |
|---|---|---|
| 适用数据 | 单个简单变量 | 任意数据、多个变量 |
| 竞争时的行为 | 忙等重试(不睡眠) | 睡眠等待(有竞争时) |
| 无竞争开销 | 极低(1-20 ns) | 较低(20-50 ns) |
| 有竞争开销 | 空转 CPU | 系统调用 + 上下文切换 |
| 顺序保证 | 通过内存序显式控制 | lock/unlock 隐含完整同步 |
| 正确性难度 | 高(内存序、ABA 等) | 低(但可能死锁) |
| 典型场景 | 计数器、标志、id、无锁队列 | 复杂数据结构的保护 |
原子操作是"最后一层"同步原语:标准库的锁、条件变量、future 内部全部建立在它之上。理解原子操作,才算真正理解多线程程序在硬件上是怎么跑起来的——这也是本系列最后一篇文章。
系列目录:


