到目前为止,我们所了解的计算机底层的知识,都依赖于冯·诺伊曼架构,这个使用了七十多年的架构,是通用计算机体系结构的基础,无论是桌面端、服务器端还是移动端,大多数计算设备都基于这个简单的架构。
在刘慈欣的 《三体:地球往事》 中,就对冯·诺伊曼架构作了形象的描述,并且同时也提到了缓存cache 的妙处:
冯·诺伊曼指着下方巨大的人列回路开始介绍:"陛下,我们把这合计算机命名为‘秦一号’。请看,那里,中心部分,是CPU,是计算机的核心计算元件。由您最精锐的五个军团构成,对照这张图您可以看到里面的加法器、寄存器、堆栈存贮器:外围整齐的部分是内存,构建这部分时我们发现人手不够,好在这部分每个单元的动作最简单,就训练每个士兵拿多种颜色的旗帜,组合起来后,一个人就能同时完成最初二十个人的操作,这就使内存容量达到了运行‘秦1.0’操作系统的最低要求;你再看那贯穿整个阵列的通道,还有那些在通道上待命的轻骑兵,那是BUS,系统总线,负责在整个系统间传递信息。
"总线结构是个伟大的发明,新的插件,最大可由十个军团构成,能够快捷地挂接到总线上运行,这使得‘秦一号’的硬件扩展和升级十分便利;再看最远处那一边,能要用望远镜才能看清,那是外存,我们又用了哥白尼起的名字,叫它‘硬盘’,那是由三百万名文化程度较高的人构成,您上次坑儒时把他们留下是对了,他们每个人手中都有一个记录本和笔,负责记录运算结果,当然,他们最大的工作量还是作为虚拟内存,存贮中间运算结果,运算速度的瓶颈就在他们那里。这儿,离我们最近的地方,是显示阵列,能显示计算机运行的主要状态参数。“
可以看到,在这个架构中,数据和指令都存储在内存中,CPU 需要频繁与内存进行交互。但是这个架构已经是70年前的,CPU 和内存从诞生的时候开始,随着时间的差异越来越大,简单来说,CPU 的速度发展地越来越快,而内存的速度,远远赶不上CPU 进化的速度,他们的差异越来越大。
因为短板效应,最终的性能就取决于CPU 和内存之间最慢的那一个,也就是说,内存的的慢速成为了性能的瓶颈。那是不是就意味着CPU 的高速毫无用武之地?当然不是,因为有了缓存cache。
1 cache
1.1 cache 的三层结构
在现代的CPU 与内存之间,会增加一层cache,cache 造价昂贵,容量小,但是访问速度几乎和CPU 速度一样快,cache 中保存了近期从内存中获取的数据,CPU 无论是需要指令还是数据,都先在cache 中查找,只要命中cache,就不需要访问内存,大大加快了CPU 执行指令的速度,从而弥补了CPU 和内存之间的速度差异。
一般地,现代CPU,如x86,与内存之间实际上增加了三层cache,分别是L1 cache,L2 cache,L3 cache。
L1 cache 的访问速度比寄存器要慢一些,但也相差无几,大概需要4个时钟周期,L2 cache 的速度比L1 cache 慢一些,大概需要10时钟周期,而L3 cache 的访问速度50个时钟周期。也就是L1,L2,L3 的访问速度依次递减。
CPU 访问内存时首先在L1 cache 中查找,如果没有命中,则在L2 cache 中查找,如果还没有命中,则在L3 cache 中查找,最后还是没有命中,则访问内存,此后再将数据更新到cache 中,这样下次再访问到,就不需要访问内存了。
在当今的CPU 芯片上,有很多一部分空间留给了cache,真正用于执行CPU 指令占据的空间反倒不大。
1.2 cache 的更新
cache 很好,速度很快,但是就是因为速度快,就会导致更新的问题,因为有了cache,CPU 直接与cache 交互而不是内存,但是这样当cache 的值被更新后,内存中的值还是旧的,这就是不一致(inconsistent)问题。
解决这个问题最简单的方法就是在更新内存的时候,一起更新内存(write-through),这样的情况下,更新cache 的同时也要访问内存,CPU也必须等内存更新完,这明显是一种同步的设计方法,如果想要优化,提高CPU 性能,把同步改成异步,是一种常见的优化方法。
**当CPU 更新数据时,直接更新cache,不用等待内存更新完毕。**那么什么时候才会把最新的数据更新到内存中呢?
很简单,因为cache 的容量是有限的,因此当cache 容量不足时,把不常用的数据移除,在这个时候,我们就可以把在cache 中移除的数据更新到内存中(如果它被修改过的话),这样的话,更新cache 与更新内存就解耦了,这明显就是异步的设计(write-back)。
这种方法明显比同步的write-through 更加复杂,但是性能也更加好。
1.3 cache 多核一致性
cache 的更新问题也不止这么简单,现在的计算机大部分是多核系统,每个CPU芯片 中都有自己cache,但是内存只有一份。这就会导致多核一致性问题。
现在如果两个核心都要读取一份内存的数据,这个时候,问题不大,因为单纯的读取不会产生一致性问题。
但是接下来,CPU1要对x
进行+2
操作,那么根据cache 的工作原理,更新完cache1 后,更新内存(这里假设同步更新内存),这个时候,CPU1 和内存中x
的值都为4
,但是这个时候,CPU2 要对x
进行+4
操作,这个时候同样的,更新完cache2 后,更新内存,可是这个时候,内存中的x
的值为6
了。
可是我们明明对同一个变量+2
再+4
,结果应该是2 + 2 + 4 = 8
,为什么现在结果却变成了6
呢?
问题出现在内存中的变量x 在每个核心的cache 中有副本,当CPU 1更新x时,没有同步修改CPU2 的值。
如何解决呢?显然,当一个在cache 中被更新的变量同时存在于其他核心时,这个变量的修改也要将其他核心的变量也一并修改。
实际上,现在CPU 中有一套协议专门用来维护多核cache 的一致性的,比较经典的就是MESI 协议。
当然,频繁维护多核cache的一致性会带来性能上的损失。
1.4 内存自己也是cache
当程序需要进行I/O 操作时,磁盘的问题就出现了,我们知道,内存的访问速度是CPU 的1/100,但是磁盘的速度和内存的速度甚至都不是一个数量级的,内存的访问速度比磁盘寻道速度快大概10万倍。但是当程序想要读取文件的时候,首先要把数据从磁盘搬运到内存,然后CPU 才能从内存中读取,那么,要怎么解决内存和磁盘间的速度差异问题呢?
我们前面一直介绍为了减少CPU 和内存之间的差异,我们使用上了cache,那么,能不能在内存和磁盘上也使用cache呢?其实,内存就是磁盘的cache。
我们把从磁盘读取的数据放入内存中,这样下次访问该文件时,就不需要访问磁盘。
这也是为什么有时候我们第一次加载大文件时会很慢,但在第二次以后加载时就非常快,原因就是因为该文件内容可能已经被缓存在内存中了。
但是当我们写文件时,有可能写到内存cache时,就直接返回了,此时最新的文件数据可能还没有传到磁盘,但是如果这个时候如果系统崩溃或者断电,那么数据将会丢失,这就是很多I/O 库提供sync
或者flush
函数的原因,就是为了确保将数据真正写入磁盘。
1.5 虚拟内存与磁盘
在本篇文章的开头引用的 《三体:地球往事》 中,有这样一句话:
他们(硬盘)最大的工作量还是作为虚拟内存,存贮中间运算结果,运算速度的瓶颈就在他们那里。
这里的“硬盘作为虚拟内存”是什么意思呢?
我们在刚刚提到,内存可以作为磁盘的cache,而磁盘,也可以作为内存的仓库。
我们在前几篇文章中一直有介绍“虚拟内存”的概念:每个进程都有一个标准大小的地址空间,每个内存都认为自己独占整块内存,但是为什么所有进程申请的内存大小竟然可以超过真实的物理内存?
这就是因为硬盘,我们可以把某些进程不常用的内存数据写入磁盘,从而释放这一部分占据的物理空间,也就是磁盘承接了内存的一部分工作,所有进程申请的内存大小可以超过真实的物理内存,甚至不局限于物理内存,这就是在 《三体》 中的“硬盘作为虚拟内存”。所以即使我们的程序不涉及磁盘I/O 操作,CPU 执行我们的程序时,可能也需要访问磁盘,尤其是在内存占用率很高的情况下。
1.6 所以,CPU 是如何读取内存的
- 首先,CPU 看到的是虚拟的内存地址,该虚拟地址通过页表的对照,转换成真实的物理内存地址。
- 转换完后,开始查找cache,按L1,L2,L3 的顺序查找,在任何一层找到都能直接返回。
- 如果查找不到,就要开始真正访问内存,找到后,就可以返回并且更新cache。
- 但是有些进程不常用的数据会被替换到磁盘中,因此可能无法命中内存,所以需要把磁盘中的进程数据再加载回内存,然后再读取内存。
1.7 分布式存储
现在的大数据时代,海量的用户数据磁盘已经存不下了,那么这个时候,客户端机器可以直接挂载分布式文件系统,也就是本地磁盘中保存着从远程分布式文件系统传输来的文件,使用时直接访问本地的磁盘而不需要经过网络,这样一来,我们又可以把磁盘当作远程分布式文件系统的cache。
海量消息存放在远程分布式文件系统中,并实时将其传递给数据的消费方。
现在我们知道,现代计算机系统的存储体系中,每一层都充当下一层的cache,每一层的容量也比下一层少。
2 如何提高cache 命中率
我们已经了解了现代计算机系统的存储体系了,现在我们要做的,就是编写出对cache 友好的代码,提高cache 命中率,最大限度发挥cache 的作用。
首先我们要了解程序的局部性原理:
程序的局部性原理本质是在说程序访问内存“很有规律”,具体来说,有时间局部性和空间局部性两种。
-
时间局部性:
程序总是访问这块内存,也就是这块内存被频繁访问,时间局部性对cache 非常友好,因为重复访问就总能命中,而不需要访问内存。
-
空间局部性:
当程序引用一块内存时,此后也会引用相邻的内存,空间局部性也对cache 非常友好,因为当cache 不能命中内存时,通常也会把内存中相邻的数据加载到cache,这样当程序访问邻近数据时,即可命中cache。
知道了这两种局部性原理后,我们就可以根据它们来提高cache 命中率。
2.1 利用空间局部性:使用内存池
我们在计算机底层3 内存中介绍过内存分配器malloc 和内存池技术,动态申请内存通常使用malloc
,但是如果我们要申请N块内存,通过malloc
申请到的内存很可能散落在堆区的各个角落,因此其空间局部性很差。而内存池技术则是一次性先申请一大块内存,这样对cache 非常友好,因为以后我们要申请的内存都是从这块连续的内存中申请的,数据访问非常集中,空间局部性好,从而cache 的命中率更加高。
2.2 利用空间局部性:struct 结构重新布局
比如现在这里有一个链表:
struct List {
List *next;
int arr[100];
int val;
};
但是如果我们程序是这样写的:
bool find(struct List* list, int target) {
while (list) {
if (list->val == target) {
return true;
}
list = list->next;
}
return false;
}
程序很简单,就是遍历链表,查找链表中是否有目标值。但是我们发现,频繁用到的是链表中的next
和value
,根本没有使用数组,但是next
和value
却被隔开了,这可能会导致较差的空间局部性,因此我们提高cache 命中率的方法可以是把next
和value
放在一起:
struct List {
List *next;
int val;
int arr[100];
};
这样,由于next
和value
相邻,因此如果cache 中有next
指针,那么大概率也会包含value
字段,这就是空间局部性原理在优化结构体布局上的应用。
2.3 利用空间局部性:选择适合cache 的数据结构
从空间局部性上来说,数组要比链表好,因为数组存放在一片连续的内存(虚拟内存的存在导致其在物理内存上也许不一定连续)中,而链表通常会散落在各个角落,显然数组的空间局部性要更好。
但是这仅仅是在提高cache 命中率的角度来看,实际上,使用数组还是链表还是其他结构,要根据需求情况,例如如果需要频繁新增,删除节点的话,那么这个时候链表肯定要优于数组。
如果既想要链表的便利,又想要提高cache 的命中率,可以使用我们刚刚说的内存池,这样各个链表的节点就会比较紧凑,空间局部性会更加好,cache 的命中率会更加高。
2.4 利用空间局部性:遍历多维数组
在遍历多维数组时,能够提高cache 命中率的方法是先行后列遍历,因为当访问数组第一个数据1
时,cache 会把1
相邻的2
,3
,4
都一起放入cache,这个时候如果下一个遍历到的是2
,那么就直接命中cache,不需要访问内存了。这也是利用了空间局部性。
与之相对的,如果遍历顺序是先列后行,那么遍历完1
后,就是遍历5
,但是5
命中cache 失败,这个时候,不但查找内存,并且把与5
相邻的6
,7
,8
也放入cache中,但是下一个遍历到的却是9
,这样又命中失败了,cache 命中率很低,程序没有呈现出良好的空间局部性。
2.4 利用时间局部性:冷热数据分离
还是2.2 中链表的例子:
struct List {
List *next;
int val;
int arr[100];
};
我们的程序中next
和value
被频繁访问,数组的访问率很低,这个时候,我们认为next
和value
是“热数据”,数组arr
是“冷数据”,为了获得更好的时间局部性,我们可以把数组arr
放到另外一个结构体中,转而在List 中增加一个指针指向这个结构体:
struct List {
List *next;
int val;
struct Arr* arr;
};
struct Arr {
int arr[100];
};
这样一来,将冷热数据分离开,获得了更好的时间局部性,同时,List 结构体大大减小,cache 也就能够容纳更多的节点。
我们介绍了好几种利用局部性原理来提高cache 命中率的方法,但是这些都要在我们已经通过了性能分析工具确定了系统性能的瓶颈就在于cache 的命中率上,才相对做出优化,否则,其实不需要过多关心优化问题。
3 多线程性能杀手
3.1 cache 一致性问题
这个问题在 1.3 中就提到了,如果两个核心的cache 中都用了同一个变量,其中的一个CPU 对这个变量进行修改操作,那么为了保持cache 一致性,就必须将另外一个CPU 中的变量设置为无效,如果CPU 2也想修改该变量的值,那么CPU 1中的值又无效了,这样频繁维持cache 一致性导致一些情况下,多核多线程操作甚至比单核单线程操作更慢。因为维护cache 的开销以及从内存中读取数据的开销占据了主导地位。
所以,要尽量避免多线程之间共享数据。
3.2 伪共享问题
在 2 如何提高cache 命中率 中,我们了解到,如果程序要访问一块数据,放在cache 中的不只是这一块数据,还有这块数据所在的“一整块”数据,这“一整块”数据,就叫cache line
,所以,cache 和内存交互的基本单位是cache line
,这块数据的大小通常为64字节,也就是如果没有命中cache,那么就会把这块数据所在的cache line
都加载到cache 中。
但是这样在多线程编程中,有时候就会有伪共享的问题。
有这样一个结构体:
struct data {
int a;
int b;
};
接下来有两个程序,都是对变量a 和变量b 加50000000次,这两个程序的区别是第一个程序是多线程操作,两个线程一个对a 相加,一个对b 相加,第二个则是简单的单线程操作**。
按照我们以前的理解,多线程意味着充分利用多核资源,速度更快,效率更高,而且两个线程一个对a 相加,一个对b 相加,并没有像刚刚说的多线程共享同一个资源,所以,理应比单线程的相加更加快。
但是事实上并不是这样,反而是像刚刚 3.1 cache 一致性问题 说的一样,单线程反而快过多线程。这就是伪共享。
表面上看,a 和b 是两个不同的变量,但是别忘了,cache 和内存交互的基本单位是cache line
,也就是说,在加载a 的时候,很有可能把它相邻的b 也作为同一个cache line
加载进cache 里了,也就是说,它们仍然是共享的。
这就又回到了3.1 cache 一致性问题了,多线程的程序维护cache 的开销和不断从内存中读取变量的开销占据了主导,使得多线程的程序在性能和效率上比不上单线程。
这个改进也很简单,只要不让变量a 和变量b 处于同一个cache line
即可,如果该结构体中还有其他变量,那我们可以改变一下顺序,只要其他变量大于cache line
,就可以把其他字段调整到变量a 和变量b 之间:
struct data {
int a;
int b;
... // 其他变量
};
改成:
struct data {
int a;
... // 其他变量
int b;
};
如果没有其他变量了,我们也可以塞一些无关数据,比如数组,因为一般的cache line
大小为64字节,那我们就可以填充一个包含16个元素的int 数组,这样就可以让变量a 和变量b 不处于同一条cache line
:、
struct data {
int a;
int arr[16];
int b;
};
4 指令重排序与内存屏障
4.1 指令乱序执行 OoOE
事实上,CPU 并不一定会严格按照程序员编写代码的顺序执行机器指令,原因很简单:一切为了提高性能。
指令乱序执行的关键思想是,处理器会在程序中获取多条指令,并根据指令之间的依赖关系以及可用的执行单元来决定哪些指令可以立即执行,而哪些必须等待。这允许处理器在等待某些指令的结果时,继续执行其他不依赖于这些结果的指令,从而减少了执行时间。
指令乱序执行会出现在两个阶段:
- 生成机器指令阶段(编译期间的指令重排序)
- CPU 执行指令阶段(运行期间的指令乱序执行)
到目前为止,我们认为的CPU 工作过程是这样的:
- 取指
- 如果指令中的操作数已经准备就绪,如已经读取到了寄存器中,那么该指令直接进入下一步执行,但是如果所需要的操作数还未就绪,比如还没有从内存中读取到寄存器中,那么这个时候CPU 需要等待(访问内存是非常慢的)。
- 执行
- 回调
可以看到,这是一种同步的执行方式,我们说过,如果要优化性能,最大限度利用CPU,就可以把同步改为异步:
- 取指
- 将指令放到队列中,并读取指令依赖的操作数。
- 指令在队列中等待操作数就绪,对于就绪的指令,就可以提前执行
- 执行
- 只有当靠前的指令执行完毕后,才回写当前指令的结果,确保执行结果是按照指令原来的顺序生效的
当然,在第3步中,只有前后两条指令没有任何依赖关系的时候,CPU 才可以提前执行后面的指令。
同时,如果仅在单线程下编程是不用关心指令乱序执行,只有在除自身以外的其他核心观察该核心时,才需要关心出现乱序的情况。
那要怎样解决指令乱序执行的问题呢?
4.2 Acquire-Release 语义
"Acquire-Release" 语义是一种内存顺序约束,通常用于多线程编程或并发编程中,以确保共享数据的一致性和可见性。这一约束涉及到两种操作:acquire 操作和 release 操作。
- Acquire 操作:在多线程环境中,当一个线程执行了一个 acquire 操作时,它确保在 acquire 操作之前的所有读取和写入操作都不会被重排序到 acquire 操作之后。这意味着acquire 操作之前的所有读取和写入操作的结果在内存中都是可见的,并且不会被其他线程所影响。
- Release 操作:当一个线程执行了一个 release 操作时,它确保在 release 操作之后的所有读取和写入操作都不会被重排序到 release 操作之前。这确保了 release 操作之后的所有操作对其他线程都是可见的,并且不会被其他线程所影响。
一些处理器架构(如 x86 和 x86-64)在硬件级别提供了类似于 Acquire-Release 语义的内存模型,也就是自带 Acquire-Release 语义,这种就是强内存模型。
与之相对,在Alpha,ARMv7等CPU 上,没有自带Acquire-Release 语义,这种也就是弱内存模型。
4.3 有锁编程和无锁编程
我们上面说的指令重排序,其实是只有无锁编程才需要关心,当共享变量在没有锁的保护下,被多个线程使用时会暴露这个问题。在有锁的情况下,锁自动帮我们处理好了指令重排序的问题,锁确保了临界区的代码不会被其他线程使用。
关于有锁编程,我们在计算机底层2 程序在运行时发生了什么 中 2.4 线程安全中有说明。
而无锁编程,就是在共享资源被其他线程使用时,不是像有锁编程那样去等待,而是转去执行别的操作。
可以看出,无锁编程对实时性要求比较高的系统来说比较重要,但是这需要处理很多复杂的资源竞争问题,相比有锁编程,编程上也更加复杂。
因此,在大部分情况下,简单的有锁编程,仍然是程序员的首选。
指令重排序有些复杂,但是总结下来是这几点:
- 为了性能,CPU 不一定严格按照程序员编写代码的顺序来执行指令
- 如果是单线程编程,那么并不需要关心指令重排序问题
- 内存屏障的目的是确保在某个核心执行指令的顺序在其他核心看来与代码的顺序是一致的
- 如果我们的编程场景不涉及多线程无锁编程,那么我们不需要关心指令重排序的问题
5 总结
从冯·诺依曼的架构看来,计算机并不需要cache,CPU 直接与内存交互,但是随着CPU 的快速发展,与内存的读取速度差距越来越大,cache 就是为了减小CPU 和内存读取速度之间的差异而诞生的,如今的CPU 也有很大一部分空间留给了cache,同时多核多线程再加上cache,也给软件设计上带来了一些挑战。
有了cache,为了再提高性能,提高cache 的命中率,我们结合程序局部性原理,希望编写出对cache 友好的代码,但是,这些都要在我们已经通过了性能分析工具确定了系统性能的瓶颈就在于cache 的命中率上,才相对做出优化,否则,其实不需要过多关心优化问题。
而有时候,为了性能,CPU 不一定严格按照程序员编写代码的顺序来执行指令,这就是 指令重排序,但除非我们要进行多线程无锁编程,否则,其实我们也不需要过多考虑。
5 下一篇文章
7 参考资料
- 陆小风. 计算机底层的秘密. 电子工业出版社, 2023.
- 计算机底层的秘密 gitbook