1. 程序执行基本过程
那CPU执行程序的过程如下:
●第一步,CPU读取[程序计数器」的值,这个值是指令的内存地址,然后CPU的「控制单元操作
「地址总线」指定需要访问的内存地址,接着通知内存设备准备数据,数据准备好后通过「数据总线」
将指令数据传给CPU, CPU收到内存传来的数据后,将这个指令数据存入到「指令寄存器」。
●第二步,[程序计数器」的值自增,表示指向下一条指令。这个自增的大小,由CPU的位宽决定,比
如32位的CPU,指令是4个字节,需要4个内存地址存放,因此「程序计数器」的值会自增4;
●第三步,CPU分析「指令寄存器」中的指令,确定指令的类型和参数,如果是计算类型的指令,就把
指令交给[逻辑运算单元」运算;如果是存储类型的指令,则交由「控制单元执行;
简单总结一下就是,一个程序执行的时候,CPU会根据程序计数器里的内存地址,从内存里面把需要执行
的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。
CPU从程序计数器读取指令、到执行、再到下-条指令,这个过程会不断循环,直到程序执行结束,这个不断循环的过程被称为CPU的指令周期。
举例:
编译器会把a=1+2翻译成4指令,存放到正文段中。如图,这4条指令被存放到了0x100~
0x10c的区域中:
●0x100的内容是load 指令将0x200地址中的数据1装入到寄存器RO ;
●0x104的内容是load 指令将0x204地址中的数据2装入到寄存器R1 ;
●0x108的内容是add 指令将寄存器RO和R1 的数据相加,并把结果存放到寄存器R2 ;
●0x10c 的内容是store指令将寄存器R2中的数据存回数据段中的0x208地址中,这个地址也就是变量a内存中的地址;
编译完成后,具体执行程序的时候,程序计数器会被设置为0x100地址,然后依次执行这4条指令。上面的例子中,由于是在32位CPU执行的,因此一条指令是占32位大小,所以你会发现每条指令间隔4个字节。而数据的大小是根据你在程序中指定的变量类型,比如int 类型的数据则占4个字节,char 类型的数据则占1个字节。
2. 存储器的层次结构
2.1 寄存器
寄存器是最靠近CPU的控制单元和逻辑计算单元的存储器,速度最快价格最高。
●32位CPU中大多数寄存器可以存储4个字节;
●64位CPU大多数寄存器可以存储8个字节。
2.2 CPU Cache(高速缓存)
CPU Cache用的是一种叫SRAM (Static Random-Access Memory,静态随机存储器)的芯片。
静态存储器:只要有电,数据就可以保持存在,而一旦断电,数据就会丢失
了。
CPU的高速缓存,通常可以分为L1、L2、 L3 这样的三层高速缓存,也称为一级缓存、二级缓存、 三级缓存。
L1高速缓存:分成指令缓存和数据缓存,速度最快。
L2高速缓存:速度次之。
L3高速缓存:多核共用,速度最慢。
2.3 内存
内存用的芯片和CPU Cache有所不同,它使用的是一种叫作DRAM (Dynamic Random Access
Memory,动态随机存取存储器)的芯片。
相比SRAM, DRAM的密度更高,功耗更低,有更大的容量,而且造价比SRAM芯片便宜很多。
DRAM存储一个bit数据,只需要一个晶体管和一 个电容就能存储,但是因为数据会被存储在电容里,电容会不断漏电,所以需要定时刷新电容,才能保证数据不会被丢失,这就是DRAM之所以被称为动态存储器的原因,只有不断刷新,数据才能被存储起来。
DRAM的数据访问电路和刷新电路都比SRAM复杂,所以访问的速度会更慢,内存速度大概在
200~300个时钟周期之间。
2.4 SSD/HDD 硬盘
SSD (Solid -state disk)就是我们常说的固体硬盘,结构和内存类似,但是它相比内存的优点是断电后数据还是存在的,而内存、寄存器、高速缓存断电后数据都会失。内存的读写速度比SSD大概快
10~1000倍。
2.5 存储器的层次关系
每个存储器只和相邻的一层存储器设备打交道,并且存储设备为了追求更快的速度,所需的材料成
本必然也是更高,也正因为成本太高,所以CPU内部的寄存器、L1\L2\L3 Cache只好用较小的容量,相反内存、硬盘则可用更大的容量,这就我们今天所说的存储器层次结构。
3. 影响代码在CPU运行速度因素
3.1 CPU访问cache方式:
CPU Cache的数据是从内存中读取过来的,它是以一小块一小块读取数据的,而不是按照单个数组元素来读取数据的,在CPU Cache中的,这样一小块-一小块的数据,称为Cache Line (缓存块)。
事实上,CPU 读取数据的时候,无论数据是否存放到Cache中, CPU都是先访问Cache,只有当Cache中找不到数据时,会访问内存,并把内存中的数据读入到Cache中,CPU 再从CPU Cache读取数据。
对于直接映射Cache采用的策略,就是把内存块的地址始终「映射」在一个CPU Cache Line (缓存块)的地址,至于映射关系实现方式,则是使用「取模运算」, 取模运算的结果就是内存块地址对应的CPUCache Line (缓存块)的地址。举个例子,内存共被划分为32个内存块,CPU Cache共有8个CPU Cache Line,假设CPU想要访问第15号内存块,如果15号内存块中的数据已经缓存在CPU Cache Line中的话,则是一定映射在7号CPUCacheLine中,因为15%8的值是7。
为了区别不同的内存块,在对应的CPU Cache Line中我们还会存储一个组标记 (Tag) 。这个组标
记会记录当前CPU Cache Line中存储的数据对应的内存块,我们可以用这个组标记来区分不同的内存块。
除了组标记信息外,CPU Cache Line还有两个信息:
●一个是,从内存加载过来的实际存放数据(Data)。
●另一个是,有效位(Valid bit) ,它是用来标记对应的CPU Cache Line中的数据是否是有效的,如果有效位是0,无论CPU Cache Line中是否有数据,CPU都会直接访问内存,重新加载数据。
●偏移量:CPU在从CPU Cache读取数据的时候,并不是读取CPU Cache Line中的整个数据块,而是读取CPU所需要的一个数据片段,这样的数据统称为一个字(Word)。利用偏移量确定数据块中的字。
因此,一个内存的访问地址,包括组标记、CPU Cache Line索引、偏移量这三种信息,于是CPU就能通过这些信息,在CPU Cache中找到缓存的数据。而对于CPU Cache里的数据结构,则是由索引+有效位+组标记+数据块组成。
举例:
如果内存中的数据已经在CPU Cache中了,那CPU访问一个内存地址的时候,经历这4个步骤:
1.根据内存地址中索引信息,计算在CPU Cache中的索引,也就是找出对应的CPU Cache Line的地址;
2.找到对应CPU Cache Line后,判断CPU Cache Line中的有效位,确认CPU Cache Line中数据是否是有效的,如果是无效的,CPU就会直接访问内存,并重新加载数据,如果数据有效,则往下执行;
3.对比内存地址中组标记和CPU Cache Line中的组标记,确认CPU Cache Line中的数据是我们要访问的内存数据,如果不是的话,CPU就会直接访问内存,并重新加载数据,如果是的话,则往下执行;
4.根据内存地址中偏移量信息,从CPU Cache Line的数据块中,读取对应的字。
3.2 提升数据缓存命中率
我们知道CPU访问内存的速度,比访问CPU Cache的速度慢了100 多倍,所以如果CPU所要操作的数据在CPU Cache中的话,这样将会带来很大的性能提升。访问的数据在CPU Cache中的话,意味着缓存命中,缓存命中率越高的话,代码的性能就会越好,CPU也就跑的越快。
于是,「如何写出让CPU跑得更快的代码?」这个问题, 可以改成「如何写出CPU缓存命中率高的代码?」。
在前面我也提到,L1 Cache通常分为「数据缓存」和「指令缓存」,这是因为CPU会分别处理数据和指令,比如1+1=2 这个运算,+ 就是指令,会被放在「指令缓存」中,而输入数字1则会被放在「数据缓存」里。因此,我们要分开来看「数据缓存」和「指令缓存的缓存命中率。
顺序访问比跳跃访问的速度更快。
3.3 如何提升指令缓存的命中率?
●第一个操作,循环遍历数组,把小于50的数组元素置为0;
●第二个操作,将数组排序;
那么问题来了,你觉得先遍历再排序速度快,还是先排序再遍历速度快呢?
在回答这个问题之前,我们先了解CPU的分支预测器。对于if条件语询,意味着此时至少可以选择跳转到两段不同的指令执行,也就是if还是else中的指令。那么,如果分支预测可以预测到接下来要执行if的指令,还是else的话,就可以提前把这些指令放在指令缓存中,这样CPU可以直接从
Cache读取到指令,是执行速度就会很快。
当数组中的元素是随机的,分支预测就无法有效工作,而当数组元素都是是顺序的,分支预测器会动态地根据历史命中数据对未来进行预测,这样命中率就会很高。
因此,先排序再遍历速度会更快,这是因为排序之后,数字是从小到大的,那么前几次循环命中if
50的次数会比较多,于是分支预测就会缓存if 里的array[i] = 0指令到Cache中,后续CPU执行
该指令就只需要从Cache读取就好了。
核心:分支预测器会动态地根据历史命中数据对未来进行预测,这样命中率就会很高。
3.4 如何提升多核 CPU 的缓存命中率?
在单核CPU,虽然只能执行一个线程,但是操作系统给每个线程分配了一个时间片,时间片用完了,就调度下一个线程,于各个线程就按时间片交替地占用CPU,从宏观上看起来各个线程同时在执行。而现代CPU都是多核心的,线程可能在不同CPU核心来回切换执行,这对CPU Cache不是有利的,虽然L3 Cache是多核心之间共享的,但是L1和L2 Cache都是每个核心独有的,如果-个线程在不同核心来切换,各个核心的缓存命中率就会受到影响,相反如果线程都在同一个核心上执行,那么其数据的L1和L2 Cache的缓存命中率可以得到有效提高,缓存命中率高就意味着CPU可以减少访问内存的频率。当有多个同时执行「计算密集型」的线程,为了防止因为切换到不同的核心,导致缓存命中率下降的问题,我们可以把线程绑定在某一个CPU核心上,这样性能可以得到非常可观的提升。
在Linux.上提供了sched setaffinity 方法,来实现将线程绑定到某个CPU核心这一功能。
4. CPU缓存一致性
4.1 CPU Cache 的数据写入
写直达:保持内存与Cache一致性最简单的方式是,把数据同时写入内存和Cache中,这种方法称为写直达(Write Through)
在这个方法里,写入前会先判断数据是否已经在CPU Cache里面了:
●如果数据已经在Cache里面,先将数据更新到Cache里面,再写入到内存里面;
●如果数据没有在Cache里面,就直接把数据更新到内存里面。
写直达法很直观,也很简单,但是问题明显,无论数据在不在Cache里面,每次写操作都会写回到内存,这样写操作将会花费大量的时间,无疑性能会受到很大的影响。
写回:
既然写直达由于每次写操作都会把数据写回到内存,导致影响性能,于是为了要减少数据写回内存的频率,就出现了回(Write Back)的方法。在写回机制中,当发生写操作时,新的数据仅仅被写入Cache Block里,只有当修改过的Cache Block「被替换」时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。
可以发现写回这个方法,在把数据写入到Cache的时候,只有在缓存不命中,同时数据对应的Cache中的Cache Block为脏标记的情况下,才会将数据写到内存中,而在缓存命中的情况下,则在写入后Cache后,只需把该数据对应的Cache Block标记为脏即可,而不用写到内存里。
这样的好处是,如果我们大量的操作都能够命中缓存,那么大部分时间里CPU都不需要读写内存,自然性能相比写直达会高很多。
4.2 缓存一致性问题
这时如果A号核心执行了i++语句的时候,为了考虑性能,使用了我们前面所说的写回策略,先把值为1的执行结果写入到L1/L2 Cache中,然后把L1/L2 Cache中对应的Block标记为脏的,这个时候数据其实没有被同步到内存中的,因为写回策略,只有在A号核心中的这个Cache Block要被替换的时候,数据才会写入到内存里。如果这时旁边的B号核心尝试从内存读取i变量的值,则读到的将会是错误的值,因为刚才A号核心更新i值还没写入到内存中,内存中的值还依然是0。这个就是所谓的缓存一致性问题, A号核心和B号核心的缓存,在这个时候是不一致, 从而会导致执行结果的错误。
那么,要解决这一问题,就需要一种机制, 来同步两个不同核心里面的缓存数据。实现的这个机制的
话,要保证做到下面这2点:
●第一点,某个CPU核心里的Cache数据更新时,必须要传播到其他核心的Cache,这个称为写传播
(Write Propagation) ;
●第二点,某个CPU核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的, 这个称为事务的串行化(Transaction Serialization)
第一点写传播很容易就理解,当某个核心在Cache更新了数据,就需要同步到其他核心的Cache里。而对于第二点事务的串行化,我们举个例子来理解它。
假设我们有一个含有4个核心的CPU,这4个核心都操作共同的变量i (初始值为0)。A 号核心先把 i
值变为100,而此时同一时间,B号核心先把 i 值变为200,这里两个修改,都会「传播」到C和D号核
心。
那么问题就来了,C号核心先收到了A号核心更新数据的事件,再收到B号核心更新数据的事件,因此C号核心看到的变量i是先变成100,后变成200。
而如果D号核心收到的事件是反过来的,则D号核心看到的是变量i先矮成200,再变成100,虽然是做到了写传播,但各个Cache面的数据还是不一致的。所以,我们要保证C号核心和D号核心都能看到相同顺序的数据变化,比如变量i都是先变成100,变成200,这样的过程就是事务的串行化。
要实现事务串行化,要做到2点:
●CPU核心对于Cache中数据的操作,需要同步给其他CPU核心;
●引入「锁」的概念,如果两个CPU核心里有相同数据的Cache, 那么对于这个Cache数据的更新,
只有拿倒了[锁」,才能进行对应的数据更新。
那接下来我们看看,写传播和事务串行化具体是用什么技术实现的。
4.3 总线嗅探
写传播的原则就是当某个CPU核心更新了Cache中的数据,要把该事件广播通知到其他核心。最常见实现的方式是总线嗅探(Bus Snooping)。
总线嗅探方法很简单,CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的Cache是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。
此外,总线嗅探只是保证了某个CPU核心的Cache更新数据这个事件能被其他CPU核心知道,但是并不能保证事务串行化。于是,有一个协议基于总线嗅探机制实现了事务串行化,也用状态机机制降低了总线带宽压力,这个协议就是MESI协议,这个协议就做到了CPU缓存一致性。
4.4 MESI协议
基于总线嗅探机制的MESI协议,是保障缓存一致性的协议。MESI协议,已修改、独占、共享已失效这四个状态的英文缩写的组合。整个MSI状态的变更,则是根据来自本地CPU核心的请求,或者来自其他CPU核心通过总线传输过来的请求,从而构成一个流动的状态机。外,对于在已修改或者独占状态的Cache Line,修改更新其数据不需要发送广播给其他CPU核心。
具体实现:
这四个状态来标记Cache Line四个不同的状态。
「已修改」状态就是我们前面提到的脏标记,代表该Cache Block. 上的数据E经被更新过,但是还没有写到内存里。而已失效」状态,示的这个Cache Block里的数据E经失效了,不可以读取该状态的数据。
「独占和[共享」状态都代表Cache Block的数据是干净的,也就是说,这个时候Cache Block的
数据和内存里面的数据是一致性的。
「独占和[共享」的差别在于,独占状态的时候,数据只存储在一个CPU核心的Cache里,其他CPU核心的Cache没有该数据。这个时候,如果要向独占的Cache写数据,就可以直接自由地写入,而不需要通知其他CPU核心,因为只有你这有这个数据,就不存在缓存一致性的问题了 ,于是就可以随便操作该数据。外,在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的Cache,那么这个时候,独占状态下的数据就会变成共享状态。
那么,[共享」 状态代表着相同的数据在多个CPU核心的Cache里都有,所以当我们要更新Cache面的数据的时候,不能直接修改,而是要先向所有的其他CPU核心广播一个请求, 要求先把其他核心的Cache中对应的Cache Line标记为「无效」状态,然后再更新当前Cache里面的数据。
通过四种状态的变化来减少广播请求的发送。
5. CPU执行任务的过程
5.1 伪共享的定义
伪共享(False Sharing)的定义: 发现如果1号和2号CPU核心这样持续交替的分别修改变量A和B,就会重复这两个步骤,Cache 并没有起到缓存的效果,虽然变量A和B之间其实并没有任何的关系,但是因为同时归于一个Cache Line,这个Cache Line中的任意数据被修改后,都会相互影响,从而出现交替修改共享和已失效状态这两个步骤。因此,这种因为多个线程同时读写同一个Cache Line的不变时,导致CPU Cache失效的现象称为伪共享(False Sharing)。
5.2 避免共享的方法
所以,为了防止前Cache伪共享问题,可以使用宏定义,将b的地址设置为Cache Line 对齐地址,如下:
避免Cache伪共享实际上是用空间换时间的思想,浪费部分Cache空间,从而换来性能的提升
5.3 CUP选择线程的依据
在Linux内核中,进程和线程都是用task struct结构体表示的,区别在于线程的task _struct结构体里
部分资源是共享了进程已创建的资源,比如内存地址空间、代码段、文件描述符等,所以Linux中的线程也被称为轻量级进程,因为线程的task_struct 相比进程的task_ struct 承载的资源比较少,因此以「轻」名。
一般来说,没有创建线程的进程,只有单个执行流,它被称为是主线程。如果想让进程处理更多的事情,可以创建多个线程分别去处理,但不管怎么样,它们对应到内核里都是task_ struct。
,Linux内核里的调度器,调度的对象就是task struct,接 下来我们就把这个数据结构统称为任
务。在Linux系统中,根据任务的优先级以及响应要求,主要分为两种,其中优先级的数值越小,优先级越高:
●实时任务,对系统的响应时间要求很高,也就是要尽可能快的执行实时任务,优先级在0~99 范围内
的就算实时任务;
●普通任务,响应时间没有很高的要求,优先级在100~139 范围内都是普通任务级别;
5.3.1 调度类
由于任务有优先级之分,Linux 系统为了保障高优先级的任务能够尽可能早的被执行,于是分为了这几种调度类,如下图:
5.3.2 完全公平调度
我们平日里遇到的基本都是普通任务,对于普通任务来说,公平性最重要,在Linux面,实现了一个基于CFS的调度算法,也就是完全公平调度(Completely Fair Scheduling)。
这个算法的理念是想让分配给每个任务的CPU时间是一样, 于是它为每 个任务安排一个虚拟运行时间vruntime,如果一个任务在运行,运行的越久,该任务的vruntime自然就会越大,而没有被运行的任务,vruntime 不会变化的。
那么,在CFS算法调度的时候,会优先选择vruntime少的任务,以保证每个任务的公平性。这就好比,让你把-桶的奶茶平均分到10杯奶茶杯里,你看着哪杯奶茶少,就多倒一些;哪个多了,就先不倒,这样经过多轮操作,虽然不能保证每杯奶茶完全一样多, 但至少是公平的。当然,上面提到的例子没有考虑到优先级的问题, 虽然是普通任务,但是普通任务之间还是有优先级区分的,所以在计算虚拟运行时间vruntime还要考虑普通任务的权重值,注意权重值并不是优先级的值,内核中会有一个nice级别与权重值的转换表,nice 级别越低的权重值就越大,于nice值是什么,我们后面会提到。于是就有了以下这个公式:
你可以不用管NICE_0_ LOAD是什么,你就认为它是一个常量,那么在「同样的实际运行时间」里,高权重任务的vruntime比低权重任务的vruntime少,你可能会奇怪为什么是少的?你还记得CFS调度吗,它是会优先选择vruntime少的任务进行调度,所以高权重的任务就会被优先调度了,于是高权重的获得的实际运行时间自然就多了。
5.3.3 CPU 运行队列
一个系统通常都会运行着很多任务,多任务的数量基本都是远超CPU核心数量,因此这时候就需要排队。事实上,每个CPU都有自己的运行队列(Run Queue, rq),用于描述在此CPU上所运行的所有进程,其队列包含三个运行队列,Deadline 运行队列dl_ rq、实时任务运行队列rt_ rq 和CFS运行队列cfs_ rq, 中cfs_ _rq是用红黑树来描述的,按vruntime大小来排序的,最左侧的叶子节点,就是下次会被调度的任务。
这几种调度类是有优先级的,优先级如下: Deadline > Realtime> Fair, 这意味着Linux 选择下一个务执行的时候,会按照此优先级顺序进行选择,也就是说先从d1_ rq里选择任务,然后从rt_ rq里选择
任务,后从cfs_ rq里选择任务。因此,实时任务总是会比普通任务优先被执行。
5.3.4 调整优先级
如果我们启动任务的时候,没有特意去指定优先级的话,默认情况下都是普通任务,普通任务的调度类是Fair,由CFS调度器来进行管理。CFS 调度器的目的是实现任务运行的公平性,也就是保障每个任务的运行的时间是差不多的。如果你想让某个普通任务有更多的执行时间,可以调整任务的nice 值,从而让优先级高-些的任务执行更多时间。nice 的值能设置的范围是-20~19, 值越低, 表明优先级越高,因此-20是最高优先级,19则是最低优先级,默认优先级是0。
不是觉得nice 值的范围很诡异?事实上, nice 值并不是表示优先级,而是表示优先级的修正数值,它与优先级(priority) 的关系这样的: priority(new) = priority(old) + nice。内核中,priority 的范围是0~139,值越低,优先级越高,其中前面的0~99范围是提供给实时任务使用的,而nice值是映射到100~139,这个范围是提供给普通任务用的,因此nice值调整的是普通任务的优先级。
6.软中断
6.1 软中断的定义
中断:是一种异步的事件处理机制, 可以提高系统的并发处理能力。
操作系统收到了中断请求,会打断其他进程的运行,所以中断请求的响应程序,也就是中断处理程序,要尽可能快的执行完,这样可以减少对正常进程运行调度地影响。
上部分要做的事情很少,先禁止网卡中断,避免频繁硬中断,而降低内核的工作效率。接着,内核会角发一个软中断,把一些处理比较耗时且复杂的事情,交给「软中断处理程序去做,也就是中断的下半部,其主要是需要从内存中找到网络数据,再按照网络协议栈,对网络数据进行逐层解析和处理,后
数据送给应用程序。所以,中断处理程序的上部分和下半部可以理解为:
还有一个区别,硬中断(上半部)是会打断CPU正在执行的任务,然后立即执行中断处理程序,而软中
断(下半部)是以内核线程的方式执行,并且每一个CPU都对应一个软中断内核线程,名字通常为
「ksoftirqd/CPU编号,比如0号CPU对应的软中断内核线程的名字是ksoftirqd/e
不过,软中断不只是包括硬件设备中断处理程序的下半部,一些内核自定义事件也属于软中断,比如内核调度等、RCU 锁(内核里常用的一种锁)等。
7. 计算机存储数据方式
7.1 负数的存储方式
7.2 小数的存储方式
现在绝大多数计算机使用的浮点数,一般采用的是 IEEE 制定的国际标准,这种标准形式如下图: