1、冯诺伊曼结构:运算器、控制器、存储器、输入输出。
和高级编程语言里面一个函数方法类似:输入输出就相当于出入参,运算器就是方法中的各种算术、逻辑或者ifelse等操作。控制器和存储器在函数方法这个例子中提现不出来,实际上控制器操作的是指令的取出和解码,取出是指从PC寄存器拿到地址,根据地址去内存中取出具体指令,加载到指令寄存器中,解码是指把指令寄存器中的指令解析成具体的操作,比如跳转,计算等等;而指令就是放在存储器中。
2、设计和使用四大组件要考虑的计算机两个核心指标是:性能和功耗。
3、程序的CPU执行时间=指令数(程序需要多少条指令)x CPI(指令需要多少个基本操作)x Clock Cycle Time(执行一个基本操作的耗时,即主频表示的时钟周期时间)
4、提升CPU性能:提升主频,增加更多CPU核心数量、并行、加速大概率事件(缓存)、流水线、预测(预加载)
5、高级编程语言->汇编语言(相当于机器码的包装,使程序易读易写)->机器码(即CPU指令二级制形式),不同体系结构CPU的机器码也不同,机器码和操作系统无关
6、一个计算机程序是怎么被分解成一条条指令执行的:
CPU由多个寄存器组成,一个寄存器由n个触发器或者锁存器组成,这个寄存器就可以保存n位的数据,比如64位intel cpu,寄存器就是64位,也就是说这块cpu的一个寄存器可以保存64位数据。
寄存器的寄存是程序委托cpu保管,保管什么东西呢?保管指令或者指令内存地址或者数据或者计算的结果。
三种特殊寄存器:PC寄存器(指令地址寄存器,用来存放下一条需要执行的指令的内存地址);指令寄存器(存放当前正在执行的指令);条件码寄存器(用一个个的标记位来存放CPU进行算术或者逻辑计算的结果,此处有疑问:存的计算结果是哪条指令计算的结果呢?答:当前执行的)程序开始执行:cpu从PC寄存器中拿到指令地址,去内存中把这条指令读取到指令寄存器中执行,然后顺序读取下一条指令,一直执行下去。当遇到J指令时(jump),此时会改变PC寄存器中的地址值,不再是顺序加载,而是改成跳转目标指令的地址值。
if…else的实现逻辑:if()对应生成两条指令:cmp(compare:比较)和jne(jump if not equal:不相等就跳转到)。cmp指令用来比较得出这个条件的结果,然后存到条件码寄存器中,如果结果是TRUE,就把条件码寄存器中的零标志条件码设置成1(记住TREU==1)。cmp执行完毕后,PC寄存器继续执行下一条指令jne,jne会查看零标志位的值,如果是0(false)就会跳转jne后面跟着的操作数x的位置,x为else条件中生成的第一条指令。这时PC寄存器不再自增变成下一条指令地址(即条件为true时生成的指令),而是直接被设置成x这个位置地址(去走else的逻辑了),然后CPU把x位置的指令加载到指令寄存器中执行。并且else里会有一条”废“指令,if里面的所有指令执行完后也会加一条jmp指令跳转到这条废指令,目的就是让if和else里内容结束后最终的位置都一样。
所以想在机器层面实现goto很简单,除了本身需要的PC和指令寄存器,再加一个条件码寄存器即可。switch…case猜想:3个case以内等同于if..else逻辑,多于3个case,先cmp所有的case,找到匹配的case,直接jmp过去。
7、cpu是如何执行函数调用的?
和if..else不同,函数A中调用了函数B,执行完函数B之后还需要回到函数A继续执行。那么是否可以先跳转函数,执行完函数再goto回来继续执行的方式呢?
答案肯定是不行的,因为跳转出去之后就不知道回哪里了,那么为什么dowile能知道回哪里呢?
for、dowhile循环最终都是满足dowhile循环的机器码:举例在do的第一行地址为L2,指令顺序执行,到while的时候判断条件后goto到L2(继续循环)或者往下执行(循环完毕)。整个跳转都是在一个函数内完成,而函数跳转不一样,main跳转到add,add执行完cpu懵逼了,不知道该继续往哪里走了,这时候就要告诉cpu,add函数你执行完之后再去main里面某个地址位置继续执行。
那谁来告诉cpu后面执行完要去main里某个位置继续执行呢,文章也说了,一开始想法是函数内联,函数内联即把被调用的函数add直接代替call写在main中,使它们变成一个函数,一个函数内肯定就顺序执行即可,也不用goto了。
但是直接用函数内联的方式在有相互调用的情况下是不适用的,那么就要找一套能适用于所有情况的策略。于是就选择了栈。main先压栈,此时栈顶栈底都是main,然后add进来,此时栈底是main,栈顶是add,add执行完后出栈,栈顶变成main继续执行。
(引用:过程在高级语言中也称为函数,方法。一个过程的调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。此外,它还必须在进入时为过程的局部变量分配空间,并在退出时释放空间。大多数机器,包括我们一直讲的 IA32,只提供转移控制到过程和从过程中转移出控制这种简单指令。数据传递和局部变量的分配释放都是通过操纵程序栈来实现。)
理论:“每一个帧栈都建立在调用者下方,即地址递减的方向。”
可以想象一个乐事薯片桶倒着放,开口向下,这个桶也不是无限容量的,是有限制的,比如这是一个20厘米的桶,开口位置是0,底部是20,这时候往桶里放一个1厘米高的薯片A,(别和我说这违反万有引力定律)它占用的内存为19(栈指针,抽象可以叫顶部)至20(帧指针,抽象叫底部),再来一个1厘米高的薯片B,它占用的内存为18(顶部)至19(底部)。新来的这个薯片B我觉得它味道不够,给它加点别的料(局部变量需要分配内存空间)导致它变成了5厘米高,所以它现在在内存中的位置为14至19,可以看到底部位置没变,顶部位置变小了(18变成了14),也就是说把栈指针减去一定的值就相当于给这个帧栈分配了更多的内存空间,相反,把顶部数字(栈指针)增大,就相当于压缩了帧栈长度,也就是内存被释放了。
为什么会发生stackoverflow?因为栈中压如的内容太多了,比如方法内部弄了一个很大的局部数组变量,或者方法无限递归调用,导致方法无限被压栈,栈就爆了。函数内联也不是一无是处,在被调用的函数中,没有再调用其他函数(此种成为叶子函数,即没有分支了),就可以用函数内联来优化,可以减少出入栈带来的损耗,但是如果这个函数被很多地方调用,就不适合这种方式了,因为很多地方都要写一遍内联,整个程序占用空间就大了。
8、为什么程序无法同时在Linux和Windows下运行?
既然程序最终都被变成机器码去执行,CPU也没有变,为什么同一个程序没法运行在两个系统上呢?
实际上“高级语言->汇编语言->机器码”这个过程是简化过的,实际上是由两部分组成:
编译和装载。程序先被编译、汇编、链接,生成可执行文件,再被装载器装载到内存中,CPU从内存中读取指令和数据,才开始真正执行程序,ps:为什么要这样设计?
linux装载器可以解析ELF格式,windows装载器可以解析PE格式可执行文件。
9、内存要多少才够用?
程序在物理内存中是已“分段”形式存在,这样会导致内存碎片,即明明剩余的内存空间足以加载某个程序,但是因为剩余空间是碎片化的,不是完整的,所以没法加载(类似网游里的背包,一把武器占4个格子,一个药水占一个格子),于是采用内存交换策略来解决,即把一些内存中程序先放到硬盘里,重新加载到内存中的时候位置调整一下,去除一下内存碎片,就像等于网游中,你把背包整理一下之后,背包碎片格子就没了。但是这样也有个问题就是硬盘速度比内存慢得多,如果很多程序或者程序很大的话,内存交换就会很慢,于是采用内存分页策略解决,即内存(虚拟和物理一样)不让程序已内存分段形式放进来了,而是内存自己先把自己划分成一段段固定大小的页(通常4KB一页),程序加载也按一页一页(分页不用连续)加载进来,并且可以不用一次性加载一个完整的程序进来,只加载当前运行所需的部分即可,可能只对应内存中少数的几页,这样内存就没有了碎片,即使内存不够,也可以让某些程序释放掉一些页,此时写入硬盘的也就很少的几页内容。
之前说过程序运行的指令是顺序执行,也就是说指令是要连续存储在一起的。那么分页不连续的话,一个程序多个页是怎么联系起来的呢?
答案是因为程序在虚拟内存的分页是连续的,虚拟和物理之前有映射表关联。
虚拟内存和虚拟内存地址和物理内存有什么关系呢?
答案是某个程序运行所需要的内存可能是大于你的内存条空间的,比如你的内存条空间是8g,而使命召唤游戏运行需要16G内存,这时候不是所有数据都能放的进内存条,有一部分数据要放在别的地方比如硬盘,等到需要这部分数据再调度到内存条里运行,虚拟内存就是这个游戏运行所需要的内存空间总和即16g。
然后虚拟内存和物理内存都被分页之后,并且是一对一映射起来的,此时虚拟内存的页数肯定大于物理内存页数,有一部分不是没有被映射到吗?操作系统会把内存条上不活跃的页,让它失效,并把它写到磁盘去,把需要用的虚拟内存的页放到内存条中,并修改页表中的映射,这就能保证所有页都会被调度到了,还是上面说的内存交换。
当一个虚拟内存地址起始位置是4,偏移量是20(即一页的大小),如果程序运行需要它了,就会先去页映射表中找到物理内存中对应的页的起始位置,比如是66,如果页不在,就用上面的内存失效机制调入此页,然后用起始位置66和偏移量在物理内存中组成一块真正存在的内存块(页),接着就是访问物理内存中数据了。
JVM为什么还要自己做内存管理呢?实际上上面说的是系统级别(硬件)的内存管理,而jvm已经是上层应用了,是需要申请内存大小来保证自己这个应用使用特定规模的资源。
10、动态链接
第8条中提到了静态链接,静态链接是生成可执行文件之前的操作,动态链接是在程序运行时。
静态链接出现的原因:一个程序的所有代码不可能在一个源文件中,并且每个源文件可能会互相依赖,比如函数的调用,但是每个源文件都是独立编译的,那么只能把这些源文件产生的目标文件进行链接,从而生成一个可以执行的文件。静态链接形成的是静态库,也可以简单看做是一组目标文件的集合。如果多个程序都依赖某个静态库,则多个程序都有这个库的副本,内存浪费严重,并且如果库更新,则所有依赖程序都要重新编译和链接生成新的可执行文件。但是也有好处就是链接好的可执行文件已经具备了这个程序执行所需的所有东西,运行的时候速度快。
为了解决内存空间浪费和更新困难,出现了动态链接。
如果能把各个程序中相同的功能抽取出来,多个程序共用这一块,而不用每个里面都有重复的,可以大大提升内存使用效率,这就是动态链接,比如.so库就是动态链接库,也叫共享库。
共享库不会像静态库一样在内存中存在多份副本,而是多个程序在执行时共享一个副本;当动态库更新,并不需要把所有依赖程序重新链接,只需要替换更新的目标文件,当程序下一次运行时,新的目标文件会被自动加载到内存并且链接起来。缺点就是把链接这环节推迟到程序运行时,每次执行程序都需要进行链接,运行时速度相对减慢。
虽然动态链接吧链接这个环节推迟到程序运行时,但是在形成可执行文件时,是需要动态链接库的。比如在形成可执行文件时,发现引用了一个外部的函数,此时会检查动态链接库,发现这个函数名是一个动态链接符号,此时可执行文件就不对这个符号进行重定位,而把这个过程留到装载或者运行时进行,即这个函数在不同可执行文件中的虚拟内存地址是不一样的,等到装载或者运行时才去重定位成物理内存地址。
静态链接库与动态链接库都是共享代码的方式,如果采用静态链接库,则无论你愿不愿意,lib中的指令都被直接包含在最终生成的EXE文件(可执行文件)中了。但是若使用DLL(或者SO),该DLL不必被包含在最终的EXE文件中,EXE文件执行时可以“动态”地引用和卸载这个与EXE独立的DLL文件。
19、CPU工作电路:“译码->执行->更新寄存器”
image.png1、自动计数器不停自增,即PC寄存器。---------取指令 (控制器中)
2、其后连接一个译码器,译码器连着D触发器组成的内存(物理内存) (存储器中)
3、译码器找到对应计数器所表示的内存地址,然后读取出里面的CPU指令。-------------寻址 (存储器中)
4、读取出来的指令会通过CPU时钟的控制,写入到一个D触发器组成的寄存器中,即指令寄存器。-----------准备执行(控制器中)
5、指令寄存器后面再跟一个译码器,不用来寻址,用来把指令解析成opcode和对应的操作数。-----------解析指令即译码 (控制器中,符合第一条中所说,控制器的工作就是取指令和解码)
6、解析之后的数据,连接着运算器,其中包含数据寄存器、ALU、条件码寄存器等,开始各种逻辑和算术运算。对应的结果,写回到D触发器组成的寄存器或者内存中。 ----执行并更新寄存器 (执行在运算器中,更新寄存器在在运算器中,更新内存在存储器中)
为什么我们的 if…else 会变成这样cmp和jmp两条指令,而不是设计成一个复杂的电路,变成一条指令?
你可以反过来想:如果设计成一条指令,那么这条指令的电路就是:译码-寻址-执行判断-写入条件码寄存器-执行各种逻辑、算术运算和跳转地址-更新结果到寄存器。而两条指令就很清晰了:
1、cmp的电路:译码-寻址-ALU-写入条件码寄存器;
2、jmp的电路:译码-寻址-控制器直接跳转。
可以看到拆分指令之后就简洁很多,并且可以复用。
20、面向流水线的指令设计
原因:有的指令执行时间长,有的指令执行时间短,如果随便设置一个时钟周期,会导致上一条指令结果还没写到内存中,下一条指令已经开始执行了。那么时钟周期设置多少合适呢?
应该设置成最复杂的那条指令执行完毕的时间为一个时间周期,就像运动会长跑,会以最慢的人跑完的时间为结束时间。但是这就有个问题,简单的指令在时间周期内很快就执行完了,此时CPU就无所事事了,大大降低了CPU效率。
于是产生了下面的指令周期设计:
1、取指令:存储器中的译码器从内存中取出指令,写入到控制器中的指令寄存器中;
2、译码:控制器中的指令译码器吧指令解析成对应的控制信号、地址、数据;
3、执行:运算器中的ALU计算、逻辑运算操作等;
4、访存:计算过程中访问各个数据内存地址;
5、写回:计算结果写回寄存器或者内存。
CPU指令执行过程,其实就是各个电路模块组成:取指令时,需要一个译码器从内存中取出写入到指令寄存器中;指令译码的时候需要另外一个译码器把指令解析成对应的控制信号、内存地址和数据;指令执行的时候需要ALU来完成计算工作等等。这些一个个独立电路模块可以看成团队中的产品经理、开发、测试等。
后端开发完接口交给前端开发,不用等功能上线就可以进行下一个需求的开发,这种协作模式,就叫做指令流水线,每一个独立的步骤,称之为流水线阶段或者流水线级。“取指令--指令译码--指令执行”这就是一个三级流水线。
从上到下三条指令,一列就是一个时钟周期,可以看到在第三列这个时钟周期内,在做第一条指令的“执行”,同时在做第二条指令的“译码”(可以看到译码的时间比一个时钟周期要短,所以仍然存在浪费和等待),同时在做第三条指令的“取指令”,即一个时钟周期内同时运行三条指令的不同阶段。
现在把原先要确保最复杂的那条指令完成时间作为时钟周期,变成了要确保最复杂的那条流水线级完成时间作为时钟周期,大大缩短时钟周期。
那么这样有一个问题,当最复杂的流水线级完成时间一样很长很长怎么办呢?那么继续对这个流水线级进行拆分,分出更多的步骤,并且满足这些步骤的时间都尽量差不多长。
现代的CPU流水线级已经达到了14,级一个时钟周期内,同时执行14条指令的不同阶段。
那么问题又来了,既然流水线能增加效率,为什么不把流水线级拆分的更多呢,比如20,30?
一个流水线级执行完毕,总会有输出,那这个输出放哪儿呢,需要放到一个流水线寄存器中,那这个输出给谁呢?是在下一个时钟周期,给下一个流水线级处理。所以每增加一个流水线级,就要增加一个流水线寄存器操作。
课后问题:“一个 CPU 的时钟周期,可以认为是完成一条简单指令的时间。在这一讲之后,你觉得这句话正确吗?为什么?在了解了 CPU 的流水线设计之后,你是怎么理解这句话的呢?”
答:在单指令周期情况下,一个CPU时钟周期是最复杂的那一条指令时间;在流水线指令周期情况下,一个CPU时钟周期是最复杂的那一条流水线级的时间,这样看这句话是不正确的。但是流水线级拆分的足够细,因为取指令这个操作是原子化的,所以时钟周期就等同于取指令那条流水线级的时间,如果取指令是最简单的指令的话,这句话又是正确的。
22、流水线也不是完美的。
现在指令已经不是顺序执行了,而是第一条指令执行到一半的时候,后面几条指令已经开始执行了,这样会有什么问题呢?
比如这么一种情况,当指令2的执行依赖指令1的执行结果,指令3的执行又依赖指令2的结果,这时候流水线并不能缩短整体执行时间。这就是所谓的“冒险问题”,这个例子只是数据冒险,还有结构冒险,控制冒险等,后面给出乱序执行、分支预测等相应的解决方案。
23冒险中的机会
风险越大,回报越大。
“结构冒险”本质上是硬件层面的资源竞争问题。比如在同一个时钟周期的不同流水线上,都去调用内存。
image.png
生活中类似的冲突就是薄膜键盘,多个按键共用一个线路,这就是为什么重度键盘使用者喜欢用机械键盘,因为每个按键都有独立线路。
这种冲突的解决方案就是增加资源。上图中的“访问内存”和“取指令”操作,可以吧内存分为两部分,各有各的地址译码器,分别对应这两个操作,这两块内存分别是“存放数据的数据内存”和“存放指令的程序内存”。但是我们今天使用的CPU并没有用这种方案,因为拆分的话,两块内存我们没法根据实际应用宝去动态分配,虽然解决了资源冲突,但是失去了灵活性。不过没有拆分内存,却拆分了CPU内部的高速缓存,分为了“指令缓存”和“数据缓存”,为什么要这样分呢,因为现代内存的访问速度远比CPU慢,所以CPU都是先把指令和数据从内存中加载到高速缓存中,然后后续的访问都是访问高速缓存。
“数据冒险”就是逻辑层面的数据依赖。解决方案就是“流水线停顿”或者叫“流水线冒泡”,当指令译码时,是可以拿到对应的指令所需要访问的寄存器和内存地址的,所以在这个时候就可以判断这个指令是否会触发数据冒险,如果触发,就让整个流水线停顿一个或多个时钟周期。如图:
image.png
指令从上到下分别是1-6。指令4的执行操作依赖指令2的写回,如果不停顿一个时钟周期,则它的执行和指令2的写回会在同一个时钟周期,结果就会出错。思考,指令5的执行停顿了2个时钟周期,那么它是依赖谁呢?或者说是为什么呢?
是因为当有一个指令暂停了,后续指令都要插入对应的暂停(如果不加后续的所有流水线就“错位”了)。实际上指令5只是依赖了指令3的写回。
为什么又叫流水线冒泡呢?是因为时钟信号会不停的0,1之间自动切换,并没有办法真正停下,所需需要停下的地方就插入一个NOP操作,实际就是一个什么都不干的操作,就好像水管中进了一个空的气泡。
此种方案是牺牲CPU性能为代价的,NOP操作表示CPU在空转,光吃饭不干活。后面会说更高级的解决方案。
23、流水线里的接力赛
image.png并不是所有的指令都有“取指令、译码、执行、访问内存、写回寄存器”这几个流水线阶段。有的只是从寄存器往内存写数据,就没有写回寄存器这个步骤,像加法操作指令,所有操作都在寄存器中完成,没有访问内存。但是在流水线中,你即使没有对应的阶段,也不能跳过这个阶段直接执行下一个阶段,而是运行一次NOP操作,否则会有结构冒险,如图:
image.png
image.png
如果指令3没有访存阶段,于是在下一个时钟周期直接执行写回,就会和指令2在同一个时钟周期都去执行写回操作,于是结构冒险产生。
上面了过多NOP会降低新能,并且每一个流水线的NOP,后续指令都要插入对应的NOP:
image.png
image.png
解决NOP过多:操作数前推或者叫操作数旁路。即把第一条指令的执行结果,直接转发给第二条指令的ALU作为输入,抛弃了第一条指令的写回寄存器和第二条指令从寄存器中读出的操作。为了能够实现这个“转发”,需要在CPU的硬件中,在单独拉一根信号信号传输的线路出来,使得ALU的计算结果,可以重新回到ALU的输入里来,这样一条线路就叫“旁路”。
image.png
24、数据冒险的继续优化方案
看一个例子:
image.png
如图所示,即使已经使用了操作数前推,但是第二条指令的执行需要的寄存器这里的数据不是第一条指令执行完毕后的结果,而是需要第一条指令从内存中读取出来,所以还是需要停顿一个时装周期,等待访存操作做完。
优化方案:在指令停顿的时候,让后面没有数据依赖的指令去执行。--乱序执行
CPU中乱序执行过程:
image.png
举例:
a = b + c
d = a * e
x = y * z
在取指令和指令译码阶段,还是顺序执行,但是指令译码阶段需要做一个分析,什么分析呢?即指令之间的数据依赖关系;
分析完依赖关系后,把指令分发到保留站中(保留站最多可以放多少条指令呢?),保留站是做什么呢,类似于火车站,指令就是火车,指令在保留站中等待其所依赖的数据,就等火车等乘客,等来了数据才会继续下一个环节:指令执行。
(但是这里有个问题啊,所有指令都是在保留站中等待自己依赖的数据,那如果依赖的是上一条指令的结果呢?)
(PS:我的理解是,指令到保留站后,检查所依赖数据是否都确定,确定的就进行下一步,去FU中执行;不确定就等在保留站中,不停地检查数据,直到可以去下一步执行。)
指令执行,这里是由功能单元FU来执行,其实就是ALU。图上可以看到有很多FU并行在运行(但是不同功能的FU能执行的指令不相同),就像铁轨,有的是从杭州到上海,有的是从北京到河北;
指令执行完毕后,结果并不立即写回寄存器,而是放到重排缓冲区,在这里面,CPU会按照取指令的顺序,对指令的某些东西进行重排,什么东西呢?即指令的计算结果;重排成什么顺序呢?重排成读取指令的顺序,即某一个指令排在其前面的指令都执行完毕,才会提交指令完成整个指令的运算结果。这里有点像git管理分支,feature分支先commit到本地(重排缓冲区),然后把主干分支合并解决冲突之后,才能去push到主干。
有了重排,就可以让CPU在执行指令时是乱序的,并且不会影响指令之间的数据依赖,但是在外部看来所有指令又是有序完成的。
指令的实际计算结果数据,并不直接写到内存或者高速缓存,而是先写到存储缓冲区,最终写入到缓冲区和内存。
比如上面的例子:
d依赖a的计算结果,所以d是在a计算完毕后执行,但是CPU不会闲着,因为x指令同样进入了保留站,并且其依赖的y、z数据是确定的,所以不会等待计算d,而是先计算x的值。极限情况,如果只有一个FU能计算乘法,那么这个FU并不会因为d要等待a的计算结果就闲置,而是会先去计算x的值。x计算完成之后,d也等来了a的计算结果,这时候这个FU会去计算d的结果。然后在重排缓冲区中,把对应的计算结果的提交顺序,改为a->d->x,但是我们知道实际的计算顺序是a->x->d。
(指令只有在执行阶段是乱序,后面的内存访问和数据写回阶段依然是有序的,为什么需要这样呢?讲道理执行可以乱序是因为知道数据没有依赖才可以乱序,那么数据都互相没有依赖了,和数据相关的内存访问和数据写回应该也没有依赖呀。比如上面的a的结果的写回和x的写回有啥依赖吗,没有呀。)
我的思考是上面的例子只是简单的计算,会不会是后面有一些逻辑运算对a和x的结果读取有顺序要求,如果不保证先正确读取a再正确读取到x的话会对逻辑运算有影响。
25、取指令和指令译码过程会出现停顿吗?如果会怎么解决?
比如if...else,要等cmp执行完毕后,去更新条件码寄存器后,才知道是否执行下一条指令,还是跳转到另一个内存地址,去取别的指令。这种等待的情况,叫做“控制冒险”。
在流水线中,第一条指令进行指令译码的时钟周期里,第二条指令进行的是取指令操作,这个时候我们其实还没有开始执行指令阶段,自然不知道比较结果。
解决思路:CPU猜一猜,条件跳转后执行的指令应该是哪一条。
最简单策略:假装分支不发生(静态预测技术),后果:猜对了,赚了;猜错了,把后面已经取出指令已经执行的阶段给丢弃掉。丢弃的操作在流水线中药zap或者flush,清楚操作是有一定的开销的。
更优化的策略:一级分支预测(One Level Branch Prediction),或者叫 1 比特饱和计数(1-bit saturating counter)。这个方法,其实就是用一个比特,去记录当前分支的比较情况,直接用当前分支的比较情况,来预测下一次分支时候的比较情况。
通过状态机保存更多状态来更优化这个预测策略:如果连续是一种情况A,我们就认为更有可能发生这种情况A,即使中间出现过一次另一种情况B,依旧认为下一次还是这种情况A。
image.png
这个状态机里,我们一共有 4 个状态,所以我们需要 2 个比特来记录对应的状态。这样这整个策略,就可以叫作 2 比特饱和计数,或者叫双模态预测器(Bimodal Predictor)。
26、CPU 只能在一个时钟周期里面,取一条指令,其实可以做到2以上
多发射(Mulitple Issue)和超标量(Superscalar):利用增加硬件的方式,让取指令和指令译码都可以并行执行。可以一次性从内存中取出多条指令,分发给多个并行的指令译码器进行译码,然后对应交给不同的功能单元去处理,这样一个时钟周期内,能够完成的指令就不只一条了。
image.png
image.png
27、CPU继续优化:超线程和SIMD
超线程:
背景:上面所说的CPU,不管是多核CPU同时运行不同程序,还是单核CPU核心里面切换运行不用线程任务,在同一时间点,一个物理的CPU核心都只会运行一个线程的指令,并没有真正做到指令的并行运行。
超线程的CPU,是把物理层面CPU核心,“伪装”成两个逻辑层面的CPU核心,这个CPU会在硬件层面增加很多电路,使得我们可以再一个CPU核心内部,维护两个不同线程的指令的状态信息。
image.png
可以看到译码器和ALU并没有在物理层面提供多份,否则就成了物理多核了。。所以超线程也并不是真的去同时运行两个指令。
目的:是在一个线程A的指令,在流水线停顿的时候,让另一个线程B去执行指令。因为这个时候CPU的译码器和ALU就空出来了,B就可以拿去干自己的事,因为线程B可没有对于线程A里面指令的关联和依赖。
这样,CPU 通过很小的代价,就能实现“同时”运行多个线程的效果。通常我们只要在 CPU 核心的添加 10% 左右的逻辑功能,增加可以忽略不计的晶体管数量,就能做到这一点。
应用场景:比如,我们需要应对很多请求的数据库应用,就很适合使用超线程。各个指令都要等待访问内存数据,但是并不需要做太多计算。也就是说我们的CPU计算没有跑满,却一直停在流水线上,等待内存里面的数据返回(IO)。这时候让CPU各个功能单元去处理另一个数据库连接的查询请求。
image.png
一个物理核心为4,逻辑核心为8的CPU。也就是说它可以利用超线程技术,同时运行8条指令。
使用循环来一步一步计算的算法,一般被称为 SISD,也就是单指令单数据(Single Instruction Single Data)的处理方式。
如果你手头的是一个多核 CPU ,那么它同时处理多个指令的方式可以叫作 MIMD,也就是多指令多数据(Multiple Instruction Multiple Dataa)。
SIMD 指令是什么呢?叫做:单指令多数据流
为什么 SIMD 指令能快那么多呢?这是因为,SIMD 在获取数据和执行指令的时候,都做到了并行。一方面,在从内存里面读取数据的时候,SIMD 通过CPU增加寄存器容量,让一个寄存器一次性加载的数据量增加,即一次性读取多个数据。比起循环分别读取多次对应数据,时间就省下来了。
当到了执行层面,4个数据各自计算没有互相依赖,也就没有冒险,只要CPU有足够多的功能单元,就能同时计算。
image.png
举例子:向量运算或者矩阵运算。比如图片、视频、音频处理、机器学习算法计算。
(从 Pentium 时代开始,我们能在电脑上听 MP3、看 VCD 了,而不用专门去买一块“声霸卡”或者“显霸卡”了。没错,在那之前,在电脑上看 VCD,是需要专门买能够解码 VCD 的硬件插到电脑上去的。而到了今天,通过 GPU 快速发展起来的深度学习技术,也一样受益于 SIMD 这样的指令级并行方案,在后面讲解 GPU 的时候,我们还会遇到它。总结延伸)
超线程,其实是一个“线程级并行”的解决方案。它通过让一个物理 CPU 核心,“装作”两个逻辑层面的 CPU 核心,使得 CPU 可以同时运行两个不同线程的指令。虽然,这样的运行仍然有着种种的限制,很多场景下超线程并不一定能带来 CPU 的性能提升。但是 Intel 通过超线程,让使用者有了“占到便宜”的感觉。同样的 4 核心的 CPU,在有些情况下能够发挥出 8 核心 CPU 的作用。而超线程在今天,也已经成为 Intel CPU 的标配了。而 SIMD 技术,则是一种“指令级并行”的加速方案,或者我们可以说,它是一种“数据并行”的加速方案。在处理向量计算的情况下,同一个向量的不同维度之间的计算是相互独立的。而我们的 CPU 里的寄存器,又能放得下多条数据。于是,我们可以一次性取出多条数据,交给 CPU 并行计算。
28、异常和中断:程序出错了怎么办?
29、CISC和RISC:为什么手机芯片都是ARM?
复杂指令集(Complex Instruction Set Computing,简称 CISC):
精简指令集(Reduced Instruction Set Computing,简称 RISC):
image.png
第一点是功耗优先的设计。一个 4 核的 Intel i7 的 CPU,设计的时候功率就是 130W。而一块 ARM A8 的单个核心的 CPU,设计功率只有 2W。两者之间差出了 100 倍。在移动设备上,功耗是一个远比性能更重要的指标,毕竟我们不能随时在身上带个发电机。ARM 的 CPU,主频更低,晶体管更少,高速缓存更小,乱序执行的能力更弱。所有这些,都是为了功耗所做的妥协。第二点则是低价。ARM 并没有自己垄断 CPU 的生产和制造,只是进行 CPU 设计,然后把对应的知识产权授权出去,让其他的厂商来生产 ARM 架构的 CPU。它甚至还允许这些厂商可以基于 ARM 的架构和指令集,设计属于自己的 CPU。像苹果、三星、华为,它们都是拿到了基于 ARM 体系架构设计和制造 CPU 的授权。ARM 自己只是收取对应的专利授权费用。多个厂商之间的竞争,使得 ARM 的芯片在市场上价格很便宜。所以,尽管 ARM 的芯片的出货量远大于 Intel,但是收入和利润却比不上 Intel。
30、GPU(上)
image.png对于图像进行实时渲染的过程,可以分为5个步骤:即“图形流水线”
1、顶点处理:每一个多边形,都有多个顶点,并且在三维空间都有其对应位置坐标。但是屏幕是二维,所以需要用顶点处理把三维位置转为二维。转变完的顶点其实仍然在一个三维空间里,只是Z轴变成了正对屏幕的”深度“。
image.png
2、图元处理:即把顶点处理完毕的各个顶点连起来,变成多边形。并且剔除和裁剪不在屏幕里的内容,减少后续流程的工作量。
image.png
3、栅格化:我们的屏幕是通过一个个像素来显示内容,栅格化即把这些多边形转化为屏幕里的一个个像素点。
image.png
4、片段处理:栅格化变成像素点后,图是”黑白“的,需要计算每一个像素的颜色、透明度等,给像素点上色,即片段处理。
image.png
5、像素处理:把不同的多边形像素点”重叠“在一起,可能前面的多边形是半透明,那么前后的颜色要混合在一起变成一个新颜色;可能前面的多边形挡住了后面的多边形,那么只要显示前面的颜色即可。
上面的每个步骤中的每个“顶点、图元、片段、像素”互相之间没有依赖,都可以并行独立计算。
简单记住:点->片->像素->上色->混合
image.png
图形流水线:
image.png
假设屏幕分辨率:640x480,那么像素点一共有307200,如果想达到60帧,那么我们每秒要重新渲染60次这些像素点,即每秒要完成60*307200=1800多万次像素的渲染,从栅格化到像素处理,每个像素3个流水线级步骤,即使每个步骤只有一个指令,也需要5400万条指令,即1秒要完成5400万条指令,大概54M条指令。
90年代CPU性能大概在66MHz-100HMz,如果用来渲染3D图形,那基本可以跑满,CPU不用干其他事了。实际每个步骤不可能只有一个指令,所以当时的CPU根本带不动3D图形渲染。
解决方式:重新设计一块专门处理图形渲染的硬件
因为:图形渲染流水线每个步骤是固定的,不会出现CPU流水线中的各种冒险,也就不会有流水线停顿、乱序执行等使CPU计算变复杂的问题。
30、GPU(下)
曾经的显卡中是没有“顶点处理”这个步骤的:
image.png
所以3D性能还是受限于CPU性能,无论你显卡有多快。
1999的256显卡开始,就把顶点处理的计算能力,从CPU放到了显卡中。
2001年Dirext3D8.0开始,出现可编程管线GPU:
image.png
image.png
芯片瘦身:
image.png
天然并行的GPU:
image.png
仿照CPU的SIMD创造SIMT:
在 SIMD 里面,CPU 一次性取出了固定长度的多个数据,放到寄存器里面,用一个指令去执行。而 SIMT,可以把多条数据,交给不同的线程去处理:
image.png
既然做成了“通用计算”架构,免不了有ifelse,并且分支预测那部分电路,已经被“瘦身”时候砍掉了,就会出现流水线停顿。于是借鉴CPU的超线程技术,给GPU也打造超线程:
image.png
35、存储器层次结构全景
常用储存器有哪些?硬盘,内存条
“存储器是一个通过各种不同的方法和设备,一层一层组合起来的系统”,有哪些方法?有哪些设备?
1、CPU中的寄存器:只能存放极其有限的信息,但是速度非常快,和CPU同步;
2、CPU中的高速缓存:使用静态随机存取存储器芯片SRAM,静态是因为只能在通电状态保存数据,一旦断点,数据立即丢失;同样存储数据有限,访问速度快。高速缓存分L1/L2/L3三层;L1嵌在CPU核心内部,通常分成“指令缓存”和“数据缓存”,分开存放CPU使用的指令和数据;L2同样每个CPU都有,但是不在CPU核心的内部,所以访问速度会比L1慢一些;而L3一般都是多个CPU核心共用,尺寸会更大,但是访问速度更慢;
3、内存:内存中的芯片和高速缓存的芯片不太一样,叫做DRAM,翻译叫动态随机存取存储器;比静态存取存储器芯片密度更高,容量更大,更便宜,动态是指DRAM需要不停的“刷新”才能保持数据被存储,为什么呢?因为数据是存储在电容中,电容是会漏电的,就需要定时的刷新充电。速度更慢。
4、硬盘:
理解记忆:CPU中的寄存器,相当于大脑当前正在思考和处理的数据,L1是我们的短期记忆,L2/L3是长期记忆,内存是书架;当我们记忆中没有资料,需要从书架上拿书翻阅,相当于从内存中加载数据到CPU的寄存器和缓存中,在通过大脑(CPU)处理和运算,但是记忆和书架都是有限大小的,想要更多的资料就要去图书馆,也就是硬盘。
image.png
!!注意:CPU并不是直接和每一种存储器设备打交道的,而是每一种存储器设备,只和它相邻的存储设备打交道,什么意思呢?比如CPU高速缓存是从内存里加载而来的,或者需要写回内存,并不会直接写回数据到硬盘,也不会直接从硬盘加载数据到CPU高速缓存中,而是先从硬盘加载到内存,再从内存加载到CPU高速缓存中。
36、局部性原理:数据库性能跟不上,加个缓存就好了?
我们能不能既享受 CPU Cache 的速度,又享受内存、硬盘巨大的容量和低廉的价格呢?
答案:存储器中数据的“局部性原理”,用原理来制定管理和访问内存的策略。
既然硬件层面没法解决,那就用软件策略方面来解决。
此原理包括“时间局部性”和“空间局部性”两种策略。
时间局部性:一个数据被访问了。那么它在短时间内还会被再次访问,如图:
image.png
比如:用户登录app后看了首屏,我们就认为他短时间内还会再次访问首屏,于是把他的登录信息从硬盘的数据库读取存储到缓存中。
空间局部性:如果一个数据被访问了。那么和它相邻数据也很快被访问。
image.png
比如:用户登录app后看了首屏,我们就认为他会继续访问其他相邻页面,于是准备好这些页面。
有了这两个策略,我们就可以把访问次数多的数据,放在快的存储器中;访问次数少的数据,放在慢但是大的存储器中。
具体怎么做呢?简单点,首先我们把用户访问过的数据,放到内存中,一旦内存里面放不下了,我们就把内存中最长时间没有被访问过的数据,从内存中移走,这就是“LRU缓存算法”。
但是这会带来一个问题,当要访问的数据不再内存中,还是要去硬盘上访问,如果这时的访问量远大于硬盘访问速度,还是导致访问不到数据(缓存穿透)。这就要看“缓存命中率”了,如果命中率大,即内存中存储的数据和用户要访问的数据越匹配,就越不会出现缓存穿透。
其实局部性原理放在四海都准确,包括人际关系,只要不是平均分布,就有优化的空间。
37、高速缓存(上)
思考:下面这两个循环所需要的时间差是多少:
int[] arr = new int[64 * 1024 * 1024];
long start = System.currentTimeMillis();
// 循环1
for (int i = 0; i < arr.length; i++) {
arr[i] *= 3;
}
long end = System.currentTimeMillis();
System.out.println("1 Time spent is " + (end - start));
start = System.currentTimeMillis();
// 循环2
for (int i = 0; i < arr.length; i += 16) {
arr[i] *= 3;
}
end = System.currentTimeMillis();
System.out.println("2 Time spent is " + (end - start) + "ms");
理论上循环2 的时间应该是循环1的1/16。
运行2次的结果:
1 Time spent is 59
2 Time spent is 33ms
1 Time spent is 35
2 Time spent is 22ms
这个差距和CPU的高速缓存有关。
CPU的速度到底比内存快多少呢?差距为120倍。举个例子:CPU是高铁,每小时350公里,内存就等于是一个老太太,每小时3公里。。。CPU需要不断的停下等待内存,因为CPU需要执行的指令和数据都在内存里。
为了弥补这个差距,不让CPU总在空转,于是引入了CPU高速缓存。
现在,内存中的指令、数据都被加载到L1-L3中,而不是直接由CPU去内存中拿。95%情况,CPU只需要访问L1-L3,从里面读取指令和数据,无需访问内存,这里的L1-L3是指物理上的SRAM芯片:
image.png
CPU从内存中读取数据到L1-L3过程中,是一小块一小块读取数据的,而不是按照单个数组元素来读取数据,者一小块数据,在CPU高速缓存中,叫做“缓存块”。
在我们日常使用的 Intel 服务器或者 PC 里,Cache Line 的大小通常是 64 字节。而在上面的循环 2 里面,我们每隔 16 个整型数计算一次,16 个整型数正好是 64 个字节。于是,循环 1 和循环 2,需要把同样数量的 Cache Line 数据从内存中读取到 CPU Cache 中,最终两个程序花费的时间就差别不大了。
高速缓存的数据结构和读取过程:
无论数据在不在高速缓存中,CPU始终会首先访问高速缓存。只有当高速缓存中找不到数据,才会访问内存,并将读取到的数据写入高速缓存中。
image.png
问题来了,CPU怎么知道想要的数据存放在高速缓存中什么位置呢?
内存块是地址,每一个内存块(Block)的地址始终映射到一个固定的高速缓存地址(Cache Line),这个映射关系使用的算法,就是求余运算:如图一共有32个内存块,8个高速缓存块,Å第21号内存块,在高速缓存中,对应的一定是第5号,因为21 mod 8 = 5。
image.png
实际情况中,通过会把高速缓存块数量设置成2的N次方,这样直接用内存块地址数字的二进制表示,取最低三位,转成十进制即是缓存块地址呦。比如21的二进制10101,最低三位是101,对应十进制是5。
但是取余的算法会导致重复的情况,比如13、29内存块对应的高速缓存块也是5,那怎么区分哪些是21对应的数据呢?
让我们先来看一下内存地址的结构,原来,CPU在读取数据时,不会读取一整个内存块的数据,而是读取一个他需要的整数,叫做CPU里的一个“字”,这个字在这个内存块位置,叫做“偏移量”。
所以一个内存的访问地址由三个部分组成:组标记(内存块的高2位信息,比如刚才的21去掉低3位的101,还剩高2位的10),索引(刚才的低3位的101),偏移量(“字”在内存块中的位置);
这三个组成部分和高速缓存中各个部分的对应关系如图:
image.png
可以看到高速缓存中的结构由“索引+有效位+组标记+数据”组成;
所以CPU访问一个高速缓存数据,步骤如下:
1、根据内存地址中的“索引”找到高速缓存中对应的“索引”;比如上面例子中的5,即21的低3位101
2、判断有效位(0无效,直接去内存中取,1有效);
3、对比内存访问地址的高位(组标记),和高速缓存中的组标记,确认高速缓存中的这块数据就是我们要找的内存数据,然后读取此高速缓存数据块;比如上面例子中的21中高2位的10
4、根据内存地址中的偏移量,从高速缓存块中对应找到希望读取到的字(CPU需要的整数)。
如果2、3这两个步骤CPU发现数据无效或者位置对不上,就会去内存中取数据,然后把数据跟新到高速缓存中,同时更新对应的有效位和组标记。
38、高速缓存(下)
volatile关键字的作用:确保一个变量的读取和写入,都会同步到主内存里,而不是从高速缓存中读取。
来由:因为同一个CPU中不同线程或者不同CPU核心中都有各自的缓存,可能A线程的更新,没有时间同步到内存,导致B线程里看不到,或者A线程有同步到内存,但是B没时间去内存取,一直读取的是自己的缓存。
一个性能相关问题:写入高速缓存的速度比写入内存快,我们要写入的数据,到底应该写到哪里呢?如果直接写到内存里,高速缓存的数据是否会失效呢?
答案:两种写入策略:
1、写直达:即每次数据都要写入到内存中:
image.png
缺点:速度慢。
2、写回:即每次数据只写到CPU高速缓存中。如果当CPU高速缓存中的数据要被替换覆盖时,才把数据写入到内存中,再吧新数据覆盖到CPU高速缓存中:
image.png
图中标记高速缓存为“脏”的意思是指:高速缓存中的数据和内存中不一致。
所以在“写回”之后,要多一步同步脏缓存的动作。并且更新缓存时候也要注意这个脏标记:如果在把内存中数据读到缓存中时,发现高速缓存块中有脏标记,也要先把缓存中写到内存,才能去覆盖这个缓存。
解决了性能问题,缓存一致性的问题如果解决?
答:MESI 协议。这是一个维护缓存一致性协议。
39、MESI协议
为了解决缓存不一致的问题,就需要一种机制,来同步两个不同核心里面的缓存数据,机制需要满足2个条件:
1、写传播:一个CPU核心的缓存数据更新,要同步到其他核的缓存中。
2、事务串行化:在一个CPU核心里面的数据读取和写入,在其他的节点(是什么?即其他CPU核心)看来,顺序是一样的。如果多个CPU核心中有同一个数据的缓存,那么对这个缓存数据的更新,就需要一个“锁”的概念,才能保证事务串行化。
简单记忆:多个CPU核心操作同一个数据,同一时间只能有一个核心去操作,操作完毕,其他核心才能去抢下一个操作权,这就是事务串行,一个一个来;并且操作完毕之后要把结果告诉其他核心,保证别人拿到操作权进行操作的数据是正确的。
解决方案:
1、总线嗅探:本质上把所有的读写请求都通过总线(Bus)广播给所有的CPU核心(万一其他CPU核心不接收呢?毕竟是广播),然后让各个核心去“嗅探”这些请求,再根据本地的情况进行响应,再根据自身情况进行响应。
基于总线嗅探机制,产生了MESI协议,也叫做“写失效”。在这个协议下,只有一个CPU核心负责写入数据,其他的核心只会同步读取这个写入。当它写入缓存之后,会去用总线嗅探机制发一个广播,广播的内容是一个“失效”请求,别的核心收到广播后自己判断是否也有一个“失效”版本的缓存块,然后把这个也标记成失效即可。
image.png
MESI四个字母代表什么意思呢?
1、M:代表已修改(Modified)就是我们上面说的“脏”的缓存块,即缓存块中的内容更细过了,但是还没有写回到主内存中。
2、E:代表独占(Exclusive)此时缓冲块中的数据是“干净”的,对应上面的脏。此块缓存块为某个CPU核独占,此核自由写入数据,而不需要告知其他CPU核心。但是在独占状态下,如果收到了一个来自总线的读取对应缓存的请求,它就会变成功效状态。
3、S:代表共享(Shared)此时缓冲块中的数据是“干净”的,对应上面的脏。共享状态下,同样的数据在多个CPU核心的缓存中都有,所以这时候想更新缓存中的数据时,不能直接的修改,而是要先发一个广播请求,要求先把其他CPU里面对应的这个数据缓存都变成无效状态,然后才能更新,
4、I:代表已失效(Invalidated)即字面意思,缓存块中数据已经失效,不可相信。
有点像多线程用的读写锁。共享状态下,大家都可以并行去读对应的数据,如果要写,就需要通过一个锁来获取对应写入位置的所有权。
image.png
图中的有写回操作的就是可能会把数据更新到主内存中的。
41、虚拟内存和内存之间的地址转换性能问题和内存安全问题。
解决性能问题:“加个缓存”。TLB(地址变换高速缓冲)。这块缓存芯片中存放了之前已经进行过地址转换的查询结果,这样同样的虚拟地址需要进行地址转换的时候,直接在TLB里面查询结果,不需要多次访问内存来进行转换。和CPU高速缓存类似,TLB也分为指令TLB和数据TLB,同样也可以分级,分为L1/L2这样多层结构。并且和高速缓存一样,也需要用脏标记这样的标记位来实现写回策略。
image.png
解决安全问题:
无论CPU这样的硬件还是操作系统这样的软件,都太复杂了,难免会被找到漏洞,就需要一个“兜底”的方案。
对于内存的管理,计算机有一些最底层的保护机制,统称:内存保护。
两个常见的内存保护:
1、可执行空间保护:只给内存中的指令区域“可执行”权限,不给数据区域可执行权限,因为不管指令还是CPU,在CPU看来都是二进制数据。避免数据区域中的黑客数据解码后变成一条合理的指令从而被执行,这样很危险。举例子:类似SQL注入
2、地址空间布局随机化:
原先一个进程的内存布局空间是固定的,所以任何第三方很容易知道指令在哪里,程序栈在哪里,数据在哪里,堆又在哪里,搞破坏很便利。当地址空间布局随机化之后,破坏者就只能去猜测内存空间地址了,猜不出自然没影响,如果随便做点修改程序只会crash掉,而不会执行恶意的修改代码。
举例:用户登录的密码,在数据库不能明文存储,一般都会用hash,但是hash后是可以用彩虹表碰撞猜测出来的,于是就加一段随机的类似乱码的字符,和密码组合在一起再hash,就不会被彩虹表猜测出来了。
42、计算机内的高速公路:总线
控制器、运算器、存储器、输入输出这五大设备之间是如何通信的?
如果有N个设备,互相之间都需要单独通信,连接的复杂度为:n²
image.png
为了降低复杂度,引入总线,复杂度降低为N:
image.png
总线的英文叫bus,即公交车,每个公交车站点就是每个设备,把需要传输的数据放在公交车上,在对应站点下车即可。
现代intel cpu体系结构中,通常有很多条总线:
1、CPU和内存以及高速缓存通信的总线有两种:双独立总线
1.1、本地总线:和高速缓存芯片通信
1.2、前端总线:和主内存以及输入输出设备通信
image.png
在物理层面,其实我们完全可以把总线看作一组“电线”。不过呢,这些电线之间也是有分工的,我们通常有三类线路:
1、数据线(Data Bus),用来传输实际的数据信息,也就是实际上了公交车的“人”。
2、地址线(Address Bus),用来确定到底把数据传输到哪里去,是内存的某个位置,还是某一个 I/O 设备。这个其实就相当于拿了个纸条,写下了上面的人要下车的站点。
3、控制线(Control Bus),用来控制对于总线的访问。虽然我们把总线比喻成了一辆公交车。那么有人想要做公交车的时候,需要告诉公交车司机,这个就是我们的控制信号。
总线裁决:因为总线是给多个设备共用的,所以不能同时给多个设备提供通信功能,就需要一个机制,去决定当多个设备都想用总线的情况下到底给哪一个设备用。
43、输入输出设备
组成:1、接口部分;2、实际的I/O设备部分。
输入输出设备并不是直接接入到总线上和CPU通信,而是通过接口,用接口连接到总线上,再通过总线和CPU通信。
image.png
接口本身就是一块电路板,CPU就是和这个接口电路板打交道。设备里面有三类寄存器,其实也都在这个设备的接口电路上,而不是在实际的设备上。
(设备的三类寄存器:1、状态寄存器2、命令寄存器3、数据寄存器)
无论是内置在主板上的接口,还是集成在设备上的接口,除了三类寄存器以外,还有对应的控制电路,作用就是让CPU可以通过这个接口电路板传输信号,来控制实际的硬件:
image.png
三类寄存器:
1、数据寄存器:CPU向I/O设备写入需要传输的数据。比如要打印“GeekTime”,CPU要先发送一个“G”给对应I/O设备。
2、命令寄存器:CPU向设备发送具体命令。比如告诉打印机要打印。此时打印机中控制电路会做两个动作:1、设置状态寄存器里面的状态,设置为not-ready;2、实际操作打印机进行打印
3、状态寄存器:告诉CPU,设备已经在工作了。这时CPU再发数据或者命令过来都是没用的。直到活干完,状态寄存器里面状态设置为ready。CPU才能继续发送下一个数据和命令。
现在清楚了接口和I/O设备之间的关系,那么CPU要往总线上发送什么样的命令,才能和I/O接口上的设备通信呢?
CPU 并不是发送一个特定的操作指令来操作不同的 I/O 设备。因为如果是那样的话,随着新的 I/O 设备的发明,我们就要去扩展 CPU 的指令集了。
答:用CPU支持的机器指令。
但是MIPS 的机器指令的分类中没有和I/O通信的指令类型,所以和访问内存一样,用“内存地址”和I/O设备通信。
首先计算机会把I/O设备中的各个寄存器,设备内部的内存地址,都映射到主内存地址空间中,主内存的地址空间中,会给不同的I/O设备预留一段一段的内存地址。当CPU想和这些I/O设备通信,就往这些地址发送数据,其中地址信息就是用上面说过的总线中的地址线发送,数据就是用数据线发送。
而I/O设备会监控地址线,发现CPU往自己的地址发送数据时,把对应的数据线中传输过来的数据,接入到对应的设备里面的寄存器和内存中。
以上这种CPU和I/O设备通信的方式叫做:内存映射IO,简称:MMIO:
image.png
44、I/O性能到底是怎么一回事
上面说了硬盘和内存的性能差异,也说了CPU高速缓存和内存的性能差异,但是当我们处在大数据时代中,越来越多的数据被存储在硬盘上,还想用缓存来解决性能问题,缓存明显是不够用的,请求还是要达到硬盘上,那怎么办呢?
硬盘I/O性能:两个指标
1、响应时间:
固态硬盘响应时间:几十微秒;
机械硬盘响应时间:几毫秒到十几毫秒。
2、吞吐率,也叫数据传输率:
SATA 3.0 的接口:每秒传输768MB数据;
这种接口在日常用的 HDD 硬盘的数据传输率,差不多在 200MB/s 左右;在固态硬盘上大概可以达到500多MB/S,但是是没法突破768这个限制的。
PCI Express 的接口:读取2GB/S,写入1.2GB/S;
这种接口只在固态硬盘上使用。
基于以上信息,来分析一下机械硬盘的效率:响应一条CPU命令时间是几毫秒,一秒钟可以传输200MB数据,如果插入一条数据到库中为1KB,一秒钟大概可以往硬盘中插入20万条左右数据。但是好像和我们平常所知道的经验不符合,为什么呢?
因为硬盘读写分为:顺序读写和随机读写。当处于随机读写时,一般情况SSD硬盘效率读取最高40MB/S左右,写入效率最高90MB/S左右。一次读4KB的话,每秒读取为10000次,写入的话为20000次;而机械硬盘大约是每秒100次。
每秒读写次数:IOPS。比如相应时间,更关心的是IOPS。
CPU主频在2GHz,即每秒可以做20亿次操作,而SSD硬盘每秒2万次操作。。。即使CPU执行一条命令需要好几个时钟周期,那么和硬盘之间的差异也是非常大的。
通过top和iostat命令可以查看CPU到底有没有在等待IO操作。
网友评论