C++ 原子操作使用及源码剖析
原子操作是指不可被中断的单个或一组操作
在多线程环境下,原子操作的执行过程中不会被其他线程打断,要么完全执行完毕,要么完全不执行,不存在 “执行一半” 的中间状态。
它的核心价值是解决多线程对共享数据的竞态条件(Race Condition) 问题(比如两个线程同时读写同一个变量,导致数据错乱),是实现线程安全的基础,无需依赖互斥锁(如std::mutex)即可保证简单数据操作的安全性。
非原子操作的反例(多线程修改同一个变量):
#include <iostream>
#include <thread>
using namespace std;
int Counter = 0;
void Increment() {
for (int i = 0; i < 100000; ++i) {
Counter++; // 非原子操作!
}
}
int main() {
thread t1(Increment);
thread t2(Increment);
t1.join();
t2.join();
cout << "Counter = " << Counter << endl; // 结果大概率≠200000
return 0;
}
输出结果:Counter = 105516 每次不一样
原因分析:
Counter++ 看似是 “一步操作”,实际拆解为 3 个步骤:
1.读取Counter的当前值到寄存器;
2.寄存器值 + 1;
3.把新值写回Counter。
多线程执行时,可能出现 “线程 A 读值后,线程 B 抢先修改并写回,线程 A 再写回旧值 + 1” 的情况,导致计数丢失。
C++11 引入
而原子操作能让Counter++变成 “不可拆分” 的一步,避免上述问题。
加入原子操作后:
#include <iostream>
#include <thread>
#include <atomic> // 原子操作头文件
using namespace std;
atomic<int> Counter = 0; // 原子整型
void Increment() {
for (int i = 0; i < 100000; ++i) {
Counter++; // 原子自增,等价于 Counter.fetch_add(1)
}
}
int main() {
thread t1(Increment);
thread t2(Increment);
t1.join();
t2.join();
cout << "Counter = " << Counter << endl; // 结果必然=200000
return 0;
}
输也结果:Counter = 200000
核心操作(以std::atomic
a.load() 原子读取值(默认内存序memory_order_seq_cst) 等价原生指令(x86): mov
a.store(val) 原子写入值 等价原生指令(x86): mov
a++/a-- 原子自增 / 自减(语法糖) 等价原生指令(x86): lock xadd
a.fetch_add(n) 原子加 n,返回旧值 等价原生指令(x86): lock xadd
a.fetch_sub(n) 原子减 n,返回旧值 等价原生指令(x86): lock xadd
a.exchange(val) 原子替换值,返回旧值 等价原生指令(x86): xchg
a.compare_exchange_weak(expected, desired) 比较并交换(CAS):若a==expected,则设为desired,返回 true;否则更新expected为a的当前值,返回 false 等价原生指令(x86): lock cmpxchg
原子操作的底层机制分析上面的代码:
无原子操作时:
Counter++会拆分为 3 步:
mov eax, [Counter](读值到寄存器);
add eax, 1(寄存器 + 1);
mov [Counter], eax(写回内存)。
此时线程协调会出问题,比如:
| 时间片 | Core0(t1) | Core1(t2) | Counter 值 | 问题 |
|---|---|---|---|---|
| 1 | 读 Counter=0 到 eax | - | 0 | - |
| 2 | - | 读 Counter=0 到 eax | 0 | 两个线程都读到旧值 0 |
| 3 | eax+1=1 | eax+1=1 | 0 | 都计算出 1 |
| 4 | 写回 Counter=1 | - | 1 | t1 先写回 |
| 5 | - | 写回 Counter=1 | 1 | t2 覆盖 t1 的结果,丢失一次自增 |
上面 两次读写同样的数,覆盖了,造成数据缺失
而原子操作通过lock xadd把 “读 - 改 - 写” 打包成不可拆分的指令,从硬件层面杜绝了 “线程 A 读值后,线程 B 抢先修改” 的情况
代码中Counter是std::atomic
原子操作的底层实现(x86 平台为例)
Counter++(原子自增)对应的 CPU 指令是:lock xadd dword ptr [Counter], 1
xadd:是 “交换并相加” 指令,完成「读取值→加 1→写回值→返回旧值」的完整逻辑;
lock前缀:是 CPU 的 “总线锁定 / 缓存锁定” 机制 ——在执行xadd期间,锁定 Counter 对应的内存地址,禁止其他 CPU 核心(线程)访问该地址,直到指令执行完毕。
这是原子操作能 “协调” 多线程的硬件基础:任何时刻,只有一个线程能执行对 Counter 的原子操作,其他线程必须等待。
| 时间片 | Core0(t1) | Core1(t2) | Counter 值 | 关键说明 |
|---|---|---|---|---|
| 1 | 执行Counter++(lock xadd) | 等待(总线被 Core0 锁定) | 0→1 | t1 的原子操作独占内存,t2 无法打断 |
| 2 | 释放总线锁定 | 执行Counter++(lock xadd) | 1→2 | t2 抢占到总线,执行原子操作 |
| 3 | 执行Counter++(lock xadd) | 等待(总线被 Core0 锁定) | 2→3 | 交替执行,无冲突 |
| ... | 循环执行剩余 99997 次自增 | 循环执行剩余 99998 次自增 | ... | 每次自增都是原子的,无中间状态 |
| 最终 | 执行完 10 万次 | 执行完 10 万次 | 200000 | 所有操作无丢失,结果准确 |
关键:内存序(Memory Order)
原子操作的 “内存可见性” 可通过内存序优化(默认是最严格的memory_order_seq_cst,性能稍低),常见内存序:
memory_order_relaxed:仅保证操作本身原子性,不保证内存可见性(最快,适用于无依赖的计数);
memory_order_acquire:读操作,保证后续操作能看到当前操作的结果;
memory_order_release:写操作,保证当前操作的结果对后续读操作可见;
memory_order_seq_cst:顺序一致性(默认),所有线程看到的操作顺序一致(最安全,性能稍差)。
示例( Relaxed 内存序优化计数):
atomic<int> Counter = 0;
void Increment() {
for (int i = 0; i < 100000; ++i) {
Counter.fetch_add(1, memory_order_relaxed); // 仅需原子性,无需严格内存序
}
}
UE5 中的原子操作(与标准 C++ 的关联)
FPlatformAtomics::InterlockedOr/InterlockedAnd等函数,本质是 UE 对平台相关原子操作的封装(兼容 Windows/Linux/PS5 等平台),对应标准 C++ 的原子操作:
FPlatformAtomics::InterlockedOr → 原子按位或(等价于atomic
FPlatformAtomics::InterlockedAnd → 原子按位与(等价于atomic
FPlatformAtomics::AtomicRead_Relaxed → 放松内存序的原子读(等价于atomic
比如 UE5 中UObjectBase的原子标志位操作:
// UE5原子设置标志位(核心逻辑)
FPlatformAtomics::InterlockedOr((int32*)&ObjectFlags, FlagsToAdd);
// 等价于标准C++
atomic<int32> objFlags;
objFlags.fetch_or(FlagsToAdd);
原子操作 vs 互斥锁
| 特性 | 原子操作 | 互斥锁(std::mutex) |
|---|---|---|
| 适用场景 | 简单数据操作(计数、标志位、CAS) | 复杂操作(多步逻辑、跨资源访问) |
| 性能 | 极快(无内核态切换) | 较慢(可能触发内核态切换) |
| 功能 | 仅支持基础数据操作 | 支持任意临界区保护 |
| 死锁风险 | 无 | 有(如锁顺序错误) |
综上总结
原子操作是 “不可中断” 的操作,解决多线程共享数据的竞态条件;
C++ 通过
优先用原子操作处理简单数据(计数、标志位),复杂逻辑用互斥锁;
内存序可优化性能,按需选择(非特殊场景用默认seq_cst即可)。
源码解析:
拆解std::atomic源码核心结构:
std::atomic是 C++ 标准库的模板实现(以 MSVC 版本为例),其核心设计围绕「硬件无锁原子」和「软件锁模拟原子」两种模式展开
template <class _Ty>
struct atomic : _Choose_atomic_base_t<_Ty> { // 核心:根据类型选择原子实现基类
// 类型约束:T必须是平凡可拷贝/可构造/可赋值
static_assert(is_trivially_copyable_v<_Ty> && ..., "atomic<T> requires T to be trivially copyable...");
atomic(const atomic&) = delete; // 禁用拷贝:原子对象拷贝会破坏原子性
atomic& operator=(const atomic&) = delete;
_Choose_atomic_base_t<_Ty>:编译期选择原子实现的基类
若_Ty是内置小类型(如int/float/ 指针,sizeof(_Ty) ≤ 8且是 2 的幂):基类是「硬件原子实现」(直接调用 CPU 原子指令);
若_Ty是大类型(如自定义结构体):基类是「软件锁模拟实现」(内部封装std::mutex)
关键判断:is_lock_free()(区分无锁 / 有锁原子)
_NODISCARD bool is_lock_free() const volatile noexcept {
constexpr bool _Result = sizeof(_Ty) <= 8 && (sizeof(_Ty) & sizeof(_Ty) - 1) == 0;
return _Result;
}
返回true:无锁原子(硬件级实现,核心场景);
返回false:有锁原子(软件模拟,用线程锁实现原子性)。
示例中atomic
核心操作(store/load/exchange/CAS)的实现逻辑
// volatile版本(适配内存映射IO等场景)
void store(const _Ty _Value) volatile noexcept {
const_cast<atomic*>(this)->_Base::store(_Value); // 转非volatile调用基类
}
// 非volatile版本(普通场景)
using _Base::store; // 委托给基类实现
所有原子操作最终委托给基类_Base;
硬件原子的基类:调用编译器内置函数(如__atomic_store_n/__atomic_fetch_add),最终翻译为 CPU 原子指令;
软件模拟的基类:内部封装std::mutex,store/load前加锁、后解锁。
操作符重载(易用性封装)
_Ty operator=(const _Ty _Value) noexcept {
this->store(_Value); // 赋值=封装store
return _Value;
}
operator _Ty() const noexcept {
return this->load(); // 隐式转换封装load
}
示例中的Counter++:是fetch_add(1)的语法糖,最终调用基类的fetch_add,翻译为 CPU 原子指令lock xadd。
原子操作的核心原理(为什么能保证线程安全,而非 “锁住线程”)
原子操作并非 “主动锁住线程”,而是通过「硬件级原子性」或「软件锁模拟」避免线程竞态,无锁原子甚至没有 “锁” 的概念 —— 所谓的 “独占” 是 CPU 硬件层面的,而非线程的主动阻塞。
无锁原子(核心场景,如atomic
这是原子操作的核心,也是你示例的底层原理:
CPU 原子指令:
编译器将store/load/fetch_add等操作翻译为带lock前缀的 CPU 指令(x86):
store → lock mov(原子写);
load → lock mov(原子读);
fetch_add → lock xadd(原子自增);
compare_exchange_strong → lock cmpxchg(CAS,原子比较交换)。
硬件锁定机制:
lock前缀是核心 —— 它让 CPU 对「原子变量对应的缓存行」加锁(现代 CPU 用缓存锁定,早期用总线锁定):
✅ 缓存锁定(MESI 协议):CPU 独占该变量的缓存行,其他 CPU 核心(线程)无法修改该缓存行,直到原子指令执行完毕;
✅ 无线程阻塞:竞争线程不会被挂起,只是 CPU 通过硬件流水线暂停其执行,直到缓存行解锁(纳秒级开销)。
指令原子性:
CPU 将 “读 - 改 - 写”(如Counter++)封装为单条不可中断指令,无中间状态,线程无法打断。
有锁原子(软件模拟):线程锁封装
若_Ty是大类型(如atomic
基类_Base内部封装了一个std::mutex;
每次store/load/fetch_add前调用lock()加锁,操作后调用unlock()解锁;
本质:用线程锁模拟原子性,此时原子操作的 “锁” 和普通线程锁机制一致,但粒度仅针对当前原子对象。
原子操作与线程锁(std::mutex)的核心机制差异
| 维度 | std::atomic(无锁) | std::mutex(线程锁) |
|---|---|---|
| 实现层面 | 硬件级(CPU 原子指令 + 缓存 / 总线锁定) | 软件级(操作系统内核态 / 用户态锁) |
| “锁” 的本质 | 无显式锁,CPU 硬件独占内存(被动等待) | 显式锁,线程主动竞争锁资源(主动阻塞) |
| 阻塞机制 | 无内核态切换:竞争时 CPU 暂停线程执行(忙等),无线程挂起 | 有内核态切换:竞争失败时线程被挂起(阻塞),直到锁释放后唤醒 |
| 锁粒度 | 极细:仅针对单个原子变量的缓存行(字节级) | 较粗:保护任意临界区(多行代码 / 多个变量) |
| 性能 | 极高(纳秒级):无上下文切换开销 | 较低(微秒级):内核态切换 + 上下文切换开销 |
| 死锁风险 | 无(无显式锁,硬件级操作) | 有(锁顺序错误、忘记解锁、递归锁等) |
| 适用场景 | 简单数据操作(计数、标志位、CAS) | 复杂逻辑(多步操作、跨资源访问) |
| 功能灵活性 | 仅支持基础操作(读 / 写 / 交换 / CAS) | 支持任意临界区保护,可配合条件变量 / 递归锁等 |
从原理上再回故最开始的例子:
atomic<int> Counter = 0;
Counter++; // 无锁原子操作
原理:Counter++调用fetch_add(1),编译器翻译为lock xadd dword ptr [Counter], 1;
线程协调:CPU 通过lock前缀锁定Counter的缓存行,两个线程的xadd指令串行化执行(同一时刻仅一个线程能操作),无中间状态,因此结果必然是 200000;
与线程锁的差异:
若用std::mutex实现:需手动加锁 / 解锁,线程竞争失败时会被挂起(内核态切换),性能远低于原子操作;
原子操作无需手动管理锁,且无死锁风险,是简单数据线程安全的最优解。
核心结论
原子操作的 “线程安全”:无锁版本依赖 CPU 硬件原子指令(lock前缀),有锁版本依赖线程锁模拟;
原子操作并非 “锁住线程”:无锁版本是 CPU 硬件独占内存,线程无主动阻塞;线程锁是主动挂起竞争失败的线程;
选择原则:简单数据(计数 / 标志位)用std::atomic,复杂逻辑用std::mutex。
浙公网安备 33010602011771号