深度解密 CPU 的缓存

楔子

本文来自于公众号《小林coding》

代码都是由 CPU 跑起来的,我们代码写的好与坏就决定了 CPU 的执行效率,特别是在编写计算密集型的程序,更要注重 CPU 的执行效率,否则将会大大影响系统性能。关于 CPU 的结构这里简单介绍一下,它由运算器、控制器、寄存器和内部总线 四部分构成,起作用分别如下:

运算器:由算术逻辑单元 ALU、通用寄存器、数据暂存器等组成。负责接收从控制器发送的命令并执行相应的动作,主要负责对数据的加工和处理。

控制器:由程序计数 PC、指令寄存器、地址寄存器、数据寄存器 DR 以及指令译码器等组成。负责对程序规定的控制信息进行分析,控制并协调输入、输出操作或内存访问。

寄存器:寄存器的功能是存储二进制代码,它是由具有存储功能的触发器组合起来的。

总线:总结是一种内部结构,它是CPU、内存、输入、输出设备传递信息的公用通道,主机的各个部件通过总线相连接,外部设备通过相应的接口电路再与总线相连接。

而 CPU 内部嵌入了 CPU Cache(高速缓存),它是我们本次的重点,首先 CPU Cache 的存储容量很小,但是离 CPU 核心很近,所以缓存的读写速度是极快的。因此 CPU 运算时,如果直接从 CPU Cache 读取数据,而不是从内存的话,那么运算速度就会变得更快。

但是,大多数人不知道 CPU Cache 的运行机制,以至于不知道如何才能够写出能够配合 CPU Cache 工作机制的代码,一旦你掌握了它,你写代码的时候,就有新的优化思路了。

那么,接下来我们就来看看,CPU Cache 到底是什么样的,是如何工作的呢,又该写出让 CPU 执行更快的代码呢?

CPU Cache 有多快?

你可能会好奇为什么有了内存,还需要 CPU Cache?首先 CPU 内部有一个寄存器,专门用来存储相关指令,因为指令是需要从内存加载到寄存器当中的,然后CPU才可以操作它。而且 CPU 访问寄存器的速度是最快的,根据摩尔定律,CPU 访问寄存器的速度每 18 个月就会翻倍,相当于每年增长 60% 左右;尽管访问内存的速度当然也会不断增长,但是增长的速度远小于 CPU 寄存器,平均每年只增长 7% 左右。于是,CPU 对寄存器与内存的访问性能的差距不断拉大。

到现在,一次内存访问所需时间是 200~300 多个时钟周期,这意味着 CPU 寄存器和内存的访问速度已经相差 200~300 多倍了。

为了弥补两者之间的性能差异,就在 CPU 内部引入了 CPU Cache,也称高速缓存。

CPU Cache 通常将缓存分为三个级别,分别是:L1 Cache(一级缓存)、L2 Cache(二级缓存) 和 L3 Cache(三级缓存)。

由于 CPU Cache 所使用的材料是 SRAM,价格比内存使用的 DRAM 高出很多,在当今每生产 1 MB 大小的 CPU Cache 需要 7 美金的成本,而内存只需要 0.015 美金的成本,成本方面相差了 466 倍。所以 CPU Cache 不像内存那样动辄以 GB 计算,它的大小是以 KB 或 MB 来计算的。

我们可以查看对应的缓存,在Windows上直接使用任务管理器来查看:

我们看到右下角显示:L1 Cache 是385KB、L2 Cache 是1.5MB、L3 Cache是12.0MB。

如果是Linux上,可以通过如下方式查看。

以我当前的阿里云服务器为例,我们看到 L1 Cache 是32KB、L2 Cache 是1MB、L3 Cache 是33MB。

其中,L1 Cache 通常会分为「数据缓存」和「指令缓存」,这意味着数据和指令在 L1 Cache 这一层是分开缓存的,上图中的 index0 也就是数据缓存,而index1 则是指令缓存,它两的大小通常是一样的。

另外,你也会注意到,L3 Cache 比 L1 Cache 和 L2 Cache 大很多,这是因为L1 Cache 和 L2 Cache 都是每个 CPU 核心独有的,而 L3 Cache 是多个 CPU 核心共享的。

程序执行时,会先将内存中的数据加载到共享的 L3 Cache 中,再加载到每个核心独有的 L2 Cache,最后进入到最快的 L1 Cache,之后才会被 CPU 读取到寄存器中。它们之间的层级关系,如下图:

越靠近 CPU 核心的缓存其访问速度越快,CPU 访问 L1 Cache 只需要 2~4个时钟周期,访问 L2 Cache 大约 10~20 个时钟周期,访问 L3 Cache 大约20~60 个时钟周期,而访问内存速度大概在 200~300 个 时钟周期之间。如下表格所示:

所以,CPU 从 L1 Cache 读取数据的速度,相比从内存读取的速度,会快 100 多倍。

CPU Cache 的数据结构和读取过程是什么样的?

CPU Cache 的数据是从内存中读取过来的,它是以一小块一小块读取数据的,而不是按照单个数组元素来读取数据的,在 CPU Cache 中的,这样一小块一小块的数据,称为Cache Line(缓存块)

你可以在你的 Linux 系统,用下面这种方式来查看 CPU 的 Cache Line,你可以看我服务器的 L1 Cache Line 大小是 64 字节,也就意味着L1 Cache 一次载入数据的大小是 64 字节

比如,有一个 int array[100] 的数组,当载入 array[0] 时,由于这个数组元素的大小在内存只占 4 字节,不足 64 字节,CPU 就会顺序加载数组元素到array[15],意味着 array[0]~array[15] 数组元素都会被缓存在 CPU Cache 中了,因此当下次访问这些数组元素时,会直接从 CPU Cache 读取,而不用再从内存中读取,大大提高了 CPU 读取数据的性能。

事实上,CPU 读取数据的时候,无论数据是否存放到 Cache 中,CPU 都是先访问 Cache,只有当 Cache 中找不到数据时,才会去访问内存,并把内存中的数据读入到 Cache 中,CPU 再从 CPU Cache 中读取数据。

所以估计有人又想起了Redis(缓存)和 数据库,没错,我们完全可以按照这种方式来理解。

这样的访问机制,跟我们使用「内存作为硬盘的缓存」的逻辑是一样的,如果内存有缓存的数据,则直接返回,否则要访问龟速一般的硬盘。

那 CPU 怎么知道要访问的内存数据,是否在 Cache 里?如果在的话,如何找到 Cache 对应的数据呢?我们从最简单、基础的直接映射 Cache(Direct Mapped Cache说起,来看看整个 CPU Cache 的数据结构和访问逻辑。

前面,我们提到 CPU 访问内存数据时,是一小块一小块数据读取的,具体这一小块数据的大小,取决于 coherency_line_size 的值,一般 64 字节。在内存中,这一块的数据我们称为内存块(block,读取的时候我们要拿到数据所在内存块的地址。

对于直接映射 Cache 采用的策略,就是把内存块的地址始终「映射」在一个 CPU Line(缓存块) 的地址,至于映射关系实现方式,则是使用「取模运算」,取模运算的结果就是内存块地址对应的 CPU Line(缓存块) 的地址。

举个例子,内存共被划分为 32 个内存块,CPU Cache 共有 8 个 CPU Line,假设 CPU 想要访问第 15 号内存块,如果 15 号内存块中的数据已经缓存在 CPU Line 中的话,则是一定映射在 7 号 CPU Line 中,因为 15 % 8 的值是 7。

机智的你肯定发现了,使用取模方式映射的话,就会出现多个内存块对应同一个 CPU Line,比如上面的例子,除了 15 号内存块是映射在 7 号 CPU Line 中,还有 7 号、23 号、31 号内存块都是映射到 7 号 CPU Line 中。

因此,为了区别不同的内存块,在对应的 CPU Line 中我们还会存储一个组标记(Tag)这个组标记会记录当前 CPU Line 中存储的数据对应的内存块,我们可以用这个组标记来区分不同的内存块。

除了组标记信息外,CPU Line 还有两个信息:

  • 一个是,从内存加载过来的实际存放数据(Data)
  • 另一个是有效位(Valid bit),它是用来标记对应的 CPU Line 中的数据是否是有效的,如果有效位是 0,则表示数据无效。那么无论 CPU Line 中是否有数据,CPU 都会直接访问内存,重新加载数据。

CPU 在从 CPU Cache 读取数据的时候,并不是读取 CPU Line 中的整个数据块,而是读取 CPU 所需要的一个数据片段,这样的数据统称为一个字(Word)。那怎么在对应的 CPU Line 中数据块中找到所需的字呢?答案是,需要一个偏移量(offset)。

因此,一个内存的访问地址,包括组标记、CPU Line 索引、偏移量这三种信息,于是 CPU 就能通过这些信息,在 CPU Cache 中找到缓存的数据。而对于 CPU Cache 里的数据结构,则是由索引 + 有效位 + 组标记 + 数据块组成。

如果内存中的数据已经在 CPU Cahe 中了,那 CPU 访问一个内存地址的时候,会经历这 4 个步骤:

  • 1. 根据内存地址中索引信息,计算在 CPU Cahe 中的索引,也就是找出对应的 CPU Line 的地址;
  • 2. 找到对应 CPU Line 后,判断 CPU Line 中的有效位,确认 CPU Line 中数据是否是有效的,如果是无效的,CPU 就会直接访问内存,并重新加载数据,如果数据有效,则往下执行;
  • 3. 对比内存地址中组标记和 CPU Line 中的组标记,确认 CPU Line 中的数据是我们要访问的内存数据,如果不是的话,CPU 就会直接访问内存,并重新加载数据,如果是的话,则往下执行;
  • 4. 根据内存地址中偏移量信息,从 CPU Line 的数据块中,读取对应的字;

到这里,相信你对直接映射 Cache 有了一定认识,但其实除了直接映射 Cache 之外,还有其他通过内存地址找到 CPU Cache 中的数据的策略,比如全相连 Cache (Fully Associative Cache)、组相连 Cache (Set Associative Cache)等,这几种策策略的数据结构都比较相似,如果你有兴趣可以去看看,相信很快就能理解。

如何写出让 CPU 跑得更快的代码?

我们知道 CPU 访问内存的速度,比访问 CPU Cache 的速度慢了 100 多倍,所以如果 CPU 所要操作的数据在 CPU Cache 中的话,这样将会带来很大的性能提升。访问的数据在 CPU Cache 中的话,意味着缓存命中,缓存命中率越高的话,代码的性能就会越好,CPU 也就跑的越快。

于是,「如何写出让 CPU 跑得更快的代码?」这个问题,可以改成「如何写出 CPU 缓存命中率高的代码?」。

在前面我也提到, L1 Cache 通常分为「数据缓存」和「指令缓存」,这是因为 CPU 会别处理数据和指令,比如 1+1 这个运算,+ 就是指令,会被放在「指令缓存」中,而输入数字 1 则会被放在「数据缓存」里。

因此,我们要分开来看「数据缓存」和「指令缓存」的缓存命中率

如何提升数据缓存的命中率?

假设要遍历二维数组,有以下两种形式,虽然代码执行结果是一样,但你觉得哪种形式效率最高呢?为什么高呢?

经过测试,形式一 array[i][j] 执行时间比形式二 array[j][i] 快好几倍。

之所以有这么大的差距,是因为二维数组 array 所占用的内存是连续的,比如长度 N 的指是 3 的话,那么内存中的数组元素的布局顺序是这样的:

array[0][0], array[0][1], array[0][2], array[1][0], array[1][1], array[1][2], array[2][0], array[2][1], array[2][2]

形式一用 array[i][j] 访问数组元素的顺序,正是和内存中数组元素存放的顺序一致。当 CPU 访问 array[0][0] 时,由于该数据不在 Cache 中,于是会「顺序」把跟随其后的 3 个元素从内存中加载到 CPU Cache,这样当 CPU 访问后面的 3 个数组元素时,就能在 CPU Cache 中成功地找到数据,这意味着缓存命中率很高,缓存命中的数据不需要访问内存,这便大大提高了代码的性能。

而如果用形式二的 array[j][i] 来访问,则访问的顺序就是:

array[0][0], array[1][0], array[2][0], array[0][1], array[1][1], array[2][1], array[0][2], array[1][2], array[2][2]

你可以看到,访问的方式跳跃式的,而不是顺序的,那么如果 N 的数值很大,那么操作 array[j][i] 时,是没办法把 array[j+1][i] 也读入到 CPU Cache 中的,既然 array[j+1][i] 没有读取到 CPU Cache,那么就需要从内存读取该数据元素了。很明显,这种不连续性、跳跃式访问数据元素的方式,可能不能充分利用到了 CPU Cache 的特性,从而代码的性能不高。

那访问 array[0][0] 元素时,CPU 具体会一次从内存中加载多少元素到 CPU Cache 呢?这个问题,在前面我们也提到过,这跟 CPU Cache Line 有关,它表示CPU Cache 一次性能加载数据的大小,可以在 Linux 里通过coherency_line_size 配置查看 它的大小,通常是 64 个字节。

也就是说,当 CPU 访问内存数据时,如果数据不在 CPU Cache 中,则会一次性会连续加载 64 字节大小的数据到 CPU Cache,那么当访问 array[0][0]时,由于该元素不足 64 字节,于是就会往后顺序读取 array[0][0]~array[0][15] 到 CPU Cache 中。顺序访问的 array[i][j] 因为利用了这一特点,所以就会比跳跃式访问的 array[j][i] 要快。

因此,遇到这种遍历数组的情况时,按照内存布局顺序访问,将可以有效的利用 CPU Cache 带来的好处,这样我们代码的性能就会得到很大的提升。

如何提升指令缓存的命中率?

提升数据的缓存命中率的方式,是按照内存布局顺序访问,那针对指令的缓存该如何提升呢?

我们以一个例子来看看,有一个元素为 0 到 100 之间随机数字组成的一维数组:

int array[N];
for (i = 0; i < N; i++) {
    array[i] = rand() % 100;
}

接下来,对这个数组做两个操作:

// 操作一:数组遍历
for (i = 0; i < N; i++) {
    if (arr[i] < 50) {
        arr[i] = 50;
    }
}

// 操作二:排序
sort(array, array + N)

那么问题来了,你觉得先遍历再排序速度快,还是先排序再遍历速度快呢?

在回答这个问题之前,我们先了解 CPU 的分支预测器。对于 if 条件语句,意味着此时至少可以选择跳转到两段不同的指令执行,也就是 if 还是 else 中的指令。那么,如果分支预测可以预测到接下来要执行 if 里的指令,还是 else 指令的话,就可以「提前」把这些指令放在指令缓存中,这样 CPU 可以直接从 Cache 读取到指令,于是执行速度就会加快

当数组中的元素是随机的,分支预测就无法有效工作,而当数组元素都是顺序的,分支预测器会动态地根据历史命中数据对未来进行预测,这样命中率就会很高。

因此,先排序再遍历速度会更快,这是因为排序之后,数字是从小到大的,那么前几次循环命中 if < 50 的次数会比较多,于是分支预测就会缓存 if 里的array[i] = 0 指令到 Cache 中,后续 CPU 执行该指令就只需要从 Cache 读取就好了。

如果你肯定代码中的 if 中的表达式判断为 true 的概率比较高,我们可以使用显示分支预测工具,比如在 C/C++ 语言中编译器提供了 likelyunlikely这两种宏,如果 if 条件为 ture 的概率大,则可以用 likely 宏把 if 里的表达式包裹起来,反之用 unlikely 宏,有兴趣可以了解一下。当然实际上,CPU 自身的动态分支预测已经是比较准的了,所以只有当非常确信 CPU 预测的不准,且能够知道实际的概率情况时,才建议使用这两种宏。

如果提升多核 CPU 的缓存命中率?

在单核 CPU,虽然只能执行一个进程,但是操作系统给每个进程分配了一个时间片,时间片用完了,就调度下一个进程,于是各个进程就按时间片交替地占用 CPU,从宏观上看起来各个进程同时在执行。

而现代 CPU 都是多核心的,进程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的,虽然 L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的,如果一个进程在不同核心来回切换,各个核心的缓存命中率就会受到影响相反如果进程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问 内存的频率。

当有多个同时执行「计算密集型」的线程,为了防止因为切换到不同的核心,而导致缓存命中率下降的问题,我们可以把线程绑定在某一个 CPU 核心上,这样性能可以得到非常可观的提升。

在 Linux 上提供了 sched_setaffinity 方法,来实现将线程绑定到某个 CPU 核心这一功能。

#define _GNU_SOURCE
#include <sched.h>

int sched_setaffinity(pid_t pid, size_t cpusetsize, cpu_set_t *mask);

总结一下:

由于随着计算机技术的发展,CPU自身的寄存器 与 内存的访问速度相差越来越多,如今差距已经高达好几百倍了,所以 CPU 内部嵌入了 CPU Cache 组件,作为内存与 CPU 之间的缓存层,CPU Cache 由于离 CPU 核心很近,所以访问速度也是非常快的。但由于所需材料成本比较高,它不像内存动辄几个 GB 大小,而是仅有几十 KB 到 MB 大小。

当 CPU 访问数据的时候,先是访问 CPU Cache,如果缓存命中的话,则直接返回数据,就不用每次都从内存读取速度了。因此,缓存命中率越高,代码的性能越好。

但需要注意的是,当 CPU 访问数据时,如果 CPU Cache 没有缓存该数据,则会从内存读取数据,但是并不是只读一个数据,而是一次性读取一块一块的数据存放到 CPU Cache 中,之后才会被 CPU 读取。

内存地址映射到 CPU Cache 地址里的策略有很多种,其中比较简单是直接映射 Cache,它巧妙的把内存地址拆分成「索引 + 组标记 + 偏移量」的方式,使得我们可以将很大的内存地址,映射到很小的 CPU Cache 地址里。

要想写出让 CPU 跑得更快的代码,就需要写出缓存命中率高的代码,CPU L1 Cache 分为数据缓存和指令缓存,因而需要分别提高它们的缓存命中率:

  • 对于数据缓存,我们在遍历数据的时候,应该按照内存布局的顺序操作,这是因为 CPU Cache 是根据 CPU Cache Line 批量操作数据的,所以顺序地操作连续内存数据时,性能能得到有效的提升;
  • 对于指令缓存,有规律的条件分支语句能够让 CPU 的分支预测器发挥作用,进一步提高执行的效率;

另外,对于多核 CPU 系统,线程可能在不同 CPU 核心来回切换,这样各个核心的缓存命中率就会受到影响,于是要想提高进程的缓存命中率,可以考虑把线程绑定 CPU 到某一个 CPU 核心。

CPU Cache 的数据写入

我们介绍了 CPU Cache 的原理和读取过程,以及如何更加高效的利用 CPU 缓存,但这还没有结束,因为数据不光有读操作,还有写操作。在写数据的时候,也会将数据写入 CPU Cache ,但此时会造成 Cache 中的数据和内存中的数据不一致,所以最终还是要将数据从 Cache 写回到内存中。那么问题来了,要在什么时机才把 Cache 中的数据写回到内存呢?为了应对这个问题,下面介绍两种写入数据的方法:

  • 写直达(Write Through)
  • 写回(Write Back)

写直达

保持内存与 Cache 一致性最简单的方式是,把数据同时写入内存和 Cache 中,这种方法称为「写直达(Write Through)」

在这个方法里,写入前会先判断数据是否已经在 CPU Cache 里面了。

  • 如果数据已经在 Cache 里面,先将数据更新到 Cache 里面,再写入到内存里面
  • 如果数据没有在 Cache 里面,就直接把数据更新到内存里面

「写直达」做法很直观,也很简单,但是问题也很明显,无论数据在不在 Cache 里面,每次写操作都会写回到内存,这样写操作将会花费大量的时间,无疑性能会受到很大的影响。

写回

既然写直达由于每次写操作都会把数据写回到内存,而导致影响性能,于是为了要减少数据写回内存的频率,就出现了「写回(Write Back)」的方法。

在写回机制中,当发生写操作时,新的数据仅仅被写入缓存的数据块中,只有当修改过的数据块(Data Block)「被替换」时才需要写到内存中,从而减少了数据写回内存的频率,这样便可以提高系统的性能。

这里需要详细解释一下:

  • 如果当发生写操作时,数据已经在 CPU Cache 的 Data Block 里的话,则把数据更新到 Block 里,同时标记 CPU Cache 里的这个 Block 为无效(invalid)的,做法就是将有效位(Valid bit)设置为 0。当 Block 无效时,代表该 Block 的数据和内存是不一致的,这种情况是不用把数据写到内存里的。
  • 如果当发生写操作时,数据所对应的 Data Block 里存放的是「别的内存地址的数据」的话,就要检查这个 Cache Block 里的数据有没有被标记为无效,如果是无效的话,我们就要把这个 Block 里的数据写回到内存,然后再把当前要写入的数据写入到这个 Block 里,同时也把它标记为无效的;但如果 Block 里面的数据没有被标记为无效,则就直接将数据写入到这个 Block 里,然后再把这个 Block 标记为无效的就好了。

可以发现写回这个方法,在把数据写入到 Cache 的时候,只有在缓存不命中,同时数据对应的 Cache 中的 Data Block 被标记无效的情况下,才会将数据写到内存中;而在缓存命中的情况下,则在写入后 Cache 后,只需把该数据对应的 Cache Block 标记为无效即可,而不用写到内存里。

如果觉得这里有点绕的话,那么可以先看下面的内容。

缓存一致性问题

重点来了,现在 CPU 都是多核的,由于 L1/L2 Cache 是多个核心各自独有的,那么会带来多核心的「缓存一致性(Cache Coherence)」的问题,如果不能保证缓存一致性的问题,就可能造成结果错误。

那缓存一致性的问题具体是怎么发生的呢?我们以一个含有两个核心的 CPU 作为例子看一看。假设 A 核心和 B 核心同时运行两个线程,都操作共同的变量 i(初始值为 0 )。

这时如果 A 核心执行了 i++ 语句的时候,为了考虑性能,使用了我们前面所说的写回策略,先把值为 1 的执行结果写入到 L1/L2 Cache 中,然后把 L1/L2 Cache 中对应的 Block 标记为脏的。但这个时候数据其实没有被同步到内存中的,因为写回策略,只有在 A 核心中的这个 Block 要被替换的时候,数据才会写入到内存里。

如果这时旁边的 B 核心尝试从内存读取 i 变量的值,则读到的将会是错误的值,因为刚才 A 核心更新的 i 值还没写入到内存中,因此内存中的值依然是 0。这个就是所谓的缓存一致性问题,由于 A 核心和 B 核心的缓存在这个时候不一致,从而会导致执行结果的错误,本来它们都应该是 1 的。所以还是很好理解的,总的来说就是 L1/L2 Cache 是每个核心独有的,一个核心不可能从另一个核心的 L1/L2 Cache 中读取,B 核心如果想看到 A 核心对变量 i 做的修改,那么必须要等到 A 核心将数据写回到内存或 L3 Cache,否则就会读到错误的值。

那么,要解决这一问题,就需要一种机制,来同步两个不同核心里面的缓存数据。要实现的这个机制的话,要保证做到下面这 2 点:

  • 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(Write Propagation)
  • 第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串形化(Transaction Serialization)

第一点写传播很容易理解,就是当某个核心在 Cache 更新了数据,必须同步到其他核心的 Cache 里。而对于第二点事务的串形化,我们举个例子来理解它。

假设我们有一个含有 4 个核心的 CPU,这 4 个核心都操作共同的变量 i(初始值为 0 )。A 核心先把 i 值变为 100,而此时同一时间,B 核心把 i 值变为 200,这里两个修改,都会「传播」到 C 和 D 核心。但问题是 C 核心和 D 核心看到的两个操作的顺序不一致要怎么办?

C 核心先收到了 A 核心更新数据的事件,再收到 B 核心更新数据的事件,因此 C 核心看到的变量 i 是先变成 100,后变成 200;而 D 核心收到的事件是反过来的,则 D 核心看到的是变量 i 先变成 200,再变成 100,虽然是做到了写传播,但是各个 Cache 里面的数据还是不一致的。

所以,我们要保证 C 核心和 D 核心都能看到相同顺序的数据变化,比如变量 i 都是先变成 100,再变成 200,这样的过程就是事务的串形化。要实现事务串形化,需要做到 2 点:

  • CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心
  • 要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新

那接下来我们看看,写传播和事务串形化具体是用什么技术实现的。

总线嗅探

写传播的原则就是当某个 CPU 核心更新了 Cache 中的数据,要把该事件广播通知到其他核心。最常见实现的方式是「总线嗅探(Bus Snooping)」。

还是以前面的 i 变量例子来说明总线嗅探的工作机制,当 A 核心修改了 L1 Cache 中 i 变量的值,通过总线把这个事件广播通知给其他所有的核心,然后每个 CPU 核心都会监听总线上的广播事件,并检查是否有相同的数据在自己的 L1 Cache 里面,如果 B 核心的 L1 Cache 中有该数据,那么也需要把该数据更新到自己的 L1 Cache。

可以发现,总线嗅探方法很简单,CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。另外,总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知道,但是并不能保证事务串形化。于是,有一个协议基于总线嗅探机制实现了事务串形化,也用状态机机制降低了总线带宽压力,这个协议就是 MESI 协议,这个协议就做到了 CPU 缓存一致性。

MESI 协议

MESI 协议其实是 4 个状态单词的开头字母缩写,分别是:

  • Modified,已修改
  • Exclusive,独占
  • Exclusive,独享
  • Invalidated,已失效

这四个状态来标记 Cache Line 四个不同的状态,还记得 Cache Line 吗?CPU Cache 的数据是从内存中读取过来的,它是以一小块一小块读取数据的,在 CPU Cache 中的,这样一小块一小块的数据,称为 Cache Line,而一个 Cache Line 由 "索引 + 有效位 + 组标记 + 数据块组成"。我们在介绍 Cache 数据写入的人时候,一直说的数据块(Data Block)指的就是 Cache Line 里的数据块。

  • 「已修改」状态就是我们前面提到的无效标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。而「已失效」状态,表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。
  • 「独占」和「共享」状态都代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。
  • 「独占」和「共享」的差别在于,独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。
  • 另外,在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成共享状态。
  • 「共享」状态着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据。

我们举个具体的例子来看看这四个状态的转换:

  • 当 A 核心从内存读取变量 i 的值,数据被缓存在 A 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,于是标记 Cache Line 状态为「独占」,此时其 Cache 中的数据与内存是一致的;
  • 然后 B 核心也从内存读取了变量 i 的值,此时会发送消息给其他 CPU 核心,由于 A 核心已经缓存了该数据,所以会把数据返回给 B 核心。在这个时候, A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的;
  • 当 A 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后 A 核心才更新 Cache 里面的数据,同时标记 Cache Line 为「已修改」状态,此时 Cache 中的数据就与内存不一致了;
  • 如果 A 核心「继续」修改 Cache 中 i 变量的值,由于此时的 Cache Line 是「已修改」状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可;
  • 如果 A 核心的 Cache 里的 i 变量对应的 Cache Line 要被「替换」,发现 Cache Line 状态是「已修改」状态,就会在替换前先把数据同步到内存;或者 B 要用 i 这个变量,由于状态无效,本来要去内存里面重新读,但如果该数据在其它的核心的 Cache 中也存在(A 核心),并且状态为「已修改」,那么必须先等 A 核心将该数据从 Cache 写到内存之后,B 核心才能从内存重新读取数据。

所以,可以发现当 Cache Line 状态是「已修改」或者「独占」状态时,修改更新其数据不需要发送广播给其他 CPU 核心,这在一定程度上减少了总线带宽压力。事实上,整个 MESI 的状态可以用一个有限状态机来表示它的状态流转。还有一点,对于不同状态触发的事件操作,可能是来自本地 CPU 核心发出的广播事件,也可以是来自其他 CPU 核心通过总线发出的广播事件。下图即是 MESI 协议的状态图:

MESI 协议的四种状态之间的流转过程,我汇总成了下面的表格,可以更详细的看到每个状态转换的原因:

下面总结一下:

CPU 在读写数据的时候,都是在 CPU Cache 读写数据的,原因是 Cache 离 CPU 很近,读写性能相比内存高出很多。对于 Cache 里没有缓存 CPU 所需要读取的数据的这种情况,CPU 则会从内存读取数据,并将数据缓存到 Cache 里面,最后 CPU 再从 Cache 读取数据。

而对于数据的写入,CPU 都会先写入到 Cache 里面,然后再在找个合适的时机写入到内存,那就有「写直达」和「写回」这两种策略来保证 Cache 与内存的数据一致性:

  • 写直达,只要有数据写入,都会直接把数据写入到内存里面,这种方式简单直观,但是性能就会受限于内存的访问速度;
  • 写回,对于已经缓存在 Cache 的数据的写入,只需要更新其数据就可以,不用写入到内存,只有在需要把缓存里面的脏数据交换出去的时候,才把数据同步到内存里,这种方式在缓存命中率高的情况,性能会更好;

当今 CPU 都是多核的,每个核心都有各自独立的 L1/L2 Cache,只有 L3 Cache 是多个核心之间共享的。所以,我们要确保多核缓存是一致性的,否则会出现错误的结果。要想实现缓存一致性,关键是要满足两点:

  • 第一点是写传播,也就是当某个 CPU 核心发生写入操作时,需要把该事件广播通知给其他核心;
  • 第二点是事物的串行化,这个很重要,只有保证了这个,才能保障我们的数据是真正一致的,我们的程序在各个不同的核心上运行的结果也是一致的;

而基于总线嗅探机制的 MESI 协议,就满足上面了这两点,因此它是保障缓存一致性的协议。

MESI 协议,是已修改、独占、共享、已实现这四个状态的英文缩写的组合。整个 MSI 状态的变更,则是根据来自本地 CPU 核心的请求,或者来自其他 CPU 核心通过总线传输过来的请求,从而构成一个流动的状态机。另外,对于在「已修改」或者「独占」状态的 Cache Line,修改更新其数据不需要发送广播给其他 CPU 核心。

最后推荐一个网站,可以在线体验 MESI 协议状态转换,有兴趣的话可以去玩一玩。

posted @ 2020-06-06 00:58  古明地盆  阅读(1747)  评论(0编辑  收藏  举报