CPU中:寄存器(程序计数器、通用暂存器、指令暂存器),控制单元(控制CPU工作),逻辑运算单元(运算)
总线:控制总线(发信号),内存总线(指定内存地址),数据总线(内存读写)
第一步,CPU读取“程序计数器”中指令的地址,然后“控制单元”操作“地址总线”指定需要访问的内存地址,接着通知内存设备准备数据,通过“数据总线”将指令数据传给CPU,CPU收到内存传来的数据后,将指令数据存到“指令寄存器”。
第二步,CPU分析“指令寄存器”中的指令,确定指令的类型和参数,计算类型的指令交给“逻辑运算单元”运算;存储类型的指令交由“控制单元”执行;
第三步,CPU执行完指令后,“程序计数器”自增,表示指向下一条指令。自增的大小,由CPU位宽决定(如32位的CPU,指令是4个字节,需要4个内存地址存放,自增 4);
每一次脉冲信号高低电平的转换就是一个周期,称为时钟周期。不同指令消耗的时钟周期不同。对于程序的CPU执行时间,可以拆解成CPU时钟周期数(CPU Cycles)和时钟周期时间(Clock Cycle Time)的乘积。
时钟周期时间就是CPU主频。
只有运算大数字的时候,64位CPU的优势才能体现出来,否则和32 位CPU的计算性能相差不大。
64位CPU可以寻址更大的内存空间。
操作系统分成32位和64位,其代表意义就是操作系统中程序的指令是多少位。
不同存储器之间性能差距很大,分级的目的是构造缓存体系。
32位CPU中寄存器存4字节,64位CPU中寄存器中存8字节。一般要求在半个CPU时钟周期完成读写(2GHz主频,时钟周期1/2G,也就是0.5ns)
SRAM(Static Random-Acess Memory)静态随机存储器,只要有电,数据就可以保持存在。
L1高速缓存:通常分为指令缓存、数据缓存,访问时间一般是2~4个时钟周期,大小在几十KB到几百KB不等。
L2高速缓存:访问时间10~20个时钟周期,大小几百KB到几MB不等。
L3高速缓存:通常是多个核心共用,访问速度是20~60个时钟周期,大小是几MB和几十MB。
DRAM(Dynamic Random-Access Memory) 存储一个 bit 数据,只需要一个晶体管和一个电容,但是因为数据存储在电容里,电容会不断漏电,所以需要“定时刷新”电容,才能保证数据不会被丢失,这就是DRAM 之所以被称为「动态」存储器的原因,内存访问速度200~300个时钟周期。
这两个存储器的结构和内存相似,但是其中的数据在断电后仍旧存在,内存比SSD快10~1000倍,比HDD(机械硬盘物理读写)快10W倍。
CPU Cache从内存中读取数据,按块读取,Cache Line(缓存块)。
比如,有一个int array[100]的数组,当载入array[0]时,由于这个数组元素的大小在内存只占 4 字节,不足 64 字节,CPU就会顺序加载数组元素到array[15]。
直接映射Cache:一个内存的访问地址,包括组标记(Tag)、CPU Line索引(Index)、偏移量(Offset)这三种信息。而对于CPU Cache里的数据结构,则是由索引 + 有效位 + 组标记 + 数据块组成。
CPU分支预测器:如果分支预测可以预测(比如连续50次if判断都是true)接下来要执行if里的指令,还是else指令的话,就可以“提前”把这些指令放在指令缓存中,这样CPU可以直接从Cache读取到指令,执行速度就会很快。在C/C++中编译器提供了likely和unlikely这两种宏进行分支预测(CPU自身的动态分支预测就是比较准的)。
了解了上面的读取过程,不难想到,如果一个进程在同一个核心上执行,那么速度就会更快(缓存命中率更高)。Linux上提供了sched_setaffinity方法,来将线程绑定到某个核心。
写直达:把数据同时写入内存和Cache中,这称为写直达(Write Through),如果在Cache,就先更新Cache,再写在内存;如果不在,就直接写到内存(不过这样性能会较差)。
写回:在写回(Write Back)中,写时,新的数据仅仅被写入Cache Block,只有当修改过的Cache Block“被替换”时,才需要写到内存中,减少了数据写回内存的频率。只有在缓存不命中,同时数据对应的Cache Block标记为脏,才会将数据写到内存中。而在缓存命中时,写入Cache后,把该数据对应的Cache Block标记为脏(如果大量缓存命中,就不需要频繁写内存)。
为了确保缓存一致性:写传播(Write Propagation,确保数据更新)、事务的串行化(Transaction Serialization,确保数据变化的顺序)。
总线嗅探(Bus Snooping):CPU监听总线上的一切活动,但是不管别的核心的 Cache是否缓存相同的数据,都需要发出一个广播事件(总线负载会加大)。
MESI协议:Modified(已修改,标记为脏)、Exclusive(独占,数据干净,只在一个核心)、Shared(数据在多个核心,从内存读取到其他核心中相同的数据,标记为共享)、Invalidate(失效,一个核心修改后,广播要求其他核心设置为失效),这个协议基于总线嗅探机制实现了事务串形化。(如此也减轻了总线的带宽压力)
多个线程同时读写同一个Cache Line的不同变量时(独占->共享),而导致CPU Cache变为失效态的现象称为伪共享(False Sharing)。
解决:①通过__cacheline_aligned_in_smp设置Cache Line对齐地址(读成两个缓存块),②Java并发框架Disruptor字节填充。
优先级:Linux中任务优先级的数值越小,优先级越高。(实时任务0~99,普通任务100~139)
Linux中的调度类:
Deadline、Realtime作用于实时任务:
SCHED_DEADLINE:按照距离当前时间最近的deadline优先调度
SCHED_FIFO:先来先服务,但是可“插队”(受优先级影响)
SCHED_RR:轮询,不过还是可以“插队”
Fair调度类作用于普通任务:
SCHED_NORMAL:普通任务的调度策略
SCHED_BATCH:后台任务的调度策略
在CFS(Completely Fair Scheduling)算法调度的时,每个任务都安排一个虚拟运行时间,运行越久vruntime越大。优先选择vruntime少的任务,在计算虚拟运行时间vruntime还要考虑普通任务的权重值。
nice级别越低,权重值就越大,vruntime越小,优先被调度。nice 值并不是表示优先级,而是表示优先级的修正数值,priority(new) = priority(old) + nice。nice调整的是普通任务的优先级,不管怎么缩小nice值(范围是-20~19),永远都是普通任务。
每个CPU都有自己的运行队列(Run Queue, rq),用于描述在此CPU上运行的所有进程,其队列包含三个运行队列,Deadline队列dl_rq、实时任务队列rt_rq、CFS队列 cfs_rq。
其中cfs_rq是用红黑树来描述的,按vruntime大小来排序的,最左侧的叶子节点,就是下次会被调度的任务。调度类优先级如下:Deadline > Realtime > Fair,因此实时任务总是会比普通任务先执行。
中断请求的响应程序,也就是中断处理程序,要尽可能快的执行完,这样可以减少对正常进程运行调度的影响。
Linux中断处理分为上半部和下半部。
上半部(硬中断)用来快速处理,一般会暂时关闭中断请求,主要负责跟硬件紧密相关的或时间敏感的
下半部(软中断)以内核线程的方式执行,延迟处理上半部未完成的工作。每个 CPU 核心都对应着一个内核线程ksoftirqd。此外,一些内核自定义事件也属于软中断,比如内核调度、RCU锁(内核里常用的一种锁)等。
内核作为应用连接硬件设备的桥梁,应用程序与内核交互,而不关心硬件的细节。
现代操作系统中,内核一般具有4个基本能力:1. 进程调度,2. 内存管理,3. 硬件通信(做桥梁),4. 提供系统调用。
大多数操作系统,把内存分为内核空间、用户空间,也就是用户态和内核态的区别。
Linux内核的设计概念:MutiTask多任务(并发、并行),SMP对称多处理,ELF可执行文件链接格式,Monolithic Kernel宏内核
SMP对称多处理:代表每个CPU的地位相等,对资源的使用权限相同,多个CPU 共享同一内存,每个CPU都可以访问完整的内存和硬件资源
ELF可执行文件链接格式:编译汇编链接->可执行文件ELF
Monolithic Kernel宏内核:Window是混合型内核,Linux是宏内核(性能高,但容易一错皆错),鸿蒙操作系统是微内核(可移植性好,但驱动不在内核,在驱动程序使用硬件资源时可能需要多次切换到内核态)。
虚拟内存:单片机的CPU是直接操作内存的“物理地址”,因为它没有操作系统,程序都是烧录进去的。
而操作系统为了避免两个程序同时对一个绝对物理地址进行引用,就通过给每个进程分配“虚拟地址”的方式,把每个进程所使用的地址“隔离”开,而CPU中的内存管理单元(MMU)会把虚拟地址映射为物理地址。
分段(Segmentation),程序由代码分段、数据分段、栈段、堆段组成。
分段机制下的虚拟地址由段选择子、段内偏移量组成。
段选择子保存在段寄存器。段选择子里最重要的是段号,用作段表的索引。段表保存的是段的基地址、段的界限和特权等级等。(对某段寻址即,段基地址+段内偏移量)
不足:内存碎片(外部碎片)、内存交换的效率低(linuxswap)。
下图是外部内存碎片的成因。而内部内存碎片,则是程序装载好了,但有程序部分的内存不常用。
内部内存碎片的问题,通过内存交换可以解决,Linux中,通过Swap空间(从硬盘上划分出来的,用于内存和硬盘的空间交换,有点调整装载位置的意味),而内存交换,很明显会让效率变低。
分页(Paging)是把整个虚拟和物理内存空间切成一段段固定尺寸的大小,Linux下每一页4KB。而虚拟地址和物理地址通过页表(MMU使用页表)来映射。页表可以在物理内存也可能在Swap区,具体需根据操作系统设计。
当进程访问的虚拟地址在页表中查不到时,系统会产生缺页异常,进入内核空间更新进程页表,再返回用户空间恢复运行。
解决内存碎片和提升内存交换效率:采用了分页,释放的内存也就都是以页为单位的,这就解决了内存碎片的问题。如果内存不足,操作系统就会把其他进程中“最近没使用”的页释放(写在硬盘上),成为换出(Swap Out),需要时再换入(Swap In),这让内存交换效率有了一定的提升。
只有在程序运行中,需要用到对应虚拟内存页的指令和数据时,再加载到物理内存。
分页如何映射:分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含页所在物理内存的基地址。(寻址某一页,页基地址+页内偏移量)
不过如此简单的分页,在空间上肯定是有缺陷的,每一个进程都有自己的页表,100个进程,每个页表为MB级,这也是很大的开销了。
为解决空间上可能带来的大开销,多级页表的方案出现了。
二级页表:如果某个一级页表的页表项没有被用到,就不需要创建这个页表项对应的二级页表,即是在需要时才会创建二级页表(假设100万页表项,可以分为1024*二级页表1024,即一个一级页表和1024个二级页表),而推广到多级,这个空间占用就会更小。
TLB:多层的页表在访问时,速度肯定变慢,为了解决此问题,在CPU中加入了专门存放程序最常访问的页表项Cache,叫做TLB(Translation Lookaside Buffer,转址旁路缓存、快表、页表缓存),MMU会首先和TLB交互,查不到再找页表。
先将程序划分为多个有逻辑意义的段(数据段、代码段、栈段、堆段),接着再把每个段划分为多个页,也就是对段的连续空间,再分为固定大小的页。地址结构由段号、段内页号和页内位移组成。(寻址会经历三次内存访问:1. 段表,2. 页表,3. 物理地址),如此虽然提高了系统开销,但是提高了内存利用率。
7种不同内存段:
程序文件段(代码段):二进制可执行代码
已初始化数据段:已初始化的静态常量(const)、全局变量、常量
未初始化数据段(BSS段):未初始化的静态变量、全局变量
堆段:动态分配的内存
文件映射段:包括动态库、共享内存等
栈段:局部变量、函数调用的上下文。栈一般8MB,可以修改系统参数来自定义
mmap()可以在文件映射段动态分配内存。