Linux C/C++ 学习日记(52):原子操作(1):cpu缓存、可见性、顺序性、内存序、缓存一致性的介绍

注:该文用于个人学习记录和知识交流,如有不足,欢迎指点。

一、CPU是如何获取数据的

cpu缓存

缓存行

CPU 获取数据的精简流程(按 "快→慢" 层级查,无效则找其他核心 / 主存):

  1. 先查自己核心的 L1 缓存:

    • 若 L1 里有该数据对应的有效缓存行,直接读;
    • 若 L1 缓存行无效,去查自己核心的 L2 缓存。
  2. 再查自己核心的 L2 缓存:

    • 若 L2 有有效缓存行,读并同步到 L1;
    • 若 L2 缓存行无效,去查所有核心共享的 L3 缓存。
  3. 接着查共享的 L3 缓存:

    • 若 L3 有有效缓存行,读并同步到 L2、L1;
    • 若 L3 缓存行无效,先看其他核心有没有该数据的有效缓存行:有则直接从其他核心读,同步到 L3、L2、L1;没有则从主存读。
  4. 最后从主存读:从主存加载该数据对应的缓存行,依次存入 L3、L2、L1,然后读取。

问:线程间的共享变量如何避免同时操作,操作的最新值如何实时同步到别的线程?下面是解答

二、原子性:

一个或一组操作,在执行过程中不可被线程调度器中断 ,且对外呈现 "要么完全执行完毕,要么完全不执行" 的状态 ------ 不存在 "执行到一半" 的中间态,其他线程也无法观察到中间结果。

简单点讲:在该变量被操作的过程中,其他线程不可访问该变量(会阻塞到操作完成才访问成功),且该操作无法被中断。

关键特征:

  1. 不可中断:操作一旦开始,必须执行到结束,不会被其他线程打断(底层依赖 CPU 原子指令、锁、内存屏障等保证);
  2. 无中间态:从其他线程的视角看,操作要么 "没开始",要么 "已完成",看不到 "执行到一半" 的中间值;
  3. 单线程无意义 :原子性是针对多线程的特性 ------ 单线程下所有操作天然不会被中断,因此讨论原子性的前提是多线程竞争

三、原子操作

具备原子性的 "具体操作":针对原子变量的操作
原子操作需要考虑内存序的问题:

内存序细讲就是控制可见性和顺序性,我们要基于变量对可见性和顺序性的要求选择合适的内存序

(一) 可见性

1. 定义

一个线程对原子变量的修改,其他线程能否及时、确定地读取到这个最新值

核心痛点:即使是原子操作,修改后的值可能只留在当前线程核心的私有缓存中,其他线程读的是自己缓存里的旧值("修改了但看不见")。

2. 原子操作可见性的问题根源

和普通操作一致(CPU 缓存 + 重排序),但原子操作可通过内存序解决:

  • 缓存层面 :修改先写入核心私有缓存,未即时刷到缓存(可能仍存在寄存器中)、主存(如果有MESI机制的化,未刷新到主存不影响,会直接从缓存行同步到其他CPU缓存行);
  • 编译器 / CPU 优化:重排序或寄存器驻留,导致读取不到最新值。

3. 内存序如何解决可见性?

通过release(写)+acquire(读)组合强制同步缓存:

  • release:写原子变量时,强制将当前线程缓存中的所有修改刷到主存;
  • acquire:读原子变量时,强制从主存加载最新值(而非本地缓存)。

(二)顺序性

1. 定义

多线程下,原子操作的执行顺序是否和代码书写顺序一致,以及 "不同线程看到的操作顺序是否一致"。

核心痛点:编译器 / CPU 为了性能会重排序指令(单线程语义不变,但多线程下打乱顺序,导致逻辑错误)。

2. 原子操作顺序性的问题根源

指令重排序:比如反例代码中 "先写 shared_data,再写 data_ready",CPU 可能重排为 "先写 data_ready,再写 shared_data"------ 线程 2 读到 data_ready=1 时,shared_data还没被修改("顺序乱了")。

3. 内存序如何解决顺序性?

内存序会约束 "哪些操作不能重排序":

  • release:当前线程中,所有在 release 操作之前的写操作,不能被重排到 release 之后;
  • acquire:当前线程中,所有在 acquire 操作之后的读操作,不能被重排到 acquire 之前;
  • seq_cst:最严格,所有线程看到的操作顺序 "全局一致"(像单线程执行)。

反例:注意不一定报错(编译器不一定有重排,这里只是举个例子)

cpp 复制代码
#include <atomic>
#include <thread>
#include <cassert>
#include <iostream>

// 全局变量:
std::atomic<bool> data_ready(false); // 原子flag:标记data是否准备好
int shared_data = 0;                 // 普通变量:生产者要写入的数据

// 生产者:先写数据,再置位flag(代码书写顺序正确)
void producer() {
    shared_data = 42; // 步骤1:写数据(代码顺序在前)
    // 错误:用宽松内存序,不约束操作顺序
    data_ready.store(true, std::memory_order_relaxed); // 步骤2:置位flag(代码顺序在后)
}

// 消费者:看到flag置位,就读取数据
void consumer() {
    // 循环等待flag置位
    while (!data_ready.load(std::memory_order_relaxed)) {}
    // 预期shared_data=42,但实际可能=0(因为重排序)
    assert(shared_data == 42 && "shared_data未正确初始化!");
    std::cout << "消费者读取到shared_data = " << shared_data << std::endl;
}

int main() {
    // 启动生产者和消费者线程
    std::thread t_prod(producer);
    std::thread t_cons(consumer);

    t_prod.join();
    t_cons.join();

    return 0;
}

(三)内存序

内存序是 C++ 给原子操作提供的约束规则,本质是告诉编译器 / CPU:

  1. 哪些原子操作之间不能重排序
  2. 哪些原子操作的修改必须同步到其他线程(刷缓存)
内存序类型 可见性保证 顺序性保证 适用场景
memory_order_relaxed 无(仅保证原子性) 无(操作可任意重排序) 无依赖的计数器(如统计访问量)
memory_order_release 修改对后续 acquire 读可见 之前的写操作不能重排到该操作之后 发布数据(写状态 / 指针)
memory_order_acquire 能看到之前 release 写的所有修改 之后的读操作不能重排到该操作之前 获取数据(读状态 / 指针)
memory_order_acq_rel 兼具 acquire(读)+ release(写)的可见性 兼具 acquire+release 的顺序性 原子交换、CAS 等读写操作
memory_order_seq_cst 强可见性(全局同步) 全局顺序一致(所有线程看到相同操作顺序) 强同步场景(性能开销大)

总结:

概念 核心目标 依赖关系
可见性 保证线程间修改能互相看到 需内存序(release/acquire)约束
顺序性 保证操作执行顺序符合代码逻辑 需内存序约束重排序
内存序 控制可见性 + 顺序性的规则 不影响原子性,是解决前两者的手段

疑问:

问1:普通变量a++具备原子性吗?

以常见的编程语言(Java/C++/Python 等)为例,看似简单的 a++ 本质是三步拆解操作,而非单一不可中断的指令:

  1. 读取(Load) :将变量 a 的值从内存加载到 CPU 寄存器;
  2. 递增(Increment) :在寄存器中对 a 的值执行 +1 运算;
  3. 存储(Store):将递增后的值从寄存器写回缓存(或内存)。

这三步是分步执行的,中间可能被线程调度器中断,导致多线程下的错误结果。

时间片 线程 1 线程 2 内存中 a 的值
T1 读取 a=0 到寄存器 --- 0
T2 被线程调度器中断 读取 a=0 到寄存器 0
T3 --- 寄存器中 a+1=1 0
T4 --- 写回缓存(或内存),a=1 1
T5 恢复执行,寄存器 a+1=1 --- 1
T6 写回缓存(或内存),a=1 --- 1

最终 a=1(而非预期的 2),证明 a++ 不是原子操作。

四、缓存一致性

缓存一致性(Cache Coherence)是硬件层面的核心机制------ 它解决了「多核心 CPU 的私有缓存中,同一变量的副本数据不一致」的问题,是原子操作能实现 "跨线程可见性" 的底层基础。

注意:缓存一致性应用于所有变量,但是不保证操作的原子性、顺序性。

(一)实现:

几乎所有现代 CPU 都用MESI 协议(Modified/Exclusive/Shared/Invalid)实现缓存一致性 ------ 它给每个缓存行(缓存的最小存储单位)标记 4 种状态,通过总线广播状态变化,强制各核心同步缓存:

缓存行状态 含义
M(Modified) 缓存行被当前核心修改过,和主存数据不一致;仅当前核心持有该缓存行(独占)
E(Exclusive) 缓存行和主存数据一致;仅当前核心持有(无其他核心共享)
S(Shared) 缓存行和主存数据一致;多个核心持有(共享状态)
I(Invalid) 缓存行无效(数据过期),必须从主存 / 其他核心重新加载

(二)MESI 协议如何保证缓存一致(结合原子操作举例):

假设初始时a=0,核心 1 执行原子自增,核心 2 执行原子读取:

  1. 核心 1 要修改a,先通过总线广播「请求独占a的缓存行」;
  2. 其他核心(如核心 2)收到广播,将自己缓存中a的缓存行标记为I(无效);
  3. 核心 1 将a的缓存行标记为E(独占),然后修改为 1,状态变为M(已修改);
  4. 核心 2 执行a.load()时,发现自己的缓存行是I,于是向总线请求a的最新值(数据来源优先级:M > E = S);
  5. 核心 1 收到请求,将a=1刷回主存并直接传给核心 2,自己的缓存行状态变为S(共享);
  6. 核心 2 从主存 / 核心 1 加载a=1,缓存行标记为S,读取到最新值。

整个过程中,MESI 协议通过 "状态标记 + 总线广播",保证了核心 2 能拿到核心 1 修改后的最新值 ------ 这就是缓存一致性的核心作用。

(三)缓存一致性救不了普通a++的核心原因:

缓存一致性不保证原子性,也就是核心1在执行操作的时候(还未更改完毕),核心2请求核心1的值,核心1很可能返回的是a的旧值1。

而原子变量,在操作前会发起lock指令,阻塞其他核心请求该值,直至操作结束完毕。

注意:

单靠原子性也不一定能获取到最新值:因为核心1操作完之后,最新值可能仍存放在寄存器中,并没有更新到缓存中,而此时lock释放,核心2获取的是旧值。

所以原子操作实时更新应该使用realse内存序,确保更新到缓存和主存。然后用acquire从主存中直接获取更稳妥!!!

疑问:

问1:MESI 中, 核心1持有a变量,此时核心2也要获取a变量,那是从内存中直接获取还是核心a同步给核心2?

在 MESI 协议中,核心 2 获取a变量时,优先从核心 1 的缓存中直接同步(缓存到缓存传输),而不是从内存获取 ------ 这是为了避免内存访问的高延迟,是现代 CPU 缓存一致性的高效实现方式。具体行为取决于核心 1 持有a对应的缓存行的状态:

分 3 种状态拆解(核心 1 的缓存行状态不同,同步方式不同)

假设核心 1 已持有a的缓存行,核心 2 发起 "获取a" 的请求:

1. 核心 1 的缓存行是 M 状态(已修改 / 脏块)
  • 核心 1 的缓存行数据 和内存不一致 (比如核心 1 修改了a但没刷回内存);
  • 此时核心 2 请求时,核心 1 会:
    1. 先将缓存行的最新数据(比如a=1直接传给核心 2(缓存到缓存传输),同时把数据刷回主存(保证内存和缓存一致);
    2. 核心 1 的缓存行状态从M变为S(共享);
    3. 核心 2 接收数据后,缓存行状态设为S
  • 结论:核心 2 从核心 1 同步最新数据,而非从内存获取(内存里还是旧值)。
2. 核心 1 的缓存行是 E 状态(独占 / 未修改)
  • 核心 1 的缓存行数据 和内存一致,且只有核心 1 持有;
  • 核心 2 请求时,核心 1 会:
    1. 直接将缓存行数据传给核心 2(无需刷内存,因为数据和内存一致);
    2. 核心 1 的缓存行状态从E变为S,核心 2 的缓存行状态设为S
  • 结论:核心 2 从核心 1 同步数据(比从内存读更快)。
3. 核心 1 的缓存行是 S 状态(共享 / 未修改)
  • 核心 1 的缓存行数据 和内存一致,且可能有多个核心持有;
  • 核心 2 请求时,核心 1 会:
    1. 直接将缓存行数据传给核心 2(或核心 2 也可以从内存读,但缓存传输更快);
    2. 核心 2 的缓存行状态设为S
  • 结论:优先从核心 1 同步数据,少数情况(比如核心 1 的缓存行已被替换)才从内存获取。

缓存到缓存传输是 MESI 的高效优化

现代 CPU 都支持 "缓存到缓存传输"(Cache-to-Cache Transfer)------ 核心间可以直接通过总线传递缓存行数据,无需绕路主存。这是因为内存访问延迟(约 100ns+)远高于缓存间传输延迟(约 10ns 内),优先核心间同步能大幅提升性能。

相关推荐
●VON8 小时前
小V健身助手开发手记(六):KeepService 的设计、实现与架构演进
学习·架构·openharmony·开源鸿蒙·von
走在路上的菜鸟8 小时前
Android学Dart学习笔记第二十节 类-枚举
android·笔记·学习·flutter
YJlio8 小时前
ZoomIt 学习笔记(11.9):绘图模式——演示时“手写板”:标注、圈画、临时白板
服务器·笔记·学习
专注于大数据技术栈8 小时前
java学习--String
java·开发语言·学习
deng-c-f8 小时前
Linux C/C++ 学习日记(50):连接池
数据库·学习·连接池
创作者mateo8 小时前
python基础学习之Python 循环及函数
开发语言·python·学习
weixin_409383128 小时前
a星学习记录 通过父节点从目的地格子坐标回溯起点
学习·cocos·a星
搞机械的假程序猿8 小时前
普中51单片机学习笔记-DS1302实时时钟芯片
笔记·学习·51单片机
车载测试工程师9 小时前
CAPL学习-SOME/IP交互层-值处理类函数2
学习·tcp/ip·以太网·capl·canoe