存储器
寄存器
寄存器:我们可以把 CPU 看成计算机的“大脑”。我们思考的东西,就好比CPU中的寄存器。寄存器与其说是存储器,其实它更像是 CPU 本身的一部分,一般只能存放极其有限的信息。但是速度非常快,和 CPU 同步。
CPU Cache
CPU Cache:而我们大脑中的记忆,就好比 CPU Cache(CPU 高速缓存,我们常常简称为“缓存”)。CPU Cache 用的是一种叫作SRAM(静态随机存取存储器)的芯片。
SRAM:SRAM 之所以被称为“静态”存储器,是因为只要处在通电状态,里面的数据就可以保持存在。而一旦断电,里面的数据就会丢失了。在 SRAM 里面,一个比特的数据,需要 6~8 个晶体管。所以 SRAM 的存储密度不高。同样的物理空间下,能够存储的数据有限。不过,因为 SRAM 的电路简单,所以访问速度非常快。
在 CPU 里,通常会有 L1、L2、L3 这样三层高速缓存。每个 CPU 核心都有一块属于自己的 L1高速缓存,Cpu和L1直接通信,其中L1也相对的比L2小很多,L1通常分成指令缓存和数据缓存,分开存放 CPU 使用的指令和数据,L2则不进行区分。-> 指令和数据分开存储的这种思想也是来自于哈佛结构。==L1的 Cache 往往就嵌在 CPU 核心的内部。==
L2 的 Cache 同样是每个 CPU 核心都有的,不过它往往不在 CPU 核心的内部。所以,L2 Cache 的访问速度会比 L1 稍微慢一些。 而 L3 Cache,则通常是多个 CPU 核心共用的,尺寸会更大一些,访问速度自然也就更慢一些。
CPU 中的 L1 Cache 可以理解为我们的短期记忆,L2/L3 Cache 可以理解成长期记忆,把内存当成我们拥有的书架或者书桌。 当我们自己记忆中没有资料的时候,可以从书桌或者书架上拿书来翻阅。这个过程中就相当于,数据从内存中加载到 CPU 的Cache和寄存器中,然后通过“大脑”,也就是 CPU,进行处理和运算。
内存
DRAM 内存用的是一种叫做DRAM的芯片,比起SRAM来说,它的电路更简单,密度也就更高,有更大的容量,而且它也比SRAM芯片便宜不少。它被称为“动态”存储器,因为 DRAM 需要靠不断地“刷新”,才能保持数据被存储起来。DRAM 的一个比特,只需要一个晶体管和一个电容就能存储。所以,DRAM 在同样的物理空间下,能够存储的数据也就更多,也就是存储的“密度”更大。但是,因为数据是存储在电容里的,电容会不断漏电,所以需要定时刷新充电,才能保持数据不丢失。DRAM 的数据访问电路和刷新电路都比 SRAM 更复杂,所以访问延时也就更长。
硬盘
对于内存来说,SSD(固态硬盘),HDD(硬盘)这些被称为硬盘的外部存储设备,就是公共图书馆。图书馆有更多的书(数据)。
存储器的层次结构
整个存储器的层次结构,其实都类似于 SRAM 和 DRAM 在性能和价格上的差异。SRAM 更贵,速度更快。DRAM 更便宜,容量更大。SRAM 好像我们的大脑中的记忆,而 DRAM 就好像属于我们自己的书桌。
L1 Cache,不仅受成本层面的限制,更受物理层面的限制。它不仅昂贵,而且它的访问速度和它到 CPU 的物理距离有关。芯片造得越大,总有部分离CPU的距离会变远。所以想要快,并不是靠多花钱就能解决的。
其中,容量越小的设备速度越快,而且,CPU 并不是直接和每一种存储器设备打交道,而是每一种存储器设备,只和它相邻的存储设备打交道。比如,CPU Cache 是从内存里加载而来的,或者需要写回内存,并不会直接写回数据到硬盘,也不会直接从硬盘加载数据到 CPU Cache 中,而是先加载到内存,再从内存加载到 Cache 中。
在一台实际的计算机里面,越是速度快的设备,容量就越小。
局部性原理
L1 Cache 一般 256K,L2 Cache 有个 1MB,L3 Cache 有 12M。
我们能不能既享受 CPU Cache 的速度,又享受内存、硬盘巨大的容量和低廉的价格呢?因为这个问题就可以引申出局部性原理,这个局部性原理包括时间局部性和空间局部性这两种策略。
- 时间局部性:如果一个数据被访问了,那么推断它在短时间内还会被再次访问。那么这个数据就从在硬盘的数据库读取到内存的缓存中来。这利用的就是时间局部性。
- 空间局部性:如果一个数据被访问了,那么和它相邻的数据也很快会被访问。这就好比我们的程序,在访问了数组的首项之后,多半会循环访问它的下一项。
有了时间局部性和空间局部性,我们不用再把所有数据都放在内存里,也不用都放在 HDD 硬盘上,而是把访问次数多的数据,放在贵但是快一点的存储器里,把访问次数少的数据,放在慢但是大一点的存储器里。这样组合的使用内存、SSD 硬盘以及 HDD 硬盘,使得我们可以用最低的成本提供实际所需要的数据存储、管理和访问的需求。
局部性原理+不同层次存储器组合
假设亚马逊 6 亿件商品,每件商品需要 4MB 的存储空间,那么一共需要 2400TB( = 6 亿 × 4MB)的数据存储空间。
如果我们把数据都放在内存里面,那就需要 3600 万美元( = 2400TB/1MB × 0.015 美元 = 3600 万美元)。但是,这 6 亿件商品中,不是每一件商品都会被经常访问。
如果我们只在内存里放前 1% 的热门商品,也就是 600 万件热门商品,而把剩下的商品,放在机械式的 HDD 硬盘上,那么,我们需要的存储成本就下降到 45.6 万美元( = 3600 万美元 × 1% + 2400TB / 1MB × 0.00004 美元),是原来成本的 1.3% 左右。
这里我们用的就是时间局部性。我们把有用户访问过的数据,加载到内存中,一旦内存里面放不下了,我们就把最长时间没有在内存中被访问过的数据,从内存中移走,这个其实就是我们常用的LRU 缓存算法 。热门商品被访问得多,就会始终被保留在内存里,而冷门商品被访问得少,就只存放在 HDD 硬盘上,数据的读取也都是直接访问硬盘。即使加载到内存中,也会很快被移除。越是热门的商品,越容易在内存中找到,也就更好地利用了内存的随机访问性能。
那么只放 600 万件热门商品是否可以满足实际的线上服务请求则取决于LRU 缓存策略的缓存命中率了。
内存的随机访问请求需要 100ns。这也就意味着,在极限情况下,内存 1 秒可以支持 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 硬盘,让单个硬盘可以支持更多的随机访问请求。
推算过程:
首选算你的货物总共需要多少内存,然后推算估计有多少热点货物需要访问加速,然后算需要加速的放到内存中需要多少钱,再加上其余货物放到磁盘中多少钱。 -> 算出钱了
然后推算某一时刻的平均每秒访问量是多少,然后根据内存随机访问的请求时间,算1秒可以支撑多少次访问,然后根据热点数据花费内存的用量计算总的内存一秒可以支撑多少次请求。再计算非热点数据总的磁盘一秒钟可以支撑多少次请求来判断能否满足某一时刻用户的访问峰值。 -> 算出够不够
以上这个例子可以把货物看成人,内存看成飞机,磁盘看成绿皮火车。先算多少人,再算多少人需要快速运达,然后算飞机钱多少,火车钱多少,然后算需要运的人峰值是多少,然后算一架飞机运一趟的时间,算一下一天可以运多少个人,然后算所有飞机的一天可以运多少,就很好理解了。
最后两者综合,在够的情况下,省钱。
通过快速估算的方式,来判断这个添加缓存的策略是否能够满足我们的需求,以及在估算的服务器负载的情况下,需要规划多少硬件设备。这个“估算 + 规划”的能力,是每一个期望成长为架构师的工程师,必须掌握的能力。
高速缓存 - 上
高速缓存 = CPU Cache = L1 Cache + L2 Cache + L3 Cache
今天来看,一次内存的访问,大约需要 120 个 CPU Cycle
CPU 需要执行的指令、需要访问的数据,都在这个速度不到自己1% 的内存里。可以如下图看到,随着时间变迁,CPU 和内存之间的性能差距越来越大
从 CPU Cache 被加入到现有的 CPU 里开始,内存中的指令、数据,会被加载到 L1-L3 Cache 中,而不是直接由 CPU 访问内存去拿。在 95% 的情况下,CPU 都只需要访问 L1-L3 Cache,从里面读取指令和数据,而无需访问内存。
现代 CPU 中大量的空间已经被 SRAM 占据,图中用红色框出的部分就是 CPU 的 L3 Cache 芯片。
int[] arr = new int[64 * 1024 * 1024];// 256MB = 4*64*2^20
// 循环 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
==========================================
循环1:50毫秒
循环2:46毫秒
上面这段代码,按我们的正常的思考,第二段代码中被执行的数组元素是第一段的1/16,也即只进行了第一段代码的1/16次的乘法运算,按理说应该花费时间也应该尽量差距在1个数量级,10倍之上。但却相差仅4毫秒。
有了上面CPU Cache和内存访问速度差异的学习,可以得知运行程序的时间主要花在了将对应的数据从内存中读取出来,加载到 CPU Cache 里。CPU 从内存中读取数据到 CPU Cache 的过程中,是一小块一小块来读取数据的,而不是按照单个数组元素来读取数据的。这样一小块一小块的数据,在 CPU Cache 里面,我们把它叫作 Cache Line(缓存块)。
在我们日常使用的 Intel 服务器或者 PC 里,通常 Cache Line 的大小通常是 64 字节。
如果下面这段不懂可以学完横线下面的内容再回来看就明白了,Block中的数据在往CPU Line中存的时候是一段数据一段数据的存了,每16个数取一次,其实这16个数都会因为那1个被读的数而一同加载到CPU Line中。
而在上面的循环 2 里面,我们每隔 16 个整型数计算一次,16 个整型数正好是 64 个字节。于是,循环 1 和循环 2,需要把同样数量的 Cache Line 的数据从内存中读取到 CPU Cache 中,最终两个程序花费的时间就差别不大了。
Cache 的数据结构和读取过程
现代 CPU 进行数据读取的时候,无论数据是否已经存储在 Cache 中,CPU 始终会首先访问 Cache。只有当 CPU 在 Cache 中找不到数据的时候,才会去访问内存,并将读取到的数据写入 Cache 之中。当时间局部性原理起作用后,这个最近刚刚被访问的数据,会很快再次被访问。而 Cache 的访问速度远远快于内存,这样,CPU 花在等待内存访问上的时间就大大变短了。 -> 在各类基准测试(Benchmark)和实际应用场景中,CPU Cache 的命中率通常能达到 95% 以上。
Cache 的数据结构和访问逻辑
直接映射:CPU 访问内存数据,是一小块一小块数据来读取的。对于读取内存中的数据,我们首先拿到的是数据所在的内存块的地址。 而直接映射采用的策略,就是确保任何一个内存块的地址,始终映射到一个固定的 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,就是对应的缓存块地址。
如图,很多很内存的数据都对应的在被读取的时候要往Cache Line中存,但一个Cache Line才64字节,所以肯定存不下那么多Block的数据,所以在Cache Line中会存储一个组标记,这个组标记会记录,当前缓存块内存储的数据对应的内存块,(==到底是Block 12的数据还是Block 13的数据,同一时刻一个Cache Line只对应的取一个Block的数据==) 而缓存块本身的地址表示的访问的内存地址的低N位。就像上面的例子,21 的低 3 位 101,缓存块本身的地址已经涵盖了对应的部分信息、对应的组标记,只需要再记录 21 剩余的高 2 位的信息,也就是 10 就可以了。
Cache Line除了组标记还有另两个数据,一个是从内存加载来的实际数组,另一个是有效位。它是用来标记,对应的缓存块中的数据是否是有效的,确保不是机器刚刚启动时候的空数据。如果有效位是 0,无论其中的组标记和 Cache Line 里的数据内容是什么,CPU 都不会管这些数据,而要直接访问内存,重新加载数据。
CPU 在读取数据的时候,并不是要读取一整个 Block,而是读取一个他需要的整数。这样的数据,我们叫作 CPU 里的一个字。具体是哪个字,就用这个字在整个Block里面的位置来决定。这个位置,叫作偏移量(Offset)。
如果内存中的数据已经在 CPU Cache 里了,那一个内存地址的访问,就会经历这样 4 个步骤:
- 根据内存地址的低位,计算在 Cache 中的索引;
- 判断有效位,确认 Cache 中的数据是有效的;
- 对比内存访问地址的高位,和 Cache 中的组标记,确认 Cache 中的数据就是我们要访问的内存数据,从 Cache Line 中读取到对应的数据块(Data Block);
- 根据内存地址的 Offset 位,从 Data Block 中,读取希望读取到的字。
如果在 2、3 这两个步骤中,CPU 发现,Cache 中的数据并不是要访问的内存地址的数据,那 CPU 就会访问内存,并把对应的 Block Data 更新到 Cache Line 中,同时更新对应的有效位和组标记的数据。
高速缓存 - 下
Java 内存模型(JMM,Java Memory Model)
JMM 只是 Java 虚拟机这个进程级虚拟机里的一个内存模型,但是这个内存模型,和计算机组成里的 CPU、高速缓存和主内存组合在一起的硬件体系非常相似。理解了 JMM,可以让你很容易理解计算机组成里 CPU、高速缓存和主内存之间的关系。
volatile:它会确保我们对于这个变量的读取和写入,都一定会同步到主内存里,而不是从 Cache 里面读取。
volatile关键字在用C语言编写嵌入式软件里面用得很多,不使用volatile关键字的代码比使用volatile关键字的代码效率要高一些,但就无法保证数据的一致性。volatile的本意是告诉编译器,此变量的值是易变的,每次读写该变量的值时务必从该变量的内存地址中读取或写入,不能为了效率使用对一个“临时”变量的读写来代替对该变量的直接读写。编译器看到了volatile关键字,就一定会生成内存访问指令,每次读写该变量就一定会执行内存访问指令直接读写该变量。若是没有volatile关键字,编译器为了效率,只会在循环开始前使用读内存指令将该变量读到寄存器中,之后在循环内都是用寄存器访问指令来操作这个“临时”变量,在循环结束后再使用内存写指令将这个寄存器中的“临时”变量写回内存。在这个过程中,如果内存中的这个变量被别的因素(其他线程、中断函数、信号处理函数、DMA控制器、其他硬件设备)所改变了,就产生数据不一致的问题。另外,寄存器访问指令的速度要比内存访问指令的速度快,这里说的内存也包括缓存,也就是说内存访问指令实际上也有可能访问的是缓存里的数据,但即便如此,还是不如访问寄存器快的。缓存对于编译器也是透明的,编译器使用内存读写指令时只会认为是在读写内存,内存和缓存间的数据同步由CPU保证。
案例
版本一代码:
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(); }
}
}
}
}
===================================
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
版本一执行:
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 监听到,然后对应的结果就会被打印出来。
解释:
刚刚第一个使用了 volatile 关键字的例子里,因为所有数据的读和写都来自主内存。那么自然地,我们的 ChangeMaker 和 ChangeListener 之间,看到的 COUNTER 值就是一样的。
版本二代码:
把定义的 COUNTER 这个变量的的 volatile 关键字给去掉。
private static int COUNTER = 0;
====================================
Incrementing COUNTER to : 1
Incrementing COUNTER to : 2
Incrementing COUNTER to : 3
Incrementing COUNTER to : 4
Incrementing COUNTER to : 5
版本二执行:
ChangeMaker 还是能正常工作的,每隔 500ms 仍然能够对 COUNTER 自增 1。但是,奇怪的事情在 ChangeListener 上发生了,ChangeListener 不再工作了。在 ChangeListener 眼里,它似乎一直觉得 COUNTER 的值还是一开始的 0。似乎 COUNTER 的变化,对于 ChangeListener 彻底“隐身”了。
解释:
去掉了 volatile 关键字。这个时候,ChangeListener 又是一个忙等待的循环,它尝试不停地获取 COUNTER 的值,这样就会从当前线程的“Cache”里面获取。于是,这个线程就没有时间从主内存里面同步更新后的 COUNTER 值。这样,它就一直卡死在 COUNTER=0 的死循环上了。
版本三代码:
不再让 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(); }
}
}
}
========================================
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
版本三执行:
虽然 COUNTER 变量,仍然没有设置 volatile 这个关键字,但是 ChangeListener 似乎“睡醒了”。在通过 Thread.sleep(5) 在每个循环里“睡上“5 毫秒之后,ChangeListener 又能够正常取到 COUNTER 的值了。
解释:
虽然还是没有使用 volatile关键字,但是短短5ms的Thead.Sleep给了这个线程喘息之机。既然这个线程没有这么忙了,它也就有机会把最新的数据从主内存同步到自己的高速缓存里面了(很有意思)。于是,ChangeListener 在下一次查看 COUNTER 值的时候,就能看到 ChangeMaker 造成的变化了。
写入策略
我们现在用的 Intel CPU,通常都是多核的的。每一个 CPU 核(四核八线程)里面,都有独立属于自己的 L1、L2 的 Cache,然后再有多个 CPU 核共用的 L3 的 Cache、主内存。
因为 CPU Cache 的访问速度要比主内存快很多,而在 CPU Cache 里面,L1/L2 的 Cache 也要比 L3 的 Cache 快。所以,CPU 始终都是尽可能地从CPUCache中去获取数据,而不是每一次都要从主内存里面去读取数据。
这个层级结构,就好像我们在 Java 内存模型里面,每一个线程都有属于自己的线程栈。==线程在读取 COUNTER 的数据的时候,其实是从本地的线程栈的 Cache 副本里面读取数据,而不是从主内存里面读取数据==。如果我们对于数据仅仅只是读,问题还不大。但如果写就有两个问题了。
- 写入 Cache 的性能也比写入主内存要快,那我们写入的数据,到底应该写到 Cache 里还是主内存呢?
- 如果我们直接写入到主内存里,Cache 里的数据是否会失效呢?
对应解决方式有两种写策略:
- 写直达
最简单的一种写入策略,叫作写直达。在这个策略里,每一次数据都要写入到主内存里面。在写直达的策略里面,写入前,我们会先去判断数据是否已经在 Cache 里面了。如果数据已经在 Cache 里面了,我们先把数据写入更新到 Cache 里面,再写入到主内存里面;如果数据不在 Cache 里,我们就只更新主内存。
写直达的这个策略很直观,但是问题也很明显,那就是这个策略很慢。无论数据是不是在 Cache 里面,我们都需要把数据写到主内存里面。这个方式就有点儿像我们上面用 volatile 关键字,始终都要把数据同步到主内存里面。
- 写回
由于我们去读数据也是默认从 Cache 里面加载,所以我们不用把所有的写入都同步到主存中。 这个策略里,不再是每次都把数据写入到主内存,而是只写到 CPU Cache 里。只有当 CPU Cache 里面的数据要被“替换”的时候,我们才把数据写入到主内存里面去。
写回策略的过程是这样的:如果发现我们要写入的数据,就在 CPU Cache 里面,那么我们就只是更新 CPU Cache 里面的数据。同时,我们会标记 CPU Cache 里的这个 Block 是脏的。所谓脏的,就是指这个时候,我们的 CPU Cache 里面的这个 Block 的数据,和主内存是不一致的。
如果我们发现,我们要写入的数据所对应的 Cache Block 里,放的是别的内存地址的数据,那么我们就要看一看,那个 Cache Block 里面的数据有没有被标记成脏的。如果是脏的话,我们要先把这个 Cache Block 里面的数据,写入到主内存里面。然后,再把当前要写入的数据,写入到 Cache 里,同时把 Cache Block 标记成脏的。如果 Block 里面的数据没有被标记成脏的,那么我们直接把数据写入到 Cache 里面,然后再把 Cache Block 标记成脏的就好了。 - 脏数据,和内存数据不同
在用了写回这个策略之后,我们在加载内存数据到 Cache 里面的时候,也要多出一步同步脏 Cache 的动作。如果加载内存里面的数据到 Cache 的时候,发现 Cache Block 里面有脏标记,我们也要先把 Cache Block 里的数据写回到主内存,才能加载数据覆盖掉 Cache。
可以看到,在写回这个策略里,如果我们大量的操作,都能够命中缓存。那么大部分时间里,我们都不需要读写主内存,自然性能会比写直达的效果好很多。
CPU Cache - MESI协议
现代计算机在不能提升 CPU 的主频之后,找到了另一种提升 CPU 吞吐率的方法就是多核 CPU 技术。但多核 CPU 里的每一个 CPU 核,都有独立的属于自己的 L1 Cache 和 L2 Cache。多个 CPU 之间,只是共用 L3 Cache 和主内存。所以因为 CPU 的每个核各有各的缓存,互相之间的操作又是各自独立的,就会带来缓存一致性问题。
缓存一致性:比如,iPhone 价格出现了浮动,我们要把 iPhone 最新的价格更新到内存里。为了性能问题,CPU 写入 CPU Cache 的策略选择的是写回策略。先把数据写入到 L2 Cache 里面,然后把 Cache Block 标记成脏的。这个时候,数据其实并没有被同步到 L3 Cache 或者主内存里。1 号核心希望在这个 Cache Block 要被交换出去的时候,数据才写入到主内存里。
当计算机是单核的时候,不会出现这个问题。但如果是多核的,此时其他CPU核心从内存里面去读取 iPhone 的价格,结果读到的是一个错误的价格,这是因为,iPhone 的价格刚刚被 1 号核心更新过。但是这个更新的信息,只出现在 1 号核心的 L2 Cache 里,而没有出现在 2 号核心的 L2 Cache 或者主内存里面。 这个问题,就是所谓的缓存一致性问题,1 号核心和 2 号核心的缓存,在此时是不一致的。
所以我们就需要同步两个不同核心里面的缓存数据,需要满足的条件:
- 写传播: 在一个 CPU 核心里,我们的 Cache 数据更新,必须能够传播到其他的对应节点的 Cache Line 里。
- 事务的串行化: 我们在一个 CPU 核心里面的读取和写入,在其他的节点看起来,顺序是一样的。
如图,就是事务的串行化未做到的例子。CPU核心3和核心4读取到的CPU1和2的事务顺序不一致,即便写传播做到了,但数据仍然出错了。
事务的串行化,不仅仅是缓存一致性中所必须的。比如,我们平时所用到的系统当中,最需要保障事务串行化的就是数据库。多个不同的连接去访问数据库的时候,我们必须保障事务的串行化。
对于事务的串行化,需要做到两点:
- 一个 CPU 核心对于数据的操作,需要同步通信给到其他 CPU 核心。
- 如果两个 CPU 核心里有同一个数据的 Cache,那么对于这个 Cache 数据的更新,需要有一个“锁”的概念。只有拿到了对应 Cache Block 的“锁”之后,才能进行对应的数据更新。
要解决缓存一致性问题,首先要解决的是多个 CPU 核心之间的数据传播问题。最常见的解决方案叫做“总线嗅探”机制。这个策略,本质上就是把所有的读写请求都通过总线(Bus)广播给所有的 CPU 核心,然后让各个核心去“嗅探”这些请求,再根据本地的情况进行响应。
MESI
写失效
MESI 协议,是一种叫作写失效的协议。在写失效协议里,只有一个 CPU 核心负责写入数据,其他的核心,只是同步读取到这个写入。在这个 CPU 核心写入 Cache 之后,它会去广播一个“失效”请求告诉所有其他的 CPU 核心。其他的 CPU 核心,只是去判断自己是否也有一个“失效”版本的 Cache Block,然后把这个也标记成失效的就好了。
写广播
写广播:相对于写失效协议,还有一种叫作写广播的协议,一个写入请求广播到所有的 CPU 核心,同时更新各个核心里的Cache。 但这个协议相对写失效需要占用更多的总线带宽。
MESI - 对 Cache Line 的四个不同的标记:
- M:代表已修改(Modified):这个 Cache Block 里面的内容我们已经更新过了,但是还没有写回到主内存里面。
- E:代表==独占==(Exclusive):无论是独占状态还是共享状态,Cache Block 里面的数据和主内存里面的数据是一致的。
- S:代表==共享==(Shared):无论是独占状态还是共享状态,,Cache Block 里面的数据和主内存里面的数据是一致的。
- I:代表已失效(Invalidated):这个 Cache Block 里面的数据已经失效了,我们不可以相信这个 Cache Block 里面的数据。
独占”和“共享”这两个状态的差别:在==独占==状态下,对应的 Cache Line 只加载到了当前 CPU 核所拥有的 Cache 里。其他的 CPU 核,并没有加载对应的数据到自己的 Cache 里。这个时候,如果要向独占的 Cache Block 写入数据,我们可以自由地写入数据,而不需要告知其他 CPU 核。
在独占状态下的数据,如果收到了一个来自于总线的==读取对应缓存的请求==,它就会变成共享状态。这个共享状态是因为,这个时候,另外一个 CPU 核心,也把对应的 Cache Block,从内存里面加载到了自己的 Cache 里来。
而在共享状态下,因为同样的数据在多个 CPU 核心的 Cache 里都有。所以,当我们想要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他 CPU 核心里面的 Cache,都变成无效的状态,然后再更新当前 Cache 里面的数据。这个广播操作,一般叫作 RFO,==也就是获取当前对应 Cache Block 数据的所有权==。
这个操作有点儿像我们在多线程里面用到的读写锁。在共享状态下,大家都可以并行去读对应的数据。但是如果要写,我们就需要通过一个锁,获取当前写入位置的所有权。
整个 MESI 的状态,可以用一个有限状态机来表示它的状态流转。对于不同状态触发的事件操作,可能来自于当前 CPU 核心,也可能来自总线里其他 CPU 核心广播出来的信号。
想要实现缓存一致性,关键是要满足两点。第一个是写传播,也就是在一个 CPU 核心写入的内容,需要传播到其他 CPU 核心里。更重要的是第二点,保障事务的串行化,才能保障我们的数据是真正一致的,我们的程序在各个不同的核心上运行的结果也是一致的。这个特性不仅在 CPU 的缓存层面很重要,在数据库层面更加重要。
内存
内存是五大组成部分里面的存储器,我们的指令和数据,都需要先加载到内存里面,才会被 CPU 拿去执行。
我们日常使用的 Linux 或者 Windows 操作系统下,程序并不能直接访问物理内存。我们的内存需要被分成固定大小的页,然后再通过虚拟内存地址到物理内存地址的地址转换,才能到达实际存放数据的物理内存位置。而我们的程序看到的内存地址,都是虚拟内存地址。
简单页表
虚拟内存地址,映射到物理内存地址,是通过一张映射表来实现一对一对应的。这个映射表,在计算机里面,就叫作页表。页表这个地址转换的办法,会把一个内存地址分成页号和偏移量两个部分。
以一个32位的内存地址为例,前面的高位,就是内存地址的页号。后面的低位,就是内存地址里面的偏移量。做地址转换的页表,只需要保留虚拟内存地址的页号和物理内存地址的页号之间的映射关系就可以了。同一个页里面的内存,在物理层面是连续的。以一个页的大小是 4K 比特为例,我们需要 20 位的高位,12 位的低位。
内存地址转换步骤:
- 把虚拟内存地址,切分成页号和偏移量的组合;
- 从页表里面,查询出虚拟页号,对应的物理页号;
- 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
这样表示的问题:32 位的内存地址空间,页表一共需要记录 2^20 个到物理页号的映射关系。这个存储关系,就好比一个 2^20 大小的数组。一个页号是完整的 32 位的 4 字节,这样一个页表就需要 4MB 的空间(这只是32位)。但这个空间我们每一个进程,都有属于自己独立的虚拟内存地址空间。这也就意味着,每一个进程都需要这样一个页表。不管我们这个进程,是个本身只有几 KB 大小的程序,还是需要几 GB 的内存空间,都需要这样一个页表。
多级页表
由于大部分进程所占用的内存是有限的,所以需要的页也是有限的。没必要存下这 2^20 个物理页表。
在整个进程的内存地址空间,通常是“两头实、中间空”。在程序运行的时候,内存地址从顶部往下,不断分配占用的栈的空间。而堆的空间,内存地址则是从底部往上,是不断分配占用的。所以,在一个实际的程序进程里面,虚拟内存占用的地址空间,通常是两段连续的空间。而不是完全散落的随机的内存地址。而多级页表,就特别适合这样的内存地址分布。
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 级页表就够了。
多级页表就像一个多叉树的数据结构,所以我们常常称它为页表树。因为虚拟内存地址分布的连续性,树的第一层节点的指针,很多就是空的,也就不需要有对应的子树了。所谓不需要子树,其实就是不需要对应的 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
由于“地址转换”是一个非常高频的动作,“地址转换”的性能就变得至关重要了。同时CPU执行的指令和数据也放在内存中,所以内存安全问题 也需要考虑。
上述多级页表的内存访问方式,由于内存的多次访问,所以在性能上表现很差。由于程序所需要使用的指令,都顺序存放在虚拟内存里面。我们执行的指令,也是一条条顺序执行下去的。也就是说,我们对于指令地址的访问,存在“空间局部性”和“时间局部性”,而需要访问的数据也是一样的。我们连续执行了 5 条指令。因为内存地址都是连续的,所以这 5 条指令通常都在同一个“虚拟页”里。
因此,这连续 5 次的内存地址转换,其实都来自于同一个虚拟页号,那我们就可以通过“加个缓存”把之前的内存转换地址缓存下来,使得我们不需要反复去访问内存来进行内存地址转换。
于是,计算机工程师们专门在 CPU 里放了一块缓存芯片。这块缓存芯片我们称之为TLB,全称是地址变换高速缓冲。 这块缓存存放了之前已经进行过地址转换的查询结果。这样,当同样的虚拟地址需要进行地址转换的时候,我们可以直接在 TLB 里面查询结果,而不需要多次访问内存来完成一次转换。
TLB 和我们前面讲的 CPU 的高速缓存类似,可以分成指令的 TLB 和数据的 TLB,也就是ITLB和DTLB。同样的,我们也可以根据大小对它进行分级,变成 L1、L2 这样多层的 TLB。 对应的,在写回数据时也需要和CPU Cache 一样需要用脏标记这样的标记位,来实现“写回”这样缓存管理策略。
为了性能,我们整个内存转换过程也要由硬件来执行。在 CPU 芯片里面,我们封装了内存管理单元MMU芯片,用来完成地址转换。和 TLB 的访问和交互,都是由这个 MMU 控制的。
安全性与内存保护
正常情况下,我们已经通过虚拟内存地址和物理内存地址的区分,隔离了各个进程。但由于CPU及操作系统执行逻辑的复杂,仍然会有很多漏洞。
可执行空间保护
这个机制是说,我们对于一个进程使用的内存,只把其中的指令部分设置成“可执行”的,对于其他部分,比如数据部分,不给予“可执行”的权限。因为无论是指令,还是数据,在我们的 CPU 看来,都是二进制的数据。我们直接把数据部分拿给 CPU,如果这些数据解码后,也能变成一条合理的指令,其实就是可执行的。
黑客对应方法:在程序的数据区里,放入一些要执行的指令编码后的数据,然后找到一个办法,让 CPU 去把它们当成指令去加载,那 CPU 就能执行他们想要执行的指令了。
对应方法:对于进程里内存空间的执行权限进行控制,可以使得 CPU 只能执行指令区域的代码。 对于数据区域的内容,即使找到了其他漏洞想要加载成指令来执行,也会因为没有权限而被阻挡掉。
与此类似的典型的网络攻击SQL注入就是如此:如果服务端执行的 SQL 脚本是通过字符串拼装出来的,那么在 Web 请求里面传输的参数就可以藏下一些我们想要执行的 SQL,让服务器执行一些我们没有想到过的 SQL 语句。这样的结果就是,或者破坏了数据库里的数据,或者被人拖库泄露了数据。
地址空间布局随机化
原先我们一个进程的内存布局空间是固定的,所以任何第三方很容易就能知道指令在哪里,程序栈在哪里,数据在哪里,堆又在哪里。这个其实为想要搞破坏的人创造了很大的便利。而地址空间布局随机化这个机制,就是让这些区域的位置不再固定,==在内存空间随机去分配这些进程里不同部分所在的内存空间地址==,让破坏者猜不出来。猜不出来呢,自然就没法找到想要修改的内容的位置。如果只是随便做点修改,程序只会 crash 掉,而不会去执行计划之外的代码。
与此类似的典型的网络攻击防护手段是:用户账户密码密文保存的时候,通过为每个用户分配一个随机的salt来进行特定的密文加密的方式。
为了节约页表所需要的内存空间,我们采用了多级页表这样一个数据结构。但是,多级页表虽然节省空间了,却要花费更多的时间去多次访问内存。于是,我们在实际进行地址转换的 MMU 旁边放上了 TLB 这个用于地址转换的缓存。TLB 也像 CPU Cache 一样,分成指令和数据部分,也可以进行 L1、L2 这样的分层。
通过让数据空间里面的内容不能执行,可以避免了类似于“注入攻击”的攻击方式。通过随机化内存空间的分配,可以避免让一个进程的内存里面的代码,被推测出来,从而不容易被攻击。
总线
计算机里其实有很多不同的硬件设备,如果我们有 N 个不同的设备,他们之间需要各自单独连接,那么系统复杂度就会变成 N^2 。为了简化系统的复杂度,我们就引入了总线,把这个 N^2 的复杂度,变成一个 N 的复杂度。所以设计出一个公用的线路,CPU 想要和什么设备通信,通信的指令是什么,对应的数据是什么,都发送到这个线路上;设备要向 CPU 发送什么信息呢,也发送到这个线路上。 这个线路就好像一个高速公路,各个设备和其他设备之间,不需要单独建公路,只建一条小路通向这条高速公路就好了。
总线,其实就是一组线路。我们的 CPU、内存以及输入和输出设备,都是通过这组线路,进行相互间通信的。其实,对应的设计思路,在软件开发中也是非常常见的。我们在做大型系统开发的过程中,经常会用到一种叫作事件总线的设计模式。
在事件总线这个设计模式里,各个模块触发对应的事件,并把事件对象发送到总线上。也就是说,每个模块都是一个发布者。而各个模块也会把自己注册到总线上,去监听总线上的事件,并根据事件的对象类型或者是对象内容,来决定自己是否要进行特定的处理或者响应。-> 我也往上发,我也从上面拿来处理。
这样的设计下,注册在总线上的各个模块就是松耦合的。模块互相之间并没有依赖关系。无论代码的维护,还是未来的扩展,都会很方便。
现代的 Intel CPU 的体系结构里面,通常有好几条总线。 首先,CPU 和内存以及高速缓存通信的总线,这里面通常有两种总线。这种方式,我们称之为双独立总线。CPU 里,有一个快速的本地总线,以及一个速度相对较慢的前端总线。 现代的 CPU 里,通常有专门的高速缓存芯片。这里的高速本地总线,就是用来和CPU Cache通信的。而前端总线,则是用来和主内存以及输入输出设备通信的。有时候,我们会把本地总线也叫作后端总线,和前面的前端总线对应起来。而前端总线也有很多其他名字,比如处理器总线、内存总线。
本地总线 = 后端总线,前端总线 = 处理器总线 = 内存总线 = 系统总线
CPU 里面的北桥芯片,把我们上面说的前端总线,一分为三,变成了三个总线。我们的前端总线,其实就是系统总线,CPU 里面的内存接口,直接和系统总线通信,然后系统总线再接入一个 I/O 桥接器。这个 I/O 桥接器,一边接入了我们的内存总线,使得我们的 CPU 和内存通信;另一边呢,又接入了一个 I/O 总线,用来连接 I/O 设备。
事实上,真实的计算机里,这个前端总线层面拆分得更细。根据不同的设备,还会分成独立的 PCI 总线、ISA 总线等等。
物理层面,其实我们完全可以把总线看作一组“电线”,它通常有三类线路:
- 数据线,用来传输实际的数据信息,也就是实际上了公交车的“人”。
- 地址线,用来确定到底把数据传输到哪里去,是内存的某个位置,还是某一个 I/O 设备。这个其实就相当于拿了个纸条,写下了上面的人要下车的站点。
- 控制线,用来控制对于总线的访问。虽然我们把总线比喻成了一辆公交车。那么有人想要做公交车的时候,需要告诉公交车司机,这个就是我们的控制信号。
尽管总线减少了设备之间的耦合,也降低了系统设计的复杂度,但同时也带来了一个新问题,那就是总线不能同时给多个设备提供通信功能。
我们的总线是很多个设备公用的,那多个设备都想要用总线,我们就需要有一个机制,去决定这种情况下,到底把总线给哪一个设备用。这个机制,就叫作总线裁决 。
输入输出设备
输入输出设备,并不只是一个设备。大部分的输入输出设备,都有两个组成部分。第一个是它的==接口==,第二个才是==实际的 I/O 设备==。硬件设备并不是直接接入到总线上和 CPU 通信的,而是通过接口,用接口连接到总线上,再通过总线和 CPU 通信。
CPU - 总线 - 接口 - IO 设备
平时听说的并行接口、串行接口、USB接口,都是计算机主板上内置的各个接口。我们的实际硬件设备,比如,使用并口的打印机、使用串口的老式鼠标或者使用 USB 接口的 U 盘,都要插入到这些接口上,才能和 CPU 工作以及通信的。
如图,SATA 硬盘,上面的整个绿色电路板和黄色的齿状部分就是接口电路,黄色齿状的就是主板内置的和IO设备对接的接口,绿色的电路板就是控制电路。
接口本身就是一块电路板。CPU 其实不是和实际的硬件设备打交道,而是和这个接口电路板打交道。==设备里面有三类寄存器,其实都在这个设备的接口电路上,而不在实际的设备上。==
除了内置在主板上的接口之外,有些接口可以集成在设备上。 所以这种设备,需要通过一个线缆,把集成了接口的设备连接到主板上去。把接口和实际设备分离,这个做法实际上来自于计算机走向开放架构 的时代。
如果你用的是 Windows 操作系统,你可以打开设备管理器,里面有各种各种的 Devices(设备)、Controllers(控制器)、Adaptors(适配器)。这些,其实都是对于输入输出设备不同角度的描述。被叫作 Devices,看重的是实际的 I/O 设备本身。被叫作 Controllers,看重的是输入输出设备接口里面的控制电路。而被叫作 Adaptors,则是看重接口作为一个适配器后面可以插上不同的实际设备。
CPU是如何控制IO设备的
无论是内置在主板上的接口,还是集成在设备上的接口,除了三类寄存器之外,还有对应的控制电路。正是通过这个控制电路,CPU 才能通过向这个接口电路板传输信号,来控制实际的硬件。
三类寄存器作用:
- 状态寄存器:就是告诉了我们的 CPU,现在设备已经在工作了,所以这个时候,CPU 你再发送数据或者命令过来,都是没有用的。直到前面的动作已经完成,状态寄存器重新变成了
ready
状态,我们的 CPU 才能发送下一个字符和命令。 - 数据寄存器:CPU 向 I/O 设备写入需要传输的数据,比如要打印的内容是“GeekTime”,我们就要先发送一个“G”给到对应的 I/O 设备。
- 命令寄存器:CPU 发送一个命令,告诉打印机,要进行打印工作。这个时候,打印机里面的控制电路会做两个动作。第一个,是去设置我们的状态寄存器里面的状态,把状态设置成
not-ready
。第二个,就是实际操作打印机进行打印。
在实际情况中,打印机里通常不只有数据寄存器,还会有数据缓冲区。CPU 不会真的一个字符一个字符这样交给打印机去打印的,而是一次性把整个文档传输到打印机的内存或者数据缓冲区里面一起打印的。
信号和地址
CPU 和 I/O 设备的通信,一样是通过 CPU 支持的机器指令来执行的。
精简指令集 MIPS,的机器指令的分类,并没有一种专门的和 I/O 设备通信的指令类型。它和IO设备的通信方式和访问主内存一样,使用“内存地址”。为了让已经足够复杂的 CPU 尽可能简单,计算机会把 I/O 设备的各个寄存器,以及 I/O 设备内部的内存地址,都映射到主内存地址空间里来。主内存的地址空间里,会给不同的 I/O 设备预留一段一段的内存地址。CPU 想要和这些 I/O 设备通信的时候呢,就往这些地址发送数据。这些地址信息,就是通过上一讲的地址线来发送的,而对应的数据信息呢,自然就是通过数据线来发送的了。
而I/O 设备呢,就会监控地址线,并且在 CPU 往自己地址发送数据的时候,把对应的数据线里面传输过来的数据,接入到对应的设备里面的寄存器和内存里面来。CPU 无论是向 I/O 设备发送命令、查询状态还是传输数据,都可以通过这样的方式。这种方式呢,叫作内存映射(MMIO)。
精简指令集 MIPS 的 CPU 特别简单,所以这里只有 MMIO。而有 2000 多个指令的 Intel X86 架构的计算机,设计了专门的和 I/O 设备通信的指令,也就是 in 和 out 指令。
Intel CPU 虽然也支持 MMIO,不过它还可以通过特定的指令,来支持端口映射 I/O(PMIO)或者也可以叫独立输入输出。PMIO 的通信方式和 MMIO 核心的区别在于,PMIO 里面访问的设备地址,不再是在内存地址空间里面,而是一个专门的端口。这个端口并不是指一个硬件上的插口,而是和 CPU 通信的一个抽象概念。
无论是 PMIO 还是 MMIO,CPU 都会传送一条二进制的数据,给到 I/O 设备的对应地址。设备自己本身的接口电路,再去解码这个数据。解码之后的数据,就会变成设备支持的一条指令,再去通过控制电路去操作实际的硬件设备。对于 CPU 来说,它并不需要关心设备本身能够支持哪些操作。它要做的,只是在总线上传输一条条数据就好了。 这个,类似设计模式里面的 Command 模式。在总线上传输的,是一个个数据对象,然后各个接受这些对象的设备,再去根据对象内容,进行实际的解码和命令执行。
这是一张我自己的显卡,在设备管理器里面的资源信息。你可以看到,里面既有 Memory Range,这个就是设备对应映射到的内存地址,也就是我们上面所说的 MMIO 的访问方式。同样的,里面还有 I/O Range,这个就是我们上面所说的 PMIO,也就是通过端口来访问 I/O 设备的地址。最后,里面还有一个 IRQ,也就是会来自于这个设备的中断信号了。
CPU 并不是发送一个特定的操作指令来操作不同的 I/O 设备。因为如果是那样的话,随着新的 I/O 设备的发明,我们就要去扩展 CPU 的指令集了。
在计算机系统里面,CPU 和 I/O 设备之间的通信,是这么来解决的:
首先,在 I/O 设备这一侧,我们把 I/O 设备拆分成,能和 CPU 通信的接口电路,以及实际的 I/O 设备本身。接口电路里面有对应的状态寄存器、命令寄存器、数据寄存器、数据缓冲区和设备内存等等。接口电路通过总线和 CPU 通信,接收来自 CPU 的指令和数据。而接口电路中的控制电路,再解码接收到的指令,实际去操作对应的硬件设备。
而在 CPU 这一侧,对 CPU 来说,它看到的并不是一个个特定的设备,而是一个个内存地址或者端口地址。CPU 只是向这些地址传输数据或者读取数据。所需要的指令和操作内存地址的指令其实没有什么本质差别。通过软件层面对于传输的命令数据的定义,而不是提供特殊的新的指令,来实际操作对应的 I/O 硬件。
同样 CPU 和蓝牙鼠标这个输入设备之间的通信也是如此。
对于CPU来说,这只是总线上的一个普通的USB设备,与其他的U盘、USB网卡之类的USB接口设备没什么区别,这些设备只是通过USB协议讲自己的数据发送给操作系统,对于这些数据是什么,USB是不管的,USB蓝牙鼠标接收器和普通USB的鼠标在这一层的数据是一样的。
对于操作系统来说,要使这些USB设备工作,就需要对发来的数据进行处理,处理数据的就是驱动程序,所以不同种类的USB设备需要不同的驱动程序。
回过头来再看USB蓝牙鼠标接收器,鼠标产生的事件通过 蓝牙发送->蓝牙接受-> USB发送-> USB接受->驱动程序 这样的路径最终到达操作系统,这里面的蓝牙和USB仅仅只是传输数据的方式而已,换为其他的什么TCP/ IP传输也是一样的,其本质是将特定的数据传输给操作系统处理。
I/O性能 - IO_WAIT
硬盘的性能指标:响应时间,数据传输率。数据传输率可以看做是硬盘的吞吐率。
现在常用的硬盘有两种:
- HDD 硬盘 - 机械硬盘 - SATA 3.0 的接口
- SSD 硬盘 - 固态硬盘,通常会用两种接口,各有部分使用 SATA 3.0 的接口 & PCI Express 的接口。
IO 顺序访问,随机访问
现在常用的 SATA 3.0 的接口,带宽是 6Gb/s。这里的“b”是比特。这个带宽相当于每秒可以传输 768MB 的数据。而我们日常用的 HDD 硬盘的数据传输率,差不多在 200MB/s 左右。
HDD 硬盘,换成 Crucial MX500 的 SSD 硬盘。它的数据传输速率能到差不多 500MB/s,比 HDD 的硬盘快了一倍不止。不过 SATA 接口的硬盘,差不多到这个速度,性能也就到顶了。因为 SATA 接口的速度也就这么快。
相比于 HDD 硬盘,SSD 硬盘能够更快,PCI Express 的接口的硬盘在读取的时候就能做到 2GB/s 左右,差不多是 HDD 硬盘的 10 倍,而在写入的时候也能有 1.2GB/s。
- Acc.Time - 响应时间:这个指标,其实就是程序发起一个硬盘的写入请求,直到这个请求返回的时间。
- Seq - 顺序读写硬盘得到的数据传输率 - 吞吐率
- 4K - 程序,去随机读取磁盘上某一个 4KB 大小的数据,一秒之内可以读取到多少数据
上面的两块 SSD 硬盘上,Acc.Time
响应时间这个指标大概时间都是在几十微秒这个级别。而 HDD 的硬盘,通常会在几毫秒到十几毫秒这个级别。这个性能的差异,就不是 10 倍了,而是在几十倍,乃至几百倍。
光看响应时间和吞吐率这两个指标,似乎我们的硬盘性能很不错。即使是廉价的 HDD 硬盘,接收一个来自 CPU 的请求,也能够在几毫秒时间返回。一秒钟能够传输的数据,也有 200MB 左右。你想一想,我们平时往数据库里写入一条记录,也就是 1KB 左右的大小。我们拿 200MB 去除以 1KB,那差不多每秒钟可以插入 20 万条数据呢。- 但这个数值这么计算是不准确的。
答案就来自于硬盘的读写。在顺序读写和随机读写的情况下,硬盘的性能是完全不同的。如上述的4K
指标
可以看到,在这个指标上,SATA 3.0 接口的硬盘和 PCI Express 接口性能差异变得很小。这是因为,在这个时候,接口本身的速度已经不是硬盘访问速度的瓶颈了。可以看到即便是 PCI Express 的接口,在随机读写的时候,数据传输率也只能到 40MB/s 左右,是顺序读写情况下的几十分之一。
按图中数据进行计算可得:40MB / 4KB = 10,000
,即一秒之内,这块 SSD 硬盘可以随机读取 1 万次的 4KB 的数据,写入的话更多一些。这个每秒随机读写的次数,我们称之为==IOPS==,也就是每秒输入输出操作的次数。事实上,比起响应时间,我们更关注 IOPS 这个性能指标。IOPS 和 DTR(数据传输率)才是输入输出性能的核心指标。
因为在实际的应用开发当中,对于数据的访问,更多的是随机读写,而不是顺序读写。我们平时所说的服务器承受的“并发”,其实是在说,会有很多个不同的进程和请求来访问服务器。自然,它们在硬盘上访问的数据,是很难顺序放在一起的。这种情况下,随机读写的 IOPS 才是服务器性能的核心指标。 HDD 硬盘的 IOPS 通常也就在 100 左右,而不是在 20 万次。
定位 IO_WAIT
即使是用上了 PCI Express 接口的 SSD 硬盘,IOPS 也就是在 2 万左右。而我们的 CPU 的主频通常在 2GHz 以上,也就是每秒可以做 20 亿次操作。即使 CPU 向硬盘发起一条读写指令,需要很多个时钟周期,一秒钟 CPU 能够执行的指令数,和我们硬盘能够进行的操作数,也有好几个数量级的差异。这也是为什么,在应用开发的时候往往会说“性能瓶颈在 I/O 上”。因为很多时候,CPU 指令发出去之后,不得不去“等”我们的 I/O 操作完成,才能进行下一步的操作。
问题:在实际遇到服务端程序的性能问题的时候,我们怎么知道这个问题是不是来自于 CPU 等 I/O 来完成操作呢?
- top 命令里面,可以看到 CPU 是否在等待 IO 操作完成
$ top
==========
top - 06:26:30 up 4 days, 53 min, 1 user, load average: 0.79, 0.69, 0.65
Tasks: 204 total, 1 running, 203 sleeping, 0 stopped, 0 zombie
%Cpu(s): 20.0 us, 1.7 sy, 0.0 ni, 77.7 id, 0.0 wa, 0.0 hi, 0.7 si, 0.0 st
KiB Mem: 7679792 total, 6646248 used, 1033544 free, 251688 buffers
KiB Swap: 0 total, 0 used, 0 free. 4115536 cached Mem
%CPU
行的 wa
的指标,这个指标就代表着 iowait,也就是 CPU 等待 IO 完成操作花费的时间占 CPU 的百分比。
- 知道了 iowait 很大,就要去看一看,实际的 I/O 操作情况是什么样的。使用
iostat
这个命令能够看到实际的硬盘读写情况。
$ iostat
=========
avg-cpu: %user %nice %system %iowait %steal %idle
17.02 0.01 2.18 0.04 0.00 80.76
Device: tps kB_read/s kB_wrtn/s kB_read kB_wrtn
sda 1.81 2.02 30.87 706768 10777408
这个命令里,不仅有 iowait 这个 CPU 等待时间的百分比,还有一些更加具体的指标,并且它还是按照机器上安装的多块不同的硬盘划分的。
这里的 tps 指标,其实就对应着我们上面所说的硬盘的 IOPS 性能。而 kB_read/s 和 kB_wrtn/s 指标,就对应着我们的数据传输率的指标。知道实际硬盘读写的 tps、kB_read/s 和 kb_wrtn/s 的指标,我们基本上可以判断出,机器的性能是不是卡在 I/O 上了。
- 那么,接下来,我们就是要找出到底是哪一个进程是这些 I/O 读写的来源了。这个时候,你需要“iotop”这个命令。
$ iotop
==========
Total DISK READ : 0.00 B/s | Total DISK WRITE : 15.75 K/s
Actual DISK READ: 0.00 B/s | Actual DISK WRITE: 35.44 K/s
TID PRIO USER DISK READ DISK WRITE SWAPIN IO> COMMAND
104 be/3 root 0.00 B/s 7.88 K/s 0.00 % 0.18 % [jbd2/sda1-8]
383 be/4 root 0.00 B/s 3.94 K/s 0.00 % 0.00 % rsyslogd -n [rs:main Q:Reg]
1514 be/4 www-data 0.00 B/s 3.94 K/s 0.00 % 0.00 % nginx: worker process
通过 iotop 这个命令,你可以看到具体是哪一个进程实际占用了大量 I/O,那么你就可以有的放矢,去优化对应的程序了。上面的这些示例里,不管是 wa 也好,tps 也好,它们都很小。那么,接下来,我就给你用 Linux 下,用 stress 命令,来模拟一个高 I/O 复杂的情况,来看看这个时候的 iowait 是怎么样的。我在一台云平台上的单个 CPU 核心的机器上输入“stress -i 2”,让 stress 这个程序模拟两个进程不停地从内存里往硬盘上写数据。
- 在一台云平台上的单个 CPU 核心的机器上输入“stress -i 2”,让 stress 这个程序模拟两个进程不停地从内存里往硬盘上写数据。
$ stress -i 2
$ top
=============
top - 06:56:02 up 3 days, 19:34, 2 users, load average: 5.99, 1.82, 0.63
Tasks: 88 total, 3 running, 85 sleeping, 0 stopped, 0 zombie
%Cpu(s): 3.0 us, 29.9 sy, 0.0 ni, 0.0 id, 67.2 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 1741304 total, 1004404 free, 307152 used, 429748 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 1245700 avail Mem
$ iostat 2 5
==============
avg-cpu: %user %nice %system %iowait %steal %idle
5.03 0.00 67.92 27.04 0.00 0.00
Device: tps kB_read/s kB_wrtn/s kB_read kB_wrtn
sda 39762.26 0.00 0.00 0 0
$ iotop
==============
Total DISK READ : 0.00 B/s | Total DISK WRITE : 0.00 B/s
Actual DISK READ: 0.00 B/s | Actual DISK WRITE: 0.00 B/s
TID PRIO USER DISK READ DISK WRITE SWAPIN IO> COMMAND
29161 be/4 xuwenhao 0.00 B/s 0.00 B/s 0.00 % 56.71 % stress -i 2
29162 be/4 xuwenhao 0.00 B/s 0.00 B/s 0.00 % 46.89 % stress -i 2
1 be/4 root 0.00 B/s 0.00 B/s 0.00 % 0.00 % init
你会看到,在 top 的输出里面,CPU 就有大量的 sy 和 wa,也就是系统调用和 iowait。
如果我们通过 iostat,查看硬盘的 I/O,你会看到,里面的 tps 很快就到了 4 万左右,占满了对应硬盘的 IOPS。
如果这个时候我们去看一看 iotop,你就会发现,我们的 I/O 占用,都来自于 stress 产生的两个进程了。
总结
在顺序读取的情况下,无论是 HDD 硬盘还是 SSD 硬盘,性能看起来都是很不错的。不过,等到进行随机读取测试的时候,硬盘的性能才能见了真章。因为在大部分的应用开发场景下,我们关心的并不是在顺序读写下的数据量,而是每秒钟能够进行输入输出的操作次数,也就是 IOPS 这个核心性能指标。
你会发现,即使是使用 PCI Express 接口的 SSD 硬盘,IOPS 也就只是到了 2 万左右。这个性能,和我们 CPU 的每秒 20 亿次操作的能力比起来,可就差得远了。所以很多时候,我们的程序对外响应慢,其实都是 CPU 在等待 I/O 操作完成。
在 Linux 下,我们可以通过 top 这样的命令,来看整个服务器的整体负载。在应用响应慢的时候,我们可以先通过这个指令,来看 CPU 是否在等待 I/O 完成自己的操作。进一步地,我们可以通过 iostat 这个命令,来看到各个硬盘这个时候的读写情况。而 iotop 这个命令,能够帮助我们定位到到底是哪一个进程在进行大量的 I/O 操作。
这些命令的组合,可以快速帮你定位到是不是我们的程序遇到了 I/O 的瓶颈,以及这些瓶颈来自于哪些程序,你就可以根据定位的结果来优化你自己的程序了。
问题解答
硬盘的随机读的性能是不如随机写的。我以前一直以为是反过来的,但是为什么呢?
磁盘内部也是有WAL队列,只需要把write request写入这个队列就可以了,但是读如果cache不命中就没有任何懒可偷。