天天看点

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

作者:马士兵老师

先提出三个问题:

  • 为什么会有 CPU 高速缓存?
  • 为什么 CPU 设计了三级缓存?
  • 为什么 CPU 的一级缓存容量比较小?

为什么会有 CPU 高速缓存?

我们常常把 CPU 比喻成计算机的“大脑”。我们思考的东西,就好比 CPU 中的寄存器(Register)。寄存器与其说是存储器,其实它更像是 CPU 本身的一部分,只能存放极其有限的信息,但是速度非常快,和 CPU 同步。而我们大脑中的记忆,就好比CPU Cache(CPU 高速缓存,我们常常简称为“缓存”)。CPU Cache 解决的是内存访问速度和 CPU 的速度差距太大的问题。

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

CPU Cache 用的是一种叫作SRAM(Static Random-Access Memory,静态随机存取存储器)的芯片。

SRAM

SRAM 之所以被称为“静态”存储器,是因为只要处在通电状态,里面的数据就可以保持存在。而一旦断电,里面的数据就会丢失了。在 SRAM 里面,一个比特的数据,需要 6~8 个晶体管。所以 SRAM 的存储密度不高。同样的物理空间下,能够存储的数据有限。不过,因为 SRAM 的电路简单,所以访问速度非常快。

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

6 个(场效应)晶体管组成 SRAM 的一个比特

DRAM

另外一种叫作DRAM(Dynamic Random Access Memory,动态随机存取存储器)的芯片,比起 SRAM 来说,它的密度更高,有更大的容量,而且它也比 SRAM 芯片便宜不少。

DRAM 被称为“动态”存储器,是因为 DRAM 需要靠不断地“刷新”,才能保持数据被存储起来。DRAM 的一个比特,只需要一个晶体管和一个电容就能存储。所以,DRAM 在同样的物理空间下,能够存储的数据也就更多,也就是存储的“密度”更大。但是,因为数据是存储在电容里的,电容会不断漏电,所以需要定时刷新充电,才能保持数据不丢失。DRAM 的数据访问电路和刷新电路都比 SRAM 更复杂,所以访问延时也就更长。

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

1 个晶体管和一个电容组成 DRAM 的一个比特

两种存储器对比因子:

  • 读写时延高低
  • 存储密度(单位体积的容量大小)

CPU 缓存与内存

  • CPU Cache 用的是SRAM(Static Random-Access Memory,静态随机存取存储器)的芯片。在 CPU 里,通常会有 L1、L2、L3 这样三层高速缓存。 每个 CPU 核心都有一块属于自己的 L1 高速缓存,通常分成指令缓存和数据缓存,分开存放 CPU 使用的指令和数据。L1 的 Cache 往往就嵌在 CPU 核心的内部。 L2 的 Cache 同样是每个 CPU 核心都有的,不过它往往不在 CPU 核心的内部。所以,L2 Cache 的访问速度会比 L1 稍微慢一些。 而 L3 Cache,则通常是多个 CPU 核心共用的,尺寸会更大一些,访问速度自然也就更慢一些。
  • 内存用的芯片和 Cache 有所不同,它用的是 DRAM(Dynamic Random Access Memory,动态随机存取存储器)的芯片,比起 SRAM 来说,它的密度更高,有更大的容量,而且它也比 SRAM 芯片便宜不少。
探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

你可以把 CPU 中的 L1 Cache 理解为我们的短期记忆,把 L2/L3 Cache 理解成长期记忆,把内存当成我们拥有的书架或者书桌。 当我们自己记忆中没有资料的时候,可以从书桌或者书架上拿书来翻阅。这个过程中就相当于,数据从内存中加载到 CPU 的寄存器和 Cache 中,然后通过“大脑”,也就是 CPU,进行处理和运算。

多级 CPU 缓存

在高速缓存出现后不久,系统变得更加复杂。高速缓存与主存之间的速度差异进一步拉 大,直到加入了另一级缓存。新加入的这一级缓存比第一级缓存更大,但是更 慢。由于加大 一级缓存的做法从经济上考虑是行不通的,所以有了二级缓存,甚至现在的有些系统拥有三 级缓存。随着单个 CPU 中核数的增加,未来甚至可能会出现更多层级的缓存。

存储器的层次

数据是海量的,那么我看是不是可以构建一个大容量的虚拟存储器,它能像小容量存储器一样被快速访问?答案是可以的,海量数据就像一个图书馆中的书,你不可能同时查看所有的书,而程序访问也是一样,不可能同时访问所有数据,并且要求快速查找。前辈们已经探索出了答案,那就是,存储器中数据的局部性原理(Principle of Locality)。我们可以利用这个局部性原理,来制定管理和访问数据的策略。

  • 时间局部性(temporal locality):某个数据项在被访问之后可能很快被再次访问的特性。
  • 空间局部性(spatial locality):某个数据项在被访问之后,与其地址相近的数据项可能很快被访问的特性。

我们可以利用局部性原理将计算机存储器组织成为存储器层次结构 (memory hierarchy)。存储器层次结构由不同速度和容量的多级存储器构成。快速存储器每比特的成本要比慢速存储器高很多,因而通常它们的容量也比较小。

存储器层次结构:一种由多存储器层次组成的结构,存储器的容量和访问时问随着离处理器距离的增加而增加。

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

访问方式

从 Cache、内存,到 SSD 和 HDD 硬盘,一台现代计算机中,就用上了所有这些存储器设备。其中,容量越小的设备速度越快,而且,CPU 并不是直接和每一种存储器设备打交道,而是每一种存储器设备,只和它相邻的存储设备打交道。比如,CPU Cache 是从内存里加载而来的,或者需要写回内存,并不会直接写回数据到硬盘,也不会直接从硬盘加载数据到 CPU Cache 中,而是先加载到内存,再从内存加载到 Cache 中。

性能与价格

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

具体可参考:

SSD:www.cnblogs.com/whl320124/a…

FLASH:ica123.com/archives/15…

闪存:zh.wikipedia.org/zh-my/%E9%9…

高速缓存在计算机体系中的位置

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道
探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道
探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

相关术语:L1d 是一级数据缓存,L1i 是 一级指令缓存。

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

CPU 高速缓存的读取

现代 CPU 进行数据读取的时候,无论数据是否已经存储在 Cache 中,CPU 始终会首先访问 Cache。只有当 CPU 在 Cache 中找不到数据的时候,才会去访问内存,并将读取到的数据写入 Cache 之中。当时间局部性原理起作用后,这个最近刚刚被访问的数据,会很快再次被访问。而 Cache 的访问速度远远快于内存,这样,CPU 花在等待内存访问上的时间就大大变短了。

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

Cache的数据放置的策略决定了内存中的数据块会拷贝到CPU Cache中的哪个位置上,因为Cache的大小远远小于内存,所以,需要有一种地址关联的算法,能够让内存中的数据可以被映射到Cache中来。

Cache Line:缓存基本上来说就是把后面的数据加载到离自己近的地方,对于CPU来说,它是不会一个字节一个字节的加载的,因为这非常没有效率,一般来说都是要一块一块的加载的,对于这样的一块一块的数据单位,术语叫“Cache Line”,一般来说,一个主流的CPU的Cache Line 是 64 Bytes(也有的CPU用32Bytes和128Bytes),64Bytes也就是16个32位的整型,这就是CPU从内存中捞数据上来的最小数据单位。

比如:Cache Line是最小单位(64Bytes),所以先把Cache分布多个Cache Line,比如:L1有32KB,那么,32KB/64B = 512 个 Cache Line。

基本上来说,我们会有如下三种方法。

  • 全相联映射:任何一个内存地址的数据可以被缓存在任何一个Cache Line里,这种方法是最灵活的,但是,如果我们要知道一个内存是否存在于Cache中,我们就需要进行O(n)复杂度的Cache遍历,这是很没有效率的。
  • 直相联映射:为了降低缓存搜索算法,我们需要使用像Hash Table这样的数据结构,最简单的hash table就是做“求模运算”,比如:我们的L1 Cache有512个Cache Line,那么,公式:(内存地址 mod 512)* 64 就可以直接找到所在的Cache地址的偏移了。
  • 组相联映射:为了避免上述的两种方案的问题,于是就要容忍一定的hash冲突,也就出现了 N-Way 关联。也就是把连续的N个Cache Line绑成一组,然后,先把找到相关的组,然后再在这个组内找到相关的Cache Line。
探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

CPU 如何知道要访问的内存数据,存储在 Cache 的哪个位置呢?接下来,从最基本的直接映射 Cache(Direct Mapped Cache)说起,来看整个 Cache 的数据结构和访问逻辑。

CPU 访问内存数据,是一小块一小块数据来读取的。对于读取内存中的数据,我们首先拿到的是数据所在的内存块(Block)的地址。

为了方便索引内存地址,把内存地址分为了三段概念表达:

  • **Tag:**内存块的高位信息为 Cache Line 的组标记,这个组标记会记录,当前缓存块内存储的数据对应的内存块,而缓存块本身的地址表示访问地址的低 N 位。
  • Index:直接映射 Cache 采用的策略,就是确保任何一个内存块的地址,始终映射到一个固定的 CPU Cache 地址(Cache Line)。而这个映射关系,通常用 mod 运算(求余运算)来实现。求余的结果为 Cache Line 的索引地址。
  • Offset:CPU 在读取数据的时候,并不是要读取一整个 Block,而是读取一个他需要的整数。这样的数据,我们叫作 CPU 里的一个字(Word)。具体是哪个字,就用这个字在整个 Block 里面的位置来决定。这个位置,我们叫作偏移量(Offset)。

一个内存的访问地址,最终包括高位代表的组标记、低位代表的索引,以及在对应的 Data Block 中定位对应字的位置偏移量。

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

而内存地址对应到 Cache 里的数据结构,则多了一个有效位和对应的数据,由“索引 + 有效位 + 组标记 + 数据”组成。如果内存中的数据已经在 CPU Cache 里了,那一个内存地址的访问,就会经历这样 4 个步骤:

  1. 根据内存地址的低位,计算在 Cache 中的索引;
  2. 判断有效位,确认 Cache 中的数据是有效的;
  3. 对比内存访问地址的高位,和 Cache 中的组标记,确认 Cache 中的数据就是我们要访问的内存数据,从 Cache Line 中读取到对应的数据块(Data Block);
  4. 根据内存地址的 Offset 位,从 Data Block 中,读取希望读取到的字。

如果在 2、3 这两个步骤中,CPU 发现,Cache 中的数据并不是要访问的内存地址的数据,那 CPU 就会访问内存,并把对应的 Block Data 更新到 Cache Line 中,同时更新对应的有效位和组标记的数据。

除了组标记信息之外,缓存块中还有两个数据。一个自然是从主内存中加载来的实际存放的数据,另一个是有效位(valid bit)。啥是有效位呢?它其实就是用来标记,对应的缓存块中的数据是否是有效的,确保不是机器刚刚启动时候的空数据。如果有效位是 0,无论其中的组标记和 Cache Line 里的数据内容是什么,CPU 都不会管这些数据,而要直接访问内存,重新加载数据。

CPU 高速缓存的写入

下面这段代码的输出结果是什么样的?

java复制代码public class App {
    private static volatile int COUNTER = 0;
 
    public static void main(String[] args) {
        new ChangeListener().start();
        new ChangeMaker().start();
    }
 
    static class ChangeListener extends Thread {
        @Override
        public void run() {
            int threadValue = COUNTER;
            while ( threadValue < 5){
                if( threadValue!= COUNTER){
                    System.out.println("Got Change for COUNTER : " + COUNTER + "");
                    threadValue= COUNTER;
                }
            }
        }
    }
 
    static class ChangeMaker extends Thread{
        @Override
        public void run() {
            int threadValue = COUNTER;
            while (COUNTER <5){
                System.out.println("Incrementing COUNTER to : " + (threadValue+1) + "");
                COUNTER = ++threadValue;
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) { e.printStackTrace(); }
            }
        }
    }
}
           

结果:

java复制代码Incrementing COUNTER to : 1
Got Change for COUNTER : 1
Incrementing COUNTER to : 2
Got Change for COUNTER : 2
Incrementing COUNTER to : 3
Got Change for COUNTER : 3
Incrementing COUNTER to : 4
Got Change for COUNTER : 4
Incrementing COUNTER to : 5
Got Change for COUNTER : 5
           

如归把 volatile 删掉又是什么输出?

java复制代码Incrementing COUNTER to : 1
Incrementing COUNTER to : 2
Incrementing COUNTER to : 3
Incrementing COUNTER to : 4
Incrementing COUNTER to : 5
           

下面这段代码输出结果是什么样的?

java复制代码public class App {
    private static int COUNTER = 0;
 
    public static void main(String[] args) {
        new ChangeListener().start();
        new ChangeMaker().start();
    }
 
    static class ChangeListener extends Thread {
        @Override
        public void run() {
            int threadValue = COUNTER;
            while ( threadValue < 5){
                if( threadValue!= COUNTER){
                    System.out.println("Got Change for COUNTER : " + COUNTER + "");
                    threadValue= COUNTER;
                } else {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }
 
    static class ChangeMaker extends Thread{
        @Override
        public void run() {
            int threadValue = COUNTER;
            while (COUNTER <5){
                System.out.println("Incrementing COUNTER to : " + (threadValue+1) + "");
                COUNTER = ++threadValue;
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) { e.printStackTrace(); }
            }
        }
    }
}
           

volatile 关键字究竟代表什么含义呢?

它会确保我们对于这个变量的读取和写入,都一定会同步到主内存里,而不是从 Cache 里面读取。

现代通常都是多核的的。每一个 CPU 核里面,都有独立属于自己的 L1、L2 的 Cache,然后再有多个 CPU 核共用的 L3 的 Cache、主内存。

因为 CPU Cache 的访问速度要比主内存快很多,而在 CPU Cache 里面,L1/L2 的 Cache 也要比 L3 的 Cache 快。所以,上一讲我们可以看到,CPU 始终都是尽可能地从 CPU Cache 中去获取数据,而不是每一次都要从主内存里面去读取数据。

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

写入 Cache 的性能也比写入主内存要快,那我们写入的数据,到底应该写到 Cache 里还是主内存呢?如果我们直接写入到主内存里,Cache 里的数据是否会失效呢?

两种写入策略:

第一:写操作只在cache上,然后再flush到内存上。

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

第二:每一次数据都要写入到主内存里面。

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

多个 CPU 核的缓存一致性如何解决?

要解决这个问题,我们需要引入一个新的方法,叫作 MESI 协议。这是一个维护缓存一致性协议。这个协议不仅可以用在 CPU Cache 之间,也可以广泛用于各种需要使用缓存,同时缓存之间需要同步的场景下。

什么是缓存一致性

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

如果我们要更新 iPhone 的价格,操作修改了核心 1 的缓存,这样会产生核心 2 缓存数据的不一致问题。为了解决这个问题,需要做到以下两点:

  • 第一点叫写传播(Write Propagation)。写传播是说,在一个 CPU 核心里,我们的 Cache 数据更新,必须能够传播到其他的对应节点的 Cache Line 里。
  • 第二点叫事务的串行化(Transaction Serialization),事务串行化是说,我们在一个 CPU 核心里面的读取和写入,在其他的节点看起来,顺序是一样的。

事务串行化

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

而在 CPU Cache 里做到事务串行化,需要做到两点:

  • 第一点是一个 CPU 核心对于数据的操作,需要同步通信给到其他 CPU 核心。
  • 第二点是,如果两个 CPU 核心里有同一个数据的 Cache,那么对于这个 Cache 数据的更新,需要有一个“锁”的概念。只有拿到了对应 Cache Block 的“锁”之后,才能进行对应的数据更新。

实现了这两个机制有一个叫做 MESI 的协议。Modified(已修改), Exclusive(独占的),Shared(共享的),Invalid(无效的)。

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

状态流转机制还是比较复杂的,下面通过一个表格来模拟写回算法的缓存状态的变化:

探秘CPU:为何存在层次结构与读写过程,解决多核缓存一致性之道

MESI 这种协议在数据更新后,会标记其它共享的CPU缓存的数据拷贝为Invalid状态,然后当其它CPU再次read的时候,就会出现 cache miss 的问题,此时再从内存中更新数据。从内存中更新数据意味着20倍速度的降低。我们能不能直接从我隔壁的CPU缓存中更新?是的,这就可以增加很多速度了,但是状态控制也就变麻烦了。还需要多来一个状态:Owner(宿主),用于标记,我是更新数据的源。于是,出现了 MOESI 协议。MOESI协议允许 CPU Cache 间同步数据,于是也降低了对内存的操作,性能是非常大的提升,但是控制逻辑也非常复杂。

作者:我是大明哥

链接:https://juejin.cn/post/7249201537913241659

继续阅读