一、为什么需要原子操作?
int counter = 0;
counter++; // 线程A和B同时执行
实际 CPU 指令:读→加→写,三步之间可能被其他线程打断:
| 步骤 | 操作 | 并发问题 |
|---|---|---|
| 1 | 从内存读 counter 到寄存器 | 两个线程都读到 0 |
| 2 | 寄存器 + 1 | 都算出 1 |
| 3 | 写回内存 | 最终 counter = 1(期望 2) |
原子操作把"读-改-写"封装成一条不可分割的指令:
std::atomic<int> counter{0};
counter.fetch_add(1); // 原子操作,多线程安全
二、底层原理(通俗版)
硬件如何保证原子性?
x86 下 fetch_add 大致对应:
lock addl $1, (%rdi) ; LOCK 前缀锁住总线/缓存,保证指令不可分割
如果多个核心同时执行 lock 指令,硬件会串行化处理:
-
核心A锁定目标内存区域,读出原值,加1,写回,解锁;
-
核心B在此期间如果也想执行,会被阻塞,直到核心A完成;
-
核心B读到的已经是更新后的值,不会丢失计数。
这就是为什么 fetch_add 不会出现"两个核心都读到旧值并各自写回"的问题。
缓存锁与总线锁
| 情况 | 机制 | 性能 |
|---|---|---|
| 数据在一行缓存行内 | 缓存锁:只锁定该缓存行,其他核心不能读写此缓存行 | 很快 |
| 数据跨越缓存行/不在缓存 | 总线锁:锁住整个内存总线 | 较慢 |
缓存行 通常是 64 字节,原子变量最好独占一个缓存行,否则会引发伪共享 (多个变量共享一行,导致无关原子操作互相拖慢)。可以用
alignas(64)隔开热点的原子变量。
缓存一致性(MESI)简单理解
多核 CPU 各有缓存,通过 MESI 协议保持数据一致:
-
核心A用
lock指令修改变量 → 通知其他核心:你们的副本失效了; -
其他核心再读取时,必须从A的缓存(或内存)获取最新值。
这个过程保证了原子写完成后,其他线程立刻能看到新值(在配合适当内存顺序的前提下)。
三、构造与类型要求
初始化与构造
| 用法 | 说明 |
|---|---|
std::atomic<int> a; |
C++11:不初始化值 |
std::atomic<int> b{0}; |
显式初始化为 0 |
std::atomic<int> c(10); |
初始化为 10 |
-
原子对象不可拷贝 (拷贝构造 = delete),因为
b = a意味着两次独立操作,不是原子。 -
赋值
a = 100;等价于a.store(100);,是原子写,右侧只能是底层值,不能是另一个原子对象。 -
移动构造/赋值也被删除。
支持的类型及区别
std::atomic<T> 对类型 T 有要求,且不同类型支持的操作不同:
(1)基本规则
-
必须是
TriviallyCopyable,即可以用memcpy复制,不能包含虚函数、动态资源等。 -
检查:
std::is_trivially_copyable_v<T>为true。
(2)内置整数类型与指针
-
支持所有方法:
load,store,exchange,fetch_add,fetch_sub,fetch_and/or/xor,CAS。 -
运算符重载也全部支持。
3)浮点类型(C++20 前支持受限)
-
C++11 的
std::atomic<float/double>仅支持load,store,exchange,CAS,不支持算术或位运算方法。 -
C++20 起,若平台支持,
std::atomic<float>可以有fetch_add/sub。
(4)自定义类型(如简单结构体)
struct Point { int x, y; };
std::atomic<Point> p;
-
支持:
load,store,exchange,compare_exchange_weak/strong。 -
不支持 :
fetch_add等算术/位运算,也不支持+=等运算符。 -
使用 CAS 时,比较是按位进行的(可能受对齐填充影响,推荐只用于无填充的 POD 类型)
(5)std::atomic<bool>
- 支持所有方法,但位运算无意义,通常只用
load/store/exchange/CAS。
是否免锁?is_lock_free()
有些大的自定义类型,std::atomic 内部可能用互斥锁来模拟原子操作。可以运行时检查:
std::atomic<BigStruct> x;
if (x.is_lock_free()) {
// 硬件原子操作
} else {
// 内部用了锁,性能可能较差
}
-
std::atomic_flag保证总是无锁。 -
大多数平台上,
std::atomic<int>、指针等也是无锁的。
四、方法使用指南
基本读写:load 和 store
场景:单纯读写一个原子变量,不修改它。
std::atomic<bool> ready{false};
// 写
ready.store(true); // 默认 seq_cst
ready.store(true, std::memory_order_release); // 配合消费者使用
// 读
bool val = ready.load(); // 默认 seq_cst
bool val = ready.load(std::memory_order_acquire);
注意 :单纯 store/load 不保证其他非原子变量的可见性,必须配合正确的内存顺序。
交换值:exchange
场景:原子地把变量设为新值,同时拿到旧值。常用于互斥标志、状态切换。
std::atomic<int> state{0};
int old = state.exchange(1); // state 变为 1,old = 0
if (old == 0) {
// 本线程抢到了执行权
}
推荐内存顺序:acq_rel。
算术运算:fetch_add / fetch_sub 及运算符
场景:计数器、生成唯一序号、索引自增等。
std::atomic<int> counter{0};
// 成员方法
int old = counter.fetch_add(1); // 返回旧值,counter 变成 1
int old = counter.fetch_sub(1); // 返回旧值
// 运算符(本质是成员方法的包装)
counter++; // 等价于 fetch_add(1),返回旧值(后置++)
++counter; // 等价于 fetch_add(1) + 1,返回新值(前置++)
counter--; // fetch_sub(1)
counter += 5; // fetch_add(5),无返回值
取号示例:
std::atomic<int> ticket{0};
int my_ticket = ticket.fetch_add(1); // 每个线程获得唯一号
内部机制回顾 :fetch_add 使用 lock add 指令,硬件保证串行执行,不会出现"两个线程读到相同旧值"的情况。
位运算:fetch_and / fetch_or / fetch_xor
场景:原子地管理一组标志位。
std::atomic<int> flags{0b1010};
flags.fetch_or(0b0001); // 打开最低位,结果 1011
flags.fetch_and(~0b0010); // 关闭第二位
flags ^= 0b1000; // 等价于 fetch_xor(0b1000),切换某位
核心重点:compare_exchange_weak / strong
场景:原子的"比较并交换"。如果当前值等于预期,就改为新值;否则不修改,并把当前值写回预期。这是无锁数据结构的基石。
方法签名和行为
bool compare_exchange_strong(T& expected, T desired);
// expected 是引用:若当前值 != expected,则把当前值写入 expected
//当前值 == expected → 写入第二个参数(desired)
关键区别:
-
weak:可能发生伪失败 (值相等却返回 false),必须放在循环里使用。 -
strong:只在值真的不同时才失败,可以单次判断,但也常循环使用。
正确的 weak 用法(循环)
std::atomic<int> a{0};
int expected = a.load();
do {
int desired = expected + 1; // 根据预期计算新值
} while (!a.compare_exchange_weak(expected, desired));
// 退出时 a 已成功加 1
ABA 问题:CAS 只检查值是否相同,如果期间值被改成 B 又改回 A,CAS 会误以为没变。在动态内存管理的无锁结构中需要注意
atomic_flag 的两个方法
场景:实现自旋锁,或需要保证无锁的布尔标志。
std::atomic_flag flag = ATOMIC_FLAG_INIT; // 必须这样初始化
// 加锁
while (flag.test_and_set(std::memory_order_acquire))
; // 自旋等待
// 解锁
flag.clear(std::memory_order_release);
-
test_and_set返回旧值,若为false表示成功获取锁。 -
atomic_flag保证无锁,不可拷贝/赋值。
is_lock_free
std::atomic<int> x;
if (x.is_lock_free()) {
// 真正的硬件原子指令
}
五、内存顺序详解
为什么需要内存顺序?
编译器/CPU 可能重排指令以获得更好性能。原子操作本身的原子性不保证其他变量的可见顺序。
int data = 0;
std::atomic<bool> ready{false};
// 线程A
data = 42;
ready.store(true); // 可能被重排到 data=42 之前
// 线程B
while (!ready.load());
assert(data == 42); // 可能失败
内存顺序参数就是用来限制重排,建立跨线程的"可见性契约"。
六种内存顺序(从宽松到严格)
| 内存顺序 | 语义 | 典型用法 |
|---|---|---|
relaxed |
只保证原子性,无同步,可任意重排 | 纯计数器 |
consume |
依赖数据的同步(不推荐,编译器支持差) | 几乎不用 |
acquire |
当前 load 之后的所有读写不能重排到此 load 之前 |
消费者读取标志 |
release |
当前 store 之前的所有读写不能重排到此 store 之后 |
生产者设置标志 |
acq_rel |
同时具有 acquire 和 release 的限制 | 读-改-写操作(如 exchange) |
seq_cst |
最严格:所有原子操作有一个全局顺序,性能最低 | 默认选项 |
强弱关系 :relaxed < acquire/release < acq_rel < seq_cst
核心配对:release - acquire
// 线程A(生产者)
data = 42;
ready.store(true, std::memory_order_release); // 释放
// 线程B(消费者)
while (!ready.load(std::memory_order_acquire)); // 获取
assert(data == 42); // 保证看到 42
规则 :生产者用 release 写,消费者用 acquire 读,则写 ready 前的所有修改对读 ready 后的所有操作可见。
选择建议速查
| 场景 | 推荐内存顺序 |
|---|---|
| 简单计数 | relaxed |
| 自旋锁加锁 | acquire |
| 自旋锁解锁 | release |
| 生产者-消费者标志 | release / acquire 配对 |
| 读-改-写操作(RMW) | acq_rel |
| 不确定或需要最强保证 | seq_cst(默认) |
六、速查表
方法 · 返回值 · 推荐内存顺序
| 方法 | 返回值 | 推荐内存顺序 | 说明 |
|---|---|---|---|
load() |
当前值 | acquire / relaxed |
原子读 |
store(val) |
void | release / relaxed |
原子写 |
exchange(val) |
旧值 | acq_rel |
原子交换 |
fetch_add(n) |
旧值 | acq_rel / relaxed |
原子加 |
fetch_sub(n) |
旧值 | 同上 | 原子减 |
fetch_and/or/xor(n) |
旧值 | 同上 | 原子位运算 |
compare_exchange_weak(exp, des) |
bool | acq_rel |
弱 CAS(必须循环) |
compare_exchange_strong(exp, des) |
bool | acq_rel |
强 CAS |
test_and_set() |
旧值 | acquire |
atomic_flag 专用 |
clear() |
void | release |
atomic_flag 专用 |
is_lock_free() |
bool | --- | 是否无锁 |
运算符等价表
| 运算符 | 等价方法 | 返回值 |
|---|---|---|
a = val |
a.store(val) |
void |
T x = a; |
a.load() |
当前值 |
a++ |
a.fetch_add(1) |
旧值 |
++a |
a.fetch_add(1) + 1 |
新值 |
a-- |
a.fetch_sub(1) |
旧值 |
--a |
a.fetch_sub(1) - 1 |
新值 |
a += n |
a.fetch_add(n) |
新值(不直接返回) |
a -= n |
a.fetch_sub(n) |
新值 |
a &= n |
a.fetch_and(n) |
新值 |
| `a | = n` | a.fetch_or(n) |
a ^= n |
a.fetch_xor(n) |
新值 |
七、常见陷阱
-
忘记初始化 →
std::atomic<int> a;后直接load()(C++11)导致未定义行为。务必显式初始化。 -
weak不在循环中 → 伪失败会使程序出错。必须写成do { ... } while(!weak(...))。 -
同步场景用
relaxed→ 其他变量的修改可能永远不可见,必须用release/acquire。 -
后置
++和前置++混淆 → 后置返回旧值,前置返回新值,不可混用。 -
对不支持的类型用算术操作 → 浮点数、自定义类型没有
fetch_add,要用 CAS 模拟。 -
大对象原子变量可能带锁 → 使用
is_lock_free()检查,避免高竞争场景的性能陷阱。 -
伪共享 → 多个原子变量挤在同一缓存行,互相拖慢。可用
alignas(64)隔离。 -
ABA 问题 → CAS 值相同不代表状态没变,无锁结构中要注意(初学了解)。
八、总结
-
原子操作 = 不可分割的"读-改-写" ,由硬件
lock指令 + 缓存一致性保证。 -
方法决定行为 :
fetch_add返回旧值,exchange返回旧值,CAS返回成功/失败并更新预期值。 -
类型要满足
TriviallyCopyable,不同特化支持的方法不同。 -
内存顺序是可见性的开关 :同步用
release/acquire,计数用relaxed,默认seq_cst。 -
atomic_flag永远无锁,是自旋锁的基石。 -
所有方法都是原子的,选择正确的方法+内存顺序,就能安全高效地编写并发代码。