35 | 存储器层次结构全景:数据存储的大金字塔长什么样?
理解存储器的层次结构
我们常常把 CPU 比喻成计算机的“大脑”。我们思考的东西,就好比 CPU 中的寄存器 (Register)。寄存器与其说是存储器,其实它更像是 CPU 本身的一部分,只能存放极其有限的信息,但是速度非常快,和 CPU 同步。
而我们大脑中的记忆,就好比CPU Cache(CPU 高速缓存,我们常常简称为“缓存”)。 CPU Cache 用的是一种叫作SRAM(Static Random-Access Memory,静态随机存取存储器)的芯片。
SRAM
SRAM 之所以被称为“静态”存储器,是因为只要处在通电状态,里面的数据就可以保持存在。而一旦断电,里面的数据就会丢失了。在 SRAM 里面,一个比特的数据,需要 6~8 个晶体管。所以 SRAM 的存储密度不高。同样的物理空间下,能够存储的数据有限。不过,因为 SRAM 的电路简单,所以访问速度非常快。
在 CPU 里,通常会有 L1、L2、L3 这样三层高速缓存。每个 CPU 核心都有一块属于自己 的 L1 高速缓存,通常分成指令缓存和数据缓存(哈佛架构),分开存放 CPU 使用的指令和数据。
L2 的 Cache 同样是每个 CPU 核心都有的,不过它往往不在 CPU 核心的内部。所以,L2 Cache 的访问速度会比 L1 稍微慢一些。而 L3 Cache,则通常是多个 CPU 核心共用的, 尺寸会更大一些,访问速度自然也就更慢一些。
DRAM
内存用的芯片和 Cache 有所不同,它用的是一种叫作DRAM(Dynamic Random Access Memory,动态随机存取存储器)的芯片,比起 SRAM 来说,它的密度更高,有更大的容量,而且它也比 SRAM 芯片便宜不少。
DRAM 被称为“动态”存储器,是因为 DRAM 需要靠不断地“刷新”,才能保持数据被 存储起来。DRAM 的一个比特,只需要一个晶体管和一个电容就能存储。所以,DRAM 在 同样的物理空间下,能够存储的数据也就更多,也就是存储的“密度”更大。但是,因为数 据是存储在电容里的,电容会不断漏电,所以需要定时刷新充电,才能保持数据不丢失。 DRAM 的数据访问电路和刷新电路都比 SRAM 更复杂,所以访问延时也就更长。
存储器的层级结构
整个存储器的层次结构,其实都类似于 SRAM 和 DRAM 在性能和价格上的差异。SRAM 更贵,速度更快。DRAM 更便宜,容量更大。SRAM 好像我们的大脑中的记忆,而 DRAM 就好像属于我们自己的书桌。
大脑(CPU)中的记忆(L1 Cache),不仅受成本层面的限制,更受物理层面的限制。这就好比 L1 Cache 不仅昂贵,其访问速度和它到 CPU 的物理距离有关。芯片造得越大,总有部分离 CPU 的距离会变远。电信号的传输速度又受物理原理的限制,没法超过光速。所以想要快,并不是靠多花钱就能解决的。
我们自己的书房和书桌(也就是内存)空间一般是有限的,没有办法放下所有书(也就是数据)。如果想要扩大空间的话,就相当于要多买几平方米的房子,成本就会很高。于是,想要放下更多的书,我们就要寻找更加廉价的解决方案。
没错,我们想到了公共图书馆。对于内存来说,SSD(Solid-state drive 或 Solid-state disk,固态硬盘)、HDD(Hard Disk Drive,硬盘)这些被称为硬盘的外部存储设备,就是公共图书馆。于是,我们就可以去家附近的图书馆借书了。图书馆有更多的空间(存储空间)和更多的书(数据)。
从 Cache、内存,到 SSD 和 HDD 硬盘,一台现代计算机中,就用上了所有这些存储器设备。其中,容量越小的设备速度越快,而且,CPU 并不是直接和每一种存储器设备打交道,而是每一种存储器设备,只和它相邻的存储设备打交道。比如,CPU Cache 是从内存里加载而来的,或者需要写回内存,并不会直接写回数据到硬盘,也不会直接从硬盘加载数据到 CPU Cache 中,而是先加载到内存,再从内存加载到 Cache 中。
这样,各个存储器只和相邻的一层存储器打交道,并且随着一层层向下,存储器的容量逐层增大,访问速度逐层变慢,而单位存储成本也逐层下降,也就构成了我们日常所说的存储器层次结构。
使用存储器的时候,该如何权衡价格和性能?
36 | 局部性原理:数据库性能跟不上,加个缓存就好了?
在数据库前添加数据缓存是常见的性能优化方式
这种添加缓存的策略一定是有效的吗?或者说,这种策略在什 么情况下是有效的呢?如果从理论角度去分析,添加缓存一定是我们的最佳策略么?进一步 地,如果我们对于访问性能的要求非常高,希望数据在 1 毫秒,乃至 100 微妙内完成处理,我们还能用这个添加缓存的策略么?
理解局部性原理
能不能既享受 CPU Cache 的速度,又享受内存、硬盘巨大的容量和低廉的价格呢?
存储器中数据的局部性原理(Principle of Locality)。可以利用这个局部性原理,来制管理和访问数据的策略。这个局部性原理包括时间局部性(temporal locality)和空间局部性(spatial locality)这两种策略。
时间局部性:如果一个数据被访问了,那么它在短时间内还会被再次访问。
空间局部性:如果一个数据被访问了,那么和它相邻的数据也很快会被访问。
有了时间局部性和空间局部性,我们不用再把所有数据都放在内存里,也不用都放在 HDD 硬盘上,而是把访问次数多的数据,放在贵但是快一点的存储器里,把访问次数少的数据, 放在慢但是大一点的存储器里。这样组合使用内存、SSD 硬盘以及 HDD 硬盘,使得我们 可以用最低的成本提供实际所需要的数据存储、管理和访问的需求。
37 | 理解CPU Cache(上):“4毫秒”究竟值多少钱?
我们为什么需要高速缓存?
按照摩尔定律,CPU 的访问速度每 18 个月便会翻一番,相当于每年增长 60%。内存的访 问速度虽然也在不断增长,却远没有这么快,每年只增长 7% 左右。而这两个增长速度的 差异,使得 CPU 性能和内存访问性能的差距不断拉大。到今天来看,一次内存的访问,大 约需要 120 个 CPU Cycle,这也意味着,在今天,CPU 和内存的访问速度已经有了 120 倍的差距。
为了弥补两者之间的性能差异,我们能真实地把 CPU 的性能提升用起来,而不是让它在那 儿空转,我们在现代 CPU 中引入了高速缓存。
从 CPU Cache 被加入到现有的 CPU 里开始,内存中的指令、数据,会被加载到 L1-L3 Cache 中,而不是直接由 CPU 访问内存去拿。在 95% 的情况下,CPU 都只需要访问 L1- L3 Cache,从里面读取指令和数据,而无需访问内存。要注意的是,这里我们说的 CPU Cache 或者 L1/L3 Cache,不是一个单纯的、概念上的缓存(比如之前我们说的拿内存作为硬盘的缓存),而是指特定的由 SRAM 组成的物理芯片。
在这一讲一开始的程序里,运行程序的时间主要花在了将对应的数据从内存中读取出来,加载到 CPU Cache 里。CPU 从内存中读取数据到 CPU Cache 的过程中,是一小块一小块来 读取数据的,而不是按照单个数组元素来读取数据的。这样一小块一小块的数据,在 CPU Cache 里面,我们把它叫作 Cache Line(缓存块)。
Cache Line 的大小通常是 64 字节。而在上面的循环 2 里面,我们每隔 16 个整型数计算一次,16 个整型数正好是 64 个字节。于是, 循环 1 和循环 2,需要把同样数量的 Cache Line 数据从内存中读取到 CPU Cache 中,最 终两个程序花费的时间就差别不大了。
Cache 的数据结构和读取过程是什么样的?
现代 CPU 进行数据读取的时候,无论数据是否已经存储在 Cache 中,CPU 始终会首先访 问 Cache。只有当 CPU 在 Cache 中找不到数据的时候,才会去访问内存,并将读取到的数据写入 Cache 之中。
CPU 如何知道要访问的内存数据,存储在 Cache 的哪个位置呢?
直接映射 Cache(Direct Mapped Cache)
CPU 访问内存数据,是一小块一小块数据来读取的。对于 读取内存中的数据,我们首先拿到的是数据所在的内存块(Block)的地址。而直接映射 Cache 采用的策略,就是确保任何一个内存块的地址,始终映射到一个固定的 CPU Cache 地址(Cache Line)。而这个映射关系,通常用 mod 运算(求余运算)来实现。
有一个小小的技巧,通常我们会把缓存块的数量设置成 2 的 N 次方。这样在计算取模的时候,可以直接取地址的低 N 位。
在对应的缓存块中,我们会存储一个组标记(Tag)。这个组标记会记录,当前 缓存块内存储的数据对应的内存块,而缓存块本身的地址表示访问地址的低 N 位。就像上 面的例子,21 的低 3 位 101,缓存块本身的地址已经涵盖了对应的信息、对应的组标记, 我们只需要记录 21 剩余的高 2 位的信息,也就是 10 就可以了。
除了组标记信息之外,缓存块中还有两个数据。一个自然是从主内存中加载来的实际存放的 数据,另一个是有效位(valid bit)。啥是有效位呢?它其实就是用来标记,对应的缓存块 中的数据是否是有效的,确保不是机器刚刚启动时候的空数据。如果有效位是 0,无论其中 的组标记和 Cache Line 里的数据内容是什么,CPU 都不会管这些数据,而要直接访问内存,重新加载数据。
CPU 在读取数据的时候,并不是要读取一整个 Block,而是读取一个他需要的整数。这样的数据,我们叫作 CPU 里的一个字(Word)。具体是哪个字,就用这个字在整个 Block 里面的位置来决定。这个位置,我们叫作偏移量(Offset)。 总结一下,一个内存的访问地址,最终包括高位代表的组标记、低位代表的索引,以及在对应的 Data Block 中定位对应字的位置偏移量。
而内存地址对应到 Cache 里的数据结构,则多了一个有效位和对应的数据,由“索引 + 有效位 + 组标记 + 数据”组成。如果内存中的数据已经在 CPU Cache 里了,那一个内存地 址的访问,就会经历这样 4 个步骤:
- 根据内存地址的低位,计算在 Cache 中的索引;
- 判断有效位,确认 Cache 中的数据是有效的;
- 对比内存访问地址的高位,和 Cache 中的组标记,确认 Cache 中的数据就是我们要访 问的内存数据,从 Cache Line 中读取到对应的数据块(Data Block);
- 根据内存地址的 Offset 位,从 Data Block 中,读取希望读取到的字。
其他
全相联
组相联:组间直接映射,组内全相联映射
38 | 高速缓存(下):你确定你的数据更新了么?
volatile 关键字会确保我们对于这个变量的读取和写入,都一定会同步到主内存里,而不是从 Cache 里面读取。
如果我们的数据,在不同的线程或者 CPU 核里 面去更新,因为不同的线程或 CPU 核有着自己各自的缓存,很有可能在 A 线程的更新,到 B 线程里面是看不见的。
CPU 高速缓存的写入
我们现在用的 Intel CPU,通常都是多核的的。每一个 CPU 核里面,都有独立属于自己的 L1、L2 的 Cache,然后再有多个 CPU 核共用的 L3 的 Cache、主内存。
因为 CPU Cache 的访问速度要比主内存快很多,而在 CPU Cache 里面,L1/L2 的 Cache 也要比 L3 的 Cache 快。所以,上一讲我们可以看到,CPU 始终都是尽可能地从 CPU Cache 中去获取数据,而不是每一次都要从主内存里面去读取数据。
对于数据,我们不光要读,还要去写入修改。这个时候,有两个问题来了。
第一个问题是,写入 Cache 的性能也比写入主内存要快,那我们写入的数据,到底应该写到 Cache 里还是主内存呢?
如果我们直接写入到主内存里,Cache 里的数据是否会失效呢?
写直达(Write-Through)
最简单的一种写入策略,叫作写直达(Write-Through)。在这个策略里,每一次数据都要写入到主内存里面。在写直达的策略里面,写入前,我们会先去判断数据是否已经在 Cache 里面了。如果数据已经在 Cache 里面了,我们先把数据写入更新到 Cache 里面,再写入到主内存里面;如果数据不在 Cache 里,我们就只更新主内存。
写直达的这个策略很直观,但是问题也很明显,那就是这个策略很慢。无论数据是不是在 Cache 里面,我们都需要把数据写到主内存里面。这个方式就有点儿像我们上面用 volatile 关键字,始终都要把数据同步到主内存里面。
写回(Write-Back)
不再是每次都把数据写入到主内存,而是只写到 CPU Cache 里。只有当 CPU Cache 里面的数据要被“替换”的时候,我们才把数据写入到主内存里面去。
写回策略的过程是这样的:如果发现我们要写入的数据,就在 CPU Cache 里面,那么我们 就只是更新 CPU Cache 里面的数据。同时,我们会标记 CPU Cache 里的这个 Block 是脏 (Dirty)的。所谓脏的,就是指这个时候,我们的 CPU Cache 里面的这个 Block 的数据,和主内存是不一致的。
如果我们发现,我们要写入的数据所对应的 Cache Block 里,放的是别的内存地址的数据,那么我们就要看一看,那个 Cache Block 里面的数据有没有被标记成脏的。如果是脏 的话,我们要先把这个 Cache Block 里面的数据,写入到主内存里面。然后,再把当前要 写入的数据,写入到 Cache 里,同时把 Cache Block 标记成脏的。如果 Block 里面的数 据没有被标记成脏的,那么我们直接把数据写入到 Cache 里面,然后再把 Cache Block 标 记成脏的就好了。
39 | MESI协议:如何让多核CPU的高速缓存保持一致?
缓存一致性问题
缓存一致性问题,1 号核心和 2 号核心的缓存,在这个时候是不一致的。
为了解决这个缓存不一致的问题,我们就需要有一种机制,来同步两个不同核心里面的缓存数据。
- 写传播(Write Propagation)。在一个 CPU 核心里,我们的 Cache 数据更新,必须能够传播到其他的对应节点的 Cache Line 里。
- 事务的串行化(Transaction Serialization),在一个 CPU 核心里面的读取和写入,在其他的节点看起来,顺序是一样的。
(我们需要的是,从 1 号到 4 号核心,都能看到相同顺序的数据变化。比如说,都 是先变成了 5000 块,再变成了 6000 块。这样,我们才能称之为实现了事务的串行化。)
事务的串行化,不仅仅是缓存一致性中所必须的。比如,我们平时所用到的系统当中,最需 要保障事务串行化的就是数据库。多个不同的连接去访问数据库的时候,我们必须保障事务 的串行化,做不到事务的串行化的数据库,根本没法作为可靠的商业数据库来使用。
在 CPU Cache 里做到事务串行化,需要做到两点,第一点是一个 CPU 核心对于数据的操作,需要同步通信给到其他 CPU 核心。第二点是,如果两个 CPU 核心里有同一个数据 的 Cache,那么对于这个 Cache 数据的更新,需要有一个“锁”的概念。只有拿到了对应 Cache Block 的“锁”之后,才能进行对应的数据更新。
总线嗅探机制和MESI协议
要解决缓存一致性问题,首先要解决的是多个 CPU 核心之间的数据传播问题。最常见的一 种解决方案呢,叫作总线嗅探(Bus Snooping)。
这个策略,本质上就是把所有的读写请求都通过总线(Bus)广播给所有的 CPU 核心,然 后让各个核心去“嗅探”这些请求,再根据本地的情况进行响应。
总线本身就是一个特别适合广播进行数据传输的机制,所以总线嗅探这个办法也是我们日常 使用的 Intel CPU 进行缓存一致性处理的解决方案。
基于总线嗅探机制,其实还可以分成很多种不同的缓存一致性协议。不过其中最常用的,就是MESI 协议。
MESI 协议,是一种叫作写失效(Write Invalidate)的协议。在写失效协议里,只有一个 CPU 核心负责写入数据,其他的核心,只是同步读取到这个写入。在这个 CPU 核心写入 Cache 之后,它会去广播一个“失效”请求告诉所有其他的 CPU 核心。其他的 CPU 核 心,只是去判断自己是否也有一个“失效”版本的 Cache Block,然后把这个也标记成失效的就好了。
相对于写失效协议,还有一种叫作写广播(Write Broadcast)的协议。在那个协议里,一个写入请求广播到所有的 CPU 核心,同时更新各个核心里的Cache。 写广播在实现上自然很简单,但是写广播需要占用更多的总线带宽。写失效只需要告诉其他的 CPU 核心,哪一个内存地址的缓存失效了,但是写广播还需要把对应的数据传输给其他 CPU 核心。
MESI 协议的由来呢,来自于我们对 Cache Line 的四个不同的标记,分别是:
- M:代表已修改(Modified):Cache Block 里面的内容我们已经更新过 了,但是还没有写回到主内存里面。
- E:代表独占(Exclusive):(干净数据)在独占状态下,对 应的 Cache Line 只加载到了当前 CPU 核所拥有的 Cache 里。其他的 CPU 核,并没有加 载对应的数据到自己的 Cache 里。这个时候,如果要向独占的 Cache Block 写入数据,我 们可以自由地写入数据,而不需要告知其他 CPU 核。
- S:代表共享(Shared):在共享状态下,因为同样的数据在多个 CPU 核心的 Cache 里都有。所以,当我们想要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他 CPU 核心里面的 Cache,都变成无效的状态,然后再更新当前 Cache 里面的数据。这个广播操作,一般叫作 RFO(Request For Ownership),也就是 获取当前对应 Cache Block 数据的所有权。
- I:代表已失效(Invalidated):Cache Block 里面 的数据已经失效了,我们不可以相信这个 Cache Block 里面的数据。
总结
寄存器、cache、内存、硬盘;局部性原理;cache、直接映射;volatile、cache写入(写直达、写回);缓存一致性、MESI;