zoukankan      html  css  js  c++  java
  • 计算机组成原理——原理篇 储存

    存储器层次结构全景

    理解存储器的层次结构

    在有计算机之前,我们通常把信息和数据存储在书、文件这样的物理介质里面。有了计算机之后,我们通常把数据存储在计算机的存储器里面。而存储器系统是一个通过各种不同的方法和设备,一层一层组合起来的系统。下面,我们把计算机的存储器层次结构和我们日常生活里处理信息、阅读书籍做个对照,好让你更容易理解、记忆存储器的层次结构。

    我们常常把 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 使用的指令和数据。

    这里的指令缓存和数据缓存,其实就是来自于哈佛架构。L1 的 Cache 往往就嵌在 CPU 核心的内部。

    L2 的 Cache 同样是每个 CPU 核心都有的,不过它往往不在 CPU 核心的内部。所以,L2 Cache 的访问速度会比 L1 稍微慢一些。而 L3 Cache,则通常是多个 CPU 核心共用的,尺寸会更大一些,访问速度自然也就更慢一些。

    你可以把 CPU 中的 L1 Cache 理解为我们的短期记忆,把 L2/L3 Cache 理解成长期记忆,把内存当成我们拥有的书架或者书桌。 当我们自己记忆中没有资料的时候,可以从书桌或者书架上拿书来翻阅。这个过程中就相当于,数据从内存中加载到 CPU 的寄存器和 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,硬盘)这些被称为硬盘的外部存储设备,就是公共图书馆。于是,我们就可以去家附近的图书馆借书了。图书馆有更多的空间(存储空间)和更多的书(数据)。

    你应该也在自己的个人电脑上用过 SSD 硬盘。过去几年,SSD 这种基于 NAND 芯片的高速硬盘,价格已经大幅度下降。

    而 HDD 硬盘则是一种完全符合“磁盘”这个名字的传统硬件。“磁盘”的硬件结构,决定了它的访问速度受限于它的物理结构,是最慢的。

    可以对照下面这幅图了解一下,对存储器层次之间的作用和关联有个大致印象就可以了。

    从 Cache、内存,到 SSD 和 HDD 硬盘,一台现代计算机中,就用上了所有这些存储器设备。其中,容量越小的设备速度越快,而且,CPU 并不是直接和每一种存储器设备打交道,而是每一种存储器设备,只和它相邻的存储设备打交道。比如,CPU Cache 是从内存里加载而来的,或者需要写回内存,并不会直接写回数据到硬盘,也不会直接从硬盘加载数据到 CPU Cache 中,而是先加载到内存,再从内存加载到 Cache 中。

    这样,各个存储器只和相邻的一层存储器打交道,并且随着一层层向下,存储器的容量逐层增大,访问速度逐层变慢,而单位存储成本也逐层下降,也就构成了我们日常所说的存储器层次结构。

    使用存储器的时候,该如何权衡价格和性能?

    存储器在不同层级之间的性能差异和价格差异,都至少在一个数量级以上。L1 Cache 的访问延时是 1 纳秒(ns),而内存就已经是 100 纳秒了。在价格上,这两者也差出了 400 倍。

    我这里放了一张各种存储器成本的对比表格,你可以看看。你也可以在点击这个链接,通过拖拉,查看 1990~2020 年随着硬件设备的进展,访问延时的变化。

    因为这个价格和性能的差异,你会看到,我们实际在进行电脑硬件配置的时候,会去组合配置各种存储设备。

    我们可以找一台现在主流的笔记本电脑来看看,比如,一款入门级的惠普战 66 的笔记本电脑。今天在京东上的价格是 4999 人民币。它的配置是下面这样的。

    1. Intle i5-8265U 的 CPU(这是一块 4 核的 CPU)
    • 这块 CPU 每个核有 32KB,一共 128KB 的 L1 指令 Cache。
    • 同样,每个核还有 32KB,一共 128KB 的 L1 数据 Cache,指令 Cache 和数据 Cache 都是采用 8 路组相连的放置策略。
    • 每个核有 256KB,一共 1MB 的 L2 Cache。L2 Cache 是用 4 路组相连的放置策略。
    • 最后还有一块多个核心共用的 12MB 的 L3 Cache,采用的是 12 路组相连的放置策略。
    1. 8GB 的内存
    2. 一块 128G 的 SSD 硬盘
    3. 一块 1T 的 HDD 硬盘

    你可以看到,在一台实际的计算机里面,越是速度快的设备,容量就越小。这里一共十多兆的 Cache,成本只是几十美元。而 8GB 的内存、128G 的 SSD 以及 1T 的 HDD,大概零售价格加在一起,也就和我们的高速缓存的价格差不多。

    总结

    我们常常把 CPU 比喻成高速运转的大脑,那么和大脑同步的寄存器(Register),就存放着我们当下正在思考和处理的数据。而 L1-L3 的 CPU Cache,好比存放在我们大脑中的短期到长期的记忆。我们需要小小花费一点时间,就能调取并进行处理。

    我们自己的书桌书架就好比计算机的内存,能放下更多的书也就是数据,但是找起来和看起来就要慢上不少。而图书馆更像硬盘这个外存,能够放下更多的数据,找起来也更费时间。从寄存器、CPU Cache,到内存、硬盘,这样一层层下来的存储器,速度越来越慢,空间越来越大,价格也越来越便宜。

    这三个“越来越”的特性,使得我们在组装计算机的时候,要组合使用各种存储设备。越是快且贵的设备,实际在一台计算机里面的存储空间往往就越小。而越是慢且便宜的设备,在实际组装的计算机里面的存储空间就会越大。

    局部性原理

    平时进行服务端软件开发的时候,我们通常会把数据存储在数据库里。而服务端系统遇到的第一个性能瓶颈,往往就发生在访问数据库的时候。这个时候,大部分工程师和架构师会拿出一种叫作“缓存”的武器,通过使用 Redis 或者 Memcache 这样的开源软件,在数据库前面提供一层缓存的数据,来缓解数据库面临的压力,提升服务端的程序性能。

    那么,不知道你有没有想过,这种添加缓存的策略一定是有效的吗?

    或者说,这种策略在什么情况下是有效的呢?

    如果从理论角度去分析,添加缓存一定是我们的最佳策略么?

    进一步地,如果我们对于访问性能的要求非常高,希望数据在 1 毫秒,乃至 100 微妙内完成处理,我们还能用这个添加缓存的策略么?

    理解局部性原理

    我们先来回顾这张不同存储器的性能和价目表。可以看到,不同的存储器设备之间,访问速度、价格和容量都有几十乃至上千倍的差异。

    Intel 8265U 的 CPU 为例,它的 L1 Cache 只有 256K,L2 Cache 有个 1MB,L3 Cache 有 12MB。一共 13MB 的存储空间,如果按照 7 美元 /1MB 的价格计算,就要 91 美元。

    我们的内存有 8GB,容量是 CPU Cache 的 600 多倍,按照表上的价格差不多就是 120 美元。如果按照今天京东上的价格,恐怕不到 40 美元。128G 的 SSD 和 1T 的 HDD,现在的价格加起来也不会超过 100 美元。虽然容量是内存的 16 倍乃至 128 倍,但是它们的访问速度却不到内存的 1/1000。

    性能和价格的巨大差异,给我们工程师带来了一个挑战:我们能不能既享受 CPU Cache 的速度,又享受内存、硬盘巨大的容量和低廉的价格呢?

    想要同时享受到这三点,前辈们已经探索出了答案,那就是,存储器中数据的局部性原理(Principle of Locality)。我们可以利用这个局部性原理,来制定管理和访问数据的策略。这个局部性原理包括时间局部性(temporal locality)和空间局部性(spatial locality)这两种策略。

    时间局部性

    如果一个数据被访问了,那么它在短时间内还会被再次访问。这么看这个策略有点奇怪是吧?我用一个简单的例子给你解释下,你一下就能明白了。

    比如说,《哈利波特与魔法石》这本小说,我今天读了一会儿,没读完,明天还会继续读。同理,在一个电子商务型系统中,如果一个用户打开了 App,看到了首屏。我们推断他应该很快还会再次访问网站的其他内容或者页面,我们就将这个用户的个人信息,从存储在硬盘的数据库读取到内存的缓存中来。这利用的就是时间局部性。

    空间局部性

    如果一个数据被访问了,那么和它相邻的数据也很快会被访问。

    我们还拿刚才读《哈利波特与魔法石》的例子来说。我读完了这本书之后,感觉这书不错,所以就会借阅整套“哈利波特”。这就好比我们的程序,在访问了数组的首项之后,多半会循环访问它的下一项。因为,在存储数据的时候,数组内的多项数据会存储在相邻的位置。这就好比图书馆会把“哈利波特”系列放在一个书架上,摆放在一起,加载的时候,也会一并加载。我们去图书馆借书,往往会一次性把 7 本都借回来。

    有了时间局部性和空间局部性,我们不用再把所有数据都放在内存里,也不用都放在 HDD 硬盘上,而是把访问次数多的数据,放在贵但是快一点的存储器里,把访问次数少的数据,放在慢但是大一点的存储器里。这样组合使用内存、SSD 硬盘以及 HDD 硬盘,使得我们可以用最低的成本提供实际所需要的数据存储、管理和访问的需求。

    如何花最少的钱,装下亚马逊的所有商品?

    了解了局部性原理,下面用一些真实世界中的数据举个例子,带你做个小小的思维体操,来看一看通过局部性原理,利用不同层次存储器的组合,究竟会有什么样的好处。

    我们现在要提供一个亚马逊这样的电商网站。我们假设里面有 6 亿件商品,如果每件商品需要 4MB 的存储空间(考虑到商品图片的话,4MB 已经是一个相对较小的估计了),那么一共需要 2400TB( = 6 亿 × 4MB)的数据存储。

    如果我们把数据都放在内存里面,那就需要 3600 万美元( = 2400TB/1MB × 0.015 美元 = 3600 万美元)。但是,这 6 亿件商品中,不是每一件商品都会被经常访问。比如说,有 Kindle 电子书这样的热销商品,也一定有基本无人问津的商品,比如偏门的缅甸语词典。

    如果我们只在内存里放前 1% 的热门商品,也就是 600 万件热门商品,而把剩下的商品,放在机械式的 HDD 硬盘上,那么,我们需要的存储成本就下降到 45.6 万美元( = 3600 万美元 × 1% + 2400TB / 1MB × 0.00004 美元),是原来成本的 1.3% 左右。

    这里我们用的就是时间局部性。我们把有用户访问过的数据,加载到内存中,一旦内存里面放不下了,我们就把最长时间没有在内存中被访问过的数据,从内存中移走,这个其实就是我们常用的LRU(Least Recently Used)缓存算法。热门商品被访问得多,就会始终被保留在内存里,而冷门商品被访问得少,就只存放在 HDD 硬盘上,数据的读取也都是直接访问硬盘。即使加载到内存中,也会很快被移除。越是热门的商品,越容易在内存中找到,也就更好地利用了内存的随机访问性能。

    那么,只放 600 万件商品真的可以满足我们实际的线上服务请求吗?这个就要看 LRU 缓存策略的缓存命中率(Hit Rate/Hit Ratio)了,也就是访问的数据中,可以在我们设置的内存缓存中找到的,占有多大比例。

    内存的随机访问请求需要 100ns。这也就意味着,在极限情况下,内存可以支持 1000 万次随机访问。我们用了 24TB 内存,如果 8G 一条的话,意味着有 3000 条内存,可以支持每秒 300 亿次( = 24TB/8GB × 1s/100ns)访问。以亚马逊 2017 年 3 亿的用户数来看,我们估算每天的活跃用户为 1 亿,这 1 亿用户每人平均会访问 100 个商品,那么平均每秒访问的商品数量,就是 12 万次。

    但是如果数据没有命中内存,那么对应的数据请求就要访问到 HDD 磁盘了。刚才的图表中,我写了,一块 HDD 硬盘只能支撑每秒 100 次的随机访问,2400TB 的数据,以 4TB 一块磁盘来计算,有 600 块磁盘,也就是能支撑每秒 6 万次( = 2400TB/4TB × 1s/10ms )的随机访问。

    这就意味着,所有的商品访问请求,都直接到了 HDD 磁盘,HDD 磁盘支撑不了这样的压力。我们至少要 50% 的缓存命中率,HDD 磁盘才能支撑对应的访问次数。不然的话,我们要么选择添加更多数量的 HDD 硬盘,做到每秒 12 万次的随机访问,或者将 HDD 替换成 SSD 硬盘,让单个硬盘可以支持更多的随机访问请求。

    当然,这里我们只是一个简单的估算。在实际的应用程序中,查看一个商品的数据可能意味着不止一次的随机内存或者随机磁盘的访问。对应的数据存储空间也不止要考虑数据,还需要考虑维护数据结构的空间,而缓存的命中率和访问请求也要考虑均值和峰值的问题。

    通过这个估算过程,你需要理解,如何进行存储器的硬件规划。你需要考虑硬件的成本、访问的数据量以及访问的数据分布,然后根据这些数据的估算,来组合不同的存储器,能用尽可能低的成本支撑所需要的服务器压力。而当你用上了数据访问的局部性原理,组合起了多种存储器,你也就理解了怎么基于存储器层次结构,来进行硬件规划了。

    总结

    在实际的计算机日常的开发和应用中,我们对于数据的访问总是会存在一定的局部性。有时候,这个局部性是时间局部性,就是我们最近访问过的数据还会被反复访问。有时候,这个局部性是空间局部性,就是我们最近访问过数据附近的数据很快会被访问到。

    而局部性的存在,使得我们可以在应用开发中使用缓存这个有利的武器。比如,通过将热点数据加载并保留在速度更快的存储设备里面,我们可以用更低的成本来支撑服务器。

    通过亚马逊这个例子,我们可以看到,我们可以通过快速估算的方式,来判断这个添加缓存的策略是否能够满足我们的需求,以及在估算的服务器负载的情况下,需要规划多少硬件设备。这个“估算 + 规划”的能力,是每一个期望成长为架构师的工程师,必须掌握的能力。

    CPU Cache

    我们先来看一个 3 行的小程序。可以猜一猜,这个程序里的循环 1 和循环 2,运行所花费的时间会差多少?

    int[] arr = new int[64 * 1024 * 1024];
     
     
    // 循环 1
    for (int i = 0; i < arr.length; i++) arr[i] *= 3;
     
     
    // 循环 2
    for (int i = 0; i < arr.length; i += 16) arr[i] *= 3
    

    在这段 Java 程序中,我们首先构造了一个 64×1024×1024 大小的整型数组。在循环 1 里,我们遍历整个数组,将数组中每一项的值变成了原来的 3 倍;在循环 2 里,我们每隔 16 个索引访问一个数组元素,将这一项的值变成了原来的 3 倍。

    按道理来说,循环 2 只访问循环 1 中 1/16 的数组元素,只进行了循环 1 中 1/16 的乘法计算,那循环 2 花费的时间应该是循环 1 的 1/16 左右。但是实际上,循环 1 在我的电脑上运行需要 50 毫秒,循环 2 只需要 46 毫秒。这两个循环花费时间之差在 15% 之内。

    我们为什么需要高速缓存?

    按照摩尔定律,CPU 的访问速度每 18 个月便会翻一番,相当于每年增长 60%。内存的访问速度虽然也在不断增长,却远没有这么快,每年只增长 7% 左右。而这两个增长速度的差异,使得 CPU 性能和内存访问性能的差距不断拉大。到今天来看,一次内存的访问,大约需要 120 个 CPU Cycle,这也意味着,在今天,CPU 和内存的访问速度已经有了 120 倍的差距。

    如果拿我们现实生活来打个比方的话,CPU 的速度好比风驰电掣的高铁,每小时 350 公里,然而,它却只能等着旁边腿脚不太灵便的老太太,也就是内存,以每小时 3 公里的速度缓慢步行。因为 CPU 需要执行的指令、需要访问的数据,都在这个速度不到自己 1% 的内存里。

    为了弥补两者之间的性能差异,我们能真实地把 CPU 的性能提升用起来,而不是让它在那儿空转,我们在现代 CPU 中引入了高速缓存。

    从 CPU Cache 被加入到现有的 CPU 里开始,内存中的指令、数据,会被加载到 L1-L3 Cache 中,而不是直接由 CPU 访问内存去拿。在 95% 的情况下,CPU 都只需要访问 L1-L3 Cache,从里面读取指令和数据,而无需访问内存。要注意的是,这里我们说的 CPU Cache 或者 L1/L3 Cache,不是一个单纯的、概念上的缓存(比如之前我们说的拿内存作为硬盘的缓存),而是指特定的由 SRAM 组成的物理芯片。

    这里是一张 Intel CPU 的放大照片。这里面大片的长方形芯片,就是这个 CPU 使用的 20MB 的 L3 Cache。

    在一开始的程序里,运行程序的时间主要花在了将对应的数据从内存中读取出来,加载到 CPU Cache 里。CPU 从内存中读取数据到 CPU Cache 的过程中,是一小块一小块来读取数据的,而不是按照单个数组元素来读取数据的。这样一小块一小块的数据,在 CPU Cache 里面,我们把它叫作 Cache Line(缓存块)。

    在我们日常使用的 Intel 服务器或者 PC 里,Cache Line 的大小通常是 64 字节。而在上面的循环 2 里面,我们每隔 16 个整型数计算一次,16 个整型数正好是 64 个字节。于是,循环 1 和循环 2,需要把同样数量的 Cache Line 数据从内存中读取到 CPU Cache 中,最终两个程序花费的时间就差别不大了。

    知道了为什么需要 CPU Cache,接下来,我们就来看一看,CPU 究竟是如何访问 CPU Cache 的,以及 CPU Cache 是如何组织数据,使得 CPU 可以找到自己想要访问的数据的。因为 Cache 作为“缓存”的意思,在很多别的存储设备里面都会用到。为了避免你混淆,在表示抽象的“缓存“概念时,用中文的“缓存”;如果是 CPU Cache,我会用“高速缓存“或者英文的“Cache”,来表示。

    Cache 的数据结构和读取过程是什么样的?

    现代 CPU 进行数据读取的时候,无论数据是否已经存储在 Cache 中,CPU 始终会首先访问 Cache。只有当 CPU 在 Cache 中找不到数据的时候,才会去访问内存,并将读取到的数据写入 Cache 之中。当时间局部性原理起作用后,这个最近刚刚被访问的数据,会很快再次被访问。而 Cache 的访问速度远远快于内存,这样,CPU 花在等待内存访问上的时间就大大变短了。

    这样的访问机制,和我们自己在开发应用系统的时候,“使用内存作为硬盘的缓存”的逻辑是一样的。在各类基准测试(Benchmark)和实际应用场景中,CPU Cache 的命中率通常能达到 95% 以上。

    问题来了,CPU 如何知道要访问的内存数据,存储在 Cache 的哪个位置呢?接下来,我就从最基本的直接映射 Cache(Direct Mapped Cache)说起,带你来看整个 Cache 的数据结构和访问逻辑。

    在开头的 3 行小程序里我说过,CPU 访问内存数据,是一小块一小块数据来读取的。对于读取内存中的数据,我们首先拿到的是数据所在的内存块(Block)的地址。而直接映射 Cache 采用的策略,就是确保任何一个内存块的地址,始终映射到一个固定的 CPU Cache 地址(Cache Line)。而这个映射关系,通常用 mod 运算(求余运算)来实现。下面我举个例子帮你理解一下。

    比如说,我们的主内存被分成 0~31 号这样 32 个块。我们一共有 8 个缓存块。用户想要访问第 21 号内存块。如果 21 号内存块内容在缓存块中的话,它一定在 5 号缓存块(21 mod 8 = 5)中。

    实际计算中,有一个小小的技巧,通常我们会把缓存块的数量设置成 2 的 N 次方。这样在计算取模的时候,可以直接取地址的低 N 位,也就是二进制里面的后几位。比如这里的 8 个缓存块,就是 2 的 3 次方。那么,在对 21 取模的时候,可以对 21 的 2 进制表示 10101 取地址的低三位,也就是 101,对应的 5,就是对应的缓存块地址。

    取 Block 地址的低位,就能得到对应的 Cache Line 地址,除了 21 号内存块外,13 号、5 号等很多内存块的数据,都对应着 5 号缓存块中。既然如此,假如现在 CPU 想要读取 21 号内存块,在读取到 5 号缓存块的时候,我们怎么知道里面的数据,究竟是不是 21 号对应的数据呢?

    这个时候,在对应的缓存块中,我们会存储一个组标记(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 个步骤:

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

    如果在 2、3 这两个步骤中,CPU 发现,Cache 中的数据并不是要访问的内存地址的数据,那 CPU 就会访问内存,并把对应的 Block Data 更新到 Cache Line 中,同时更新对应的有效位和组标记的数据。

    好了,讲到这里,相信你明白现代 CPU,是如何通过直接映射 Cache,来定位一个内存访问地址在 Cache 中的位置了。其实,除了直接映射 Cache 之外,我们常见的缓存放置策略还有全相连 Cache(Fully Associative Cache)、组相连 Cache(Set Associative Cache)。这几种策略的数据结构都是相似的,理解了最简单的直接映射 Cache,其他的策略你很容易就能理解了。

    减少 4 毫秒,公司挣了多少钱?

    刚才我花了很多篇幅,讲了 CPU 和内存之间的性能差异,以及我们如何通过 CPU Cache 来尽可能解决这两者之间的性能鸿沟。你可能要问了,这样做的意义和价值究竟是什么?毕竟,一次内存的访问,只不过需要 100 纳秒而已。1 秒钟时间内,足有 1000 万个 100 纳秒。别着急,我们先来看一个故事。

    2008 年,一家叫作 Spread Networks 的通信公司花费 3 亿美元,做了一个光缆建设项目。目标是建设一条从芝加哥到新泽西,总长 1331 公里的光缆线路。建设这条线路的目的,其实是为了将两地之间原有的网络访问延时,从 17 毫秒降低到 13 毫秒。

    你可能会说,仅仅缩短了 4 毫秒时间啊,却花费 3 个亿,真的值吗?为这 4 毫秒时间买单的,其实是一批高频交易公司。它们以 5 年 1400 万美元的价格,使用这条线路。利用这短短的 4 毫秒的时间优势,这些公司通过高性能的计算机程序,在芝加哥和新泽西两地的交易所进行高频套利,以获得每年以 10 亿美元计的利润。现在你还觉得这个不值得吗?

    其实,只要 350 微秒的差异,就足够高频交易公司用来进行无风险套利了。而 350 微秒,如果用来进行 100 纳秒一次的内存访问,大约只够进行 3500 次。而引入 CPU Cache 之后,我们可以进行的数据访问次数,提升了数十倍,使得各种交易策略成为可能。

    总结

    很多时候,程序的性能瓶颈,来自使用 DRAM 芯片的内存访问速度。

    根据摩尔定律,自上世纪 80 年代以来,CPU 和内存的性能鸿沟越拉越大。于是,现代 CPU 的设计者们,直接在 CPU 中嵌入了使用更高性能的 SRAM 芯片的 Cache,来弥补这一性能差异。通过巧妙地将内存地址,拆分成“索引 + 组标记 + 偏移量”的方式,使得我们可以将很大的内存地址,映射到很小的 CPU Cache 地址里。而 CPU Cache 带来的毫秒乃至微秒级别的性能差异,又能带来巨大的商业利益,十多年前的高频交易行业就是最好的例子。

    高速缓存

    一个面试题:“volatile 这个关键字有什么作用?

    这里面最常见的理解错误有两个,

    一个是把 volatile 当成一种锁机制,认为给变量加上了 volatile,就好像是给函数加了 sychronized 关键字一样,不同的线程对于特定变量的访问会去加锁;

    另一个是把 volatile 当成一种原子化的操作机制,认为加了 volatile 之后,对于一个变量的自增的操作就会变成原子性的了。

    // 一种错误的理解,是把 volatile 关键词,当成是一个锁,可以把 long/double 这样的数的操作自动加锁
    private volatile long synchronizedValue = 0;
     
    // 另一种错误的理解,是把 volatile 关键词,当成可以让整数自增的操作也变成原子性的
    private volatile int atomicInt = 0;
    amoticInt++;
    

    事实上,这两种理解都是完全错误的。很多工程师容易把 volatile 关键字,当成和锁或者数据数据原子性相关的知识点。而实际上,volatile 关键字的最核心知识点,要关系到 Java 内存模型(JMM,Java Memory Model)上。

    虽然 JMM 只是 Java 虚拟机这个进程级虚拟机里的一个内存模型,但是这个内存模型,和计算机组成里的 CPU、高速缓存和主内存组合在一起的硬件体系非常相似。理解了 JMM,可以让你很容易理解计算机组成里 CPU、高速缓存和主内存之间的关系。

    “隐身”的变量

    我们先来一起看一段 Java 程序。这是一段经典的 volatile 代码,来自知名的 Java 开发者网站dzone.com,后续我们会修改这段代码来进行各种小实验。

    public class VolatileTest {
        private static volatile int COUNTER = 0;
     
        public static void main(String[] args) {
            new ChangeListener().start();
            new ChangeMaker().start();
        }
     
        static class ChangeListener extends Thread {
            @Override
            public void run() {
                int threadValue = COUNTER;
                while ( threadValue < 5){
                    if( threadValue!= COUNTER){
                        System.out.println("Got Change for COUNTER : " + COUNTER + "");
                        threadValue= COUNTER;
                    }
                }
            }
        }
     
        static class ChangeMaker extends Thread{
            @Override
            public void run() {
                int threadValue = COUNTER;
                while (COUNTER <5){
                    System.out.println("Incrementing COUNTER to : " + (threadValue+1) + "");
                    COUNTER = ++threadValue;
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) { e.printStackTrace(); }
                }
            }
        }
    }
    

    我们先来看看这个程序做了什么。在这个程序里,我们先定义了一个 volatileint 类型的变量,COUNTER。

    然后,我们分别启动了两个单独的线程,一个线程我们叫 ChangeListener。另一个线程,我们叫 ChangeMaker

    ChangeListener 这个线程运行的任务很简单。它先取到 COUNTER 当前的值,然后一直监听着这个 COUNTER 的值。一旦 COUNTER 的值发生了变化,就把新的值通过 println 打印出来。直到 COUNTER 的值达到 5 为止。这个监听的过程,通过一个永不停歇的 while 循环的忙等待来实现。

    ChangeMaker 这个线程运行的任务同样很简单。它同样是取到 COUNTER 的值,在 COUNTER 小于 5 的时候,每隔 500 毫秒,就让 COUNTER 自增 1。在自增之前,通过 println 方法把自增后的值打印出来。

    最后,在 main 函数里,我们分别启动这两个线程,来看一看这个程序的执行情况。程序的输出结果并不让人意外。ChangeMaker 函数会一次一次将 COUNTER 从 0 增加到 5。因为这个自增是每 500 毫秒一次,而 ChangeListener 去监听 COUNTER 是忙等待的,所以每一次自增都会被 ChangeListener 监听到,然后对应的结果就会被打印出来。

    Incrementing COUNTER to : 1
    Got Change for COUNTER : 1
    Incrementing COUNTER to : 2
    Got Change for COUNTER : 2
    Incrementing COUNTER to : 3
    Got Change for COUNTER : 3
    Incrementing COUNTER to : 4
    Got Change for COUNTER : 4
    Incrementing COUNTER to : 5
    Got Change for COUNTER : 5
    

    这个时候,我们就可以来做一个很有意思的实验。如果我们把上面的程序小小地修改一行代码,把我们定义 COUNTER 这个变量的时候,设置的 volatile 关键字给去掉,会发生什么事情呢?

    private static int COUNTER = 0;
    

    没错,你会发现,我们的 ChangeMaker 还是能正常工作的,每隔 500ms 仍然能够对 COUNTER 自增 1。但是,奇怪的事情在 ChangeListener 上发生了,我们的 ChangeListener 不再工作了。在 ChangeListener 眼里,它似乎一直觉得 COUNTER 的值还是一开始的 0。似乎 COUNTER 的变化,对于我们的 ChangeListener 彻底“隐身”了。

    Incrementing COUNTER to : 1
    Incrementing COUNTER to : 2
    Incrementing COUNTER to : 3
    Incrementing COUNTER to : 4
    Incrementing COUNTER to : 5
    

    这个有意思的小程序还没有结束,我们可以再对程序做一些小小的修改。我们不再让 ChangeListener 进行完全的忙等待,而是在 while 循环里面,小小地等待上 5 毫秒,看看会发生什么情况。

    static class ChangeListener extends Thread {
        @Override
        public void run() {
            int threadValue = COUNTER;
            while ( threadValue < 5){
                if( threadValue!= COUNTER){
                    System.out.println("Sleep 5ms, Got Change for COUNTER : " + COUNTER + "");
                    threadValue= COUNTER;
                }
                try {
                    Thread.sleep(5);
                } catch (InterruptedException e) { e.printStackTrace(); }
            }
        }
    }
    

    又一个令人惊奇的现象要发生了。虽然我们的 COUNTER 变量,仍然没有设置 volatile 这个关键字,但是我们的 ChangeListener 似乎“睡醒了”。在通过 Thread.sleep(5) 在每个循环里“睡上“5 毫秒之后,ChangeListener 又能够正常取到 COUNTER 的值了。

    Incrementing COUNTER to : 1
    Sleep 5ms, Got Change for COUNTER : 1
    Incrementing COUNTER to : 2
    Sleep 5ms, Got Change for COUNTER : 2
    Incrementing COUNTER to : 3
    Sleep 5ms, Got Change for COUNTER : 3
    Incrementing COUNTER to : 4
    Sleep 5ms, Got Change for COUNTER : 4
    Incrementing COUNTER to : 5
    Sleep 5ms, Got Change for COUNTER : 5
    

    这些有意思的现象,其实来自于我们的 Java 内存模型以及关键字 volatile 的含义。volatile 关键字究竟代表什么含义呢?它会确保我们对于这个变量的读取和写入,都一定会同步到主内存里,而不是从 Cache 里面读取。该怎么理解这个解释呢?我们通过刚才的例子来进行分析。

    刚刚第一个使用了 volatile 关键字的例子里,因为所有数据的读和写都来自主内存。那么自然地,我们的 ChangeMakerChangeListener 之间,看到的 COUNTER 值就是一样的。

    到了第二段进行小小修改的时候,我们去掉了 volatile 关键字。这个时候,ChangeListener 又是一个忙等待的循环,它尝试不停地获取 COUNTER 的值,这样就会从当前线程的“Cache”里面获取。于是,这个线程就没有时间从主内存里面同步更新后的 COUNTER 值。这样,它就一直卡死在 COUNTER=0 的死循环上了。

    而到了我们再次修改的第三段代码里面,虽然还是没有使用 volatile 关键字,但是短短 5ms 的 Thead.Sleep 给了这个线程喘息之机。既然这个线程没有这么忙了,它也就有机会把最新的数据从主内存同步到自己的高速缓存里面了。于是,ChangeListener 在下一次查看 COUNTER 值的时候,就能看到 ChangeMaker 造成的变化了。

    虽然 Java 内存模型是一个隔离了硬件实现的虚拟机内的抽象模型,但是它给了我们一个很好的“缓存同步”问题的示例。也就是说,如果我们的数据,在不同的线程或者 CPU 核里面去更新,因为不同的线程或 CPU 核有着自己各自的缓存,很有可能在 A 线程的更新,到 B 线程里面是看不见的。

    CPU 高速缓存的写入

    事实上,我们可以把 Java 内存模型和计算机组成里的 CPU 结构对照起来看。

    我们现在用的 Intel CPU,通常都是多核的的。每一个 CPU 核里面,都有独立属于自己的 L1、L2 的 Cache,然后再有多个 CPU 核共用的 L3 的 Cache、主内存。

    因为 CPU Cache 的访问速度要比主内存快很多,而在 CPU Cache 里面,L1/L2 的 Cache 也要比 L3 的 Cache 快。所以,上一讲我们可以看到,CPU 始终都是尽可能地从 CPU Cache 中去获取数据,而不是每一次都要从主内存里面去读取数据。

    这个层级结构,就好像我们在 Java 内存模型里面,每一个线程都有属于自己的线程栈。线程在读取 COUNTER 的数据的时候,其实是从本地的线程栈的 Cache 副本里面读取数据,而不是从主内存里面读取数据。如果我们对于数据仅仅只是读,问题还不大。

    但是,对于数据,我们不光要读,还要去写入修改。这个时候,有两个问题来了。

    第一个问题是,写入 Cache 的性能也比写入主内存要快,那我们写入的数据,到底应该写到 Cache 里还是主内存呢?如果我们直接写入到主内存里,Cache 里的数据是否会失效呢?为了解决这些疑问,下面我要给你介绍两种写入策略。

    写直达(Write-Through)

    最简单的一种写入策略,叫作写直达(Write-Through)。在这个策略里,每一次数据都要写入到主内存里面。在写直达的策略里面,写入前,我们会先去判断数据是否已经在 Cache 里面了。如果数据已经在 Cache 里面了,我们先把数据写入更新到 Cache 里面,再写入到主内存里面;如果数据不在 Cache 里,我们就只更新主内存。

    写直达的这个策略很直观,但是问题也很明显,那就是这个策略很慢。无论数据是不是在 Cache 里面,我们都需要把数据写到主内存里面。这个方式就有点儿像我们上面用 volatile 关键字,始终都要把数据同步到主内存里面。

    写回(Write-Back)

    这个时候,我们就想了,既然我们去读数据也是默认从 Cache 里面加载,能否不用把所有的写入都同步到主内存里呢?只写入 CPU Cache 里面是不是可以?

    当然是可以的。在 CPU Cache 的写入策略里,还有一种策略就叫作写回(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 标记成脏的就好了。

    在用了写回这个策略之后,我们在加载内存数据到 Cache 里面的时候,也要多出一步同步脏 Cache 的动作。如果加载内存里面的数据到 Cache 的时候,发现 Cache Block 里面有脏标记,我们也要先把 Cache Block 里的数据写回到主内存,才能加载数据覆盖掉 Cache。

    可以看到,在写回这个策略里,如果我们大量的操作,都能够命中缓存。那么大部分时间里,我们都不需要读写主内存,自然性能会比写直达的效果好很多。

    然而,无论是写回还是写直达,其实都还没有解决我们在上面 volatile 程序示例中遇到的问题,也就是多个线程,或者是多个 CPU 核的缓存一致性的问题。这也就是我们在写入修改缓存后,需要解决的第二个问题。

    要解决这个问题,我们需要引入一个新的方法,叫作 MESI 协议。这是一个维护缓存一致性协议。这个协议不仅可以用在 CPU Cache 之间,也可以广泛用于各种需要使用缓存,同时缓存之间需要同步的场景下。

    总结

    通过一个使用 Java 程序中使用 volatile 关键字程序,我们可以看到,在有缓存的情况下会遇到一致性问题。volatile 这个关键字可以保障我们对于数据的读写都会到达主内存。

    进一步地,我们可以看到,Java 内存模型和 CPU、CPU Cache 以及主内存的组织结构非常相似。在 CPU Cache 里,对于数据的写入,我们也有写直达和写回这两种解决方案。写直达把所有的数据都直接写入到主内存里面,简单直观,但是性能就会受限于内存的访问速度。而写回则通常只更新缓存,只有在需要把缓存里面的脏数据交换出去的时候,才把数据同步到主内存里。在缓存经常会命中的情况下,性能更好。

    MESI协议

    缓存一致性问题

    那什么是缓存一致性呢?我们拿一个有两个核心的 CPU,来看一下。你可以看这里这张图,我们结合图来说。

    在这两个 CPU 核心里,1 号核心要写一个数据到内存里。这个怎么理解呢?我拿一个例子来给你解释。

    比方说,iPhone 降价了,我们要把 iPhone 最新的价格更新到内存里。为了性能问题,它采用了写回策略,先把数据写入到 L2 Cache 里面,然后把 Cache Block 标记成脏的。这个时候,数据其实并没有被同步到 L3 Cache 或者主内存里。1 号核心希望在这个 Cache Block 要被交换出去的时候,数据才写入到主内存里。

    如果我们的 CPU 只有 1 号核心这一个 CPU 核,那这其实是没有问题的。不过,我们旁边还有一个 2 号核心呢!这个时候,2 号核心尝试从内存里面去读取 iPhone 的价格,结果读到的是一个错误的价格。这是因为,iPhone 的价格刚刚被 1 号核心更新过。但是这个更新的信息,只出现在 1 号核心的 L2 Cache 里,而没有出现在 2 号核心的 L2 Cache 或者主内存里面。这个问题,就是所谓的缓存一致性问题,1 号核心和 2 号核心的缓存,在这个时候是不一致的。

    为了解决这个缓存不一致的问题,我们就需要有一种机制,来同步两个不同核心里面的缓存数据。那这样的机制需要满足什么条件呢?能够做到下面两点就是合理的。

    第一点叫写传播(Write Propagation)。写传播是说,在一个 CPU 核心里,我们的 Cache 数据更新,必须能够传播到其他的对应节点的 Cache Line 里。

    第二点叫事务的串行化(Transaction Serialization),事务串行化是说,我们在一个 CPU 核心里面的读取和写入,在其他的节点看起来,顺序是一样的。

    第一点写传播很容易理解。既然我们数据写完了,自然要同步到其他 CPU 核的 Cache 里。但是第二点事务的串行化,可能没那么好理解,我这里仔细解释一下。

    我们还拿刚才修改 iPhone 的价格来解释。这一次,我们找一个有 4 个核心的 CPU。1 号核心呢,先把 iPhone 的价格改成了 5000 块。差不多在同一个时间,2 号核心把 iPhone 的价格改成了 6000 块。这里两个修改,都会传播到 3 号核心和 4 号核心。

    然而这里有个问题,3 号核心先收到了 2 号核心的写传播,再收到 1 号核心的写传播。所以 3 号核心看到的 iPhone 价格是先变成了 6000 块,再变成了 5000 块。而 4 号核心呢,是反过来的,先看到变成了 5000 块,再变成 6000 块。虽然写传播是做到了,但是各个 Cache 里面的数据,是不一致的。

    事实上,我们需要的是,从 1 号到 4 号核心,都能看到相同顺序的数据变化。比如说,都是先变成了 5000 块,再变成了 6000 块。这样,我们才能称之为实现了事务的串行化。

    事务的串行化,不仅仅是缓存一致性中所必须的。比如,我们平时所用到的系统当中,最需要保障事务串行化的就是数据库。多个不同的连接去访问数据库的时候,我们必须保障事务的串行化,做不到事务的串行化的数据库,根本没法作为可靠的商业数据库来使用。

    而在 CPU Cache 里做到事务串行化,需要做到两点,

    第一点是一个 CPU 核心对于数据的操作,需要同步通信给到其他 CPU 核心。

    第二点是,如果两个 CPU 核心里有同一个数据的 Cache,那么对于这个 Cache 数据的更新,需要有一个“锁”的概念。只有拿到了对应 Cache Block 的“锁”之后,才能进行对应的数据更新。

    总线嗅探机制和 MESI 协议

    要解决缓存一致性问题,首先要解决的是多个 CPU 核心之间的数据传播问题。最常见的一种解决方案呢,叫作总线嗅探(Bus Snooping)。

    这个策略,本质上就是把所有的读写请求都通过总线(Bus)广播给所有的 CPU 核心,然后让各个核心去“嗅探”这些请求,再根据本地的情况进行响应。

    总线本身就是一个特别适合广播进行数据传输的机制,所以总线嗅探这个办法也是我们日常使用的 Intel CPU 进行缓存一致性处理的解决方案。

    基于总线嗅探机制,其实还可以分成很多种不同的缓存一致性协议。不过其中最常用的,就是今天我们要讲的 MESI 协议。和很多现代的 CPU 技术一样,MESI 协议也是在 Pentium 时代,被引入到 Intel CPU 中的。

    MESI 协议,是一种叫作写失效(Write Invalidate)的协议。在写失效协议里,只有一个 CPU 核心负责写入数据,其他的核心,只是同步读取到这个写入。在这个 CPU 核心写入 Cache 之后,它会去广播一个“失效”请求告诉所有其他的 CPU 核心。其他的 CPU 核心,只是去判断自己是否也有一个“失效”版本的 Cache Block,然后把这个也标记成失效的就好了。

    相对于写失效协议,还有一种叫作写广播(Write Broadcast)的协议。在那个协议里,一个写入请求广播到所有的 CPU 核心,同时更新各个核心里的 Cache。

    写广播在实现上自然很简单,但是写广播需要占用更多的总线带宽。写失效只需要告诉其他的 CPU 核心,哪一个内存地址的缓存失效了,但是写广播还需要把对应的数据传输给其他 CPU 核心。

    MESI 协议的由来呢,来自于我们对 Cache Line 的四个不同的标记,分别是:

    • M:代表已修改(Modified)
    • E:代表独占(Exclusive)
    • S:代表共享(Shared)
    • I:代表已失效(Invalidated)

    我们先来看看“已修改”和“已失效”,这两个状态比较容易理解。所谓的“已修改”,就是我们上一讲所说的“脏”的 Cache Block。Cache Block 里面的内容我们已经更新过了,但是还没有写回到主内存里面。而所谓的“已失效“,自然是这个 Cache Block 里面的数据已经失效了,我们不可以相信这个 Cache Block 里面的数据。

    然后,我们再来看“独占”和“共享”这两个状态。这就是 MESI 协议的精华所在了。无论是独占状态还是共享状态,缓存里面的数据都是“干净”的。这个“干净”,自然对应的是前面所说的“脏”的,也就是说,这个时候,Cache Block 里面的数据和主内存里面的数据是一致的。

    那么“独占”和“共享”这两个状态的差别在哪里呢?

    这个差别就在于,在独占状态下,对应的 Cache Line 只加载到了当前 CPU 核所拥有的 Cache 里。其他的 CPU 核,并没有加载对应的数据到自己的 Cache 里。这个时候,如果要向独占的 Cache Block 写入数据,我们可以自由地写入数据,而不需要告知其他 CPU 核。

    在独占状态下的数据,如果收到了一个来自于总线的读取对应缓存的请求,它就会变成共享状态。这个共享状态是因为,这个时候,另外一个 CPU 核心,也把对应的 Cache Block,从内存里面加载到了自己的 Cache 里来。

    而在共享状态下,因为同样的数据在多个 CPU 核心的 Cache 里都有。所以,当我们想要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他 CPU 核心里面的 Cache,都变成无效的状态,然后再更新当前 Cache 里面的数据。这个广播操作,一般叫作 RFO(Request For Ownership),也就是获取当前对应 Cache Block 数据的所有权。

    有没有觉得这个操作有点儿像我们在多线程里面用到的读写锁。在共享状态下,大家都可以并行去读对应的数据。但是如果要写,我们就需要通过一个锁,获取当前写入位置的所有权。

    整个 MESI 的状态,可以用一个有限状态机来表示它的状态流转。需要注意的是,对于不同状态触发的事件操作,可能来自于当前 CPU 核心,也可能来自总线里其他 CPU 核心广播出来的信号。把对应的状态机流转图放在了下面,你可以对照着Wikipedia 里面 MESI 的内容,仔细研读一下。

    总结

    想要实现缓存一致性,关键是要满足两点。第一个是写传播,也就是在一个 CPU 核心写入的内容,需要传播到其他 CPU 核心里。更重要的是第二点,保障事务的串行化,才能保障我们的数据是真正一致的,我们的程序在各个不同的核心上运行的结果也是一致的。这个特性不仅在 CPU 的缓存层面很重要,在数据库层面更加重要。

    之后,我介绍了基于总线嗅探机制的 MESI 协议。MESI 协议是一种基于写失效的缓存一致性协议。写失效的协议的好处是,我们不需要在总线上传输数据内容,而只需要传输操作信号和地址信号就好了,不会那么占总线带宽。

    MESI 协议,是已修改、独占、共享以及已失效这四个缩写的合称。独占和共享状态,就好像我们在多线程应用开发里面的读写锁机制,确保了我们的缓存一致性。而整个 MESI 的状态变更,则是根据来自自己 CPU 核心的请求,以及来自其他 CPU 核心通过总线传输过来的操作信号和地址信息,进行状态流转的一个有限状态机。

    理解内存

    简单页表

    想要把虚拟内存地址,映射到物理内存地址,最直观的办法,就是来建一张映射表。这个映射表,能够实现虚拟内存里面的页,到物理内存里面的页的一一映射。这个映射表,在计算机里面,就叫作页表(Page Table)。

    页表这个地址转换的办法,会把一个内存地址分成页号(Directory)和偏移量(Offset)两个部分。这么说太理论了,以一个 32 位的内存地址为例,帮你理解这个概念。

    其实,前面的高位,就是内存地址的页号。后面的低位,就是内存地址里面的偏移量。做地址转换的页表,只需要保留虚拟内存地址的页号和物理内存地址的页号之间的映射关系就可以了。同一个页里面的内存,在物理层面是连续的。以一个页的大小是 4K 比特(4KiB)为例,我们需要 20 位的高位,12 位的低位。

    总结一下,对于一个内存地址转换,其实就是这样三个步骤:

    1. 把虚拟内存地址,切分成页号和偏移量的组合;
    2. 从页表里面,查询出虚拟页号,对应的物理页号;
    3. 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。

    看起来这个逻辑似乎很简单,很容易理解,不过问题马上就来了。你能算一算,这样一个页表需要多大的空间吗?我们以 32 位的内存地址空间为例。

    不知道你算出的数字是多少?32 位的内存地址空间,页表一共需要记录 2^20 个到物理页号的映射关系。这个存储关系,就好比一个 2^20 大小的数组。一个页号是完整的 32 位的 4 字节(Byte),这样一个页表就需要 4MB 的空间。听起来 4MB 的空间好像还不大啊,毕竟我们现在的内存至少也有 4GB,服务器上有个几十 GB 的内存和很正常。

    不过,这个空间可不是只占用一份。我们每一个进程,都有属于自己独立的虚拟内存地址空间。这也就意味着,每一个进程都需要这样一个页表。不管我们这个进程,是个本身只有几 KB 大小的程序,还是需要几 GB 的内存空间,都需要这样一个页表。如果你用的是 Windows,你可以打开你自己电脑上的任务管理器看看,现在你的计算机里同时在跑多少个进程,用这样的方式,页表需要占用多大的内存。

    这还只是 32 位的内存地址空间,现在大家用的内存,多半已经超过了 4GB,也已经用上了 64 位的计算机和操作系统。这样的话,用上面这个数组的数据结构来保存页面,内存占用就更大了。那么,我们有没有什么更好的解决办法呢?你可以先仔细思考一下。

    多级页表

    仔细想一想,我们其实没有必要存下这 2^20 个物理页表啊。大部分进程所占用的内存是有限的,需要的页也自然是很有限的。我们只需要去存那些用到的页之间的映射关系就好了。如果你对数据结构比较熟悉,你可能要说了,那我们是不是应该用哈希表(Hash Map)这样的数据结构呢?

    在实践中,我们其实采用的是一种叫作多级页表(Multi-Level Page Table)的解决方案。这是为什么呢?为什么我们不用哈希表而用多级页表呢?

    我们先来看一看,一个进程的内存地址空间是怎么分配的。在整个进程的内存地址空间,通常是“两头实、中间空”。在程序运行的时候,内存地址从顶部往下,不断分配占用的栈的空间。而堆的空间,内存地址则是从底部往上,是不断分配占用的。

    所以,在一个实际的程序进程里面,虚拟内存占用的地址空间,通常是两段连续的空间。而不是完全散落的随机的内存地址。而多级页表,就特别适合这样的内存地址分布。

    我们以一个 4 级的多级页表为例,来看一下。同样一个虚拟内存地址,偏移量的部分和上面简单页表一样不变,但是原先的页号部分,我们把它拆成四段,从高到低,分成 4 级到 1 级这样 4 个页表索引。

    对应的,一个进程会有一个 4 级页表。我们先通过 4 级页表索引,找到 4 级页表里面对应的条目(Entry)。这个条目里存放的是一张 3 级页表所在的位置。4 级页面里面的每一个条目,都对应着一张 3 级页表,所以我们可能有多张 3 级页表。

    找到对应这张 3 级页表之后,我们用 3 级索引去找到对应的 3 级索引的条目。3 级索引的条目再会指向一个 2 级页表。同样的,2 级页表里我们可以用 2 级索引指向一个 1 级页表。

    而最后一层的 1 级页表里面的条目,对应的数据内容就是物理页号了。在拿到了物理页号之后,我们同样可以用“页号 + 偏移量”的方式,来获取最终的物理内存地址。

    我们可能有很多张 1 级页表、2 级页表,乃至 3 级页表。但是,因为实际的虚拟内存空间通常是连续的,我们很可能只需要很少的 2 级页表,甚至只需要 1 张 3 级页表就够了。

    事实上,多级页表就像一个多叉树的数据结构,所以我们常常称它为页表树(Page Table Tree)。因为虚拟内存地址分布的连续性,树的第一层节点的指针,很多就是空的,也就不需要有对应的子树了。所谓不需要子树,其实就是不需要对应的 2 级、3 级的页表。找到最终的物理页号,就好像通过一个特定的访问路径,走到树最底层的叶子节点。

    以这样的分成 4 级的多级页表来看,每一级如果都用 5 个比特表示。那么每一张某 1 级的页表,只需要 2^5=32 个条目。如果每个条目还是 4 个字节,那么一共需要 128 个字节。而一个 1 级索引表,对应 32 个 4KiB 的也就是 16KB 的大小。一个填满的 2 级索引表,对应的就是 32 个 1 级索引表,也就是 512KB 的大小。

    我们可以一起来测算一下,一个进程如果占用了 1MB 的内存空间,分成了 2 个 512KB 的连续空间。那么,它一共需要 2 个独立的、填满的 2 级索引表,也就意味着 64 个 1 级索引表,2 个独立的 3 级索引表,1 个 4 级索引表。一共需要 69 个索引表,每个 128 字节,大概就是 9KB 的空间。比起 4MB 来说,只有差不多 1/500。

    不过,多级页表虽然节约了我们的存储空间,却带来了时间上的开销,所以它其实是一个“以时间换空间”的策略。原本我们进行一次地址转换,只需要访问一次内存就能找到物理页号,算出物理内存地址。但是,用了 4 级页表,我们就需要访问 4 次内存,才能找到物理页号了。

    加速地址转换:TLB

    从虚拟内存地址到物理内存地址的转换,我们通过页表这个数据结构来处理。为了节约页表的内存存储空间,我们会使用多级页表数据结构。

    不过,多级页表虽然节约了我们的存储空间,但是却带来了时间上的开销,变成了一个“以时间换空间”的策略。原本我们进行一次地址转换,只需要访问一次内存就能找到物理页号,算出物理内存地址。但是用了 4 级页表,我们就需要访问 4 次内存,才能找到物理页号。

    我们知道,内存访问其实比 Cache 要慢很多。我们本来只是要做一个简单的地址转换,现在反而要一下子多访问好多次内存。这种情况该怎么处理呢?你是否还记得之前讲过的“加个缓存”的办法呢?我们来试一试。

    程序所需要使用的指令,都顺序存放在虚拟内存里面。我们执行的指令,也是一条条顺序执行下去的。也就是说,我们对于指令地址的访问,存在前面几讲所说的“空间局部性”和“时间局部性”,而需要访问的数据也是一样的。我们连续执行了 5 条指令。因为内存地址都是连续的,所以这 5 条指令通常都在同一个“虚拟页”里。

    因此,这连续 5 次的内存地址转换,其实都来自于同一个虚拟页号,转换的结果自然也就是同一个物理页号。那我们就可以用前面几讲说过的,用一个“加个缓存”的办法。把之前的内存转换地址缓存下来,使得我们不需要反复去访问内存来进行内存地址转换。

    于是,计算机工程师们专门在 CPU 里放了一块缓存芯片。这块缓存芯片我们称之为TLB,全称是地址变换高速缓冲(Translation-Lookaside Buffer)。这块缓存存放了之前已经进行过地址转换的查询结果。这样,当同样的虚拟地址需要进行地址转换的时候,我们可以直接在 TLB 里面查询结果,而不需要多次访问内存来完成一次转换。

    TLB 和我们前面讲的 CPU 的高速缓存类似,可以分成指令的 TLB 和数据的 TLB,也就是ITLBDTLB。同样的,我们也可以根据大小对它进行分级,变成 L1、L2 这样多层的 TLB。

    除此之外,还有一点和 CPU 里的高速缓存也是一样的,我们需要用脏标记这样的标记位,来实现“写回”这样缓存管理策略。

    为了性能,我们整个内存转换过程也要由硬件来执行。在 CPU 芯片里面,我们封装了内存管理单元(MMU,Memory Management Unit)芯片,用来完成地址转换。和 TLB 的访问和交互,都是由这个 MMU 控制的。

    安全性与内存保护

    进程的程序也好,数据也好,都要存放在内存里面。实际程序指令的执行,也是通过程序计数器里面的地址,去读取内存内的内容,然后运行对应的指令,使用相应的数据。

    虽然我们现代的操作系统和 CPU,已经做了各种权限的管控。正常情况下,我们已经通过虚拟内存地址和物理内存地址的区分,隔离了各个进程。但是,无论是 CPU 这样的硬件,还是操作系统这样的软件,都太复杂了,难免还是会被黑客们找到各种各样的漏洞。

    就像我们在软件开发过程中,常常会有一个“兜底”的错误处理方案一样,在对于内存的管理里面,计算机也有一些最底层的安全保护机制。这些机制统称为内存保护(Memory Protection)。我这里就为你简单介绍两个。

    可执行空间保护

    第一个常见的安全机制,叫可执行空间保护(Executable Space Protection)。

    这个机制是说,我们对于一个进程使用的内存,只把其中的指令部分设置成“可执行”的,对于其他部分,比如数据部分,不给予“可执行”的权限。因为无论是指令,还是数据,在我们的 CPU 看来,都是二进制的数据。我们直接把数据部分拿给 CPU,如果这些数据解码后,也能变成一条合理的指令,其实就是可执行的。

    这个时候,黑客们想到了一些搞破坏的办法。我们在程序的数据区里,放入一些要执行的指令编码后的数据,然后找到一个办法,让 CPU 去把它们当成指令去加载,那 CPU 就能执行我们想要执行的指令了。对于进程里内存空间的执行权限进行控制,可以使得 CPU 只能执行指令区域的代码。对于数据区域的内容,即使找到了其他漏洞想要加载成指令来执行,也会因为没有权限而被阻挡掉。

    其实,在实际的应用开发中,类似的策略也很常见。我下面给你举两个例子。

    比如说,在用 PHP 进行 Web 开发的时候,我们通常会禁止 PHP 有 eval 函数的执行权限。这个其实就是害怕外部的用户,所以没有把数据提交到服务器,而是把一段想要执行的脚本提交到服务器。服务器里在拼装字符串执行命令的时候,可能就会执行到预计之外被“注入”的破坏性脚本。这里我放了一个例子,用这个办法可以去删除服务器上的数据。

    script.php?param1=xxx
     
    // 我们的 PHP 接受一个传入的参数,这个参数我们希望提供计算功能
    $code = eval($_GET["param1"]);
    // 我们直接通过 eval 计算出来对应的参数公式的计算结果
    script.php?param1=";%20echo%20exec('rm -rf ~/');%20//
    // 用户传入的参数里面藏了一个命令
    $code = ""; echo exec('rm -rf ~/'); //";
    // 执行的结果就变成了删除服务器上的数据
    

    还有一个例子就是 SQL 注入攻击。如果服务端执行的 SQL 脚本是通过字符串拼装出来的,那么在 Web 请求里面传输的参数就可以藏下一些我们想要执行的 SQL,让服务器执行一些我们没有想到过的 SQL 语句。这样的结果就是,或者破坏了数据库里的数据,或者被人拖库泄露了数据。

    地址空间布局随机化

    第二个常见的安全机制,叫地址空间布局随机化(Address Space Layout Randomization)。

    内存层面的安全保护核心策略,是在可能有漏洞的情况下进行安全预防。上面的可执行空间保护就是一个很好的例子。但是,内存层面的漏洞还有其他的可能性。

    这里的核心问题是,其他的人、进程、程序,会去修改掉特定进程的指令、数据,然后,让当前进程去执行这些指令和数据,造成破坏。要想修改这些指令和数据,我们需要知道这些指令和数据所在的位置才行。

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

    这样的“随机化”策略,其实也是我们日常应用开发中一个常见的策略。一个大家都应该接触过的例子就是密码登陆功能。网站和 App 都会需要你设置用户名和密码,之后用来登陆自己的账号。然后,在服务器端,我们会把用户名和密码保存下来,在下一次用户登陆的时候,使用这个用户名和密码验证。

    我们的密码当然不能明文存储在数据库里,不然就会有安全问题。如果明文存储在数据库里,意味着能拿到数据库访问权限的人,都能看到用户的明文密码。这个可能是因为安全漏洞导致被人拖库,而且网站的管理员也能直接看到所有的用户名和密码信息。

    比如,前几年 CSDN 就发生过被人拖库的事件。虽然用户名和密码都是明文保存的,别人如果只是拿到了 CSDN 网站的用户名密码,用户的损失也不会太大。但是很多用户可能会在不同的网站使用相同的密码,如果拿到这些用户名和密码的人,能够成功登录用户的银行、支付、社交等等其他网站的话,用户损失就大了去了。

    于是,大家会在数据库里存储密码的哈希值,比如用现在常用的 SHA256,生成一一个验证的密码哈希值。但是这个往往还是不够的。因为同样的密码,对应的哈希值都是相同的,大部分用户的密码又常常比较简单。于是,拖库成功的黑客可以通过彩虹表的方式,来推测出用户的密码。

    这个时候,我们的“随机化策略”就可以用上了。我们可以在数据库里,给每一个用户名生成一个随机的、使用了各种特殊字符的盐值(Salt)。这样,我们的哈希值就不再是仅仅使用密码来生成的了,而是密码和盐值放在一起生成的对应的哈希值。哈希值的生成中,包括了一些类似于“乱码”的随机字符串,所以通过彩虹表碰撞来猜出密码的办法就用不了了。

    $password = "goodmorning12345";
    // 我们的密码是明文存储的
     
    $hashed_password = hash('sha256', password);
    // 对应的 hash 值是 054df97ac847f831f81b439415b2bad05694d16822635999880d7561ee1b77ac
    // 但是这个 hash 值里可以用彩虹表直接“猜出来”原始的密码就是 goodmorning12345
     
    $salt = "#21Pb$Hs&Xi923^)?";
    $salt_password = $salt.$password;
    $hashed_salt_password = hash('sha256', salt_password);
    // 这个 hash 后的 slat 因为有部分随机的字符串,不会在彩虹表里面出现。
    // 261e42d94063b884701149e46eeb42c489c6a6b3d95312e25eee0d008706035f
    

    可以看到,通过加入“随机”因素,我们有了一道最后防线。即使在出现安全漏洞的时候,我们也有了更多的时间和机会去补救这些问题。

    虽然安全机制似乎在平时用不太到,但是在开发程序的时候,还是要有安全意识。毕竟谁也不想看到,被拖库的新闻里出现的是自己公司的名字,也不希望用户因为我们的错误遭受到损失。

    总结

    我们从最简单的进行虚拟页号一一映射的简单页表说起,仔细讲解了现在实际应用的多级页表。多级页表就像是一颗树。因为一个进程的内存地址相对集中和连续,所以采用这种页表树的方式,可以大大节省页表所需要的空间。而因为每个进程都需要一个独立的页表,这个空间的节省是非常可观的。

    在优化页表的过程中,我们可以观察到,数组这样的紧凑的数据结构,以及树这样稀疏的数据结构,在时间复杂度和空间复杂度的差异。另外,纯粹理论软件的数据结构和硬件的设计也是高度相关的。

    为了节约页表所需要的内存空间,我们采用了多级页表这样一个数据结构。但是,多级页表虽然节省空间了,却要花费更多的时间去多次访问内存。于是,我们在实际进行地址转换的 MMU 旁边放上了 TLB 这个用于地址转换的缓存。TLB 也像 CPU Cache 一样,分成指令和数据部分,也可以进行 L1、L2 这样的分层。

    然后,我为你介绍了内存保护。无论是数据还是代码,我们都要存放在内存里面。为了防止因为各种漏洞,导致一个进程可以访问别的进程的数据或者代码,甚至是执行对应的代码,造成严重的安全问题,我们介绍了最常用的两个内存保护措施,可执行空间保护和地址空间布局随机化。

    通过让数据空间里面的内容不能执行,可以避免了类似于“注入攻击”的攻击方式。通过随机化内存空间的分配,可以避免让一个进程的内存里面的代码,被推测出来,从而不容易被攻击。

  • 相关阅读:
    前端工具Rythem介绍
    Redis持久化————AOF与RDB模式
    Hessian——轻量级远程调用方案
    JavaScript中的类数组对象
    在SpringMVC中获取request对象
    linux下,远程连接mysql
    nohup后台运行jar与关闭
    Vi常用命令
    spring mvc 重定向问题
    eclipse修改jdk后版本冲突问题
  • 原文地址:https://www.cnblogs.com/wwj99/p/12842859.html
Copyright © 2011-2022 走看看