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 引入头文件,提供std::atomic模板类,封装原子操作,支持常见数据类型(int/bool/指针等)。
而原子操作能让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类型,Counter++是原子自增操作—— 它不是普通的 “读 - 改 - 写” 三步,而是被 CPU 封装成「不可中断的单一指令」,这是线程协调的核心。
原子操作的底层实现(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::fetch_or);
FPlatformAtomics::InterlockedAnd → 原子按位与(等价于atomic::fetch_and);
FPlatformAtomics::AtomicRead_Relaxed → 放松内存序的原子读(等价于atomic::load(memory_order_relaxed))。
比如 UE5 中UObjectBase的原子标志位操作:

// UE5原子设置标志位(核心逻辑)
FPlatformAtomics::InterlockedOr((int32*)&ObjectFlags, FlagsToAdd);
// 等价于标准C++
atomic<int32> objFlags;
objFlags.fetch_or(FlagsToAdd);

原子操作 vs 互斥锁

特性 原子操作 互斥锁(std::mutex)
适用场景 简单数据操作(计数、标志位、CAS) 复杂操作(多步逻辑、跨资源访问)
性能 极快(无内核态切换) 较慢(可能触发内核态切换)
功能 仅支持基础数据操作 支持任意临界区保护
死锁风险 有(如锁顺序错误)

综上总结
原子操作是 “不可中断” 的操作,解决多线程共享数据的竞态条件;
C++ 通过的std::atomic实现,UE5 通过FPlatformAtomics封装平台相关原子操作;
优先用原子操作处理简单数据(计数、标志位),复杂逻辑用互斥锁;
内存序可优化性能,按需选择(非特殊场景用默认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满足sizeof(int)=4 ≤8且是 2 的幂,因此是无锁原子。

核心操作(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),is_lock_free()返回false,此时:
基类_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。

posted @ 2025-12-17 20:54  D大人  阅读(24)  评论(0)    收藏  举报