📣 大家好,我是Zhan,一名个人练习时长两年的大三后台练习生🏀
📣 这篇文章是 操作系统 第二篇笔记📙
📣 如果有不对的地方,欢迎各位指正🙏🏼
📣 Just do it! 🫵🏼🫵🏼🫵🏼
🔔 引言
正如上文 《无论你是科技爱好者还是程序猿,冯诺依曼体系结构你得知道! - 掘金 (juejin.cn)》 中所说,存储器包含磁盘、内存 ,并且 CPU 中也有寄存器 用于存储,还有本文会重点的 CPU Cache ,那么这些存储器的读写速度、能够存储的数据量、材质、如何进行数据的检索等特点将在本文介绍:
- CPU 访问存储器的顺序
- 各类存储器的访问速度 以及造价
- 存储器的用材 和存储容量
- CPU Cache 的三级缓存内部数据结构以及映射方式
- 在了解了 CPU Cache 之后,我们作为 Coder 应该怎么提高缓存命中率
1️⃣ 存储器的访问顺序
这么多可以存储数据的地方,我们要使用数据应该先去哪里找数据呢,其实用一张图就大概概述,但是还是有一些细节以及知识需要我们去学习
我们先解释一下这张图,对于 CPU 它访问数据的路径依次是:寄存器 -> CPU Cache -> 内存 -> 磁盘
:
- 寄存器 :寄存器是最靠近 CPU 的控制单元和逻辑计算单元的存储器,处理的速度最快,同样的,坏处就是能够存储的数据量不多
- CPU Cache:它同样在 CPU 内部,但是它位于 核心 和 内存之间,与 CPU 核心之间的距离相对较远,且寄存器是直接与 CPU 核心内的逻辑电路相连,而它的访问机制是相对比较复杂的
- 内存 :它距离 CPU 就远了,而内存不像前面的寄存器、缓存,它所需要存储的数据量是相较于更多的,而对于存储器,读写速度越快、能耗越高,它材料的成本也是更高的,在材料上的选择也会考虑成本不选择速度那么快的材料
- 磁盘 :也就是我们平时所知的
固体硬盘、机械硬盘
,与内存不同的是,它断电后数据仍然存在,是通过物理读写的方式访问数据,因此访问速度非常慢
2️⃣ 存储器的访问速度对比
此处的 L1 Cache 和 L2 Cache 是两级缓存,我们后面会详细讲解 CPU 内部的多级缓存的工作原理
存储器 | 硬件介质 | 单位成本(美元/MB) | 随机访问延时 |
---|---|---|---|
L1 Cache | SRAM | 7 | 1ns |
L2 Cache | SRAM | 7 | 4ns |
内存 | DRAM | 0.015 | 100ns |
固体硬盘 | SSD | 0.0004 | 150μs |
机械硬盘 | HDD | 0.00004 | 10ms |
不难发现:L1 Cache 的访问速度是内存的 100 倍,而机械硬盘的访问延时与 L1 Cache 相比,二者也是差了好几个数量级,随之价格也是差了好几个数量级
3️⃣ 存储器的材料和大小对比
每个存储器只和相邻的一层存储器设备沟通,而且存储设备为了追求更快的速度,所需要的材料成本随之更高,因此 CPU 内部的存储器、三级缓存、只好用小容量,而内存和硬盘可以使用更大的容量:
- 寄存器:数量通常在几十到几百不等,每个存储器可以用来存储一定字节的数据,例如 32 位的一个存储器可以存储 4B,64 位的一个存储器可以存储 8B
- CPU Cache :使用
SRAM
材质,需要使用 6 个晶体管存储 1bit 数据,不过高速缓存可以分为三级缓存:- L1 Cache :访问速度几乎和寄存器一样快,每个 CPU 核心都有一块属于自己的 L1 Cache,用于缓存指令和数据,我自己的服务器的指令缓存大小和数据缓存大小都是 32K
- L2 Cache :同样是每个 CPU 核心都有,但是它离 CPU 更远,访问速度相对更慢,不过它能够存储的大小更多,通常大小是在几百KB 到几 MB 不等,我自己的服务器是 4096K
- L3 Cache :它是被所有的 CPU 核心公用,按照规律,它访问速度更慢,不过大小在几 MB 到几十 MB 不等,我自己的服务器是 16384K
- 内存:使用的是 DRAM 芯片,它的容量是更大且造价更便宜,但是由于它是使用晶体管和电容存储数据,而电容会不断漏电,因此需要定时刷新电容,也就是说在断电的情况下数据会发生丢失
- 固体硬盘:与上述的三种不同,它断电后数据仍然存在,不过读写速度不尽人意,内存的读写速度是 SSD 的 10-1000 倍
4️⃣ CPU Cache 的三级缓存
在早期的计算机系统中是没有高速缓存的,主要是因为在早期 CPU 和 内存之间的速度差异相对较小
而随着计算机的发展,CPU 的处理能力的增长速度远远快鱼内存的访问速度,因此拉开的差距就会导致:CPU 在等待内存中获取数据的时候浪费了大量的时间
因此就引入了CPU Cache,使得 CPU 能够更快地访问这些数据
🎡 Cache Line
在程序执行的时候,会首先把内存中的数据加载到共享 的 L3 Cache 中,然后依次加载到每个核心独有的 L2 Cache、L1 Cache:
CPU Cache 被分割成固定大小的块,通常我们称之为 Cache Line:
- 每个 Cache Line 通常存储 4-128 Bytes
- 在从内存中读取数据存入 Cache 的时候,就是以 Cache Line 为基本单位
- Cache Line 中不仅仅包含从内存中获取的元数据,还会有标记和有效位 :
- 标记:用于标识 Cache 块所包含的数据在内存中的真实物理地址,当 CPU 需要读取某个内存地址的数据的时候,会根据标记判断是否在 CPU Cache
- 有效位:表明该缓存是否包含有效的数据。即如果有效位为1数据为有效,有效位为 0 其中数据可能不存在或者为脏数据
🎯 直接映射 Cache
在了解了 Cache Line 的结构后,我们就要思考一个问题,Cache 控制器如何判断数据是否存在与 Cache 中呢?也就是如何判断缓存是否命中,这里有三种映射方法,不妨把它当做一种 Hash,三种方法各有优势
假设我们一共有4个Cache Line,大小为 8 Bytes,也就是说缓存总的大小为 32Bytes。
而我们现在要访问内存地址为 0X14
的数据,也就是十进制为 20 的数据,直接映射会把内存地址的一部分映射到缓存中的行号 ,例如我们把内存地址低三位来映射行号,也就是 100
它就对应着第四行缓存,也就是第四个 Cache Line
找到对应的 Cache Line 之后就开始寻找是否存在该数据,但是这种直接映射的情况,每个内存地址只能映射到缓存中的一个特定位置,但是可能会导致缓存冲突,其实这种方法有点类似于 Java 中的简单 Hash,很容易造成碰撞,从而降低了命中率
⚒️ 关联映射 Cache
关联映射这种方式,每个内存地址可以映射到多个缓存位置中的一个,进而提高缓存命中率,我们用例子来说明:
同样假设我们一共有4个Cache Line,大小为 8 Bytes,也就是说缓存总的大小为 32Bytes 。
不过这里要提到一个词叫关联度 ,这里的情景我们假设缓存系统的关联度为2,它代表每个内存地址可以映射到两个缓存行中的一个
关系映射中,我们会把内存地址的一部分映射到缓存中的一组缓存行 ,这里说的是一组,假设我们使用内存地址的低2位来映射到缓存组 ,内存地址 0X14
的低2位是 00,所以我们映射到缓存的第 0 组:
组内的每个缓存行都有一个标记,用来表示这个行存储了哪些内存地址的数据,CPU 在两个 Cache Line 中查找标记是否匹配,如果有匹配的标记则代表缓存命中
总结:在关联映射中,每个内存地址可以映射到一组 Cache Line,提高了缓存的命中率,因为不同的内存地址可以在同一个组内共享缓存行。但是这种方式需要更多的硬件逻辑来标记和比较,更加复杂和昂贵
🔎 全关联映射
与直接映射不同的是:直接映射使用索引进行映射,得到 Cache Line 的索引 ,而全关联映射使用标记进行映射,得到 Cache Line 的索引
这样做会需要更多的硬件资源来实现,但是能够有效地减少缓存冲突,从而提高缓存的命中率,进而提高数据访问的效率。
因此,在实际设计中,通常会根据系统需求和成本因素来选择适当的映射策略,可能是直接映射、关联映射或者一些混合策略。
5️⃣ Coder 怎么提高缓存命中率?
🗓️ 二维数组怎么遍历更快?
这里说的 Coder 并不是机器语言、汇编语言的 Coder,而是在座的所有人,我们以 Java 语言的这两段代码为例,来聊聊如何利用 CPU Cache:
java
public class RegularTraversal {
public static void main(String[] args) {
int[][] array = new int[1000][1000];
long startTime = System.nanoTime();
int sum = 0;
// 缓存不友好的方法
for (int i = 0; i < array.length; i++) {
for (int j = 0; j < array[i].length; j++) {
sum += array[i][j];
}
}
// 缓存友好的方法
for (int j = 0; j < array[0].length; j++) {
for (int i = 0; i < array.length; i++) {
sum += array[i][j];
}
}
long endTime = System.nanoTime();
System.out.println("总时间: " + sum);
System.out.println("遍历时间: " + (endTime - startTime));
}
}
经过测试,我们其实可以发现缓存友好的方式比缓存不友好的方式快了几倍,为什么会这样呢?
因为数组占用的内存是连续的 ,而二维数组的内存是连续的先存行,然后连续的存列的数据,而缓存友好的方式就是访问数组元素的顺序是和内存中数组存放的顺序一致
在第一个遍历中,每次内层循环迭代都跳跃性地访问数组的不同行 ,可能导致缓存不命中。而在第二个遍历中,内层循环遍历的是同一列的元素,利用了连续的内存访问,从而更好地利用了CPU缓存,减少了缓存不命中。
📍 分支预测器
对于一个数组,里面的数据为随机数,现在需要对这个数执行两个操作:
- 循环遍历数组,把小于 50 的数组元素置零
- 排序数组
java
public class TraverseAndSort {
public static void main(String[] args) {
int[] array = new int[100];
Random random = new Random();
for (int i = 0; i < array.length; i++) {
array[i] = random.nextInt(101);
}
for (int i = 0; i < array.length; i++) {
array[i] = 0;
}
Arrays.sort(array);
}
}
如果仅仅通过时间复杂度进行计算的话,其实是发现怎么算都是 n + NlogN,但是这里我们需要注意的点是:如果我们先进行赋值,那么此时我们作为 Coder 是知道数组里面全部都是 0,我们就可以使用计数排序,将时间复杂度降到 O(n)
为什么要举这个例子呢,这就不得不说到分支预测器,在我们进行
if-else
判断的时候,如果分支预测器可以预测到接下来要执行 if 还是 else 中的语句,它就可以提前把指令放在指令缓存中
在 C/C++ 语言中就提供了 likely
和 unlikely
这两种宏,如果 if
条件为 ture
的概率大,则可以用 likely
宏把 if
里的表达式包裹起来,反之用 unlikely
宏。
如果我们换一种情况,不是把所有的数据置零,而是把数据小于 50 的置零,那么这个时候我们从 "自然智慧"的直觉来看是 先排序后置零 更快,其本质就和这个有关:
java
public class TraverseAndSort {
public static void main(String[] args) {
int[] array = new int[100];
Random random = new Random();
for (int i = 0; i < array.length; i++) {
array[i] = random.nextInt(101);
}
for (int i = 0; i < array.length; i++) {
if (array[i] < 50) {
array[i] = 0;
}
}
Arrays.sort(array);
}
}
这是因为排序之后,数字是从小到大的,那么前几次循环命中 if < 50
的次数会比较多,于是分支预测就会缓存 if
里的 array[i] = 0
指令到 Cache 中,后续 CPU 执行该指令就只需要从 Cache 读取就好了
💬 总结
本文首先紧接着上文的学习的冯诺依曼体系结构 ,深入学习存储器的知识,了解了 CPU 是如何一层一层的去取数据,以及随着离 CPU 的距离,存储器的材质、造价、访问速度也是不同。
通过数据,我们可以知道,CPU Cache 的访问速度可以达到内存的 100 倍 ,为了学习如何去利用 CPU Cache,我们先后介绍了 Cache Line ,Cache 的寻址方式 ,可以类比为哈希函数,如何避免碰撞提高缓存的利用率和命中率。
最后我们以二维数组为例子,讲解了我们作为程序员应该如何去利用 CPU Cache 写出更快的代码。
🍁 友链
- 无论你是科技爱好者还是程序猿,冯诺依曼体系结构你得知道! - 掘金 (juejin.cn)
- Cache的基本原理 - 知乎 (zhihu.com)
- 小林coding (xiaolincoding.com)
✒写在最后
都看到这里啦~,给个点赞再走呗~,也欢迎各位大佬指正以及补充,在评论区一起交流,共同进步!也欢迎加微信一起交流:Goldfish7710。咱们明天见~