在传播缓存行时,内存控制器如何保证原子的内存顺序?

gla*_*des 2 c++ atomic cpu-architecture memory-model stdatomic

我目前正在深入研究std::atomicsC++ 内存模型。真正对我的思维模型有帮助的是 CPU 的存储和加载缓冲区的概念,它基本上是一个 fifo 队列,用于存储必须写入 L1 缓存或从 L1 缓存读取的数据,至少在英特尔架构中存在。据我了解,原子操作基本上是对 CPU 的指令,可防止包装类型在编译时或运行时撕裂并跨屏障重新排序写入或读取指令。为了说明我的思维模型中的差距,我很快就想出了这个例子:

演示

#include <atomic>
#include <iostream>
#include <thread>

int a;
int b;
int c;
std::atomic<int> x;
int e = 0;

auto thread1() {
    while(1) {
        a = 3;
        b = 5;
        c = 1;
        x.store(10, std::memory_order::release);
        e++;
        std::cout << "stored!" << std::endl;
    }
}

auto thread2() {
    while(1) {
        x.load(std::memory_order::acquire);
        std::cout << b << std::endl;
    }
}


int main() {
    [[maybe_unused]] auto t1 = std::thread(&thread1);
    [[maybe_unused]] auto t2 = std::thread(&thread2);
}
Run Code Online (Sandbox Code Playgroud)

这里,一个线程写入全局变量 a、b、c、原子变量 x 和普通变量 e(在增量之前读取),而另一个线程从原子变量 x 和普通变量 b 读取。对于下一部分,假设两个线程实际上运行在不同的 CPU 核心上。另请记住,这个简单的示例完全忽略了争用同步,仅提供静态示例。

现在这是我对这个过程的心理模型:

内存模型

正如您所看到的,存储缓冲区以有序的方式将数据馈送到 L1 缓存中。然后数据通过 L2 和 L3 缓存传播到主内存。虽然没有人知道它何时会到达那里,但它将以 64 Kb 的完整缓存线到达(在大多数架构上)。现在我们假设全局变量 a、b、c 碰巧放置在与 x 和 e 不同的缓存行上。这引发了我的问题:内存控制器如何知道传播两个缓存行,以便尊重 x 上的原子操作所隐含的内存顺序

我的意思是,如果缓存行 1) 碰巧在 CL 2) 之前到达主内存,则一切都很好,新写入的 a、b 和 c 的值在 x 存储之前对其他线程可见。但如果相反的情况发生呢?如果高速缓存行 2) 首先传播,则对 x 和可能 e 的写入将在对 a、b 和 c 的写入之前可见,这将导致无效的内存排序。必须以某种方式防止这种情况发生。我想出了一些可能的解决方案:

  1. 内存控制器将始终按照在 L1 中更新的顺序传播缓存行。由于 CL 2) 在 1) 之后更新,它将在 2) 之前将 1) 首先推送到 main,并且满足约束。
  2. 内存控制器以某种方式“知道”高速缓存行的排序关系,并且基本上记住哪个 CL 首先通过高速缓存传播。

我现在可能想不到其他解决方案,但我认为理解这个拼图将帮助我完成我的心理理解,达到可接受的细节量。如果我的理解有什么缺陷,还请纠正我。

Pet*_*des 5

但没人知道它什么时候会到达那里

内部缓存参与缓存一致性协议。 AFAIK,所有现代 CPU 都使用MESI的某种变体。(维基百科文章用处理器监听共享总线来描述它,但实际的 CPU 使用“目录”,例如具有包容L3 缓存的 Intel CPU 使用 L3 标签来跟踪哪个核心可能具有缓存行的修改副本Skylake-Xeon 及更高版本有一个单独的一致性目录和监听过滤器,因为它们的 L3 缓存是 9 个(不包含,不排他)。)

在存储可以从存储缓冲区提交到高速缓存行之前,该核心必须获得它的独占所有权。如果它尚未处于“已修改”或“独占”状态,则需要执行“读取所有权”操作,这会使其他核心中的任何副本无效,并等待响应以确认其拥有所有权。只有这样它才能提交存储,使其在此时全局可见。(来自另一个核心的共享请求或 RFO 可能会进来并查看该值。)

对于一致缓存,内存重新排序仅是本地的(在每个 CPU 核心内,对其加载和存储进行排序到一致缓存)。例如,存储缓冲区会延迟加载,并且乱序执行(或者只是按命中未命中缓存的顺序)会提前加载并且可能会乱序。

内核之间的互连可能需要注意不要引入新的内存重新排序,但大多数情况下它只是通过保持一致性来工作。实际上,硬件确实保持顺序。


因此,内存屏障指令只需让后面的内存操作等待一些早期的事情完成,例如,如果它是像 x86 这样的完整屏障,则存储缓冲区会耗尽mfence。或者,如果它只是一个acq_rel栅栏(https://preshing.com/20130922/acquire-and-release-fences/)。

请注意,原子操作store(val, release)具有单向排序,它们可以在稍后的存储和加载中重新排序,但不能更早。(https://preshing.com/20120913/acquire-and-release-semantics/)。栅栏与操作不同,而且更强,需要是 2-way 的


x86 的强有序内存模型必须保留程序顺序的假象(加上具有存储转发功能的存储缓冲区,这可能导致 StoreLoad 重新排序),但弱排序 ISA 允许创建 LoadLoad、StoreStore 甚至 LoadStore 重新排序。( https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/ ) 在这种情况下,加载和存储缓冲区不是 FIFO。

即使 x86 CPU 也会推测性地乱序加载,并稍后检查一旦架构上允许加载发生(可能是在退役时),高速缓存行是否仍然拥有。(这就是为什么当另一个核心正在修改我们正在加载的数据时,x86 会使用 machine_clears.memory_ordering 管道。)


共享缓存是一致性的后盾(在本例中为 L3);数据不必一直传输到 DRAM。即使在多插槽 Xeon 或具有多个 L3 集群的大型 Zen 上,它们也可以相互通信并将缓存行相互传递,而无需缓慢地往返 DRAM。

一般来说,标准定义的 C++ 内存模型比您可以用简单的缓存一致性硬件来解释的任何模型都要弱得多。例如,IRIW 重新排序只能在现实生活中的少数机器上实现,例如 POWER,并在同一物理核心上的逻辑核心之间进行存储转发。请参阅其他线程是否总是以相同的顺序看到对不同线程中不同位置的两个原子写入?

另一个很好的例子是,只有原子 RMW 会继续释放序列,因此获取加载仅与其加载值的释放存储建立发生之前关系,而不是与修改顺序中较早的释放存储建立关系。但在存储只能通过提交到一致缓存对其他核心可见的机器中,纯存储也必须等待缓存行的所有权,因此在缓存一致性方面与 RMW 类似。因此,一致性缓存模型无法解释 ISO C++ 标准纸面上允许的这种效应。

在 C++ 标准的早期草案中,即使是纯存储也将保证继续发布序列,但这一点发生了变化。也许是因为某些实际硬件的情况并非如此,例如 PowerPC,或者至少某些 ISA 不保证纸面上的某些内容。

不要仅仅因为无法想到可以解释违反标准的重新排序的硬件机制而陷入认为 ISO 标准的形式主义中可以保证某些内容的陷阱。(但是从硬件角度思考在另一个方向上很有用:例如,如果您知道C++ 原子如何编译为AArch64 的 asm,并且可以想到在 AArch64 上可以重新排序的方法,则 ISO C++ 一定不能保证它。映射中出现错误的可能性极小。)

在大多数现代 CPU 上,高速缓存行为 64 字节 (64 B),而不是 64 KB (64 Kb = 8KB)。


有关的: