0%

内存模型

内存模型的定义

内存模型(Memory Model)是定义数据一致性执行顺序规则的一组规范。

备忘录

  • 编程语言保证单线程的“程序顺序一致性”,比如为了防止CPU乱序执行,甚至主动对有依赖关系的变量插入内存屏障。

多核缓存

分层:

  • 寄存器:最快,由重命名机制管理
  • L1/L2/L3 Cache:核心本地缓存,按缓存行一致性(MESI)维护共享状态
  • 主内存(DRAM):CPU访问需经总线或内存控制器
缓存级别 位置 速度(快) 容量(小) 每核私有/共享 说明
L1 Cache CPU核心内部 ✅最快 ❌最小 每核私有 通常分为指令缓存(L1I)和数据缓存(L1D)
L2 Cache 核心内部或旁边 较快 较小 每核私有或共享 有助于减缓L1未命中后的访问压力
L3 Cache 核心集群共享 慢一些 更大 多核共享 减少跨核访问主存的延迟,常位于芯片中心
L4 Cache 一些高端CPU或封装外DRAM 最慢 最大 多芯片共享 非常少见(例如Intel的eDRAM)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[CPU Core] 
├── [Load Buffer] ← 处理 load 指令
├── [Store Buffer] ← 处理 store 指令
├── [L1 Cache]
└── [Execution Unit]

⬇️

[Shared L2 Cache] / [L3 Cache]

⬇️

[Memory Bus] ↔ [Main Memory (DRAM)]



[Other CPU Cores]

Store/Load Buffer

  • Load Buffer:

    • 位置:在 CPU 核心内部,靠近 Load/Store Unit。
    • 功能:暂存“load”(读取)指令的结果,等待内存或缓存返回数据。
    • 使用场景:比如你访问一个变量 x,如果还没拿到内存值,它就被放在 load buffer 等待。
  • Store Buffer:保存尚未提交的 store 写操作(延迟写回),可被后续 load 查询(store-to-load forwarding)。内存屏障(memory fence)可强制“写入完成后再继续”。

    • 位置:同样在 CPU 核心内部,紧挨 Load Buffer。
    • 功能:当执行 x = 5 这样的写操作时,并不会立刻写入内存,而是先写入 store buffer。
    • 延迟写回:写操作会被延后,在合适时机批量提交到缓存或主存中。
    • 注意:这就是为什么需要内存屏障(memory fence)来强制“写入完成后再继续”。

Store/Load Buffer是一个具备调度逻辑的指令队列,不是为了“缓存数据”。
之所以叫”Buffer”而不是”Queue”,是强调其“乱序 + 并发调度能力”。

与普通队列的对比:

对比项 普通队列 Load Buffer
数据结构 FIFO 队列 类似 CAM(Content Addressable Memory)或乱序数组
访问方式 一次 pop 一个 任意条目都能被访问、发射、取消
顺序 严格顺序 支持乱序发射、乱序完成、乱序提交
功能 暂存数据 追踪地址、状态、与 Store Buffer 比较、rollback 等

Intel 和 ARM 的实际叫法

  • Intel 官方架构手册:

    • 用词是 Load Buffer / Store Buffer
    • 有时叫 “Memory Ordering Buffer”
  • ARM 架构(如 Cortex-A76)

    • 常叫:Load-Store Queue(LSQ)
    • 还会细分为:
      • Load Queue(LQ)
      • Store Queue(SQ)

但即使叫“Queue”,也不一定是严格的队列行为,而是调度窗口结构。

缓存行(Cache Line)

  • 位置:在 CPU 的缓存(L1/L2/L3)中,缓存是以行(line)为单位存储的。
  • 功能:每行通常是 64 字节,它是 CPU 和内存之间最小的同步单位。
  • 状态(MESI 协议):
    • M(Modified): 修改过,主存未同步
    • E(Exclusive): 独占但未修改
    • S(Shared): 可共享读取
    • I(Invalid): 已失效

总线(Memory Bus / Interconnect)

  • 位置:连接多个 CPU 核心、缓存、主存的通信通道。
  • 功能:
    • 数据在 CPU 和内存之间传输
    • 执行原子操作时用于发送 LOCK# 或缓存一致性请求

🔗 示例:Intel 使用 Ring Bus、AMD 使用 Infinity Fabric。

主内存(Main Memory / DRAM)

  • 位置:通常是系统主板上的 DRAM 芯片。
  • 功能:存储所有不在 cache 中的数据,访问速度比 cache 慢很多。

🕓 延迟:访问主存通常需要 100ns ~ 几百ns,而访问 L1 Cache 只需几个 cycle。

内存控制器通道与总线的区别

维度 总线(Bus) 内存控制器通道(Memory Channel)
硬件层级 CPU、缓存、内存之间的共享通信总线 内存控制器内部的通路,直接连接 DRAM 芯片
作用范围 跨多核、跨缓存,整个系统范围 只负责访问主内存的某一部分,细粒度数据路径
连接对象 CPU核心、缓存、内存控制器等 内存控制器与物理内存条之间的接口
作用 传递内存访问请求,实现缓存一致性协议 调度内存读写,执行具体内存命令
原子操作锁定对象 锁定总线,阻止其他核访问内存 锁定内存通道,防止同通道冲突访问

乱序执行

现代 CPU 采用乱序执行(Out-of-Order Execution, OoO)优化性能:

  • 发射顺序有序(程序顺序)
  • 执行顺序无序(根据数据/资源依赖动态调度)
  • 提交顺序有序(借助 ROB 保证提交的程序语义)

核心机制:

  • 指令乱序执行:由调度器和执行单元动态决定执行顺序。
  • 寄存器重命名:使用临时物理寄存器消除写后写、写后读冲突。
  • Load/Store Buffer:用于暂存尚未提交的读写操作。
  • ROB(Reorder Buffer):按程序顺序提交指令(写入寄存器/内存),确保最终结果可预测。

程序顺序一致性(program-order consistency):

  • 在本线程中观察到的顺序要“看起来像顺序执行”。
  • 注意:多线程中可能出现乱序提交。
1
2
3
4
5
6
7
1. Fetch         <-- ✅ 顺序取指,从指令缓存中取出下一条指令
2. Decode <-- 顺序解码,分析操作数和目的寄存器
3. Rename <-- 寄存器重命名
4. Dispatch <-- 投递到调度窗口,等待执行条件满足
5. Execute <-- ✅ 乱序执行(由调度器决定),实际在执行单元上运行指令
6. Writeback <-- 写结果到 ROB
7. Commit <-- ✅ 按程序顺序提交(retire),更新寄存器状态或进行内存写入

TODO: 内存控制器

扩展:寄存器重命名(Register Renaming)

现代 CPU 为了支持乱序执行(Out-of-Order Execution)和指令级并行(ILP),会引入比架构寄存器更多的物理寄存器,来存储中间值。

寄存器重命名:CPU 用一张 寄存器映射表(Register Alias Table, RAT) 来将架构寄存器映射到物理寄存器。

  • 架构寄存器:由 ISA 定义,固定不变。x86-64 是 16 个通用寄存器。
  • 物理寄存器:由具体 CPU 实现,通常远多于架构寄存器。例如:
    • Intel 的 Skylake 有 180 个左右物理寄存器(整数+浮点)
    • ARM 的 Cortex-A 系列也有几百个物理寄存器(实际数量通常保密)
类型 说明
物理寄存器(Physical Register) CPU 内部实际使用的、很多个
架构寄存器(Architectural Register) 程序看到的,例如 RAX、RBX 等,一般只有 16~32 个

举例:

1
2
3
MOV RAX, 1
MOV RAX, 2
ADD RAX, 3

执行流程:

指令 分配的物理寄存器 对应动作
1 RAX → P1 P1 ← 1
2 RAX → P2 P2 ← 2(与1可并行执行)
3 用 P2 + 3 写入 P3,RAX → P3

注意:

  • 三条指令都写的是 RAX,但实际上写的是 不同的物理寄存器(P1、P2、P3)
  • 只有最后提交阶段才把 RAX 映射为最终结果的寄存器(P3)

内存屏障

屏障类型 会阻止 作用说明
SFENCE Store → Store 重排序 前面的写必须完成再执行后面的写
LFENCE Load → Load 重排序 后面的读必须等前面的读完成
MFENCE 所有内存访问 保证前面的 load/store 全部完成
问题 答案
内存屏障是运行时插入的吗? ❌ 插入时机是编译期,但运行时会执行它
屏障如何阻止后面指令提前? ✅ 屏障执行时会强制刷新缓冲区、阻止乱序执行

缓存一致性协议(MESI)

MESI 协议的核心操作:

  • 读(load):如果某核 cache 没有,就从主内存或其他核拉一份。
  • 写(store):必须先让其他核的对应 cache line 失效(invalidate),否则就可能出现数据竞争。

atomic(哪怕是 relaxed)为什么会触发 invalidate?

当你写入一个 std::atomic(哪怕是 relaxed):

1
x.store(123, std::memory_order_relaxed);
  • 这个 store 会编译为一条特殊的汇编指令(如 MOV 带 lock 前缀,或 STLR, STR 等)
  • CPU 会发出总线事务(bus transaction)或利用缓存一致性协议 广播这个写入:
    • 让其他核心中缓存这个变量所在 cache line 的副本 全部失效(Invalidate)
  • 所有核心必须从这个核心或主内存读取最新的值(下一次 load)

内存序

内存序 = 程序中读写内存操作的可见顺序。

在理想世界(顺序一致的架构)中,你写的代码顺序就是 CPU 执行和其他线程观察到的顺序。但现实中:

  • CPU 会乱序执行(out-of-order)
  • 编译器会重排指令
  • 缓存系统和 store/load buffer 会拖延内存访问
  • 多核之间访问的是缓存,而不是主内存

👉 所以,需要有一套规则来定义:“内存操作到底是何时、如何被其他线程看见的”,这就是内存序的目标。

概念 ^1 ^2

  • load: 从内存变量的值到CPU:内存 ➝ CPU。

  • store: 把一个值从 CPU 的寄存器或处理单元中,到内存中的某个地址(变量):CPU ➝ 内存。

  • relaxed: 对其他线程的读写没有同步或顺序约束,仅仅保证本操作的原子性。

  • acquire: 对 load 操作的内存序语义约束。

    • 当前线程中的任何读写都不能重排序到此 load 之前;
    • 在其他线程中 release 同一原子变量的所有写入在当前线程中可见。
  • release: 对 store 操作的内存序语义约束。

    • 当前线程中的任何读写都不能重排序到此 store 之后;
    • 当前线程的写入对 acquire 同一原子变量的其他线程可见。
  • acquire-release: 对 read-modify-write 操作的内存序语义约束。

    • 当前线程中的任何读写不能重排序到此 load 之前,也不能重排序到此 store 之后。
    • 其他线程 release 同一原子变量的写操作,对该 modification 可见;
    • modificationacquire 同一原子变量的其他线程可见。
  • sequentially-consistent:

    • load 操作遵循 acquire 语义;
    • store 操作遵循 release 语义;
    • read-modify-write 操作同时遵循 acquirerelease 语义;
    • 存在一个全序,其中所有线程都以相同的顺序观察所有修改。
内存序 插入屏障 特点
relaxed ❌ 无 只保证原子性,不保证顺序
acquire ✅ 读屏障 阻止后续操作提前
release ✅ 写屏障 阻止前面操作延后
acquire-release ✅ 两者都有 双向有序
seq_cst ✅ 全屏障 全局顺序一致性
步骤 普通变量 atomic(relaxed)
1 编译器可优化 编译器必须保留这个操作
2 CPU 可能重排 CPU 仍可能重排(因为是 relaxed)
3 缓存写入 可能停在本核 cache 中,不通知他人
4 不触发 invalidate ✅ 会触发 cache line invalidate
5 其他线程可见性低 ✅ 其他线程可读取到最新的值(但时序不保证)

私注:

  • acquire: 当本线程读取后,本线程之后的操作都可以依赖这次读取(“后面的读写不得提前”);
  • release: 当本线程允许写后,本线程之前的读写都完成了,即之后的操作都可以依赖此前的写入(“前面的读写不得滞后”)。

和普通变量相比,atomic(即使是 relaxed)做了哪些额外工作?

atomic(即使使用 memory_order_relaxed)和普通变量相比,最核心的区别是:原子变量保证原子性(atomicity)和可见性,而普通变量不能。

  • 保证操作的原子性

    所谓“原子性”,是指某个操作(如 load、store、CAS)要么全部完成、要么完全不做,不会出现中间状态。

    • atomic x;

      1
      x.store(42, std::memory_order_relaxed);

      这个 store 是原子的:不会被打断、不会被分拆。

    • 普通变量可能被编译器或 CPU 拆成多条指令(比如低 4 字节和高 4 字节分开发),多线程下可能看到“半成品”状态。

  • 触发 CPU 的 cache coherence 协议(MESI等)

    原子变量的访问会参与硬件层的缓存一致性协议,以保证多核 CPU 看到一致的值。

    • 即使是 relaxed,store 也会触发对应 cache line 的写入通知(invalidate);
    • 普通变量(非 volatile、非 atomic)的访问,编译器会缓存到寄存器中,不一定会回写。
  • 禁止编译器优化(只针对原子变量本身)

    编译器不能将多个 atomic store 合并,也不能删去它(不会当作“死代码”)。

    • x.store(42, relaxed) 每次都必须发出一条指令;

    • 对普通变量:

      1
      2
      x = 42;
      x = 43;

      可能直接编译为 x = 43;,前一条被优化掉。

atomic<T, relaxed> 不做的事:

功能 atomic(relaxed) 普通变量
保证原子性 ✅ 是 ❌ 否
保证可见性(跨线程) ✅ 是(通过 cache 协议) ❌ 否
禁止指令重排(保证顺序) ❌ 否(只 relaxed) ❌ 否

屏障为什么要设置两种?

性能优化 + 最小化开销 + 精确控制重排序方向。

屏障的本质是“控制指令重排序”
但它并不一刀切地“全部阻止”,而是:

屏障类型 阻止的方向(简化理解)
读屏障 阻止 读-after-读 重排序(即前面的读取不能晚于后面的读取)
写屏障 阻止 写-after-写 重排序(即前面的写不能晚于后面的写)
全屏障 阻止 读/写 与 所有后续指令的重排序

为什么分开这么细?(硬件视角)

现代 CPU 架构(如 x86, ARM)中:

  • 读和写通路是分离的

    • 读用 load buffer
    • 写用 store buffer
  • 两者乱序的模式、处理机制、性能代价都不同!

所以,硬件支持你只加读屏障(LFENCE)或写屏障(SFENCE),能以更小代价完成你想要的顺序语义。

举例:

操作 屏障类型 效果 例子
flag==1 后再读取 data 读屏障 禁止 data = ? 提前执行 load-acquire fence
data=42 后,才写 flag=1 写屏障 禁止 flag=1 提前写出 store-release fence
多线程通信中,强一致顺序 全屏障 所有读写顺序完全禁止重排 x86 MFENCE
  • 你可以只阻止读乱序 → 读屏障
  • 你可以只阻止写乱序 → 写屏障
  • 你可以全部阻止 → 全屏障

原子操作

层次 机制 说明
指令层 原子指令(如 lock cmpxchgxaddldrex/strex 提供不可中断的执行
缓存层 MESI 协议 保证多个CPU缓存数据一致
总线层 总线锁定(旧方式) 强制所有核等待该操作完成
内存层 内存屏障 保证内存操作顺序正确可见

CPU的原子指令如何实现的?

CPU 的原子指令是在硬件微架构层面通过微操作(micro-ops)调度、缓存控制、总线协议协调等机制联合实现的。我们可以从以下几个层面来详细剖析:

微架构层面:锁住特定资源

当执行原子指令时,CPU 内部会采取 锁定机制,确保该操作的所有子步骤在执行时不会被打断。

  • 对于访问内存的原子操作,CPU 会尝试锁定:
    • 缓存行(现代 CPU)
    • 或内存总线(旧 CPU)

以 x86 的 LOCK CMPXCHG 为例,它内部其实是:

1
2
3
4
5
6
7
if (mem == eax) {
mem = reg;
ZF = 1;
} else {
eax = mem;
ZF = 0;
}

这个流程在硬件中不是三条指令,而是一个专门的微操作指令组合包,称为“fused micro-op”,由 CPU 保证“执行期间中断关闭、禁止抢占”。

缓存一致性协议(MESI)保障原子性

原子指令通常访问共享变量,这就需要解决缓存一致性问题。

举例:两个 CPU 都缓存了某个共享变量的值
当 CPU1 执行原子写入(例如 lock xadd)时:

CPU1 会尝试将该缓存行从 Shared → Modified 状态

MESI 协议要求其他 CPU(如 CPU2)将该缓存行标记为 Invalid

一旦独占,CPU1 就能安全完成读-改-写过程

这种方式实现了 原子访问缓存行。

总线或互联互锁(bus lock / interconnect lock)

对于不在缓存中的内存地址(如非一致内存访问 NUMA、I/O 内存等),现代 CPU 仍可能退回到:

  • 总线锁定(Bus Lock)
  • 内存控制器互锁(memory interconnect lock)

比如 x86 的 lock 前缀会让 CPU 发出 LOCK# 信号,告诉其他核暂时禁止访问这段地址。

这是一种慢但可靠的 fallback 机制,避免跨节点访问带来的竞态。

禁止乱序与流水线干预

现代 CPU 为了性能采用乱序执行(Out-of-Order Execution)和流水线(Pipelining)机制。但原子操作必须:

  • 执行期间不得乱序
  • 禁止乱序缓存访问(如 Store Buffer 提前写入)
  • 强制所有早于它的读写完成,之后的读写等待

这依赖于:

  • 微指令插入内存屏障(memory fence)
  • 暂停 load/store buffer 的重排
  • Flush pipeline / reorder buffer

这就是为什么原子操作通常比普通操作要慢的原因之一。

例子:lock cmpxchg 的硬件执行流程

假设 CPU1 执行 lock cmpxchg [X], eax,大致流程如下:

  • CPU1 发出 LOCK# 信号或锁住缓存行 X(取决于地址是否在 cache)
  • 向 MESI 控制器发请求,抢占地址 X 的 cache line 独占权限
  • 在其他核心收到 invalidation 后,CPU1 获得修改权限
  • CPU1 比较 X 与 eax 的值,如果相等则更新为新值
  • 完成后通知 MESI controller,X 的状态设为 Modified
  • 操作结束,缓存一致性仍然维护

整个过程是由硬件控制单元(Load/Store Queue、Execution Unit、Bus Interface Unit)协调完成的。

小结:CPU 实现原子操作的关键机制

机制 功能
原子指令(如 lock cmpxchg, ldrex/strex 提供原子读-改-写
微操作调度锁定资源 保证在流水线中不可拆分
MESI 协议 多核共享缓存一致性
总线锁或互联锁 防止跨核心或I/O地址出现竞态
内存屏障 & 禁止乱序 防止指令与缓存失序访问