了解完CPU缓存的基本概念后,我们就可以深入探讨缓存一致性领域了。
在系统学习缓存一致性之前,了解多核系统的基础知识是必要的。但为了避免割裂整体认知流程,这里我们仅作一个简要说明:多核系统意味着系统内存在多个处理核心,且核心之间需要进行数据交换。数据交互可能发生在共享的L3或SLC缓存中。然而,缓存本身无法被直接寻址,并且某些缓存是相对于核心私有的。因此,在程序运行过程中,我们必须保证在一个时间窗口内,所有核心看到的数据是一致的。
在详细讲解缓存一致性之前,我们先来厘清几个基础概念。
1. 访存空间一致性
设想这样一个场景:
- a. RAM中同一地址的数据被同时缓存到了Core1和Core2的私有缓存中,此时三者的值是一致的。
- b. 之后,Core1修改了它缓存中的数据,此时RAM和Core2的数据一致,但和Core1不同。
此时,Core2若继续使用其缓存中的原始数据进行计算,就极有可能导致程序出错,这就是空间上的不一致。
因此,空间一致性指的是在一个时间窗口内,整个系统中对同一内存地址不会存在多个不同值的副本。这个问题通常被称为缓存一致性,而它正是由缓存本身引起的。
2. 访存时间一致性
回看访存空间一致性的描述,我们提到了“时间窗口”的概念。这是因为在多核系统中,硬件通过数据同步机制来实现空间一致性是需要时间的,因此访存的时间一致性问题是天然存在的。
这种因数据同步延迟而导致的一致性问题,被称为访存时序一致性。
3. 理解访存时间一致性问题
为了更清晰地理解访存时间一致性问题,我们不妨先将缓存从系统模型中移除。此时,多核心的所有数据操作都将通过总线网络直接读写SDRAM。我们期望所有核心都能一致地看到其他核心写入SDRAM的数据,但现实情况往往并非如此。
a. 延迟到达导致问题
假设一个系统中有三个核心 Core0、Core1、Core2,并发生以下操作:
- a. Core0 向地址 A 写入数据 data0。
- b. Core1 向地址 A 发出读请求,将读到的数据存入寄存器 a,然后向地址 B 写入数据 data1。
- c. Core2 向地址 B 发起读请求,将数据存入寄存器 a,随后向地址 A 发起读请求,并将数据存入寄存器 b。
由于网络中各个核心之间的物理距离不同或总线仲裁延迟,可能会出现以下场景:Core1 成功读取到了 data0 并完成了向地址 B 的写入,但 Core2 向地址 A 发起的读请求却没有拿到最新的 data0,而是拿到了旧值。
这就会导致 Core0 和 Core1 认为地址 A 的数据是正确的,而 Core2 看到的数据却是错误的。对于地址 B 也存在类似问题。这个问题的根源在于分布式系统中永远无法做到零延迟通信。
b. 访问冲突导致问题
再考虑另一种场景:Core0 和 Core1 同时发起对同一地址的修改操作,例如:
- Core0 读取地址 a 的数据到寄存器0,然后对寄存器0进行自增,最后将结果写回地址 a。
- Core1 执行完全相同的操作序列。
程序预期的结果是,Core0 和 Core1 的操作具有先后顺序,即在其中一个自增的结果基础上再进行自增。若存在缓存,则需要保证空间一致性;但这里由于网络延迟,必须保证每个核心产生的新数据能够正确同步到其他核心,而同步是需要时间窗口的。因此,当某个核心的新数据尚未被其他核心看到时,其他核心不能操作该数据。这就是由访问冲突或访问延迟所引发的问题。
c. 提前执行导致错乱
我们通过一个代码示例来看这个问题:
| core0 |
core1 |
| 代码段 0; |
1. 代码段 0 |
| Store data 地址A; |
2. load 地址P regA; |
| Inc 地址P; |
3. load 地址P1 regB; |
| 代码段 1; |
4. cmp regA regB; |
|
5. Load 地址A 寄存器C |
|
6. 代码段 1 |
Core0 和 Core1 分别执行不同的代码,但存在数据交互。
Core1 在执行时,由于分支预测机制,假设预测条件不跳转,可能会提前执行第 5 行代码(Load 地址A)。如果此时 Core0 尚未完成对地址 A 的数据更新,Core1 就会读到旧数据。然而,在下一次循环执行到指令 1 时,Core1 可能读到了 Core0 更新后的最新数据,从而满足条件跳出循环。
请注意,前一次循环中提前执行的指令 5 读取的却是旧数据,这就会导致程序执行错误。回顾这个问题,其本质是多核之间产生了读后写相关,但核心间的硬件机制未能保证执行顺序而引发的问题。这是计算机基础中并发与内存模型要解决的核心难题之一。
d. 乱序执行导致问题
我们再看一个由乱序执行引发问题的例子:
| core0 |
core1 |
| 代码段 0; |
1. 代码段 0 |
| Store data0 地址A; |
2. Store data1 地址b; |
| Ioad 地址B regA; |
3. load 地址A regA; |
| 代码段 1; |
4. 代码段 1; |
在这个例子中,Core0 和 Core1 的指令看似相互独立执行,没有交集。但实际上,由于它们操作了相同的内存地址(A 或 B),依然可能像分支预测的例子一样,因为跨核心的读写相关性问题而出错。与分支预测不同的是,分支预测错误可以冲刷流水线重新执行,而乱序执行导致的问题则更加棘手。
因此,最终需要解决的,就变成了如何有效处理跨核心之间的指令相关性问题。本节主要描述了多核系统中访存时间一致性的几种典型问题,下一节我们将探讨当前用于解决这些问题的一些基本方案。