C++11 并发支持库中 atomic

一、为什么需要原子操作?

复制代码
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>、指针等也是无锁的。

四、方法使用指南

基本读写:loadstore

场景:单纯读写一个原子变量,不修改它。

复制代码
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) 新值

七、常见陷阱

  1. 忘记初始化std::atomic<int> a; 后直接 load()(C++11)导致未定义行为。务必显式初始化。

  2. weak 不在循环中 → 伪失败会使程序出错。必须写成 do { ... } while(!weak(...))

  3. 同步场景用 relaxed → 其他变量的修改可能永远不可见,必须用 release/acquire

  4. 后置 ++ 和前置 ++ 混淆 → 后置返回旧值,前置返回新值,不可混用。

  5. 对不支持的类型用算术操作 → 浮点数、自定义类型没有 fetch_add,要用 CAS 模拟。

  6. 大对象原子变量可能带锁 → 使用 is_lock_free() 检查,避免高竞争场景的性能陷阱。

  7. 伪共享 → 多个原子变量挤在同一缓存行,互相拖慢。可用 alignas(64) 隔离。

  8. ABA 问题 → CAS 值相同不代表状态没变,无锁结构中要注意(初学了解)。


八、总结

  1. 原子操作 = 不可分割的"读-改-写" ,由硬件 lock 指令 + 缓存一致性保证。

  2. 方法决定行为fetch_add 返回旧值,exchange 返回旧值,CAS 返回成功/失败并更新预期值。

  3. 类型要满足 TriviallyCopyable,不同特化支持的方法不同。

  4. 内存顺序是可见性的开关 :同步用 release/acquire,计数用 relaxed,默认 seq_cst

  5. atomic_flag 永远无锁,是自旋锁的基石。

  6. 所有方法都是原子的,选择正确的方法+内存顺序,就能安全高效地编写并发代码。

相关推荐
思麟呀1 小时前
C++工业级日志项目(四)日志落地
linux·开发语言·c++·windows
玖釉-1 小时前
单词搜索:二维网格中的 DFS 回溯与剪枝优化
c++·windows·算法·深度优先·剪枝
吴可可1231 小时前
C++与C#版Teigha样条离散化差异解析
c++·算法·c#
搬砖的小码农_Sky1 小时前
macOS Sequoia上如何安装gcc/g++环境?
c语言·c++·macos
MC皮蛋侠客1 小时前
C++17 多线程系列(二):共享数据与同步——mutex 与 condition_variable
开发语言·c++·多线程
郝学胜-神的一滴2 小时前
中级OpenGL教程 007:解决背面光照异常高光问题
c++·unity·游戏引擎·three.js·opengl·unreal
晚风叙码2 小时前
《C++基础进阶:函数重载、引用、inline与nullptr全解析》
c++
雪度娃娃2 小时前
ASIO异步通信——服务器网络层和逻辑层设计
开发语言·网络·c++·php
Zhang~Ling2 小时前
C++ 多态完全指南:虚函数、重写、虚表与动态绑定深度解析
开发语言·c++