硬件结构
局部性原理
局部性原理主要包括两种类型:时间局部性(Temporal Locality)和空间局部性(Spatial Locality)
时间局部性 时间局部性指的是程序在最近访问过的内存位置(如某个数据或指令),在短时间内可能会再次被访问。
空间局部性 空间局部性指的是如果程序访问了某个内存位置,那么其附近的内存位置在不久的将来也可能会被访问。 局部性原理在计算机系统中的应用包括:缓存(Cache)设计、虚拟内存和分页、编译器优化、预取机制
图灵机的工作方式
图灵机的基本组成如下:
有一条纸带,纸带由一个个连续的格子组成,每个格子可以写入字符,纸带就好比内存,而纸带上的格子的字符就好比内存中的数据或程序。
有一个「读写头」,读写头可以读取纸带上任意格子的字符,也可以把字符写入到纸带的格子;
读写头上有一些部件,比如存储单元、控制单元以及运算单元:存储单元用于存放数据、控制单元用于识别字符是数据还是指令,以及控制程序的流程等、运算单元用于执行运算指令。 执行步骤: 首先,用读写头把 「1、2、+」这3个字符分别写入到纸带上的3个格子,然后读写头先停在1字符对应的格子上; 接着,读写头读入1到存储设备中,这个存储设备称为图灵机的状态; 然后读写头向右移动一个格,用同样的方式把2读入到图灵机的状态,于是现在图灵机的状态中存储着两个连续的数字,1和2; 读写头再往右移动一个格,就会碰到 + 号,读写头读到 + 号后,将 + 号传输给「控制单元」,控制单元发现是一个 + 号而不是数字,所以没有存入到状态中,因为 + 号是运算符指令,作用是加和目前的状态,于是通知「运算单元」工作。运算单元收到要加和状态中的值的通知后,就会把状态中的1和2读入并计算,再将计算的结果3存放到状态中; 最后,运算单元将结果返回给控制单元,控制单元将结果传输给读写头,读写头向右移动,把结果 3写入到纸带的格子中;
冯诺依曼模型
在1945年冯诺依曼和其他计算机科学家们提出了计算机具体实现的报告,其遵循了图灵机的设计,而且还提出用电子元件构造计算机,并约定了用二进制进行计算和存储。最重要的是定义计算机基本结构为5个部分,分别是运算器、控制器、存储器、输入设备、输出设备,这5个部分也被称为冯诺依曼模型。
运算器、控制器是在中央处理器里的,存储器就我们常见的内存,输入输出设备则是计算机外接的设备,比如键盘就是输入设备,显示器就是输出设备。
存储单元和输入输出设备要与中央处理器打交道的话,离不开总线。所以,它们之间的关系如下图:

内存
内存的最小存储单位是字节(byte),每一个字节都对应一个内存地址。内存的地址是从0开始编号的,然后自增排列,最后一个地址为内存总字节数-1,这种结构好似我们程序里的数组,所以内存的读写任何一个数据的速度都是一样的。
中央处理器
中央处理器也就是我们常说的CPU,32位和64位CPU最主要区别在于一次能计算多少字节数据:
32位CPU 一次可以计算4个字节;
64位CPU 一次可以计算8个字节。 这里的32位和64位,通常称为CPU的位宽,代表的是CPU一次可以计算(运算)的数据量。 常见的寄存器种类:
通用寄存器,存储临时数据和操作数,它们可以用于算术运算、逻辑运算和数据传输。
程序计数器,用来存储CPU要执行下一条指令「所在的内存地址」,注意不是存储了下一条要执行的指令,此时指令还在内存中,程序计数器只是存储了下一条指令「的地址」
指令寄存器,用来存放当前正在执行的指令,也就是指令本身,指令被执行完成之前,指令都存储在这里。
段寄存器,用于保存内存段的基地址,常用于分段存储管理模式下的内存寻址
堆栈指针寄存器,存储当前堆栈顶的位置,用于管理函数调用、局部变量和返回地址等
总线
总线是用于CPU和内存以及其他设备之间的通信,总线可分为3种:
地址总线,用于指定CPU将要操作的内存地址;
数据总线,用于读写内存的数据;
控制总线,用于发送和接收信号,比如中断、设备复位等信号,CPU收到信号后自然进行响应,这时也需要控制总线; 当CPU要读写内存数据的时候,一般需要通过下面这三个总线:
首先要通过「地址总线」来指定内存的地址;
然后通过「控制总线」控制是读或写命令;
最后通过「数据总线」来传输数据;
输入、输出设备
输入设备向计算机输入数据,计算机经过计算后,把数据输出给输出设备。期间,如果输入设备是键盘,按下按键时是需要和 CPU 进行交互的,这时就需要用到控制总线了。
线路位宽与CPU位宽
数据是如何通过线路传输的呢?其实是通过操作电压,低电压表示 0,高压电压则表示 1。 这样一位一位传输的方式,称为串行,下一个bit必须等待上一个bit传输完成才能进行传输。当然,想一次多传一些数据,增加线路即可,这时数据就可以并行传输。 为了避免低效率的串行传输的方式,线路的位宽最好一次就能访问到所有的内存地址。 CPU 想要操作「内存地址」就需要「地址总线」: 想要CPU操作4G大的内存,那么就需要32条地址总线,因为2^32=4G 但是并不代表64位CPU性能比32位CPU高很多,很少应用需要算超过32位的数字,所以如果计算的但是并不代表64位CPU性能比32位CPU高很多,很少应用需要算超过32位的数字,所以如果计算的情况下,64位的优势才能体现出来。 另外,32位CPU最大只能操作4GB内存,就算你装了8GB内存条,也没用。而64位CPU寻址范围则很大,理论最大的寻址空间为2^64。 一般64位的操作系统支持48到52根地址总线,且地址总线的寻址单位是字节,因为内存地址的最小存储单位是字节
程序执行的基本过程
程序实际上是一条一条指令,所以程序的运行过程就是把每一条指令一步一步的执行起来,负责执行指令的就是CPU了。
第一步,CPU读取「程序计数器」的值,这个值是指令的内存地址,然后 CPU 的「控制单元」操作「地址总线」指定需要访问的内存地址,接着通知内存设备准备数据,数据准备好后通过「数据总线」将指令数据传给CPU,CPU收到内存传来的数据后,将这个指令数据存入到「指令寄存器」。
代码段存储的是程序编译后的机器指令
可执行文件格式(如ELF、PE)在文件头中包含了程序的入口地址信息。
操作系统加载程序时,会读取该入口地址并设置CPU的程序计数器(PC),让CPU从该地址开始执行程序。
程序入口点通常指向启动代码,而非直接指向main函数。启动代码负责初始化运行时环境,之后再跳转到程序的主函数。 第二步,「程序计数器」的值自增,表示指向下一条指令。这个自增的大小,由 CPU 的位宽决定,比如32位的CPU,指令是 4个字节,需要4个内存地址存放,因此「程序计数器」的值会自增4 注意对于程序计算器:
程序计数器的更新不仅仅是简单的加1,更新规则取决于当前指令的类型和长度。
对于顺序执行的指令,PC通常是根据指令的长度进行更新。
对于跳转、调用和中断等情况,PC会被设置为特定的目标地址。 第三步,CPU 分析「指令寄存器」中的指令,确定指令的类型和参数,如果是计算类型的指令就把指令交给「逻辑运算单元」运算;如果是存储类型的指令,则交由「控制单元」执行; 一个程序执行的时候,CPU 会根据程序计数器里的内存地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。 CPU从程序计数器读取指令、到执行、再到下一条指令,这个过程会不断循环,直到程序执行结束这个不断循环的过程被称为CPU的指令周期。
a = 1 + 2 执行具体过程
CPU 是不认识 a = 1 + 2 这个字符串,这些字符串只是方便我们程序员认识,要想这段程序能跑起来,还需要把整个程序翻译成汇编语言的程序,这个过程称为编译成汇编代码。 针对汇编代码,我们还需要用汇编器翻译成机器码,这些机器码由0和1组成的机器语言,这一条条机器码,就是一条条的计算机指令,这个才是CPU能够真正认识的东西。 程序编译过程中,编译器通过分析代码,发现 1 和 2 是数据,于是程序运行时,内存会有个专门的区域来存放这些数据,这个区域就是「数据段」。如下图,数据 1 和 2 的区域位置:
数据 1 被存放到 0x200 位置
数据 2 被存放到 0x204 位置;
QE1:程序编译后的指令是不是一定不包含数据?
编译后的指令可能包含部分静态数据(如全局常量和立即数),这些数据在编译时已经确定。
大多数操作的数据(如局部变量、指针、动态分配的内存)是在运行时动态绑定到寄存器或内存地址的。
寄存器通常用于临时存储运行时数据,指令执行时才会将实际数据绑定到寄存器或内存地址。
数据和指令是分开区域存放的,存放指令区域的地方称为「正文段」也就是代码段
编译器会把 a = 1 + 2 翻译成 4 条指令,存放到正文段中。如图,这 4 条指令被存放到了 0x100 ~0x10c 的区域中:
0x100 的内容是 load 指令将 0x200 地址中的数据 1 装入到寄存器 R0;
0x104 的内容是 load 指令将 0x204 地址中的数据 2 装入到寄存器 R1;
0x104 的内容是 load 指令将 0x204 地址中的数据 2 装入到寄存器 R1;
0x10c 的内容是 store 指令将寄存器 R2 中的数据存回数据段中的 0x208 地址中,这个地址也就是变量 a 内存中的地址; 编译完成后,具体执行程序的时候,程序计数器会被设置为 0x100 地址,然后依次执行这 4 条指令 由于是在 32 位 CPU 执行的,因此一条指令是占 32 位大小,所以你会发现每条指令间隔4 个字节。 而数据的大小是根据你在程序中指定的变量类型,比如 int 类型的数据则占 4 个字节,char 类型的数据则占 1 个字节。 说明: 在精简指令集计算机(RISC)架构中,例如ARM、MIPS等,指令长度通常是固定的。对于32位RISC架构,指令长度通常为32位(4字节)。这使得指令解码更加简单和快速。 在x86架构(也称为IA-32)中,指令长度是可变的,可以从1字节到15字节不等。x86指令集是一个复杂的、可变长度的指令集。这种设计允许指令具有不同的长度,以便更好地适应不同的操作和优化程序性能。
指令
对于可变长度指令,CPU无法直接知道每条指令的确切字节数,而是依靠逐字节读取并解码指令来确定长度。
基础
上面的例子中,图中指令的内容我写的是简易的汇编代码,目的是为了方便理解指令的具体内容,事实上指令的内容是一串二进制数字的机器码,每条指令都有对应的机器码,CPU 通过解析机器码来知道指令的内容。 不同的 CPU 有不同的指令集,也就是对应着不同的汇编语言和不同的机器码
精简指令集:CISC架构,通常用于x86架构的CPU。x86和x86-64(或x64)处理器,一般用于桌面和服务器,通常使用可变长度指令,长度从1字节到15字节不等。
复杂指令集:RISC架构,常见于ARM架构的处理器,这种架构广泛应用于移动设备,通常使用 32位 固定长度指令。 MIPS 的指令是一个 32 位的整数,高 6 位代表着操作码,表示这条指令是一条什么样的指令,剩下的 26 位不同指令类型所表示的内容也就不相同,主要有三种类型R、I 和 J。

具体看看这三种类型的含义:
R 指令,用在算术和逻辑操作,里面有读取和写入数据的寄存器地址。如果是逻辑位移操作,后面还有位移操作的「位移量」,而最后的「功能码」则是再前面的操作码不够的时候,扩展操作码来表示对应的具体指令的;
I 指令,用在数据传输、条件分支等。这个类型的指令,就没有了位移量和功能码,也没有了第三个寄存器,而是把这三部分直接合并成了一个地址值或一个常数
J 指令,用在跳转,高 6 位之外的 26 位都是一个跳转后的地址 「add 指令将寄存器 R0 和 R1 的数据相加,并把结果放入到 R2」,翻译成机器码。

加和运算 add 指令是属于 R 指令类型:
add 对应的 MIPS 指令里操作码是 000000,以及最末尾的功能码是 100000,这些数值都是固定的,查一下 MIPS 指令集的手册就能知道的;
rs 代表第一个寄存器 R0 的编号,即 00000
rt 代表第二个寄存器 R1 的编号,即 00001
rd 代表目标的临时寄存器 R2 的编号,即 00010
因为不是位移操作,所以位移量是 00000 把上面这些数字拼在一起就是一条32位的MIPS加法指令了,那么用 16 进制表示的机器码则是 0x00011020。 编译器在编译程序的时候,会构造指令,这个过程叫做指令的编码。CPU 执行程序的时候,就会解析指令,这个过程叫作指令的解码。 现代大多数 CPU 都使用来流水线的方式来执行指令,所谓的流水线就是把一个任务拆分成多个小任务,于是一条指令通常分为 4 个阶段,称为 4 级流水线,如下图:

CPU 通过程序计数器读取对应内存地址的指令,这个部分称为 Fetch(取得指令);
CPU 对指令进行解码,这个部分称为Decode(指令译码);
CPU 执行指令,这个部分称为Execution(执行指令);
CPU 将计算结果存回寄存器或者将寄存器的值存入内存,这个部分称为Store(数据回写); 上面这4个阶段,我们称为指令周期(Instrution Cycle),CPU的工作就是一个周期接着一个周期,周而复始。 事实上,不同的阶段其实是由计算机中的不同组件完成的:

取指令的阶段,我们的指令是存放在存储器里的,实际上,通过程序计数器和指令寄存器取出指令的过程,是由控制器操作的;
指令的译码过程,也是由控制器进行的;
指令执行的过程,无论是进行算术操作、逻辑操作,还是进行数据传输、条件分支操作,都是由算术逻辑单元操作的,也就是由运算器处理的。但是如果是一个简单的无条件地址跳转,则是直接在控制器里面完成的,不需要用到运算器
指令的类型
指令从功能角度划分,可以分为 5 大类:
数据传输类型的指令,比如 store/load是寄存器与内存间数据传输的指令,mov 是将一个内存地址的数据移动到另一个内存地址的指令;
运算类型的指令,比如加减乘除、位运算、比较大小等等,它们最多只能处理两个寄存器中的数据;
跳转类型的指令,通过修改程序计数器的值来达到跳转执行指令的过程,比如编程中常见的 if-else、switch-case、函数调用等。
信号类型的指令,比如发生中断的指令trap;
闲置类型的指令,比如指令nop,执行后CPU会空转一个周期;
指令的执行速度
在计算机中,CPU中的时钟脉冲(时钟信号)由硬件产生,主要依赖于晶体振荡器、锁相环、倍频/分频电路和时钟分配网络等硬件电路。这个时钟信号是计算机系统中同步操作的基础,确保CPU和其他组件能够按照正确的时序进行操作。
上图的一个方波称为一个脉冲,类似于人类的脉搏跳动。对于每一个方形脉冲,电压或电路从0上升到最大值的那条线叫做上升沿;反之,电压或电流逐渐下降的那条线叫做下降沿。一个脉冲称为CPU的一个时钟信号,或者时钟脉冲。一个脉冲周期就叫CPU时钟周期,一个时钟周期内时钟信号震荡一次。
两个脉冲相继出现的间隔时间,就是脉冲周期,它是频率的倒数;而将在单位时间(1秒)内所产生的脉冲个数称为频率。
频率的单位有: Hz(赫)、kHz(千赫)、MHz(兆赫)、GHz(吉赫)
计算脉冲信号周期的时间单位及相应的换算关系是:s(秒)、ms(毫秒)、μs(微秒)、ns(纳秒)
CPU 的硬件参数都会有GHz这个参数,比如一个1GHz的CPU,指的是时钟频率是1G,代表着1秒会产生1G (10亿)次数的脉冲信号,每一次脉冲信号高低电平的转换就是一个周期,称为时钟周期。 对于CPU来说,在一个时钟周期内,CPU仅能完成一个最基本的动作,时钟频率越高,时钟周期就越短,工作速度也就越快。 一个时钟周期一定能执行完一条指令吗?答案是不一定的,大多数指令不能在一个时钟周期完成,通常需要若干个时钟周期。不同的指令需要的时钟周期是不同的,加法和乘法都对应着一条CPU指令,但是乘法需要的时钟周期就要比加法多。
流水线
流水线技术能够让CPU在一个时钟周期内执行多个指令的不同阶段:
指令被分解为多个独立的阶段,每个阶段的操作由专门的硬件单元完成。
这些硬件单元彼此独立,可以在同一个时钟周期内同时工作,处理不同的指令。
流水线的设计使得每个阶段在一个时钟周期内完成一个操作,因此可以实现多个指令在不同阶段的并行处理。 单核流水线的并行度在5到20个阶段,而现代处理器通过多核和多线程设计,能够处理更多并行的操作
指令乱序执行
指令乱序执行(Out-of-Order Execution, OoOE),也被称为指令乱排,是一种CPU优化技术,旨在提高处理器的执行效率和吞吐量。它允许处理器不按程序中指令的书写顺序(顺序执行,In-Order Execution)来执行指令,而是根据数据的可用性和资源的空闲状态来动态调度指令的执行顺序,从而最大化硬件资源的利用率。 几乎所有现代高性能CPU(如Intel的Core系列、AMD的Ryzen系列、ARM的高端处理器等)都采用了乱序执行技术。乱序执行与其他优化技术(如超标量架构、分支预测、超线程等)结合使用,显著提高了处理器的性能。 举例:
1. LOAD R1, [A] ; 从内存地址A加载值到寄存器R1
2. LOAD R2, [B] ; 从内存地址B加载值到寄存器R2
3. ADD R3, R1, R2 ; 将R1和R2的值相加,结果存入R3
4. STORE [C], R3 ; 将R3的值存储到内存地址C
5. LOAD R4, [D] ; 从内存地址D加载值到寄存器R4
CPU会分析指令之间的依赖关系,发现指令5(LOAD R4, [D])不依赖于前面的任何指令结果。于是,处理器可以将这条指令提前执行,而不是等待前面指令的完成。
1. LOAD R1, [A] ; 从内存地址A加载值到寄存器R1
2. LOAD R2, [B] ; 从内存地址B加载值到寄存器R2
5. LOAD R4, [D] ; 从内存地址D加载值到寄存器R4 (重排)
3. ADD R3, R1, R2 ; 将R1和R2的值相加,结果存入R3
4. STORE [C], R3 ; 将R3的值存储到内存地址C
数据依赖性:指令之间的数据依赖性是指令重排的主要限制因素。CPU必须保证重排后,指令的执行不会影响程序的正确性。
控制依赖性:某些指令的执行依赖于条件判断的结果(如分支指令)。这些指令的重排也必须考虑到控制流的依赖性,否则会导致程序逻辑错误。
内存操作顺序:内存访问的顺序必须小心处理,尤其是涉及到写操作时。乱序执行器通常有特殊的硬件逻辑来保证内存一致性模型。
如何让程序跑的更快?
程序执行的时候,耗费的 CPU 时间少就说明程序是快的,对于程序的 CPU 执行时间,我们可以拆解成 CPU时钟周期数(CPU Cycles)和时钟周期时间(Clock Cycle Time)的乘积。 程序的CPU执行时间=CPU时钟周期数x时钟周期时间 时钟周期时间就是CPU主频,主频越高说明 CPU 的工作速度就越快 要想 CPU 跑的更快,自然缩短时钟周期时间,也就是提升 CPU主频,但是今非彼日,摩尔定律早已失效,当今的 CPU 主频已经很难再做到翻倍的效果了。如果能减少程序所需的CPU时钟周期数量,一样也是能提升程序的性能的。 对于CPU时钟周期数我们可以进一步拆解成:「指令数 x 每条指令的平均时钟周期数(Cycles PerInstruction,简称 CPI)」,于是程序的 CPU 执行时间的公式可变成如下: 程序的CPU执行时间=(指令数x每条指令的平均时钟周期数)x时钟周期时间 因此,要想程序跑的更快,优化这三者即可:
指令数,表示执行程序所需要多少条指令,以及哪些指令。这个层面是基本靠编译器来优化,毕竟同样的代码,在不同的编译器,编译出来的计算机指令会有各种不同的表示方式。
每条指令的平均时钟周期数CPI,表示一条指令需要多少个时钟周期数,现代大多数CPU通过流水线技术(Pipeline),让一条指令需要的CPU时钟周期数尽可能的少;
时钟周期时间,表示计算机主频,取决于计算机硬件。有的 CPU 支持超频技术,打开了超频意味着把 CPU 内部的时钟给调快了,于是 CPU 工作速度就变快了,但是也是有代价的,CPU跑的越快,散热的压力就会越大,CPU 会很容易奔溃。
Q1:64 位相比32位CPU的优势在哪吗?64位CPU的计算性能一定比32位CPU高很多吗? 64位相比32位CPU的优势主要体现在两个方面:
64位CPU可以一次计算超过32位的数字,而32位CPU如果要计算超过32位的数字,要分多步骤进行计算,效率就没那么高,但是大部分应用程序很少会计算那么大的数字,所以只有运算大数字的时候,64位CPU的优势才能体现出来,否则和32位CPU的计算性能相差不大。
通常来说64位CPU的地址总线是48位,而32位CPU的地址总线是32位,所以64位CPU可以寻址更大的物理内存空间。如果一个32位CPU的地址总线是32位,那么该CPU最大寻址能力是4G,即使你加了8G大小的物理内存,也还是只能寻址到4G大小的地址,而如果一个64位CPU的地址总线是48位,那么该CPU最大寻址能力是2^48,远超于32位CPU最大寻址能力。
Q2:软件的32位和64位之间的区别?32位的操作系统可以运行在64位的电脑上吗?64位的操作系统可以运行在32位的电脑上吗?如果不行,原因是什么? 64位和32位软件,实际上代表指令是64位还是32位的:
如果32位指令在64位机器上执行,需要一套兼容机制,就可以做到兼容运行了。但是如果64位指令在32 位机器上执行,就比较困难了,因为32位的寄存器存不下64位的指令;
操作系统其实也是一种程序,我们也会看到操作系统会分成32位操作系统、64位操作系统,其代表意义就是操作系统中程序的指令是多少位,比如64位操作系统,指令也就是64位,因此不能装在32位机器上。 硬件的64位和32位指的是CPU的位宽,软件的64位和32位指的是指令的位宽。
处理单位
内存存储的最小单位是字节,CPU一般都是以字节进行寻址 CPU的处理单位通常是指字(Word)
存储器
存储器的层次结构
CPU Cache通常会分为L1、L2、L3 三层,其中L1Cache 通常分成「数据缓存」和「指令缓存」,L1是距离CPU最近的,因此它比L2、L3的读写速度都快、存储空间都小。我们大脑中短期记忆,就好比L1Cache,而长期记忆就好比L2/L3 Cache。 寄存器和CPU Cache都是在CPU内部,跟CPU挨着很近,因此它们的读写速度都相当的快,但是能存储的数据很少,毕竟CPU就这么丁点大。 对于存储器,它的速度越快、能耗会越高、而且材料的成本也是越贵的,以至于速度快的存储器的容量都比较小。 CPU里的寄存器和Cache,是整个计算机存储器中价格最贵的,虽然存储空间很小,但是读写速度是极快的,而相对比较便宜的内存和硬盘,速度肯定比不上 CPU 内部的存储器,但是能弥补存储空间的不足。 存储器通常可以分为这么几个级别:
寄存器
CPU Cache
L1 Cache
L2 Cache
L3 Cache
内存
硬盘
其他存储器
寄存器
寄存器的数量通常在几十到几百之间,每个寄存器可以用来存储一定的字节(byte)的数据。比如:
32位CPU中大多数寄存器可以存储4个字节;
64位CPU中大多数寄存器可以存储8个字节。 寄存器的访问速度非常快,一般要求在半个CPU时钟周期内完成读写,CPU时钟周期跟CPU主频息息相关,比如2GHz主频的CPU,那么它的时钟周期就是1/2G,也就是0.5ns(纳秒)。 CPU处理一条指令的时候,除了读写寄存器,还需要解码指令、控制指令执行和计算。如果寄存器的速度太慢,则会拉长指令的处理周期,从而给用户的感觉,就是电脑「很慢」。
CPU Cache
CPU Cache用的是一种叫SRAM(Static Random-Access Memory,静态随机存储器)的芯片。SRAM 之所以叫「静态」存储器,是因为只要有电,数据就可以保持存在,而一旦断电,数据就会丢失了。 在SRAM里面,一个 bit 的数据,通常需要6个晶体管,所以SRAM的存储密度不高,同样的物理空间下,能存储的数据是有限的,不过也因为SRAM的电路简单,所以访问速度非常快。 CPU的高速缓存,通常可以分为L1、L2、L3 这样的三层高速缓存,也称为一级缓存、二级缓存、三级缓存。
L1 高速缓存 L1 高速缓存的访问速度几乎和寄存器一样快,通常只需要2~4个时钟周期,而大小在几十KB到几百KB不等。每个CPU核心都有一块属于自己的L1高速缓存,指令和数据在L1是分开存放的,所以L1高速缓存通常分成指令缓存和数据缓存。
L2 高速缓存 L2 高速缓存同样每个CPU核心都有,但是L2高速缓存位置比L1高速缓存距离CPU核心更远,它大小比L1高速缓存更大,CPU型号不同大小也就不同,通常大小在几百KB到几MB不等,访问速度则更慢,速度在10~20 个时钟周期。
L3 高速缓存 L3 高速缓存通常是多个CPU核心共用的,位置比L2高速缓存距离CPU核心更远,大小也会更大些,通常大小在几MB到几十MB不等,具体值根据CPU型号而定。访问速度相对也比较慢一些,访问速度在20~60个时钟周期。
内存
内存用的芯片和CPU Cache有所不同,它使用的是一种叫作DRAM(Dynamic Random Access Memory,动态随机存取存储器)的芯片。 相比SRAM,DRAM的密度更高,功耗更低,有更大的容量,而且造价比SRAM芯片便宜很多。 DRAM存储一个bit数据,只需要一个晶体管和一个电容就能存储,但是因为数据会被存储在电容里,电容会不断漏电,所以需要「定时刷新」电容,才能保证数据不会被丢失,这就是 DRAM 之所以被称为「动态」存储器的原因,只有不断刷新,数据才能被存储起来。 DRAM 的数据访问电路和刷新电路都比 SRAM 更复杂,所以访问的速度会更慢,内存速度大概在 200~300 个 时钟周期之间。
内存控制器负责管理内存的访问、刷新、读写时序等操作,确保电容能够在正确的时间进行充电或放电。
DRAM芯片中的电路(如位线、字线、感应放大器)负责具体执行电容的充放电操作,并将电容中的电荷转换为可供处理器使用的数据。
晶体管在充放电过程中起到开关的作用,控制电容何时参与读写操作。
SSD/HDD 硬盘
SSD(Solid-state disk)就是我们常说的固体硬盘,结构和内存类似,但是它相比内存的优点是断电后数据还是存在的,而内存、寄存器、高速缓存断电后数据都会丢失。内存的读写速度比SSD大概快10~1000 倍。 还有一款传统的硬盘,也就是机械硬盘(Hard Disk Drive, HDD),它是通过物理读写的方式来访问数据的,因此它访问速度是非常慢的,它的速度比内存慢10W倍左右。 由于SSD的价格快接近机械硬盘了,因此机械硬盘已经逐渐被SSD替代了。
存储器的层次关系
现代的一台计算机,都用上了CPU Cahce、内存、到SSD或HDD硬盘这些存储器设备了。
其中,存储空间越大的存储器设备,其访问速度越慢,所需成本也相对越少。
CPU并不会直接和每一种存储器设备直接打交道,而是每一种存储器设备只和它相邻的存储器设备打交道。
比如,CPU Cache的数据是从内存加载过来的,写回数据的时候也只写回到内存,CPU Cache不会直接把数据写到硬盘,也不会直接从硬盘加载数据,而是先加载到内存,再从内存加载到CPU Cache中。

所以,每个存储器只和相邻的一层存储器设备打交道,并且存储设备为了追求更快的速度,所需的材料成本必然也是更高,也正因为成本太高,所以 CPU 内部的寄存器、L1\L2\L3 Cache 只好用较小的容量,相反内存、硬盘则可用更大的容量,这就所说的存储器层次结构。 当CPU需要访问内存中某个数据的时候,如果寄存器有这个数据,CPU就直接从寄存器取数据即可,如果寄存器没有这个数据,CPU就会查询L1高速缓存,如果L1没有,则查询L2高速缓存,L2还是没有的话就查询L3高速缓存,L3依然没有的话,才去内存中取数据。
CPU-Cache 细节
越靠近CPU核心的缓存其访问速度越快,CPU访问 L1 Cache只需要2~4个时钟周期,访问L2 Cache大约 10~20个时钟周期,访问L3 Cache大约20~60个时钟周期,而访问内存速度大概在200~300个时钟周期之间。如下表格:
CPU 从L1 Cache读取数据的速度,相比从内存读取的速度,会快100多倍。
CPU Cache是由很多个Cache Line组成的,Cache Line是CPU从内存读取数据的基本单位,而Cache Line是由各种标志(Tag)+数据块(Data Block)组成,
CPU Cache 的数据是从内存中读取过来的,它是以一小块一小块读取数据的,而不是按照单个数组元素来读取数据的,在 CPU Cache 中的,这样一小块一小块的数据,称为 Cache Line(缓存块)。 Linux 系统,用下面这种方式来查看 CPU 的 Cache Line,
cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
64
这个64,表示的是64字节,也就是说,CPU Cache一次加载的数据大小是64字节。 CPU Cache的数据加载,是按需加载的,也就是说,只有当CPU访问某个内存地址的时候,才会将内存地址附近的64字节的数据加载到CPU Cache中。 比如,有一个int array[100]的数组,当载入array[0]时,由于这个数组元素的大小在内存只占4字节,不足64字节,CPU就会顺序加载数组元素到 array[15],意味着 array[0]~array[15] 数组元素都会被缓存在 CPU Cache 中了,因此当下次访问这些数组元素时,会直接从 CPU Cache 读取,而不用再从内存中读取,大大提高了 CPU 读取数据的性能。 ,而不用再从内存中读取,大大提高了 CPU 读取数据的性能。只有当 Cache中找不到数据时,才会去访问内存,并把内存中的数据读入到 Cache 中,CPU 再从 CPU Cache 读取数据。
/proc 目录主要提供与进程和内核相关的信息,适合查看和操作内核状态、进程状态、系统资源等信息。
/sys 目录则主要提供与硬件和设备驱动相关的信息,适合查看和配置设备、内核模块、总线等系统硬件组件的状态。
这样的访问机制,跟我们使用「内存作为硬盘的缓存」的逻辑是一样的,如果内存有缓存的数据,则直接返回,否则要访问龟速一般的硬盘。那CPU怎么知道要访问的内存数据,是否在Cache里?如果在的话,如何找到Cache对应的数据呢?我们从最简单、基础的直接映射Cache(Direct Mapped Cache)说起,来看看整个CPU Cache的数据结构和访问逻辑。
CPU访问内存数据时,是一小块一小块数据读取的,具体这一小块数据的大小,取决于 coherency_line_size 的值,一般64字节。在内存中,这一块的数据我们称为内存块(Block),读取的时候我们要拿到数据所在内存块的地址。对于直接映射Cache采用的策略,就是把内存块的地址始终「映射」在一个CPU Cache Line(缓存块)的地址,至于映射关系实现方式,则是使用「取模运算」,取模运算的结果就是内存块地址对应的CPU Cache Line(缓存块)的地址。
举个例子,内存共被划分为32个内存块,CPU Cache共有8个CPU Cache Line,假设CPU想要访问第15 号内存块,如果15号内存块中的数据已经缓存在CPU Cache Line中的话,则是一定映射在7号CPU Cache Line中,因为15 % 8的值是 7。
使用取模方式映射的话,就会出现多个内存块对应同一个CPU Cache Line,比如上面的例子,除了15号内存块是映射在7号CPU Cache Line中,还有7号、23号、31号内存块都是映射到7号CPU Cache Line 中。
为了区别不同的内存块,在对应的CPU Cache Line 中我们还会存储一个组标记(Tag)为了区别不同的内存块,在对应的 CPU Cache Line 中我们还会存储一个组标记(Tag)块。
除了组标记信息外,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 中数据块中找到所需的字呢?答案是,需要一个偏移量(Offset)。 一个内存的访问地址,包括组标记、CPU Cache Line索引、偏移量这三种信息,于是 CPU 就能通过这些信息,在 CPU Cache 中找到缓存的数据。而对于 CPU Cache 里的数据结构,则是由索引 + 有效位 + 组标记 + 数据块组成。缓存的索引无法直接唯一标识主存中的一个特定位置
计算 Tag 位数的步骤
确定物理地址的位数:假设在32位系统中,物理地址位数为32位。
确定缓存行大小:假设缓存行大小为 64 字节。这意味着最低的 6 位(log2(64) = 6)用于表示缓存行内的数据偏移。
确定缓存的组数:假设我们有一个 4KB 的缓存,且缓存是 4 路组关联映射(4-way setassociative)。在这种情况下,缓存中有 4KB / 64B = 64 个缓存行。由于缓存是 4 路组关联映射,意味着每个组有 4 个缓存行,因此有 64 / 4 = 16 个组(log2(16) = 4 位用于表示组索引)。
计算 Tag 位数:Tag 的位数就是地址总位数减去缓存行偏移位数和组索引位数。 Tag 的位数为 32 - 6 - 4 = 22 位。
注意事项:
TLB采用组相联
页表采用两级页表
cache采用组相联
cache仅考虑L1 d-cache,不考虑L1 i-cache、L2 cache和L3 cache
未考虑页表缺页
简化了cache未命中情况

DMA中内存的数据结构
在典型的直接映射缓存或组关联缓存中,内存地址通常被分为三个部分:标签(Tag)、索引(Index)和块偏移量(Block Offset)。
标签(Tag):用于验证缓存行是否存储了所需的内存数据。如果内存地址的标签部分与缓存行的标签匹配且有效位为1,则表示缓存命中。
索引(Index):用于选择缓存中可能包含所需数据的缓存行。索引指向缓存中的一组缓存行,表示当前内存地址可能对应的缓存行位置。
块偏移量(Block Offset):用于从缓存行中提取具体数据。因为一个缓存行通常包含多个字节的数据,块偏移量用于确定所需字节的位置。
缓存行的数据结构
数据字段:缓存行的主要部分,是实际存储的数据。典型的缓存行大小通常是32、64、或128字节,具体大小取决于CPU架构。 标记字段:标签是用来标识缓存行中的数据对应的主存地址的高位部分。由于缓存容量有限,不能直接存储完整的内存地址,标签用于匹配主存地址的高位部分。 有效位:这是一个布尔标志,用来指示当前缓存行中的数据是否有效为“1”时,表示缓存行中的数据是有效的,为“0”则表示无效 脏位:脏位是一个标志,指示缓存行中的数据是否已被修改 共享位:在多核处理器中常见,用于指示当前缓存行是否在多个处理器核心的缓存中存在 其他控制位:
最近使用位(LRU Bit):这个位用于替换策略,如LRU(Least Recently Used,最近最少使用),以跟踪缓存行的使用频率或顺序。
无效位(Invalid Bit):在某些架构中,可能有一个明确的无效位,用于标记缓存行不可用的状态。
预取位(Prefetch Bit):在某些高级缓存控制器中,用于标记某个缓存行的数据是否是通过预取操作加载的。 脏数据只是说明数据被修改了,还能被使用,而数据无效则表示数据不能被cpu使用了
直接Cache映射
如果内存中的数据已经在 CPU Cache 中了,那 CPU 访问一个内存地址的时候,会经历这 4 个步骤:
根据内存地址中索引信息,计算在 CPU Cache 中的索引,也就是找出对应的 CPU Cache Line 的地址
找到对应 CPU Cache Line 后,判断 CPU Cache Line 中的有效位,确认 CPU Cache Line 中数据是否是有效的,如果是无效的,CPU 就会直接访问内存,并重新加载数据,如果数据有效,则往下执行;
对比内存地址中组标记和 CPU Cache Line 中的组标记,确认 CPU Cache Line 中的数据是我们要访问的内存数据,如果不是的话,CPU 就会直接访问内存,并重新加载数据,如果是的话,则往下执行;
根据内存地址中偏移量信息,从 CPU Cache Line 的数据块中,读取对应的字。 除了直接映射 Cache 之外,还有其他通过内存地址找到 CPU Cache 中的数据的策略,比如全相连 Cache (Fully Associative Cache)、组相连 Cache(Set Associative Cache)等,这几种策策略的数据结构都比较相似。 举例:
假设有一个32位的内存地址,使用了1KB(1024字节)的缓存,每个缓存行为64字节:
标签(Tag):32位地址的高22位,用于标识内存块。
索引(Index):接下来的4位(因为1024字节缓存 ÷ 64字节/行=16行,4位足以表示16行)
块偏移量(Block Offset):最低的6位,用于选择缓存行中的特定字节。 缓存控制器会使用4位索引定位到一个特定的缓存行,然后使用22位的标签与缓存行中的标签进行比较,确定是否命中。 CPU从缓存行中操作数据是以字为单位的,在DMA下,内存结构中的TAG标签+索引能够唯一的锁定内存块,而偏移量又进一步的明确了要读取的字的位置,也就是说内存结构能够确定唯一的内存地址值
如何写出让 CPU 跑得更快的代码?
CPU 访问内存的速度,比访问 CPU Cache 的速度慢了 100 多倍,所以如果 CPU 所要操作的数据在 CPU Cache 中的话,这样将会带来很大的性能提升。访问的数据在 CPU Cache 中的话,意味着缓存命中,缓存命中率越高的话,代码的性能就会越好,CPU 也就跑的越快。 「如何写出让 CPU 跑得更快的代码?」这个问题,可以改成「如何写出 CPU 缓存命中率高的代码?」。 L1 Cache 通常分为「数据缓存」和「指令缓存」,这是因为 CPU 会分别处理数据和指令,要分开来看**「数据缓存」和「指令缓存」的缓存命中率**。
如何提升数据缓存的命中率? 假设要遍历二维数组,有以下两种形式,虽然代码执行结果是一样,但你觉得哪种形式效率最高呢?为什么高呢?
经过测试,形式一 array[i][j] 执行时间比形式二 array[j][i] 快好几倍。
之所以有这么大的差距,是因为二维数组array所占用的内存是连续的,比如长度 N 的值是2 的话,那么内存中的数组元素的布局顺序是这样
形式一用 array[i][j] 访问数组元素的顺序,正是和内存中数组元素存放的顺序一致。当 CPU 访问 array[0][0] 时,由于该数据不在 Cache 中,于是会「顺序」把跟随其后的 3 个元素从内存中加载到CPU Cache,这样当 CPU 访问后面的 3 个数组元素时,就能在 CPU Cache 中成功地找到数据这意味着缓存命中率很高,缓存命中的数据不需要访问内存,这便大大提高了代码的性能。
那访问 array[0][0] 元素时,CPU 具体会一次从内存中加载多少元素到 CPU Cache 呢?这个问题,在前面我们也提到过,这跟 CPU Cache Line 有关,它表示 CPU Cache 一次性能加载数据的大小,可以在inux 里通过 coherency_line_size 配置查看 它的大小,通常是 64 个字节。
当 CPU 访问内存数据时,如果数据不在 CPU Cache 中,则会一次性会连续加载 64 字节大小的数据到 CPU Cache,那么当访问 array[0][0] 时,由于该元素不足 64 字节,于是就会往后顺序读取array[0][0]~array[0][15]到 CPU Cache 中。顺序访问的 array[i][j] 因为利用了这一特点所以就会比跳跃式访问的 array[j][i] 要快。
遇到这种遍历数组的情况时,按照内存布局顺序访问,将可以有效的利用 CPU Cache 带来的好处,这样我们代码的性能就会得到很大的提升如何提升指令缓存的命中率? 提升数据的缓存命中率的方式,是按照内存布局顺序访问,那针对指令的缓存该如何提升呢? 有一个元素为 0 到 100 之间随机数字组成的一维数组:
接下来,对这个数组做两个操作:

第一个操作,循环遍历数组,把小于 50 的数组元素置为 0;
第二个操作,将数组排序; 先遍历再排序速度快,还是先排序再遍历速度快呢? CPU 的分支预测器: 对于 if 条件语句,意味着此时至少可以选择跳转到两段不同的指令执行,也就是 if 还是 else 中的指令如果分支预测可以预测到接下来要执行 if里的指令,还是 else 指令的话,就可以「提前」把这些指令放在指令缓存中,这样 CPU 可以直接从Cache 读取到指令,于是执行速度就会很快。 当数组中的元素是随机的,分支预测就无法有效工作,而当数组元素都是是顺序的,分支预测器会动态地根据历史命中数据对未来进行预测,这样命中率就会很高。 因此,先排序再遍历速度会更快,这是因为排序之后,数字是从小到大的,那么前几次循环命中 if < 50 的次数会比较多,于是分支预测就会缓存 if 里的 array[i] = 0指令到 Cache 中,后续 CPU 执行该指令就只需要从 Cache 读取就好了。
如何提升多核 CPU 的缓存命中率?
在单核 CPU,虽然只能执行一个线程,但是操作系统给每个线程分配了一个时间片,时间片用完了,就调度下一个线程,于是各个线程就按时间片交替地占用 CPU,从宏观上看起来各个线程同时在执行。
现代 CPU 都是多核心的,线程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的虽然L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的,如果一个线程在不同核心来回切换,各个核心的缓存命中率就会受到影响,相反如果线程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache的缓存命中率可以得到有效提高缓存命中率高就意味着CPU可以减少访问内存的频率。
当有多个同时执行「计算密集型」的线程,为了防止因为切换到不同的核心而导致缓存命中率下降的问题,我们可以把线程绑定在某一个CPU核心上,这样性能可以得到非常可观的提升.
在 Linux 上提供了 sched_setaffinity 方法,来实现将线程绑定到某个CPU核心这一功能。

CPU缓存一致性
数据不光是只有读操作,还有写操作,那么如果数据写入Cache 之后,内存与Cache相对应的数据将会不同,这种情况下Cache和内存数据都不一致了,于是我们肯定是要把Cache中的数据同步到内存里的。 那在什么时机才把Cache中的数据写回到内存呢?为了应对这个问题,下面介绍两种针对写入数据的方法:
写直达(write through)
写回 (write back) 写回(Write-back)和写直达(Write-through)策略通常由硬件(如缓存控制器)控制,而不是由程序直接决定。在一台机器上,写直达(Write-through)和写回(Write-back)策略可以并存。这通常由硬件和系统架构决定。不同级别的缓存或不同的内存区域可以采用不同的策略,以平衡性能和一致性需求。
缓存命中与未命中
当处理器请求的数据已经存在于缓存中,且缓存中的数据是有效的,这种情况称为缓存命中。 当处理器请求的数据不在缓存中,或缓存中没有该数据的有效副本,这种情况称为缓存未命中。 缓存中存在脏数据并不意味着缓存未命中。 缓存被标记为无效的场景:
缓存一致性协议(MESI) 为了保持数据一致性,缓存一致性协议(如MESI、MOESI协议)会对其他缓存中的该数据进行无效化处理。 例如:处理器A修改了地址X的数据,其他处理器的缓存中缓存了地址X的数据。这时,处理器A会通过一致性协议通知其他处理器,使其缓存中与地址X相关的缓存行标记为无效。
缓存替换 当缓存已满并且需要加载新数据时,缓存替换策略(如LRU、FIFO等)会选择某个缓存行来腾出空间给新的数据块。如果被替换的缓存行是干净的(Clean),直接替换即可。如果是脏的(Dirty),则需要先写回主内存,然后缓存行被标记为无效,以便可以存放新的数据。 例如:缓存中已经满了,新的数据块被加载进缓存,旧的数据块会被替换并标记为无效。
系统或程序请求的显式缓存无效化 在某些情况下,系统或程序可能会主动发出指令,将缓存中的某些数据标记为无效。这通常发生在需要确保数据从主内存重新读取,而不是使用缓存中的旧数据时。 DMA(直接内存访问)操作:DMA是一种允许外设直接访问主内存的机制,而不经过CPU。如果外设通DMA写入了主内存中的某些数据,那么CPU缓存中的对应数据可能已经过时。为了确保数据一致性,系统可能会显式地将对应的缓存行标记为无效,
写直达策略下的无效化 在写直达(Write-Through)缓存策略中,每次写操作都会直接更新主内存。如果其他设备或处理器修改了主内存中的数据,并且该数据被多个处理器缓存了,可能会导致缓存无效化操作,以确保读取到的是最新的内存数据。 例如:在多处理器系统中,一个处理器将数据直接写到主内存,其他处理器的缓存可能会将该数例如:在多处理器系统中,一个处理器将数据直接写到主内存,其他处理器的缓存可能会将该数据标记为无效,以确保从内存中读取最新数据。
上下文切换 在操作系统中,当进行上下文切换时(即切换到另一个进程执行),某些缓存行可能会变得无效,尤其是当缓存中保存了与上一个进程相关的数据时。为了防止新进程访问到旧进程的数据,系统可能会将这些缓存行标记为无效。
写直达
保持内存与 Cache 一致性最简单的方式是,把数据同时写入内存和Cache中,这种方法称为写直达,这意味着每次写操作都会立即传递到主内存,不会有延迟。
在这个方法里,写入前会先判断数据是否已经在CPU Cache里面了:
如果数据已经在Cache里面,先将数据更新到 Cache里面,再写入到内存里面;
如果数据没有在Cache里面,就直接把数据更新到内存里面。 数据直接写入主内存而不更新缓存,可能是由于以下原因:
性能优化:避免缓存污染,防止不常用的数据占用宝贵的缓存空间。
一致性需求:确保多个处理器或设备能够立即看到更新的数据,保持内存一致性。
简单性:减少缓存管理的复杂性,特别是在处理大量数据时。
写直达法很直观,也很简单,但是问题明显,无论数据在不在Cache里面,每次写操作都会写回到内存,这样写操作将会花费大量的时间,无疑性能会受到很大的影响。 写直达虽然会频繁的写内存,但是对那些写操作频率低但是数据一致性较高的场景,还是比较实用的
写回
既然写直达由于每次写操作都会把数据写回到内存,而导致影响性能,于是为了要减少数据写回内存的频率,就出现了写回(Write Back)的方法。
在写回机制中,当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。
写回的时候,会根据缓存是否命中而采取不同的处理方法,如果缓存命中,则直接更新缓存数据,然后将数据标记为脏,此时无需写回主存;如果缓存未命中,则需要根据是否需要替换缓存块(缓存空间是否已满)而采取不同的处理方法,如果缓存未满的情况下,直接选择一个空闲的数据块将数据写入缓存并标记为脏,如果缓存已满的情况下,需要对缓存中的某个数据块进行替换,此时会优先替换已存在但状态无效的数据块进行替换,
因为此时数据块的状态是无效状态则直接进行覆盖,而无需写回主存,然后标记为脏;如果被替换的数据块是脏的,则应该将该缓存数据块写回主内存,否则的话将数据写入缓存并标记为脏。
系统倾向于使用未使用的空闲块,以确保缓存空间的充分利用。在缓存空间不足的情况下,才会考虑替换现有的块,包括无效块
在写回策略下,当缓存命中时,处理器不需要判断缓存数据块是否为脏,因为写回策略的设计本质上就是为了在缓存中直接进行写操作,并推迟主存的更新。脏位的作用是在缓存块被替换时决定是否需要写回主存,而不是在每次写操作时进行判断。
写回这个方法,在把数据写入到Cache的时候,只有在缓存不命中,同时数据对应的Cache中的Cache Block为脏标记的情况下,才会将数据写到内存中,而在缓存命中的情况下,则在写入后 Cache 后,只需把该数据对应的 Cache Block 标记为脏即可,而不用写到内存里。
这样的好处是,如果我们大量的操作都能够命中缓存,那么大部分时间里 CPU 都不需要读写内存,自然性能相比写直达会高很多。
为什么缓存没命中时,还要定位 Cache Block?这是因为此时是要判断数据即将写入到 cache block 里的位置,是否被「其他数据」占用了此位置,如果这个「其他数据」是脏数据,那么就要帮忙把它写回到内存。
CPU 缓存与内存使用「写回」机制的流程图如下,左半部分就是读操作的流程:
是否命中缓存,是根据内存结构中的索引+tag来进行判定的,如果缓存未命中,则需要从主内存中加载数据到缓存中,如果缓存未满直接使用一个空闲的缓存行,如果缓存已满则需要进行替换,替换的过程中如果该缓存行被标记为脏数据则需要该数据刷回内存,如果缓存行不为脏,则直接进行替换
总结: 对于写直到,无论数据是否在缓存,都要将数据写入缓存和主存,强调缓存和主存数据的一致性。 对于写回,无论数据是否在缓存,写操作时数据会先写入缓存,但不会立即写入主存,强调的是缓存数据的最新性,主存只有在缓存行被替换或刷新时才会更新。 对于写回,需要两看,一看写回数据是否已在缓存行中存在,而看缓存行的状态是否为脏
缓存一致性问题
现在CPU都是多核的,由于L1/L2 Cache是多个核心各自独有的,那么会带来多核心的缓存一致性(Cache Coherence)的问题,如果不能保证缓存一致性的问题,就可能造成结果错误。
那缓存一致性的问题具体是怎么发生的呢?
在单核cpu上不存在混存一致性的问题,因为在单核cpu中缓存都是共享的,不存在缓存状态不一致的情况
假设A号核心和B号核心同时运行两个线程,都操作共同的变量i(初始值为 0 )。
这时如果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)。 假设我们有一个含有 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 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。 保证一致性需要解决两点
写传播,其他的核心能及时感知缓存变化
事务串行化,其他的核心要知道写的顺序。事务的串行化需要锁来进行保证
总线嗅探
写传播的原则就是当某个 CPU 核心更新了Cache中的数据,要把该事件广播通知到其他核心。最常见实现的方式是总线嗅探(Bus Snooping)。 当A号CPU核心修改了L1 Cache中i变量的值,通过总线把这个事件广播通知给其他所有的核心,然后每个CPU核心都会监听总线上的广播事件,并检查是否有相同的数据在自己的L1 Cache里面,如果B号CPU核心的L1 Cache中有该数据,那么也需要把该数据更新到自己的L1 Cache。 总线嗅探方法很简单,CPU需要每时每刻监听总线上的一切活动,但是不管别的核心的Cache是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。 总线嗅探只是保证了某个CPU核心的Cache更新数据这个事件能被其他CPU核心知道,但是并不能保证事务串行化。 于是,有一个协议基于总线嗅探机制实现了事务串行化,也用状态机机制降低了总线带宽压力,这个协议就是MESI协议,这个协议就做到了CPU缓存一致性。
MESI 协议
MESI 协议其实是 4 个状态单词的开头字母缩写,分别是:
Modified,已修改
Exclusive,独占
Shared,共享
Invalidated,已失效 「已修改」状态就是我们前面提到的脏标记,代表该Cache Block上的数据已经被更新过,但是还没有写到内存里「已失效」状态,表示的是这个Cache Block里的数据已经失效了,不可以读取该状态的数据。 「独占」和「共享」状态都代表Cache Block里的数据是干净的也就是说,这个时候Cache Block里的数据和内存里面的数据是一致性的。 「独占」和「共享」的差别在于,独占状态的时候,数据只存储在一个CPU核心的Cache里,而其他CPU核心的Cache没有该数据。这个时候,如果要向独占的Cache写数据,就可以直接自由地写入而不需要通知其他 CPU核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。 另外,在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的Cache那么这个时候,独占状态下的数据就会变成共享状态。 「共享」状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据。 在执行一个非多线程并发的程序时,数据主要会被缓存到执行该程序的核心的缓存中,此时缓存状态被设置为独占状态,当缓存行被标记为修改状态时,表示该核心有数据块的唯一有效副本 在非多线程情况下,一个进程的指令不会随机地分配到多个CPU核心上执行。操作系统会根据调度策略,倾向于将进程绑定到一个核心上运行,以保持CPU亲和性,从而提高缓存利用率和整体性能。进程在核心之间的切换通常是根据系统资源和负载情况进行的,而非随机分配。
当缓存状态为独占和修改状态时不需要给其他核心发广播
当A号CPU核心从内存读取变量i的值,数据被缓存在A号CPU核心自己的Cache里面,此时其他CPU核心的 Cache 没有缓存该数据,于是标记Cache Line状态为「独占」,此时其Cache中的数据与内存是一致的;
然后B号CPU核心也从内存读取了变量i的值,此时会发送消息给其他CPU核心,由于A号CPU核心已经缓存了该数据,所以会把数据返回给B号CPU核心。在这个时候,A和B核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的;
当A号CPU核心要修改Cache中i变量的值,发现数据对应的Cache Line的状态是共享状态,则要向所有的其他CPU核心广播一个请求,要求先把其他核心的Cache 中对应的Cache Line 标记为「无效」状态,然后A号CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line为「已修改」状态,此时Cache中的数据就与内存不一致了。
如果A号CPU核心「继续」修改Cache中i变量的值,由于此时的Cache Line是「已修改」状态,因此不需要给其他CPU核心发送消息,直接更新数据即可。
如果A号CPU核心的Cache里的i变量对应的Cache Line要被「替换」,发现Cache Line状态是「已修改」状态,就会在替换前先把数据同步到内存。 可以发现当Cache Line状态是「已修改」或者「独占」状态时,修改更新其数据不需要发送广播给其他CPU核心,这在一定程度上减少了总线带宽压力。
事实上,整个 MESI 的状态可以用一个有限状态机来表示它的状态流转。还有一点,对于不同状态触发的事件操作,可能是来自本地 CPU 核心发出的广播事件,也可以是来自其他 CPU 核心通过总线发出的广播事件。

CPU执行任务的过程

Cache 伪共享
多个线程同时读写同一个Cache Line的不同变量时,而导致CPU Cache失效的现象称为伪共享(False Sharing)
假设有一个双核心的CPU,这两个CPU核心并行运行着两个不同的线程,它们同时从内存中读取两个不同的数据,分别是类型为long的变量A和B,这个两个数据的地址在物理内存上是连续的,如果Cahce Line的大小是64字节,并且变量A在Cahce Line的开头位置,那么这两个数据是位于同一个那么这两个数据是位于同一个所以这两个数据会被同时读入到了两个CPU核心中各自Cache中。
如果这两个不同核心的线程分别修改不同的数据,比如1号CPU核心的线程只修改了变量A,或2号CPU核心的线程的线程只修改了变量B,会发生什么呢?
最开始变量A和B都还不在Cache里面,假设1号核心绑定了线程A,2号核心绑定了线程B,线程A只会读写变量A,线程B只会读写变量B。

1号核心读取变量A,由于CPU从内存读取数据到Cache的单位是Cache Line,也正好变量A和变量B的数据归属于同一个Cache Line,所以A和B的数据都会被加载到Cache,并将此Cache Line标记为「独占」状态。

接着,2号核心开始从内存里读取变量B,同样的也是读取Cache Line大小的数据到Cache中,此 Cache Line中的数据也包含了变量A和变量B,此时1号和2号核心的Cache Line状态变为「共享」状态。

1号核心需要修改变量A,发现此Cache Line的状态是「共享」状态,所以先需要通过总线发送消息给 2号核心,通知2号核心把Cache中对应的Cache Line标记为「已失效」状态,然后1号核心对应的Cache Line状态变成「已修改」状态,并且修改变量A。

之后,2号核心需要修改变量B,此时2号核心的Cache中对应的Cache Line是已失效状态另外由于1 号核心的 Cache也有此相同的数据,且状态为「已修改」状态,所以要先把1号核心的Cache对应的 Cache Line写回到内存,然后 2 号核心再从内存读取Cache Line大小的数据到Cache中,最后把变量B修改到2号核心的Cache中,并将状态标记为「已修改」状态。
可以发现如果1号和2号CPU核心这样持续交替的分别修改变量A和B,就会重复④和⑤这两个步骤,Cache并没有起到缓存的效果,虽然变量A和B之间其实并没有任何的关系,但是因为同时归属于一个 Cache Line ,这个Cache Line中的任意数据被修改后,都会相互影响,从而出现④和⑤这两个步骤。
这种因为多个线程同时读写同一个Cache Line的不同变量时而导致CPU Cache失效的现象称为伪共享(False Sharing)。
伪共享中的变量:
全局变量:如果多个线程访问的变量是全局变量,并且这些变量恰好位于同一个缓存行内,那么可能会导致伪共享。比如,两个线程分别操作两个不同的全局变量x和y,但这两个变量在内存中恰好是连续存储的,且共享一个缓存行。
局部变量:如果不同线程的局部变量在内存布局上靠得很近(例如,它们可能是同一个对象或数组的不同元素),且这些变量落在同一个缓存行内,也会导致伪共享。例如,在多线程访问数组的不同元素时,如果这些元素位于同一个缓存行内,可能会导致伪共享问题。
避免伪共享的方法
对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同一个 Cache Line 中,否则就会出现为伪共享的问题。 在 Linux 内核中存在 __cacheline_aligned_in_smp 宏定义,是用于解决伪共享的问题。
如果在多核(MP)系统里,该宏定义是 __cacheline_aligned,也就是 Cache Line 的大小;
而如果在单核系统里,该宏定义是空的; 针对在同一个Cache Line中的共享的数据,如果在多核之间竞争比较严重,为了防止伪共享现象的发生,可以采用上面的宏定义使得变量在Cache Line里是对齐的。 举个例子,有下面这个结构体:
结构体里的两个成员变量a和b在物理内存地址上是连续的,于是它们可能会位于同一个Cache Line中,如下图:
为了防止前面提到的Cache伪共享问题,我们可以使用上面介绍的宏定义,将b的地址设置为Cache Line对齐地址,
这样a和b变量就不会在同一个Cache Line中了,如下图:
避免Cache伪共享实际上是用空间换时间的思想,浪费一部分Cache空间,从而换来性能的提升。
再来看一个应用层面的规避方案,有一个Java并发框架Disruptor使用「字节填充 + 继承」的方式,来避免伪共享的问题。
Disruptor中有一个RingBuffer类会经常被多个线程使用,代码如下:
CPU Cache从内存读取数据的单位是CPU Cache Line,一般64位CPU的CPU Cache Line的大小是64个字节,一个long类型的数据是8个字节,所以CPU一下会加载8个long 类型的数据。
根据JVM对象继承关系中父类成员和子类成员,内存地址是连续排列布局的,因此RingBufferPad中的7个long类型数据作为Cache Line前置填充,而 RingBuffer 中的7个long类型数据则作为 Cache Line 后置填充,这14个long变量没有任何实际用途,更不会对它们进行读写操作。
RingBufferFelds 里面定义的这些变量都是 final 修饰的,意味着第一次加载之后不会再修改,又由于「前后」各填充了 7 个不会被读写的 long 类型变量,所以无论怎么加载 Cache Line,这整个Cache Line 里都没有会发生更新操作的数据,于是只要数据被频繁地读取访问,就自然没有数据被换出Cache 的可能,也因此不会产生伪共享的问题。
缓存行对齐是指在内存中安排数据的方式,以确保数据的存取在缓存行级别上是高效的。伪共享示例
import threading
import time
import numpy as np
# 缓存行大小一般为 64 字节
CACHE_LINE_SIZE = 64
NUM_THREADS = 4
ITERATIONS = 10_000_000
class PaddedArray:
def __init__(self, size):
# 使用 np.zeros 初始化一个大于缓存行大小的数组来分隔变量,防止伪共享
self.data = np.zeros(size * CACHE_LINE_SIZE, dtype=np.int64)
# 定义无填充和填充的数组
unpadded = np.zeros(NUM_THREADS, dtype=np.int64)
padded = PaddedArray(NUM_THREADS)
def increment_unpadded(index):
for _ in range(ITERATIONS):
unpadded[index] += 1
def increment_padded(index):
for _ in range(ITERATIONS):
padded.data[index * CACHE_LINE_SIZE] += 1
def measure_performance(func, desc):
threads = []
start_time = time.time()
for i in range(NUM_THREADS):
thread = threading.Thread(target=func, args=(i, ))
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
duration = time.time() - start_time
print(f"{desc}: {duration:.4f} seconds")
if __name__ == "__main__":
# 测量无填充的性能
measure_performance(increment_unpadded, "Without Padding")
# 测量填充的性能
measure_performance(increment_padded, "With Padding")
CPU 如何选择线程的
CPU是根据什么来选择当前要执行的线程: 在Linux内核中,进程和线程都是用task_struct结构体表示的,区别在于线程的task_struct结构体里部分资源是共享了进程已创建的资源,比如内存地址空间、代码段、文件描述符等,所以Linux中的线程也被称为轻量级进程,因为线程的task_struct 相比进程的 task_struct 承载的资源比较少,因此以「轻」得名。
一般来说,没有创建线程的进程,是只有单个执行流,它被称为是主线程。如果想让进程处理更多的事情,可以创建多个线程分别去处理,但不管怎么样,它们对应到内核里都是 task_struct。
Linux内核里的调度器,调度的对象就是task_struct,接下来我们就把这个数据结构统称为任务。
在Linux系统中,根据任务的优先级以及响应要求,主要分为两种,其中优先级的数值越小,优先级越高:
实时任务,对系统的响应时间要求很高,也就是要尽可能快的执行实时任务,优先级在0~99范围内的就算实时任务;
普通任务,响应时间没有很高的要求,优先级在100~139范围内都是普通任务级别; 线程和进程的 task_struct 结构体具有很多相同的字段,但在细节和实现上可能有所不同,在内核中,每个线程和进程都有一个task_struct实例来管理和调度 task_struct 结构体定义在[[/usr/include/linux/sched.h]]文件中,包含了很多与进程和线程相关的信息。下面是task_struct结构体的一些重要字段和功能:
state: 当前进程的状态(如运行、睡眠、停止等)
调度信息:prio:进程的优先级;sched_policy:调度策略(如 SCHED_FIFO、SCHED_RR 等;cpu:当前执行该进程的CPU。
内存管理:mm: 指向进程的内存管理信息的指针,类型为 mm_struct。它包含虚拟内存区域、页表等信息。active_mm: 指向活动内存管理信息的指针,通常用于线程
进程标识符:pid: 进程 ID,tgid: 线程组 ID(线程组的标识符,即线程组的所有线程共享的 PID)
进程间通信:files: 指向 files_struct 结构体的指针,表示该进程打开的文件描述符表,fs: 指向 fs_struct 结构体的指针,表示进程的文件系统信息(根目录、当前工作目录等)
进程管理:parent: 指向父进程的指针,children: 指向子进程链表的指针,sibling: 指向兄弟进程链表的指针
其他:comm: 进程名称(通常是可执行文件的名称)start_time: 进程的启动时间,signal: 进程的信号处理信息。
调度类
由于任务有优先级之分,Linux 系统为了保障高优先级的任务能够尽可能早的被执行,于是分为了这几种调度类,如下图:
Deadline和Realtime这两个调度类,都是应用于实时任务的,这两个调度类的调度策略合起来共有这三种,它们的作用如下:
SCHED_DEADLINE:是按照 deadline 进行调度的,距离当前时间点最近的 deadline 的任务会被优先调度;
SCHED_FIFO:对于相同优先级的任务,按先来先服务的原则,但是优先级更高的任务,可以抢占低优先级的任务,也就是优先级高的可以「插队」;
SCHED_RR:对于相同优先级的任务,轮流着运行,每个任务都有一定的时间片,当用完时间片的任务会被放到队列尾部,以保证相同优先级任务的公平性,但是高优先级的任务依然可以抢占低优先级的任务; 而 Fair 调度类是应用于普通任务,都是由 CFS 调度器管理的,分为两种调度策略:
SCHED_NORMAL:普通任务使用的调度策略;
SCHED_BATCH:后台任务的调度策略,不和终端进行交互,因此在不影响其他需要交互的任务,可以适当降低它的优先级。
完全公平调度
我们平日里遇到的基本都是普通任务,对于普通任务来说,公平性最重要,在 Linux 里面,实现了一个基于 CFS 的调度算法,也就是完全公平调度(Completely Fair Scheduling)。
这个算法的理念是想让分配给每个任务的 CPU 时间是一样,于是它为每个任务安排一个虚拟运行时间vruntime,如果一个任务在运行,其运行的越久,该任务的 vruntime 自然就会越大,而没有被运行的任务,vruntime 是不会变化的。
在 CFS 算法调度的时候,会优先选择 vruntime 少的任务以保证每个任务的公平性。
这就好比,让你把一桶的奶茶平均分到 10 杯奶茶杯里,你看着哪杯奶茶少,就多倒一些;哪个多了,就先不倒,这样经过多轮操作,虽然不能保证每杯奶茶完全一样多,但至少是公平的。
上面提到的例子没有考虑到优先级的问题,虽然是普通任务,但是普通任务之间还是有优先级区分的,所以在计算虚拟运行时间 vruntime 还要考虑普通任务的权重值,注意权重值并不是优先级的值,内核中会有一个 nice 级别与权重值的转换表,nice 级别越低的权重值就越大.

CPU 运行队列
一个系统通常都会运行着很多任务,多任务的数量基本都是远超 CPU 核心数量,因此这时候就需要排队。
每个 CPU 都有自己的运行队列(Run Queue, rq),用于描述在此 CPU 上所运行的所有进程,其队列包含三个运行队列,Deadline 运行队列 dl_rq、实时任务运行队列 rt_rq 和 CFS 运行队列 cfs_rq,其中cfs_rq 是用红黑树来描述的,按 vruntime 大小来排序的,最左侧的叶子节点,就是下次会被调度的任务。
这几种调度类是有优先级的,优先级如下:Deadline > Realtime > Fair,这意味着 Linux 选择下一个任务执行的时候,会按照此优先级顺序进行选择,也就是说先从 dl_rq 里选择任务,然后从 rt_rq 里选择任务,最后从 cfs_rq 里选择任务。因此,实时任务总是会比普通任务优先被执行。
调整优先级
如果我们启动任务的时候,没有特意去指定优先级的话,默认情况下都是普通任务,普通任务的调度类是 Fair,由 CFS 调度器来进行管理。CFS 调度器的目的是实现任务运行的公平性,也就是保障每个任务的运行的时间是差不多的。
如果你想让某个普通任务有更多的执行时间,可以调整任务的 nice 值,从而让优先级高一些的任务执行更多时间。nice 的值能设置的范围是 -20~19值越低,表明优先级越高,因此 -20 是最高优先级,19 则是最低优先级,默认优先级是 0。
事实上,nice 值并不是表示优先级,而是表示优先级的修正数值它与优先级(priority)的关系是这样的:priority(new) = priority(old) + nice。内核中,priority 的范围是0~139,值越低,优先级越高
其中前面的 0~99 范围是提供给实时任务使用的,而 nice 值是映射到 100~139,这个范围是提供给普通任务用的,因此 nice 值调整的是普通任务的优先级。
权重值与 nice 值的关系的,nice 值越低,权重值就越大,计算出来的 vruntime 就会越少,由于 CFS 算法调度的时候,就会优先选择 vruntime 少的任务进行执行,所以 nice 值越低,任务的优先级就越高。
我们可以在启动任务的时候,可以指定 nice 的值,比如将 mysqld 以 -3 优先级:
nice -n -3 /usr/sbin/mysqld
如果想修改已经运行中的任务的优先级,则可以使用 renice 来调整 nice 值:
renice -10 -p <进程id>
nice 调整的是普通任务的优先级,所以不管怎么缩小 nice 值,任务永远都是普通任务,如果某些任务要求实时性比较高,那么你可以考虑改变任务的优先级以及调度策略,使得它变成实时任务,比如

中断
在计算机中,中断是系统用来响应硬件设备请求的一种机制,操作系统收到硬件的中断请求,会打断正在执行的进程,然后调用内核中的中断处理程序来响应请求。 中断是一种异步的事件处理机制,可以提高系统的并发处理能力。 操作系统收到了中断请求,会打断其他进程的运行,所以中断请求的响应程序,也就是中断处理程序,要尽可能快的执行完,这样可以减少对正常进程运行调度地影响。 而且,中断处理程序在响应中断时,可能还会「临时关闭中断」,这意味着,如果当前中断处理程序没有执行完之前,系统中其他的中断请求都无法被响应,也就说中断有可能会丢失,所以中断处理程序要短且快。
软中断
Linux 系统为了解决中断处理程序执行过长和中断丢失的问题,将中断过程分成了两个阶段,分别是「上半部和下半部分」。
上半部用来快速处理中断,一般会暂时关闭中断请求,主要负责处理跟硬件紧密相关或者时间敏感的事情。
下半部用来延迟处理上半部未完成的工作,一般以「内核线程」的方式运行。 再举一个计算机中的例子,常见的网卡接收网络包的例子。 网卡收到网络包后,通过DMA方式将接收到的数据写入内存,接着会通过硬件中断通知内核有新的数据到了,于是内核就会调用对应的中断处理程序来处理该事件,这个事件的处理也是会分成上半部和下半部。 上部分要做的事情很少,会先禁止网卡中断,避免频繁硬中断,而降低内核的工作效率。接着,内核会触发一个软中断,把一些处理比较耗时且复杂的事情,交给「软中断处理程序」去做,也就是中断的下半部,其主要是需要从内存中找到网络数据,再按照网络协议栈,对网络数据进行逐层解析和处理,最后把数据送给应用程序。
上半部直接处理硬件请求,也就是硬中断,主要是负责耗时短的工作,特点是快速执行;
下半部是由内核触发,也就说软中断,主要是负责上半部未完成的工作,通常都是耗时比较长的事情,特点是延迟执行; 硬中断(上半部)是会打断 CPU 正在执行的任务,然后立即执行中断处理程序,而软中断(下半部)是以内核线程的方式执行,并且每一个 CPU 都对应一个软中断内核线程,名字通常为ksoftirqd/CPU 编号」,比如 0 号 CPU 对应的软中断内核线程的名字是ksoftirqd/0 软中断不只是包括硬件设备中断处理程序的下半部,一些内核自定义事件也属于软中断,比如内核调度等、RCU 锁(内核里常用的一种锁)等。
系统里有哪些软中断
在 Linux 系统里,我们可以通过查看 /proc/softirqs 的 内容来知晓「软中断」的运行情况,以及 /proc/interrupts的 内容来知晓「硬中断」的运行情况。
简单的解析下 /proc/softirqs 文件的内容:
每一个 CPU 都有自己对应的不同类型软中断的累计运行次数,有 3 点需要注意下。
第一点,要注意第一列的内容,它是代表着软中断的类型,软中断包括了 10 个类型,分别对应不同的工作类型,比如NET_RX 表示网络接收中断,NET_TX 表示网络发送中断、TIMER 表示定时中断、RCU 表示 RCU 锁中断、SCHED 表示内核调度中断。
第二点,要注意同一种类型的软中断在不同 CPU 的分布情况,正常情况下,同一种中断在不同 CPU 上的累计次数相差不多,
第三点,这些数值是系统运行以来的累计中断次数,数值的大小没什么参考意义,但是系统的中断次数的但是系统的中断次数的变化速率才是我们要关注的,我们可以使用 watch -d cat /proc/softirqs命令查看中断次数的变化速率。
软中断是以内核线程的方式执行的,我们可以用ps命令可以查看到。
内核线程的名字外面都有有中括号,这说明ps无法获取它们的命令行参数,所以一般来说,名字在中括号里的都可以认为是内核线程。
Q1:如何定位软中断CPU使用率过高的问题?
要想知道当前的系统的软中断情况,我们可以使用 top 命令查看,下面是一台服务器上的 top 的数据:
上图中的黄色部分si,就是CPU在软中断上的使用率而且可以发现,每个CPU使用率都不高,CPU的使用率虽然只有3%和4%左右,但是都是用在软中断上了。
另外,也可以看到CPU使用率最高的进程也是软中断ksoftirqd,因此可以认为此时系统的开销主要来源于软中断。
如果要知道是哪种软中断类型导致的,我们可以使用watch -d cat /proc/softirqs命令查看每个软中断类型的中断次数的变化速率。
一般对于网络I/O比较高的Web服务器,NET_RX网络接收中断的变化速率相比其他中断类型快很多。
如果发现NET_RX网络接收中断次数的变化速率过快,接下来就可以使用sar -n DEV查看网卡的网络包接收速率情况,然后分析是哪个网卡有大量的网络包进来。
接着,在通过tcpdump抓包,分析这些包的来源,如果是非法的地址,可以考虑加防火墙,如果是正常流量,则要考虑硬件升级等。
数字表示
基础概念
原码:一个正数,按照绝对值大小转换成的二进制数;一个负数按照绝对值大小转换成的二进制数,然后最高位补1,称为原码。 反码:正数的反码与原码相同,负数的反码为对该数的原码除符号位外各位取反[每一位取反(除符号位)]。 补码:正数的补码与原码相同,负数的补码为对该数的原码除符号位外各位取反,然后在最后一位加1. **涉及有负数参与的
负数的表示
int类型是32位的,其中最高位是作为「符号标志位」,正数的符号位是0,负数的符号位是1,剩余的31位则表示二进制数据。那么,对于int类型的数字1的二进制数表示如下:
而负数就比较特殊了点,负数在计算机中是以「补码」表示的,所谓的补码就是把正数的二进制全部取反再加1,比如-1的二进制是把数字1的二进制取反后再加1,如下图:
如果负数不是使用补码的方式表示,则需要取绝对值进行加减,再添加符号。而用了补码的表示方式,对于负数的加减法操作,实际上是和正数加减法操作一样的。而用了补码的表示方式,对于负数的加减法操作,实际上是和正数加减法操作一样的。

十进制小数与二进制的转换
小数部分的转换不同于整数部分,它采用的是乘 2 取整法将十进制中的小数部分乘以2作为二进制的一位然后继续取小数部分乘以2作为下一位,直到不存在小数为止。
最后把「整数部分 + 小数部分」结合在一起后,其结果就是1000.101
但是,并不是所有小数都可以用二进制表示,前面提到的0.625小数是一个特例,刚好通过乘2取整法的方式完整的转换成二进制。
如果我们用相同的方式,来把 0.1 转换成二进制,过程如下:
可以发现,0.1 的二进制表示是无限循环的。
由于计算机的资源是有限的,所以是没办法用二进制精确的表示 0.1,只能用「近似值」来表示,就是在有限的精度情况下,最大化接近 0.1 的二进制数,于是就会造成精度缺失的情况。
对于二进制小数转十进制时,需要注意一点,小数点后面的指数幂是负数。
比如,二进制 0.1 转成十进制就是 2^(-1),也就是十进制 0.5,二进制 0.01 转成十进制就是2^-2,也就是十进制 0.25,以此类推。举个例子,二进制 1010.101 转十进制的过程,如下图:

计算机是怎么存小数的?
1000.101这种二进制小数是「定点数」形式,代表着小数点是定死的,不能移动,如果你移动了它的小数点,这个数就变了,就不再是它原来的值了。计算机并不是这样存储的小数的,计算机存储小数的采用的是浮点数,名字里的「浮点」表示小数点是可以浮动的。 比如1000.101这个二进制数,可以表示成1.000101 x 2^3,类似于数学上的科学记数法。 比如有个很大的十进制数 1230000,我们可以也可以表示成1.23 x 10^6,这种方式就称为科学记数法。 该方法在小数点左边只有一个数字,而且把这种整数部分没有前导0的数字称为规格化,比如1.0 x 10^(-9)是规格化的科学记数法,而0.1 x 10^(-9)和10.0 x 10^(-9)就不是了。 如果二进制要用到科学记数法,同时要规范化,那么不仅要保证基数为2,还要保证小数点左侧只有1位,而且必须为1。 所以通常将1000.101这种二进制数,规格化表示成1.000101 x 2^3,其中,最为关键的是000101和3这两个东西,它就可以包含了这个二进制小数的所有信息:
000101称为尾数,即小数点后面的数字;
3称为指数,指定了小数点在数据中的位置; 现在绝大多数计算机使用的浮点数,一般采用的是IEEE制定的国际标准,这种标准形式如下图: 符号位 – 指数位 – 尾数
符号位:表示数字是正数还是负数,为0表示正数,为1表示负数;
指数位:指定了小数点在数据中的位置,指数可以是负数,也可以是正数,指数位的长度越长则数值的表达范围就越大;
尾数位:小数点右侧的数字,也就是小数部分,比如二进制 1.0011 x 2^(-2),尾数部分就是 0011,而且尾数的长度决定了这个数的精度,因此如果要表示精度更高的小数,则就要提高尾数位的长度; 用 32 位来表示的浮点数,则称为单精度浮点数,也就是我们编程语言中的 float 变量,而用 64 位来表示的浮点数,称为双精度浮点数,也就是 double 变量,它们的结构如下:

double 的尾数部分是52位,float的尾数部分是23位,由于同时都带有一个固定隐含位,所以 double 有53个二进制有效位,float有24个二进制有效位,所以所以它们的精度在十进制中分别是log10(2^53) 约等于15.95和log10(2^24) 约等于7.22 位因此 double 的有效数字是15~16位,float 的有效数字是7~8 位,这些有效位是包含整数部分和小数部分;
double 的指数部分是11位,而float的指数位是8位,意味着 double相比float 能表示更大的数值范围; 那二进制小数,是如何转换成二进制浮点数的呢?
移动后的小数点左侧的有效位(即 1)消失了,它并没有存储到float里。
这是因为 IEEE 标准规定,二进制浮点数的小数点左侧只能有 1 位,并且还只能是 1,既然这一位永远都是1,那就可以不用存起来了。
于是就让 23 位尾数只存储小数部分,然后在计算时会自动把这个 1 加上,这样就可以节约 1 位的空间,尾数就能多存一位小数,相应的精度就更高了一点。
从 float 的二进制浮点数转换成十进制时,要考虑到这个隐含的 1,转换公式如下:
举个例子,我们把下图这个 float 的数据转换成十进制,过程如下:

float 的指数部分是8位,IEEE 标准规定单精度浮点的指数取值范围是-126 ~ +127,于是为了把指数转换成无符号整数,就要加个偏移量,比如float的指数偏移量是127,这样指数就不会出现负数了 比如,指数如果是8,则实际存储的指数是8 + 127(偏移量=135,即把135转换为二进制之后再存储,而当我们需要计算实际的十进制数的时候,再把指数减去「偏移量」即可。 首先,我们计算出10.625 的二进制小数为 1010.101。 然后把小数点,移动到第一个有效数字后面即将1010.101右移3位成 1.010101,右移3位就代表+3,左移 3位就是-3 float中的「指数位」就跟这里移动的位数有关系,把移动的位数再加上「偏移量」,float的话偏移量是 127,相加后就是指数位的值了,即指数位这8位存的是10000010(十进制 130),因此你可以认为「指数位」相当于指明了小数点在数据中的位置。 1.010101这个数的小数点右侧的数字就是float里的「尾数位」,由于尾数位是23位,则后面要补充0,所以最终尾数位存储的数字是01010100000000000000000。 指数可能是正数,也可能是负数,即指数是有符号的整数,而有符号整数的计算是比无符号整数麻烦的,所以为了减少不必要的麻烦,在实际存储指数的时候,需要把指数转换成无符号整数。
0.1 + 0.2 == 0.3 ?
并不是所有小数都可以用「完整」的二进制来表示的,比如十进制0.1在转换成二进制小数的时候,是一串无限循环的二进制数,计算机是无法表达无限循环的二进制数的,毕竟计算机的资源是有限。
**计算机只能用「近似值」来表示该二进制,**那么意味着计算机存放的小数可能不是一个真实值。
现在基本都是用 IEEE 754 规范的「单精度浮点类型」或「双精度浮点类型」来存储小数的,根据精度的不同,近似值也会不同。
那计算机是存储0.1是一个怎么样的二进制浮点数呢?
使用线上工具,将十进制0.1小数转换成 float 浮点数:
binaryconvert
结果如下:
可以看到,8位指数部分是01111011,23位的尾数部分是10011001100110011001101,可以看到尾数部分是0011是一直循环的,只不过尾数是有长度限制的,所以只会显示一部分,所以是一个近似值,精度十分有限。
接下来,我们看看 0.2 的 float 浮点数:
可以看到,8位指数部分是01111100,稍微和0.1的指数不同,23位的尾数部分是10011001100110011001101和 0.1 的尾数部分是相同的,也是一个近似值。0.1的二进制浮点数转换成十进制的结果是0.100000001490116119384765625
0.2的二进制浮点数转换成十进制的结果是0.20000000298023223876953125
这两个结果相加就是 0.300000004470348358154296875。
所以,你会看到在计算机中 0.1 + 0.2 并不等于完整的 0.3。
这主要是因为有的小数无法可以用「完整」的二进制来表示,所以计算机里只能采用近似数的方式来保存,那两个近似数相加,得到的必然也是一个近似数。
而我们二进制只能精准表达 2 除尽的数字 1/2,1/4, 1/8,但是对于 0.1(1/10) 和 0.2(1/5),在二进制中都无法精准表示时,需要根据精度舍入。
float类型的指数位是8位,偏移量为127,double类型的指数位是11位,偏移量是1023
举例计算
小数转二机制
A1:10.625的小数表现形式
使用除2取余法找出整数部分的二进制,使用乘2去整法找出小数部分的二进制表示1010.101
将二进制小数进行规格化处理,即将整体数字向右移动3位,得到1.010101
按照IEEE规范,找出符号位、指数位和尾数位 符号位 指数位 尾数位 0 3 01010100000000000000000
指数位需要加上偏移量127,即3+127=130,转换为二进制为10000010
将符号位、指数位和尾数位拼接起来,得到最终结果01000001001010100000000000000000
A2:0.1的小数表现形式
使用除2取余法找出整数部分的二进制,使用乘2去整法找出小数部分的二进制表示 0.0001100110011001100110011001100110011001100110011001101
将二进制小数进行规格化处理,即将整体数字向左移动4位,得到 1.100110011001100110011001100110011001100110011001101
按照IEEE规范,找出符号位、指数位和尾数位 符号位 指数位 尾数位 0 -4 100110011001100110011001100110011001100110011001101
指数位需要加上偏移量123,即-4+127=123,转换为二进制为1111011
将符号位、指数位和尾数位拼接起来,得到最终结果 0 01111011 10011001100110011001101
二机制转小数
二进制转小数的计算格式为: (-1)^符号位2^(指数位-127)(1+尾数位) A1:01000001001010100000000000000000
符号位为0,表示正数
指数位为10000010,转换为十进制为130,减去偏移量127,得到3
尾数位为01010100000000000000000,转换为十进制为42
将符号位、指数位和尾数位拼接起来,得到最终结果 (-1)^02^3(1+42) =10.625
总结
小数在计算机中以浮点数进行表示,采用的是乘2取整法,根据国际标准,将整个二进制表示划分为三段,符号位,指数位和尾数位。其中float和double的精度为32位和64位,float的三段式划分为1-8-23,double的三段式划分为1-11-52. 虽然 IEEE 754 浮点数标准通过规范化数和非规范化数组合,尽可能地覆盖了较宽广的实数范围,特别是在接近零的区域,减少了突然下溢的情况,但在实数轴上仍然存在“空洞”,即不可表示的数值。这是由于浮点数的离散性和有限精度所决定的。 浮点数的离散性决定了在实数轴上仍然有许多无法表示的数值,这些数值就是所谓的“空洞”。
正向计算小数的二进制表示
float为例 小数转化为浮点二进制 0.21 乘2取整法 整数部分 0.21 * 2 = 0.42 0 0.42 * 2 = 0.84 0 0.84 * 2 = 1.68 1 0.68 * 2 = 1.36 1 0.36 * 2 = 0.72 0 0.72 * 2 = 1.44 1 0.44 * 2 = 0.88 0 0.88 * 2 = 1.76 1 0.76 * 2 = 1.52 1 0.52 * 2 = 1.04 1 0.04 * 2 = 0.08 0 0.08 * 2 = 0.16 0 0.16 * 2 = 0.32 0 0.32 * 2 = 0.64 0 0.64 * 2 = 1.28 1 0.28 * 2 = 0.56 0 0.56 * 2 = 1.12 1 0.12 * 2 = 0.24 0 0.24 * 2 = 0.48 0 0.48 * 2 = 0.96 0 0.96 * 2 = 1.92 1 0.92 * 2 = 1.84 1 0.84 * 2 = 1.68 1 0.68 * 2 = 1.36 1 0.36 * 2 = 0.72 0 0.72 * 2 = 1.44 1 ##—————————–## 大致二进制表示为0.00110101110000101000111101 进行标准化 符号位 0 指数位 01111100 尾数位 10101110000101000111101 ##——————————–## 最终结果 0 01111100 10101110000101000111101 ##———————————-##
逆向从二进制推算小数
符号位 s 指数位 m 尾数位 n(十进制) 小数 = (-1)^m*(1+n)2^(m-127) 0 01111100 10101110000101000111101 转十进制 (-1)^0(124-127)*(1+0.5+0+0.125+…) = 0.20999999344348907
float和double的取值范围
符号位为0,指数位全1,尾数位全0,表示+∞ 符号位为1,指数位全1,尾数位全0,表示-∞ 指数位全1,尾数位非全0,表示NaN(Not a Number) float的取值范围,最大值符号位0,指数位11111110,尾数为11111111111111111111111(1+(1-2^-23)),即2^127*(2-2^-23)≈3.402823466×10^38 最小值符号位为1,指数位11111110,尾数为11111111111111111111111(1+(1-2^-23)),即-2^127*(2-2^-23)≈-3.402823466×10^38

