Linux操作系统学习笔记(二十九)深入理解存储器

一. 前言

  本文延续上文介绍CPU的基本思路,继续探索计算机的存储器,包括寄存器、CPU缓存、内存和硬盘,在深入了解存储器的基础上,我们可以写出性能更为优异的代码。

二. 存储分级

  存储分级的根本原因还是性价比,即性能和价格的权衡。最快的当然是SRAM(Static Random-Access Memory,静态随机存取存储器),但是其密度较低、存储数据有限、价格偏高。而DRAM(Dynamic Random Access Memory,动态随机存取存储器)的芯片,比起 SRAM 来说,它的密度更高,有更大的容量,而且它也比 SRAM 芯片便宜不少。由此,我们使用SRAM+DRAM+磁盘,构成了存储分级系统。

img

  CPU中分为L1, L2, L3三级缓存,其中L1又根据前文所述将指令和数据分开存取,形成了指令缓存和数据缓存。 L1 Cache 不仅昂贵,其访问速度和它到 CPU 的物理距离有关。芯片造得越大,总有部分离 CPU 的距离会变远。电信号的传输速度又受物理原理的限制,没法超过光速。所以想要快,并不是靠多花钱就能解决的。出于此,L2缓存才会设计的更大,但是速度慢于L1。

  实现存储分级设计的根本方案来自于存储数据的局部性原理,这里包括了时间局部性(temporal locality)和空间局部性(spatial locality)两种策略。时间局部性指的是如果一个数据被访问了,那么它在短时间内还会被再次访问。空间局部性则指的是如果一个数据被访问了,那么和它相邻的数据也很快会被访问。

三. 高速缓存

  内存访问速度年增长只有7%左右,而根据摩尔定律CPU的访问速度则18个月翻一番(现在不一定),由此导致了CPU和内存的性能差异逐渐增大,在今天,CPU 和内存的访问速度已经有了 120 倍的差距。为了弥补两者之间的性能差异,我们能真实地把 CPU 的性能提升用起来,而不是让它在那儿空转,我们在现代 CPU 中引入了高速缓存。

  运行程序时,内存中的指令、数据,会被加载到 L1-L3 Cache 中,而不是直接由 CPU 访问内存去拿。在 95% 的情况下,CPU 都只需要访问 L1-L3 Cache,从里面读取指令和数据,而无需访问内存。CPU中的高速缓存分为一个个的小块,称之为CPU 缓存块(Cache Line),常见大小为64字节。下面介绍换存款和内存的读/写方法。

3.1 读缓存

  CPU 访问内存数据,是一小块一小块数据来读取的。对于读取内存中的数据,我们首先拿到的是数据所在的内存块(Block)的地址。而直接映射 Cache 采用的策略,就是确保任何一个内存块的地址,始终映射到一个固定的 CPU Cache 地址(Cache Line)。而这个映射关系,通常用 mod 运算(求余运算)来实现。实际计算中,有一个小小的技巧,通常我们会把缓存块的数量设置成 2 的 N 次方。这样在计算取模的时候,可以直接取地址的低 N 位,也就是二进制里面的后几位。

img

  除了取模对应的偏移量外,我们还需要存储组标记(Tag)和有效位(Valid bit)。

  • 组标记:组标记会记录,当前缓存块内存储的数据对应的内存块,而缓存块本身的地址表示访问地址的低 N 位,因此这里只要记录剩余的高位即可。
  • 有效位:用来标记对应的缓存块中的数据是否是有效的,确保不是机器刚刚启动时候的空数据。如果有效位是 0,无论其中的组标记和 Cache Line 里的数据内容是什么,CPU 都不会管这些数据,而要直接访问内存,重新加载数据。

img

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

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

  如果在 2、3 这两个步骤中,CPU 发现,Cache 中的数据并不是要访问的内存地址的数据,那 CPU 就会访问内存,并把对应的 Block Data 更新到 Cache Line 中,同时更新对应的有效位和组标记的数据。除了直接映射 Cache 之外,我们常见的缓存放置策略还有全相连 Cache(Fully Associative Cache)、组相连 Cache(Set Associative Cache)。这几种策略的数据结构都是相似的,只是进行了一些优化而已。

3.2 写缓存

  由于缓存速度远快于内存,因此写操作也会优先写缓存。但是这里存在一个问题:我们还需要修改内存保持数据的一致性。为此主要有两种写策略。

3.2.1 写直达(Write Through)

  在这个策略里,每一次数据都要写入到主内存里面。在写直达的策略里面,写入前,我们会先去判断数据是否已经在 Cache 里面了。如果数据已经在 Cache 里面了,我们先把数据写入更新到 Cache 里面,再写入到主内存里面;如果数据不在 Cache 里,我们就只更新主内存。写直达的这个策略很直观,但是问题也很明显,那就是这个策略很慢。无论数据是不是在 Cache 里面,我们都需要把数据写到主内存里面。

img

3.2.2 写回(Write Back)

  在 CPU Cache 的写入策略里,还有一种策略就叫作写回(Write-Back)。这个策略不再是每次都把数据写入到主内存,而是只写到 CPU Cache 里。只有当 CPU Cache 里面的数据要被“替换”的时候,才把数据写入到主内存里面去。如果发现要写入的数据就在 CPU Cache 里面,那么我们就只是更新 CPU Cache 里面的数据。同时,我们会标记 CPU Cache 里的这个 Block 是脏(Dirty)的,即Cache和主存不一致。

  在写入数据的时候,首先需要检测Cache Block里对应的是否是别的内存地址的数据。如果是的,则检测该数据有没有被标记成脏的。如果是脏的话,则先把这个 Cache Block 里面的数据写入到主内存里面。然后再把当前要写入的数据写入到 Cache 里,同时把 Cache Block 标记成脏的。如果 Block 里面的数据没有被标记成脏的,那么我们直接把数据写入到 Cache 里面,然后再把 Cache Block 标记成脏的就好了。如果加载内存里面的数据到 Cache 的时候,发现 Cache Block 里面有脏标记,我们也要先把 Cache Block 里的数据写回到主内存,才能加载数据覆盖掉 Cache。

  写回的方法是对局部性原理很好的诠释,在写回这个策略里,如果我们大量的操作都能够命中缓存。那么大部分时间里,我们都不需要读写主内存,自然性能会比写直达的效果好很多。

img

3.3 MESI协议

  多核CPU中由于L2,L3缓存共用,所以不存在一致性的问题。但是L1缓存是每个CPU独立使用的,如果1号核心和2号核心分别对主存的数据进行读、写操作,则会存在一致性问题。为了解决一致性问题,我们需要满足以下两点

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

  要解决缓存一致性问题,首先要解决的是多个 CPU 核心之间的数据传播问题。最常见的一种解决方案叫作总线嗅探(Bus Snooping)。这个策略本质上就是把所有的读写请求都通过总线(Bus)广播给所有的 CPU 核心,然后让各个核心去“嗅探”这些请求,再根据本地的情况进行响应。而响应的方法,就是MESI协议,其本质是将Cache状态表示为状态机,并通过一套同步方法,使用总线嗅探来保证数据一致性。

img

  MESI 协议来自于我们对 Cache Line 的四个不同的标记,分别是:M:代表已修改(Modified)E:代表独占(Exclusive)S:代表共享(Shared)I:代表已失效(Invalidated)。

  • 已修改:就是前文所述的“脏”的 Cache Block。Cache Block 里面的内容我们已经更新过了,但是还没有写回到主内存里面。
  • 已失效:指该 Cache Block 里面的数据已经失效,不可信任。
  • 独占:在独占状态下,对应的 Cache Line 只加载到了当前 CPU 核所拥有的 Cache 里,其他的 CPU 核并没有加载对应的数据到自己的 Cache 里。这个时候如果要向独占的 Cache Block 写入数据,我们可以自由地写入数据,而不需要告知其他 CPU 核。在独占状态下的数据,如果收到了一个来自于总线的读取对应缓存的请求,它就会变成共享状态。
  • 共享状态:对应独占状态,即有多个CPU的Cache拥有该数据。当我们想要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他 CPU 核心里面的 Cache,都变成无效的状态,然后再更新当前 Cache 里面的数据。这个广播操作,一般叫作 RFO(Request For Ownership),也就是获取当前对应 Cache Block 数据的所有权。

  通过MESI,我们可以实现多CPU间Cache的一致性,从而保证了数据不会出错。

四. 内存

  在前文中,我们已经介绍了页表、多级页表、虚拟内存等的相关内容,除此之外,内存部分为了提高性能和安全性,还有一些别的设计。

4.1 TLB和DMA

  多级页表的出现是为了解决单级页表占用太多空间的问题,但是多级页表也带来了时间损耗的增加。为了充分利用局部性原理以减小时空损耗,现代CPU设计了TLB芯片,即地址转换高速缓冲(Translation-Lookaside Buffer)。这块缓存存放了之前已经进行过地址转换的查询结果。这样,当同样的虚拟地址需要进行地址转换的时候,我们可以直接在 TLB 里面查询结果,而不需要多次访问内存来完成一次转换。为了性能,我们整个内存转换过程也要由硬件来执行。在 CPU 芯片里面,我们封装了内存管理单元(MMU,Memory Management Unit)芯片,用来完成地址转换。和 TLB 的访问和交互,都是由这个 MMU 控制的。

img

  DMA 技术就是我们在主板上放一块独立的芯片。在进行内存和 I/O 设备的数据传输的时候,我们不再通过 CPU 来控制数据传输,而直接通过 DMA 控制器(DMA Controller,简称 DMAC)。这块芯片,我们可以认为它其实就是一个协处理器(Co-Processor)。DMAC 最有价值的地方体现在,当我们要传输的数据特别大、速度特别快,或者传输的数据特别小、速度特别慢的时候。

  比如说,我们用千兆网卡或者硬盘传输大量数据的时候,如果都用 CPU 来搬运的话,肯定忙不过来,所以可以选择 DMAC。而当数据传输很慢的时候,DMAC 可以等数据到齐了,再发送信号,给到 CPU 去处理,而不是让 CPU 在那里忙等待。随着人们对于数据传输的需求越来越多,先是出现了主板上独立的 DMAC 控制器。到了今天,各种 I/O 设备越来越多,数据传输的需求越来越复杂,使用的场景各不相同。加之显示器、网卡、硬盘对于数据传输的需求都不一样,所以各个设备里面都有自己的 DMAC 芯片了。

4.2 内存保护

  内存保护是为了避免恶意程序对CPU进行一些破坏性的写入,执行我们不希望执行的命令。对此常见的做法有可执行空间保护和地址空间布局随机化。

  可执行空间保护指的是对于一个进程使用的内存,只把其中的指令部分设置成“可执行”的,对于其他部分,比如数据部分,不给予“可执行”的权限。因为无论是指令,还是数据,在我们的 CPU 看来,都是二进制的数据。我们直接把数据部分拿给 CPU,如果这些数据解码后,也能变成一条合理的指令,其实就是可执行的。这种破坏方式常见于SQL指令注入以及PHP命令注入。

  原先我们一个进程的内存布局空间是固定的,所以任何第三方很容易就能知道指令在哪里,程序栈在哪里,数据在哪里,堆又在哪里。这个其实为想要搞破坏的人创造了很大的便利。而地址空间布局随机化这个机制,就是让这些区域的位置不再固定,在内存空间随机去分配这些进程里不同部分所在的内存空间地址,让破坏者猜不出来。猜不出来呢,自然就没法找到想要修改的内容的位置。如果只是随便做点修改,程序只会 crash 掉,而不会去执行计划之外的代码。

五. 磁盘

5.1 机械硬盘

  机械磁盘一大缺陷就在于随机IO访问因为其结构问题,需要悬臂切换扇区再寻道,因此IOPS最多仅在100次/s。但其实对于 HDD 硬盘的顺序数据读写,吞吐率还是很不错的,可以达到 200MB/s 左右。目前机械硬盘在数据库、服务器使用的已越来越少,取而代之的是随机访问更为快捷的固态硬盘。

5.2 固态硬盘

  固态硬盘之所以快,是因为其存储结构适宜进行大量IO读写操作。能够记录一个比特很容易理解,给电容里面充上电有电压的时候就是 1,给电容放电里面没有电就是 0。采用这样方式存储数据的 SSD 硬盘,我们一般称之为使用了 SLC 的颗粒,全称是 Single-Level Cell,也就是一个存储单元中只有一位数据。如果只用 SLC,我们就会遇到存储容量上不去,并且价格下不来的问题。于是硬件工程师们就陆续发明了 MLC(Multi-Level Cell)、TLC(Triple-Level Cell)以及 QLC(Quad-Level Cell),也就是能在一个电容里面存下 2 个、3 个乃至 4 个比特。

  在的 SSD 硬盘用的是 SATA 或者 PCI Express 接口。在控制电路里,有一个很重要的模块,叫作 FTL(Flash-Translation Layer),也就是闪存转换层。这个可以说是 SSD 硬盘的一个核心模块,SSD 硬盘性能的好坏,很大程度上也取决于 FTL 的算法好不好。就像在管理内存的时候,我们通过一个页表映射虚拟内存页和物理页一样,在 FTL 里面,存放了逻辑块地址(Logical Block Address,简称 LBA)到物理块地址(Physical Block Address,简称 PBA)的映射。通过TLF,我们可以实现对固态硬盘各个块的访问次数尽可能平均,从而延长固体硬盘的寿命。

  下图所示为固态硬盘实际 I/O 设备,它其实和机械硬盘很像。现在新的大容量 SSD 硬盘都是 3D 封装的了,也就是说,是由很多个裸片(Die)叠在一起的,就好像我们的机械硬盘把很多个盘面(Platter)叠放再一起一样,这样可以在同样的空间下放下更多的容量。一张裸片上可以放多个平面(Plane),一般一个平面上的存储容量大概在 GB 级别。一个平面上面,会划分成很多个块(Block),一般一个块(Block)的存储大小, 通常几百 KB 到几 MB 大小。一个块里面,还会区分很多个页(Page),就和我们内存里面的页一样,一个页的大小通常是 4KB。

img

  对于 SSD 硬盘来说,数据的写入叫作 Program。写入不能像机械硬盘一样,通过覆写(Overwrite)来进行的,而是要先去擦除(Erase),然后再写入。SSD 的读取和写入的基本单位,不是一个比特(bit)或者一个字节(byte),而是一个页(Page)。SSD 的擦除单位就更夸张了,我们不仅不能按照比特或者字节来擦除,连按照页来擦除都不行,我们必须按照块来擦除。SSD 的使用寿命,其实是每一个块(Block)的擦除的次数。SLC 的芯片,可以擦除的次数大概在 10 万次,MLC 就在 1 万次左右,而 TLC 和 QLC 就只在几千次了。

  下图所示为固态硬盘和机械硬盘的优缺点对比。

img

总结

  本文总结了计算机存储设备的各个特点和其组成原理,由此我们完成了对存储设备的学习。

参考文献

[1] Linux-insides

[2] 深入理解Linux内核

[3] Linux内核设计的艺术

[4] 深入理解计算机系统

[5] 深入理解Linux网络技术内幕

[6] 计算机组成原理

[7] 极客时间 深入浅出计算机组成原理

[8] 计算机组成与设计:硬件/软件接口

[9] 编码:隐匿在计算机软硬件背后的语言

坚持原创,坚持分享,谢谢鼓励和支持