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 内),优先核心间同步能大幅提升性能。

相关推荐
西岸行者5 天前
学习笔记:SKILLS 能帮助更好的vibe coding
笔记·学习
悠哉悠哉愿意5 天前
【单片机学习笔记】串口、超声波、NE555的同时使用
笔记·单片机·学习
别催小唐敲代码5 天前
嵌入式学习路线
学习
毛小茛5 天前
计算机系统概论——校验码
学习
babe小鑫5 天前
大专经济信息管理专业学习数据分析的必要性
学习·数据挖掘·数据分析
winfreedoms5 天前
ROS2知识大白话
笔记·学习·ros2
在这habit之下5 天前
Linux Virtual Server(LVS)学习总结
linux·学习·lvs
我想我不够好。5 天前
2026.2.25监控学习
学习
im_AMBER5 天前
Leetcode 127 删除有序数组中的重复项 | 删除有序数组中的重复项 II
数据结构·学习·算法·leetcode
CodeJourney_J5 天前
从“Hello World“ 开始 C++
c语言·c++·学习