CPU 是如何读写内存的?

https://mp.weixin.qq.com/s/S3Cn6KsDGKqxxP58y2m67Q

如果你觉得这是一个非常简单的问题,那么你真应该好好读读本文,我敢保证这个问题绝没有你想象的那么简单。注意,一定要完本文,否则可能会得出错误的结论。闲话少说,让我们来看看 CPU 在读写内存时底层究竟发生了什么。 谁来告诉 CPU 读写内存 我们第一个要搞清楚的问题是:谁来告诉 CPU 去读写内存?答案很明显,是程序员,更具体的是编译器。CPU 只是按照指令按部就班的执行,机器指令从哪里来的呢?是编译器生成的,程序员通过高级语言编写程序,编译器将其翻译为机器指令,机器指令来告诉 CPU 去读写内存。在精简指令集架构下会有特定的机器指令,Load/Store 指令来读写内存,以 x86 为代表的复杂指令集架构下没有特定的访存指令。精简指令集下,一条机器指令操作的数据必须来存放在寄存器中,不能直接操作内存数据,因此 RISC 下,数据必须先从内存搬运到寄存器,这就是为什么 RISC 下会有特定的 Load/Store 访存指令,明白了吧。而 x86 下无此限制,一条机器指令操作的数据可以来自于寄存器也可以来自内存,因此这样一条机器指令在执行过程中会首先从内存中读取数据。关于复杂指令集以及精简指令集你可以参考这两篇文章《CPU 进化论:复杂指令集》与《不懂精简指令集还敢说自己是程序员?

两种内存读写

现在我们知道了,是特定的机器指令告诉 CPU 要去访问内存。不过,值得注意的是,不管是 RISC 下特定的 Load/Store 指令还是 x86 下包含在一条指令内部的访存操作,这里读写的都是内存中的数据,除此之外还要意识到,CPU 除了从内存中读写数据外,还要从内存中读取下一条要执行的机器指令。毕竟,我们的计算设备都遵从冯诺依曼架构:程序和数据一视同仁,都可以存放在内存中现在,我们清楚了 CPU 读写内存其实是由两个因素来驱动的:

  1. 程序执行过程中需要读写来自内存中的数据

  2. CPU 需要访问内存读取下一条要执行的机器指令

然后 CPU 根据机器指令中包含的内存地址或者 PC 寄存器中下一条机器指令的地址访问内存。这不就完了吗?有了内存地址,CPU 利用硬件通路直接读内存就好了,你可能也是这样的想的。真的是这样吗?别着急,我们接着往下看,这两节只是开胃菜,正餐才刚刚开始。

急性子吃货 VS 慢性子厨师

假设你是一个整天无所事事的吃货,整天无所事事,唯一的爱好就是找一家餐厅吃吃喝喝,由于你是职业吃货,因此吃起来非常职业,1 分钟就能吃完一道菜,但这里的厨师就没有那么职业了,炒一道菜速度非常慢,大概需要 1 小时 40 分钟才能炒出一道菜,速度比你慢了 100 倍,如果你是这个吃货,大概率会疯掉的。而 CPU 恰好就是这样一个吃货,内存就是这样一个慢吞吞的厨师,而且随着时间的推移这两者的速度差异正在越来越大:在这种速度差异下,CPU 执行一条涉及内存读写指令时需要等**“很长一段时间“数据才能”缓缓的“从内存读取到 CPU 中,在这种情况你还认为 CPU 应该直接读写内存吗**?

无处不在的 28 定律

28 定律我想就不用多介绍了吧,在《不懂精简指令集还敢说自己是程序员集中起来然后呢?放到哪里呢?当然是放到一种比内存速度更快的存储介质上,这种介质就是我们熟悉的 SRAM,普通内存一般是 DRAM,这种读写速度更快的介质充当 CPU 和内存之间的 Cache,这就是所谓的缓存。

四两拨千斤

我们把经常用到的数据放到 cache 中存储,CPU 访问内存时首先查找 cache,如果能找到,也就是命中,那么就赚到了,直接返回即可,找不到再去查找内存并更新 cache。我们可以看到,有了 cache,CPU 不再直接与内存打交道了但 cache 的快速读写能力是有代价的,代价就是 Money,造价不菲,因此我们不能把内存完全替换成 cache 的 SRAM,那样的计算机你我都是买不起的。因此 cache 的容量不会很大,但由于程序局部性原理,因此很小的 cache 也能有很高的命中率,从而带来性能的极大提升,有个词叫四两拨千斤,用到 cache 这里再合适不过。

天下没有免费的午餐

虽然小小的 cache 能带来性能的极大提升,但,这也是有代价的。这个代价出现在写内存时。当 CPU 需要写内存时该怎么办呢?现在有了 cache,CPU 不再直接与内存打交道,因此 CPU 直接写 cache,但此时就会有一个问题,那就是 cache 中的值更新了,但内存中的值还是旧的,这就是所谓的不一致问题,inconsistent.就像下图这样,cache 中变量的值是 4,但内存中的值是 2。

同步缓存更新

常用 redis 的同学应该很熟悉这个问题,可是你知道吗?这个问题早就在你读这篇文章用的计算设备其包含的 CPU 中已经遇到并已经解决了。最简单的方法是这样的,当我们更新 cache 时一并把内存也更新了,这种方法被称为 write-through,很形象吧。可是如果当 CPU 写 cache 时,cache 中没有相应的内存数据该怎么呢?这就有点麻烦了,首先我们需要把该数据从内存加载到 cache 中,然后更新 cache,再然后更新内存。这种实现方法虽然简单,但有一个问题,那就是性能问题,在这种方案下写内存就不得不访问内存,上文也提到过 CPU 和内存可是有很大的速度差异哦,因此这种方案性能比较差。有办法解决吗?答案是肯定的。

异步更新缓存

这种方法性能差不是因为写内存慢,写内存确实是慢,更重要的原因是 CPU 在同步等待,因此很自然的,这类问题的统一解法就是把同步改为异步。关于同步和异步的话题,你可以参考这篇文章《从小白到高手,你需要理解同步和异步现在你应该能看到,添加 cache 后会带来一系列问题,更不用说 cache 的替换算法,毕竟 cache 的容量有限,当 cache 已满时,增加一项新的数据就要剔除一项旧的数据,那么该剔除谁就是一个非常关键的问题,限于篇幅就不在这里详细讲述了,你可以参考《深入理解操作系统》第 7 章有关于该策略的讲解。

多级 cache

现代 CPU 为了增加 CPU 读写内存性能,已经在 CPU 和内存之间增加了多级 cache,典型的有三级,L1、L2 和 L3,CPU 读内存时首先从 L1 cache 找起,能找到直接返回,否则就要在 L2 cache 中找,L2 cache 中找不到就要到 L3 cache 中找,还找不到就不得不访问内存了。因此我们可以看到,现代计算机系统 CPU 和内存之间其实是有一个 cache 的层级结构的你以为这就完了吗?哈哈,哪有这么容易的,否则也不会是终面题目了。那么当 CPU 读写内存时除了面临上述问题外还需要处理哪些问题呢?

多核,多问题

当摩尔定律渐渐失效后鸡贼的人类换了另一种提高 CPU 性能的方法,既然单个 CPU 性能不好提升了,我们还可以堆数量啊,这样,CPU 进入多核时代,程序员开始进入苦逼时代。拥有一堆核心的 CPU 其实是没什么用的,关键需要有配套的多线程程序才能真正发挥多核的威力,但写过多线程程序的程序员都知道,能写出来不容易,能写出来并且能正确运行更不容易,关于多线程与多线程编程的详细阐述请参见《深入理解操作系统》第 5、6 两章(关注公众号“码农的荒岛求生”并回复“操作系统”)。CPU 开始拥有多个核心后不但苦逼了软件工程师,硬件工程师也不能幸免。前文提到过,为提高 CPU 访存性能,CPU 和内存之间会有一个层 cache,但当 CPU 有多个核心后新的问题来了: 看出问题在哪里了吗?一个初始值为 2 的变量,在分别+2 和+4 后正确的结果应该是 2+2+4 = 8,但从上图可以看出内存中 X 的值却为 6,问题出在哪了呢?

多核 cache 一致性

有的同学可能已经发现了,问题出在了内存中一个 X 变量在 C1 和 C2 的 cache 中有共计两个副本,当 C1 更新 cache 时没有同步修改 C2 cache 中 X 的值解决方法是什么呢?显然,如果一个 cache 中待更新的变量同样存在于其它核心的 cache,那么你需要一并将其它 cache 也更新好。现在你应该看到,CPU 更新变量时不再简单的只关心自己的 cache 和内存,你还需要知道这个变量是不是同样存在于其它核心中的 cache,如果存在需要一并更新。当然,这还只是简单的读,写就更加复杂了,实际上,现代 CPU 中有一套协议来专门维护缓存的一致性,比较经典的包括 MESI 协议等。为什么程序员需要关心这个问题呢?原因很简单,你最好写出对 cache 一致性协议友好的程序因为 cache 频繁维护一致性也是有性能代价的。同样的,限于篇幅,这个话题不再详细阐述,该主题同样值得单独成篇,敬请期待。

够复杂了吧!

怎么样?到目前为止,是不是 CPU 读写内存没有看上去那么简单?现代计算机中 CPU 和内存之间有多级 cache,CPU 读写内存时不但要维护 cache 和内存的一致性,同样需要维护多核间 cache 的一致性你以为这就完了,NONO,最大的谜团其实是接下来要讲的。

你以为的不是你以为的

现代程序员写程序基本上不需要关心内存是不是足够这个问题,但这个问题在远古时代绝对是困扰程序员的一大难题。如果你去想一想,其实现代计算机内存也没有足够大的让我们随便申请的地步,但是你在写程序时是不是基本上没有考虑过内存不足该怎么办?为什么我们在内存资源依然处于匮乏的现代可以做到申请内存时却进入内存极大丰富的共产主义理想社会了呢?原来这背后的功臣是我们熟悉的操作系统。操作系统对每个进程都维护一个假象,即,每个进程独占系统内存资源;同时给程序员一个承诺,让程序员可以认为在写程序时有一大块连续的内存可以使用。这当然是不可能不现实的,因此操作系统给进程的地址空间必然不是真的,但我们又不好将其称之为“假的地址空间”,这会让人误以为计算机科学界里骗子横行,因此就换了一个好听的名字,虚拟内存,一个“假的地址空间”更高级的叫法。进程其实一直活在操作系统精心维护的幻觉当中,就像《盗梦空间》一样,关于虚拟内存的详尽阐述请参见《深入理解操作系统》第七章(关注公众号“码农的荒岛求生”并回复“操作系统”)。从这个角度看,其实最擅长包装的是计算机科学界,哦,对了,他们不但擅长包装还擅长抽象。

天真的 CPU

CPU 真的是很傻很天真的存在。上一节讲的操作系统施加的障眼法把 CPU 也蒙在鼓里。CPU 执行机器指令时,指令指示 CPU 从内存地址 A 中取出数据,然后 CPU 执行机器指令时下发命令:“给我从地址 A 中取出数据”,尽管真的能从地址 A 中取出数据,但这个地址 A 不是真的,不是真的,不是真的。因为这个地址 A 属于虚拟内存,也就是那个“假的地址空间”,现代 CPU 内部有一个叫做 MMU 的模块将这假的地址 A 转换为真的地址 B,将地址 A 转换为真实的地址 B 之后才是本文之前讲述的关于 cache 的那一部分。你以为这终于应该讲完了吧!NONO!CPU 给出内存地址,此后该地址被转为真正的物理内存地址,接下来查 L1 cache,L1 cache 不命中查 L2 cache,L2 cache 不命中查 L3 cache,L3 cache 不能命中查内存。各单位注意,各单位注意,到查内存时还不算完,现在有了虚拟内存,内存其实也是一层 cache,是磁盘的 cache,也就是说查内存也有可能不会命中,因为内存中的数据可能被虚拟内存系统放到磁盘中了,如果内存也不能命中就要查磁盘。So crazy,限于篇幅这个过程不再展开,《深入理解操作系统》第七章有完整的讲述。至此,CPU 读写内存时完整的过程阐述完毕。

总结

现在你还认为 CPU 读写内存非常简单吗?这一过程涉及到的硬件以及硬件逻辑包括:L1 cache、L2 cache、L3 cache、多核缓存一致性协议、MMU、内存、磁盘;软件主要包括操作系统。这一看似简单的操作涉及几乎所有计算机系统中的核心组件,需要软件以及硬件密切配合才能完成。这个过程给程序员的启示是:1),现代计算机系统是非常复杂的;2),你需要写出对 cache 友好的程序。我是小风哥,希望这篇文章对大家理解 CPU 以及内存读写有所帮助。***参考资料***1,《深入理解操作系统》第七章关注公众号“码农的荒岛求生”并回复“操作系统”即可阅读2,CPU 进化论:复杂指令集的诞生**3,不懂精简指令集还敢说自己是程序员?**

最后修改 April 15, 2023: upgrade (dbd415b4)