小林coding硬件结构

CPU是如何执行程序的

冯诺依曼模型

在 1945 年冯诺依曼和其他计算机科学家们提出了计算机具体实现的报告,其遵循了图灵机的设计,而且还提出用电子元件构造计算机,并约定了用二进制进行计算和存储。

最重要的是定义计算机基本结构为5个不分,分别是运算器,控制器,存储器,输入设备,输出社保。这五个设备也被称为冯诺依曼模型。

运算器,控制器都是在中央处理器里的,存储器就是我们常见的内存,输入输出设备是计算机的外界设备,比如键盘是输入设备,显示器就是输出设备。

内存

我们的程序和数据都是存储在内存,存储的区域是线性的。

在计算机数据存储中,存储数据的基本单位是字节(byte),1字节等于8位(8bit)。每一个字节都对应一个内存地址。

内存的地址是从0开始编号的,然后自增排列,最后一个地址为内存总字节数-1,这种结构好似我们程序里的数组,所以内存的读写任何一个数据的速度都是一样的。

中央处理器

中央处理器也就是我们常说的 CPU,32 位和 64 位 CPU 最主要区别在于一次能计算多少字节数据:

  • 32 位 CPU 一次可以计算 4 个字节;
  • 64 位 CPU 一次可以计算 8 个字节;

这里的 32 位和 64 位,通常称为 CPU 的位宽,代表的是 CPU 一次可以计算(运算)的数据量。

之所以CPU要这样设计,就是为了能计算更大的数值,如果是8位的cpu,那么一次只能计算1个字节0-255范围内的数值,这样就无法一次完成计算1000*500,于是为了能一次计算大数的运算,CPU需要支持多个byte一起计算,比如32位cpu能计算的整数是4294967295。

CPU内部还有一些组件,常见的有寄存器控制单元和逻辑运算单元等。其中单元负责控制CPU的工作,逻辑运算单元负责计算,而寄存器可以分为多种类,每种寄存器的功能又不相同。

CPU 中的寄存器主要作用是存储计算时的数据,你可能好奇为什么有了内存还需要寄存器?原因很简单,因为内存离 CPU 太远了,而寄存器就在 CPU 里,还紧挨着控制单元和逻辑运算单元,自然计算时速度会很快。、

常见的寄存器种类

  • 通用寄存器,用来存放需要进行运算的数据,比如需要进行加和运算的俩个数据。
  • 程序计数器,用来存储CPU要执行下一条指令,所在的内存地址,注意不是存储了下一条要执行的指令,此时指令还在内存中,程序计数器只是存储了下一道指令的地址
  • 指令寄存器,用来存发给当前正在执行的指令,也就是指令本身,指令被执行完成之前。都存储在这里。

总线

总线是用于CPU和内存以及其他设备之间的通讯地址,总线可分为三种

  • 地址总线,用于指定CPI将要操作的内存地址;
  • 数据总线,用于读写内存的数据;
  • 控制总线,用于发送和接收信号,比如终端,设备复位等信号,CPU收到信号后自然进行响应,这是也需要控制总线;

当 CPU 要读写内存数据的时候,一般需要通过下面这三个总线:

  • 首先要通过「地址总线」来指定内存的地址;
  • 然后通过「控制总线」控制是读或写命令;
  • 最后通过「数据总线」来传输数据;

输入、输出设备

输入设备向计算机输入数据,计算机经过计算后,把数据输出给输出设备。期间,如果输入设备是键盘,按下按键时是需要和 CPU 进行交互的,这时就需要用到控制总线了。

线路与CPU位宽

数据是如何通过线路传输的呢?其实是通过操作电压,低电压表示0,高电压表示1.

如果构造了高低高这样的信号,其实就是 101 二进制数据,十进制则表示 5,如果只有一条线路,就意味着每次只能传递 1 bit 的数据,即 0 或 1,那么传输 101 这个数据,就需要 3 次才能传输完成,这样的效率非常低。

这样一位一位传输的方式,称为串行,下一个 bit 必须等待上一个 bit 传输完成才能进行传输。当然,想一次多传一些数据,增加线路即可,这时数据就可以并行传输。

为了避免低效率的串行传输的方式,线路的位宽最好一次就能访问到所有的内存地址。

CPU想要操作内存地址 就需要地址总线:

  • 如果总线地址只有1条,那么每次只能表示0或1这俩种地址,所以CPU能操作的内存地址最大数量为 2(2^1)个(注意,不要理解成同时能操作 2 个内存地址);
  • 如果地址总线有 2 条,那么能表示 00、01、10、11 这四种地址,所以 CPU 能操作的内存地址最大数量为 4(2^2)个。

那么,想要CPU操作4G的大的内存,那就需要32条总线地址,因为2^32=4G。

知道了线路位宽的意义后,我们在来看看CPU位宽。

CPU 的位宽最好不要小于线路位宽,比如 32 位 CPU 控制 40 位宽的地址总线和数据总线的话,工作起来就会非常复杂且麻烦,所以 32 位的 CPU 最好和 32 位宽的线路搭配,因为 32 位 CPU 一次最多只能操作 32 位宽的地址总线和数据总线。

如果用32位CPU去加俩个64为大小的数字,就需要把这2个64位的数字分成2个低位32为数字和2个高位32位数字来计算,先加俩个低位的32为数字,算出仅为,然后加和俩个高位的32位数字,最后再加上进位,就能算出结果了,可以发现32为CPU不能一次性计算出加和俩个64为数字的结果。

对于 64 位 CPU 就可以一次性算出加和两个 64 位数字的结果,因为 64 位 CPU 可以一次读入 64 位的数字,并且 64 位 CPU 内部的逻辑运算单元也支持 64 位数字的计算。

但是并不代表 64 位 CPU 性能比 32 位 CPU 高很多,很少应用需要算超过 32 位的数字,所以如果计算的数额不超过 32 位数字的情况下,32 位和 64 位 CPU 之间没什么区别的,只有当计算超过 32 位数字的情况下,64 位的优势才能体现出来

另外,32 位 CPU 最大只能操作 4GB 内存,就算你装了 8 GB 内存条,也没用。而 64 位 CPU 寻址范围则很大,理论最大的寻址空间为 2^64

程序执行的基本过程

在前面,我们知道了程序在图灵机的执行过程,接下来我们来看看程序在冯诺依曼模型上是怎么执行的。

程序实际上是一条一条指令,所以程序的运行过程就是把每一条指令一步一步的执行起来,负责执行指令的就是 CPU 了。

那 CPU 执行程序的过程如下:

  1. CPU读取程序计数器的值,这个值是指令的内存地址,然后CPU控制单元操作地址总线需要访问的内存地址,接着通知内存设备准备数据,数据准备好后通过数据总线讲指令数据传输给CPU,CPU收到内存传来的数据后,将这个指令数据存入到指令寄存器。
  2. 程序计数器的值自增,表示指向下一条指令,这个自增大小,由CPU的位宽决定,比如32为的CPU指令是4个字节,需要4个内存地址存放,因此程序计数器的值会自增4;
  3. CPU分析指令计算存器中的指令,确定指令的类型和参数,如果是计算类型的指令,就把指令交给逻辑运算单元运算;如果是存储类型的指令,则交由控制单元执行;

简单总结一下就是,一个程序执行的时候,CPU 会根据程序计数器里的内存地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。

CPU从程序计数器读取指令,到执行,再到下一条指令,这个过程会不断循环,知道程序结束,这个不断循环的过程被称为,CPU的指令周期。

a=1+2执行过程

知道了基本的程序执行过程后,接下来用 a = 1 + 2 的作为例子,进一步分析该程序在冯诺伊曼模型的执行过程。

CPU 是不认识 a = 1 + 2 这个字符串,这些字符串只是方便我们程序员认识,要想这段程序能跑起来,还需要把整个程序翻译成汇编语言的程序,这个过程称为编译成汇编代码。

针对汇编代码,我们还需要用汇编器翻译成机器码,这些机器码由 0 和 1 组成的机器语言,这一条条机器码,就是一条条的计算机指令,这个才是 CPU 能够真正认识的东西。

下面来看看 a = 1 + 2 在 32 位 CPU 的执行过程。

程序变异的过程中,编译器通过分析代码发现1和2是数据,于是程序运行时,内存会有个专门的区域来存放这些数据,这个区域是数据段,如下图

  1. 数据1被存放到0x200的位置
  2. 数据2被存放到0x204的位置

注意数据和指令的地址是分开存放的,存放指令区域的地方称为 正文段

编译器会把a=1+2翻译成4条指令,放到正文段中,如图这4条指令被存放到了0x100-0x10c的区域中

  • 0x100 的内容是 load 指令将 0x200 地址中的数据 1 装入到寄存器 R0
  • 0x104 的内容是 load 指令将 0x204 地址中的数据 2 装入到寄存器 R1
  • 0x108 的内容是 add 指令将寄存器 R0R1 的数据相加,并把结果存放到寄存器 R2
  • 0x10c 的内容是 store 指令将寄存器 R2 中的数据存回数据段中的 0x208 地址中,这个地址也就是变量 a 内存中的地址;

编译完成后,具体执行程序的时候,程序计算器会被设置为0x100地址,然后一次执行这4条指令。

上面的例子中,由于是在32位cpu执行的,因此一条指令占32位大小,所以你会发现每条指令间隔4个字节。

而数据的大小是根据你在程序中指定的变量类型,比如int类型的数据占4个字节,char类型的数据占一个字节。

指令

上面的例子中,图中指令的内容我写的是简易的汇编代码,目的是为了方便理解指令的具体内容,事实上指令的内容是一串二进制数字的机器码,每条指令都有对应的机器码,CPU通过解析机器码来知道指令的内容。

不同的CPU有不同的指令集,也就是对应着不同的汇编语言和不同的机器码,接下来选用最简单的MIPS指令集,来看看机器码是如何生成的,这样也能明白二进制机器码的具体含义。

MIPS的指令是一个32位的整数,高6位代表着操作码,表示这条指令是一条什么样的指令,剩下的26位不同指令类型所表示的内容也就不相同,主要有三种类型R,I和J。

一起来看看这三种类型的含义

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

接下来,我们把前面例子的这条指令:「add 指令将寄存器 R0R1 的数据相加,并把结果放入到 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 级流水线,如下图:

四阶段具体含义

  1. CPU通过程序计数器读取对应内存地址的指令,这个部分呢称为Fetch(取得指令)
  2. CPU对指令进行解码,这个不分称为Decode(指令译码)
  3. CPU执行指令,这个部分称为Execution(执行指令)
  4. CPU讲计算结果存回寄存的值存入内存,这个部分称为Store(数据回写)

上面这4个阶段我们称为指令周期(instrution cycle)CPU的工作就是一个周期接着一个周期,周而复始。

事实上,不同阶段其实是由计算机中的不同组件完成的:

  • 取指令的阶段,我们的指令是存放在存储器里的,实际上,通过程序计数器取出指令的过程,是由控制器操作的;
  • 指令的译码过程,也是控制器进行的;
  • 指令执行的过程,无论是进行算数操作,逻辑操作,还是进行数据传输、条件分支操作,都是由算数逻辑单元操作的,也就是由运算器处理的。但是如果是一个简单的条件地址跳转,则是直接在控制器里面完成的,不需要用到运算器。

指令的类型

指令从功能角度划分,可以分为5大类:

  • 数据传输类型的指令,比如store/load是寄存器与内存数据传输指令,mov是将一个内存地址的数据移动到另一个内存地址的指令。
  • 运算类型的指令,比如加减乘除,位运算,比较大小等等,它们最多只能处理俩个寄存器中的数据;
  • 跳转类型的指令,通过修改程序计数器的值来达到跳转执行指令的过程,比如编程中常见的if-else,switch-case函数调用
  • 信号类型的指令,比如发生中断的指令trap
  • 闲置类型的指令,比如指令nop,执行后CPU会空转一个周期;

指令的执行速度

CPU的硬件阐述都会有GHZ这个参数,比如一个1GHZ的CPU,指的是时钟频率是1G,代表着1秒就会产生1G次数的脉冲信号,每一次脉冲信号高低电平的转换就是一个周期,称为时钟周期。

对于CPU来说,在一个时钟周期内,CPU仅能完成一个最基本的动作,时钟频率越高,时钟周期就越短,工作速度也就越快。

一个时钟周期一定能完成一条指令吗?答案是不一定的,大多数指令不能再一个时钟周期完成,通常需要若干个时钟周期。不同的指令需要的时钟周期是不同的,加法和乘法都对应着一条 CPU 指令,但是乘法需要的时钟周期就要比加法多。

如何让程序跑的更快?

程序执行的时候,耗费CPU时间少就说明程序是快的,对于程序的CPU执行时间,我们可以拆解成CPU时钟周期数(CPU Cycles)和时钟周期时间(Clock Cycle Time)

时钟周期时间就是我们前面提及的 CPU 主频,主频越高说明 CPU 的工作速度就越快,比如我手头上的电脑的 CPU 是 2.4 GHz 四核 Intel Core i5,这里的 2.4 GHz 就是电脑的主频,时钟周期时间就是 1/2.4G。

要想 CPU 跑的更快,自然缩短时钟周期时间,也就是提升 CPU 主频,但是今非彼日,摩尔定律早已失效,当今的 CPU 主频已经很难再做到翻倍的效果了。

另外,换一个更好的 CPU,这个也是我们软件工程师控制不了的事情,我们应该把目光放到另外一个乘法因子 —— CPU 时钟周期数,如果能减少程序所需的 CPU 时钟周期数量,一样也是能提升程序的性能的。

对于 CPU 时钟周期数我们可以进一步拆解成:「指令数 x 每条指令的平均时钟周期数(*Cycles Per Instruction*,简称 CPI」,于是程序的 CPU 执行时间的公式可变成如下:

因此,要想程序跑的更快,优化这三者即可:

  • 指令数,表示执行程序所需要多少条指令,以及哪些指令。这个层面是基本靠编译器来优化,毕竟同样的代码,在不同的编译器,编译出来的计算机指令会有各种不同的表达方式。
  • 每条指令的平均时钟周期数CPI表示一条指令需要多少个时钟周期数,现代大多数CPU通过流水线技术PipLine,让一条指令需要的CPU时钟周期尽可能的少;
  • 时钟周期时间,表示计算机主频,起绝育计算机硬件。由的CPU支持超频技术,打开了超频意味着把CPU内部的时钟给调快了,于是CPU工作速度就变快了,但是也有代价的,CPU跑的越快,散热压力就会越大,CPU很容易崩溃。

总结

最后我们再来回答开头的问题。

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 最大寻址能力。

你知道软件的 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 中的寄存器,处理速度是最快的,但是能存储的数据也是最少的,毕竟我们也不能一下同时思考太多的事情,除非你练过。

我们大脑中的记忆,就好比 CPU Cache,中文称为 CPU 高速缓存,处理速度相比寄存器慢了一点,但是能存储的数据也稍微多了一些。

CPU Cache 通常会分为 L1、L2、L3 三层,其中 L1 Cache 通常分成「数据缓存」和「指令缓存」,L1 是距离 CPU 最近的,因此它比 L2、L3 的读写速度都快、存储空间都小。我们大脑中短期记忆,就好比 L1 Cache,而长期记忆就好比 L2/L3 Cache。

寄存器和 CPU Cache 都是在 CPU 内部,跟 CPU 挨着很近,因此它们的读写速度都相当的快,但是能存储的数据很少,毕竟 CPU 就这么丁点大。

知道 CPU 内部的存储器的层次分布,我们放眼看看 CPU 外部的存储器。

当我们大脑记忆中没有资料的时候,可以从书桌或书架上拿书来阅读,那我们桌子上的书,就好比内存,我们虽然可以一伸手就可以拿到,但读写速度肯定远慢于寄存器,那图书馆书架上的书,就好比硬盘,能存储的数据非常大,但是读写速度相比内存差好几个数量级,更别说跟寄存器的差距了。

我们从图书馆书架取书,把书放到桌子上,再阅读书,我们大脑就会记忆知识点,然后再经过大脑思考,这一系列过程相当于,数据从硬盘加载到内存,再从内存加载到 CPU 的寄存器和 Cache 中,然后再通过 CPU 进行处理和计算。

对于存储器,它的速度越快、能耗会越高、而且材料的成本也是越贵的,以至于速度快的存储器的容量都比较小。

CPU 里的寄存器和 Cache,是整个计算机存储器中价格最贵的,虽然存储空间很小,但是读写速度是极快的,而相对比较便宜的内存和硬盘,速度肯定比不上 CPU 内部的存储器,但是能弥补存储空间的不足。

存储器通常可以分为这么几个级别

寄存器

最靠近 CPU 的控制单元和逻辑计算单元的存储器,就是寄存器了,它使用的材料速度也是最快的,因此价格也是最贵的,那么数量不能很多。

寄存器的数量通常在几十到几百之间,每个寄存器可以用来存储一定的字节(byte)的数据。比如:

  • 32 位 CPU 中大多数寄存器可以存储 4 个字节;
  • 64 位 CPU 中大多数寄存器可以存储 8 个字节。

寄存器的访问速度非常快,一般要求在半个 CPU 时钟周期内完成读写,CPU 时钟周期跟 CPU 主频息息相关,比如 2 GHz 主频的 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 高速缓存通常分成指令缓存数据缓存

在 Linux 系统,我们可以通过这条命令,查看 CPU 里的 L1 Cache 「数据」缓存的容量大小:

$ cat /sys/devices/system/cpu/cpu0/cache/index0/size
32K

而查看 L1 Cache 「指令」缓存的容量大小,则是:

$ cat /sys/devices/system/cpu/cpu0/cache/index1/size
32K
L2 高速缓存

L2 高速缓存同样每个 CPU 核心都有,但是 L2 高速缓存位置比 L1 高速缓存距离 CPU 核心 更远,它大小比 L1 高速缓存更大,CPU 型号不同大小也就不同,通常大小在几百 KB 到几 MB 不等,访问速度则更慢,速度在 10~20 个时钟周期。

在 Linux 系统,我们可以通过这条命令,查看 CPU 里的 L2 Cache 的容量大小:

$ cat /sys/devices/system/cpu/cpu0/cache/index2/size
256K
L3 高速缓存

L3 高速缓存通常是多个 CPU 核心共用的,位置比 L2 高速缓存距离 CPU 核心 更远,大小也会更大些,通常大小在几 MB 到几十 MB 不等,具体值根据 CPU 型号而定。

访问速度相对也比较慢一些,访问速度在 20~60个时钟周期。

在 Linux 系统,我们可以通过这条命令,查看 CPU 里的 L3 Cache 的容量大小:

$ cat /sys/devices/system/cpu/cpu0/cache/index3/size 
3072K

内存

内存用的芯片和 CPU Cache 有所不同,它使用的是一种叫作 DRAM (*Dynamic Random Access Memory*,动态随机存取存储器) 的芯片。

DRAM 存储一个 bit 数据,只需要一个晶体管和一个电容就能存储,但是因为数据会被存储在电容里,电容会不断漏电,所以需要「定时刷新」电容,才能保证数据不会被丢失,这就是 DRAM 之所以被称为「动态」存储器的原因,只有不断刷新,数据才能被存储起来。

DRAM 的数据访问电路和刷新电路都比 SRAM 更复杂,所以访问的速度会更慢,内存速度大概在 200~300 个 时钟周期之间。

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 内部的寄存器、L1L2L3 Cache 只好用较小的容量,相反内存、硬盘则可用更大的容量,这就我们今天所说的存储器层次结构

另外,当 CPU 需要访问内存中某个数据的时候,如果寄存器有这个数据,CPU 就直接从寄存器取数据即可,如果寄存器没有这个数据,CPU 就会查询 L1 高速缓存,如果 L1 没有,则查询 L2 高速缓存,L2 还是没有的话就查询 L3 高速缓存,L3 依然没有的话,才去内存中取数据。

所以,存储层次结构也形成了缓存的体系。

CPU缓存一致性

CPU Cache 的数据写入

随着时间的推移,CPU 和内存的访问性能相差越来越大,于是就在 CPU 内部嵌入了 CPU Cache(高速缓存),CPU Cache 离 CPU 核心相当近,因此它的访问速度是很快的,于是它充当了 CPU 与内存之间的缓存角色。

CPU Cache 通常分为三级缓存:L1 Cache、L2 Cache、L3 Cache,级别越低的离 CPU 核心越近,访问速度也快,但是存储容量相对就会越小。其中,在多核心的 CPU 里,每个核心都有各自的 L1/L2 Cache,而 L3 Cache 是所有核心共享使用的。

我们先简单了解下 CPU Cache 的结构,CPU Cache 是由很多个 Cache Line 组成的,CPU Line 是 CPU 从内存读取数据的基本单位,而 CPU Line 是由各种标志(Tag)+ 数据块(Data Block)组成,你可以在下图清晰的看到:

写直达

保持内存与cache,最简单的一致性方法是写直达,把数据同时写入内存和cache中,这种方法称为写直达(*Write Through*)

在这个方法里,写入前会先判断数据是否在CPUCache里了

  • 如果数据已经在 Cache 里面,先将数据更新到 Cache 里面,再写入到内存里面;
  • 如果数据没有在 Cache 里面,就直接把数据更新到内存里面。

写直达法很直观,也很简单,但是问题明显,无论数据在不在 Cache 里面,每次写操作都会写回到内存,这样写操作将会花费大量的时间,无疑性能会受到很大的影响。

在写回机制中,当发生写操作时,新的数据仅仅被写入Cache Block里,只有当修改过的Cache Block被替换时程序员写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。

那具体是如何做到的呢?

  • 如果当发生写操作时,数据已经在CPU Cache里的话,则把数据更新到CPUCache里,同时标记CPUCache里的这个Cache Block为脏Dirty的,这个脏的标记代表这个时候,我们的CPU Cache里的数据和内存是不一致的,这种情况是不用把数据写到内存里的。
  • 如果当发生写操作时,数据所对应的CacheBlock里存放的是别的内存地址的数据的话,就要检查这个Cache Block里的数据有没有被标记为脏的:

    • 如果是脏的话,我们就要把这个CacheBlock里的数据回写到内存,然后再把当前要写入的数据,先从内存读到Cache Block里,然后再把当前要写入的数据写入到Cache Block,最后把它标记为脏的。
    • 如果不是脏的话,把当前要写入的数据先从内存读入到Cache Block里,接着将数据写入到这个Cache Block里,然后再把这个Cache Block标记为脏就好了。

可以发现写回的这个方法,在把数据写入到Cache的时候,只有在缓存不命中,同时数据对应的Cache中的Cache Block为脏标记的情况下,才会将数据写到内存中,而在缓存命中的情况下,则在写入Cache后,只需把该数据对应的 Cache Block 标记为脏即可,而不用写到内存里。

这样的好处是,如果我们大量的操作都能够命中缓存,那么大部分时间里 CPU 都不需要读写内存,自然性能相比写直达会高很多。

为什么缓存没命中时,还要定位 Cache Block?这是因为此时是要判断数据即将写入到 cache block 里的位置,是否被「其他数据」占用了此位置,如果这个「其他数据」是脏数据,那么就要帮忙把它写回到内存。

CPU 缓存与内存使用「写回」机制的流程图如下,左半部分就是读操作的流程,右半部分就是写操作的流程,也就是我们上面讲的内容。

缓存一致性

现在 CPU 都是多核的,由于 L1/L2 Cache 是多个核心各自独有的,那么会带来多核心的缓存一致性(*Cache Coherence*) 的问题,如果不能保证缓存一致性的问题,就可能造成结果错误。

那缓存一致性的问题具体是怎么发生的呢?我们以一个含有两个核心的 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*)

第一点写传播很容易就理解,当某个核心在 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 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。

那接下来我们看看,写传播和事务串行化具体是用什么技术实现的。

总线嗅探

写传播的原则就是当某个CPU更新了Cache中的数据,要把改时间广播通知到其他核心。最常见的视线方式是总线嗅探(Bus Snooping)。

我还是以前面的 i 变量例子来说明总线嗅探的工作机制,当 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 Line 四个不同的状态。

「已修改」状态就是我们前面提到的脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。而「已失效」状态,表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。

「独占」和「共享」状态都代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。

「独占」和「共享」的差别在于,独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。

另外,在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成共享状态。

那么,「共享」状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据。

我们举个具体的例子来看看这四个状态的转换:

  1. 当 A 号 CPU 核心从内存读取变量 i 的值,数据被缓存在 A 号 CPU 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,于是标记 Cache Line 状态为「独占」,此时其 Cache 中的数据与内存是一致的;
  2. 然后 B 号 CPU 核心也从内存读取了变量 i 的值,此时会发送消息给其他 CPU 核心,由于 A 号 CPU 核心已经缓存了该数据,所以会把数据返回给 B 号 CPU 核心。在这个时候, A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的;
  3. 当 A 号 CPU 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后 A 号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line 为「已修改」状态,此时 Cache 中的数据就与内存不一致了。
  4. 如果 A 号 CPU 核心「继续」修改 Cache 中 i 变量的值,由于此时的 Cache Line 是「已修改」状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可。
  5. 如果 A 号 CPU 核心的 Cache 里的 i 变量对应的 Cache Line 要被「替换」,发现 Cache Line 状态是「已修改」状态,就会在替换前先把数据同步到内存。

所以,可以发现当 Cache Line 状态是「已修改」或者「独占」状态时,修改更新其数据不需要发送广播给其他 CPU 核心,这在一定程度上减少了总线带宽压力。

事实上,整个 MESI 的状态可以用一个有限状态机来表示它的状态流转。还有一点,对于不同状态触发的事件操作,可能是来自本地 CPU 核心发出的广播事件,也可以是来自其他 CPU 核心通过总线发出的广播事件。下图即是 MESI 协议的状态图:

MESI 协议的四种状态之间的流转过程,我汇总成了下面的表格,你可以更详细的看到每个状态转换的原因:

CPU 在读写数据的时候,都是在 CPU Cache 读写数据的,原因是 Cache 离 CPU 很近,读写性能相比内存高出很多。对于 Cache 里没有缓存 CPU 所需要读取的数据的这种情况,CPU 则会从内存读取数据,并将数据缓存到 Cache 里面,最后 CPU 再从 Cache 读取数据。

而对于数据的写入,CPU 都会先写入到 Cache 里面,然后再在找个合适的时机写入到内存,那就有「写直达」和「写回」这两种策略来保证 Cache 与内存的数据一致性:

  • 写直达,只要有数据写入,都会直接把数据写入到内存里面,这种方式简单直观,但是性能就会受限于内存的访问速度;
  • 写回,对于已经缓存在 Cache 的数据的写入,只需要更新其数据就可以,不用写入到内存,只有在需要把缓存里面的脏数据交换出去的时候,才把数据同步到内存里,这种方式在缓存命中率高的情况,性能会更好;

当今 CPU 都是多核的,每个核心都有各自独立的 L1/L2 Cache,只有 L3 Cache 是多个核心之间共享的。所以,我们要确保多核缓存是一致性的,否则会出现错误的结果。

要想实现缓存一致性,关键是要满足 2 点:

  • 第一点是写传播,也就是当某个 CPU 核心发生写入操作时,需要把该事件广播通知给其他核心;
  • 第二点是事物的串行化,这个很重要,只有保证了这个,才能保障我们的数据是真正一致的,我们的程序在各个不同的核心上运行的结果也是一致的;

基于总线嗅探机制的 MESI 协议,就满足上面了这两点,因此它是保障缓存一致性的协议。

MESI 协议,是已修改、独占、共享、已失效这四个状态的英文缩写的组合。整个 MSI 状态的变更,则是根据来自本地 CPU 核心的请求,或者来自其他 CPU 核心通过总线传输过来的请求,从而构成一个流动的状态机。另外,对于在「已修改」或者「独占」状态的 Cache Line,修改更新其数据不需要发送广播给其他 CPU 核心。

cpu是如何执行任务的

CPU 从内存中读取数据到 Cache 的时候,并不是一个字节一个字节读取,而是一块一块的方式来读取数据的,这一块一块的数据被称为 CPU Cache Line(缓存块),所以 CPU Cache Line 是 CPU 从内存读取数据到 Cache 的单位

至于 CPU Cache Line 大小,在 Linux 系统可以用下面的方式查看到,你可以看我服务器的 L1 Cache Line 大小是 64 字节,也就意味着 L1 Cache 一次载入数据的大小是 64 字节

那么对数组的加载, CPU 就会加载数组里面连续的多个数据到 Cache 里,因此我们应该按照物理内存地址分布的顺序去访问元素,这样访问数组元素的时候,Cache 命中率就会很高,于是就能减少从内存读取数据的频率, 从而可提高程序的性能。

但是,在我们不使用数组,而是使用单独的变量的时候,则会有 Cache 伪共享的问题,Cache 伪共享问题上是一个性能杀手,我们应该要规避它。

接下来,就来看看 Cache 伪共享是什么?又如何避免这个问题?

现在假设有一个双核心的 CPU,这两个 CPU 核心并行运行着两个不同的线程,它们同时从内存中读取两个不同的数据,分别是类型为 long 的变量 A 和 B,这个两个数据的地址在物理内存上是连续的,如果 Cahce Line 的大小是 64 字节,并且变量 A 在 Cahce Line 的开头位置,那么这两个数据是位于同一个 Cache Line 中,又因为 CPU Cache Line 是 CPU 从内存读取数据到 Cache 的单位,所以这两个数据会被同时读入到了两个 CPU 核心中各自 Cache 中。

,这种因为多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象称为伪共享(*False Sharing*)

因此,对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同一个 Cache Line 中,否则就会出现为伪共享的问题。

cpu是如何选择线程的

了解完 CPU 读取数据的过程后,我们再来看看 CPU 是根据什么来选择当前要执行的线程。

在linux内核中,进程和线程都是用task_struct结构来表示的,区别在于线程的task_struct结构体里部分资源是共享了进程已创建的资源,比如内存地址空间、代码段、文件描述符等,所以Linux中简称也被称为轻量级进程,因为线程的task_struct相比进程的task_struct承载的资源比较少,因此以轻得名。

一般来说,没有常见线程的进程,是只有单个执行流,它被称为是主线程。如果想让进程处理更多的事情,可以创建多个线程分别去处理,但不管怎么样对应到内核中都是task_struct。

所以,Linux 内核里的调度器,调度的对象就是 task_struct,接下来我们就把这个数据结构统称为任务

在Linux系统中,根据任务的优先级以及响应要求主要分为俩种,其中优先级的数值越小,优先级越高:

  • 实时任务,对系统的响应时间要求很高,也就是要尽可能快的执行实时任务,优先级在0-99范围内的就算实时任务;
  • 普通任务,响应时间没有很高的要求,优先级在100-139范围内都是普通任务级别;

调度类

由于任务有优先级之分,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 级别越低的权重值就越大,至于 nice 值是什么,我们后面会提到。 于是就有了以下这个公式:

你可以不用管 NICE_0_LOAD 是什么,你就认为它是一个常量,那么在「同样的实际运行时间」里,高权重任务的 vruntime 比低权重任务的 vruntime ,你可能会奇怪为什么是少的?你还记得 CFS 调度吗,它是会优先选择 vruntime 少的任务进行调度,所以高权重的任务就会被优先调度了,于是高权重的获得的实际运行时间自然就多了。

cpu运行队列

一个系统通常都会运行着很多任务,多任务的数量基本都是远超 CPU 核心数量,因此这时候就需要排队

事实上,每个 CPU 都有自己的运行队列(*Run Queue, rq*),用于描述在此 CPU 上所运行的所有进程,其队列包含三个运行队列,Deadline 运行队列 dl_rq、实时任务运行队列 rt_rq 和 CFS 运行队列 cfs_rq,其中 cfs_rq 是用红黑树来描述的,按 vruntime 大小来排序的,最左侧的叶子节点,就是下次会被调度的任务。

PS:下图中的 csf_rq 应该是 cfs_rq,由于找不到原图了,我偷个懒,我就不重新画了,嘻嘻。

这几种调度类是有优先级的,优先级如下:Deadline > Realtime > Fair,这意味着 Linux 选择下一个任务执行的时候,会按照此优先级顺序进行选择,也就是说先从 dl_rq 里选择任务,然后从 rt_rq 里选择任务,最后从 cfs_rq 里选择任务。因此,实时任务总是会比普通任务优先被执行

调整优先级

如果我们启动任务的时候,没有特意去指定优先级的话,默认情况下都是普通任务,普通任务的调度类是 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 值调整的是普通任务的优先级。

在前面我们提到了,权重值与 nice 值的关系的,nice 值越低,权重值就越大,计算出来的 vruntime 就会越少,由于 CFS 算法调度的时候,就会优先选择 vruntime 少的任务进行执行,所以 nice 值越低,任务的优先级就越高。

我们可以在启动任务的时候,可以指定 nice 的值,比如将 mysqld 以 -3 优先级:

总结

理解 CPU 是如何读写数据的前提,是要理解 CPU 的架构,CPU 内部的多个 Cache + 外部的内存和磁盘都就构成了金字塔的存储器结构,在这个金字塔中,越往下,存储器的容量就越大,但访问速度就会小。

CPU 读写数据的时候,并不是按一个一个字节为单位来进行读写,而是以 CPU Cache Line 大小为单位,CPU Cache Line 大小一般是 64 个字节,也就意味着 CPU 读写数据的时候,每一次都是以 64 字节大小为一块进行操作。

因此,如果我们操作的数据是数组,那么访问数组元素的时候,按内存分布的地址顺序进行访问,这样能充分利用到 Cache,程序的性能得到提升。但如果操作的数据不是数组,而是普通的变量,并在多核 CPU 的情况下,我们还需要避免 Cache Line 伪共享的问题。

所谓的 Cache Line 伪共享问题就是,多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象。那么对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同一个 Cache Line 中,避免的方式一般有 Cache Line 大小字节对齐,以及字节填充等方法。

系统中需要运行的多线程数一般都会大于 CPU 核心,这样就会导致线程排队等待 CPU,这可能会产生一定的延时,如果我们的任务对延时容忍度很低,则可以通过一些人为手段干预 Linux 的默认调度策略和优先级。

软中断

中断是什么

中断是系统用来响应硬件设备请求的一种机制,操作系统收到硬件的中断请求,会打断正在执行的进程,然后调用内核中的中断处理程序来响应请求。

操作系统收到了中断请求,会打断其他进程的运行,所以中断请求的响应程序,也就是中断处理程序,要尽可能快的执行完,这样可以减少对正常进程运行调度地影响。

而且,中断处理程序在响应中断时,可能还会「临时关闭中断」,这意味着,如果当前中断处理程序没有执行完之前,系统中其他的中断请求都无法被响应,也就说中断有可能会丢失,所以中断处理程序要短且快。

软中断是什么

前面我们也提到了,中断请求的处理程序应该要短且快,这样才能减少对正常进程运行调度地影响,而且中断处理程序可能会暂时关闭中断,这时如果中断处理程序执行时间过长,可能在还未执行完中断处理程序前,会丢失当前其他设备的中断请求。

那Linux系统为了解决中断处理程序执行过长和中断丢失的问题,将中断过程分成了俩个阶段,分别是上半部和下半部分。

  • 上半部分用来快速处理中断,一般会暂时关闭中断请求,主要负责处理跟硬件紧密相关或者时间敏感的事情。
  • 下半部用来延迟处理上半部未完成的工作,一般以内核线程的方式运行

例如,网卡收到网络包后,通过DMA方式将接收到的数据写入内存,接着会通过硬件中断通知内核有新的数据到了,于是内核就会调用对应的中断处理程序来处理该事件,这个事件的处理也是会分成上半部和下半部。

上部分要做的事情很少,会先禁止网卡中断,避免频繁硬中断,而降低内核的工作效率。接着,内核会触发一个软中断,把一些处理比较耗时且复杂的事情,交给「软中断处理程序」去做,也就是中断的下半部,其主要是需要从内存中找到网络数据,再按照网络协议栈,对网络数据进行逐层解析和处理,最后把数据送给应用程序。

所以,中断处理程序的上部分和下半部可以理解为:

  • 上半部直接处理硬件请求,也就是硬中断,主要是负责时短的工作,特点是快速执行;
  • 下半部是由内核触发,也就是说软中断,主要是负责上半部未完成的工作,通常都是耗时比较长的事情,特点是延迟执行;

还有一个区别,硬中断(上半部)是会打断CPU正在执行的任务,然后立即中断处理程序,而软中断(下半部)是以内核綫程的方式执行,并且每一个CPU都对应一个软中断的内核线程,名字通常为「ksoftirqd/CPU 编号」,比如 0 号 CPU 对应的软中断内核线程的名字是 ksoftirqd/0

不过,软中断不只是包括硬件设备中断处理程序的下半部,一些内核自定义事件也属于软中断,比如内核调度等、RCU 锁(内核里常用的一种锁)等。

总结

为了避免由于中断处理程序执行时间过长,而影响正常进程的调度,Linux 将中断处理程序分为上半部和下半部:

  • 上半部,对应硬中断,由硬件触发中断,用来快速处理中断;
  • 下半部,对应软中断,由内核触发中断,用来异步处理上半部未完成的工作;

Linux 中的软中断包括网络收发、定时、调度、RCU 锁等各种类型,可以通过查看 /proc/softirqs 来观察软中断的累计中断次数情况,如果要实时查看中断次数的变化率,可以使用 watch -d cat /proc/softirqs 命令。

每一个 CPU 都有各自的软中断内核线程,我们还可以用 ps 命令来查看内核线程,一般名字在中括号里面到,都认为是内核线程。

如果在 top 命令发现,CPU 在软中断上的使用率比较高,而且 CPU 使用率最高的进程也是软中断 ksoftirqd 的时候,这种一般可以认为系统的开销被软中断占据了。

这时我们就可以分析是哪种软中断类型导致的,一般来说都是因为网络接收软中断导致的,如果是的话,可以用 sar 命令查看是哪个网卡的有大量的网络包接收,再用 tcpdump 抓网络包,做进一步分析该网络包的源头是不是非法地址,如果是就需要考虑防火墙增加规则,如果不是,则考虑硬件升级等。

为什么0.1+0.2不等于0.3

十进制小数与二进制的转换

十进制数转二进制采用的是除 2 取余法,我们以 int 类型的数字作为例子,int 类型是 32 位的,其中最高位是作为「符号标志位」,正数的符号位是 0,负数的符号位是 1剩余的 31 位则表示二进制数据

而负数就比较特殊了点,负数在计算机中是以「补码」表示的,所谓的补码就是把正数的二进制全部取反再加 1,比如 -1 的二进制是把数字 1 的二进制取反后再加 1,如下图:

不知道你有没有想过,为什么计算机要用补码的方式来表示负数?在回答这个问题前,我们假设不用补码的方式来表示负数,而只是把最高位的符号标志位变为 1 表示负数,如下图过程:

如果采用这种方式来表示负数的二进制的话,试想一下 -2 + 1 的运算过程,如下图:

按道理,-2 + 1 = -1,但是上面的运算过程中得到结果却是 -3,所可以发现,这种负数的表示方式是不能用常规的加法来计算了,就需要特殊处理,要先判断数字是否为负数,如果是负数就要把加法操作变成减法操作才可以得到正确对结果。

到这里,我们就可以回答前面提到的「负数为什么要用补码方式来表示」的问题了。

如果负数不是使用补码的方式表示,则在做基本对加减法运算的时候,还需要多一步操作来判断是否为负数,如果为负数,还得把加法反转成减法,或者把减法反转成加法,这就非常不好了,毕竟加减法运算在计算机里是很常使用的,所以为了性能考虑,应该要尽量简化这个运算过程。

而用了补码的表示方式,对于负数的加减法操作,实际上是和正数加减法操作一样的。你可以看到下图,用补码表示的负数在运算 -2 + 1 过程的时候,其结果是正确的:

十进制小数与二进制的转换

好了,整数十进制转二进制我们知道了,接下来看看小数是怎么转二进制的,小数部分的转换不同于整数部分,它采用的是乘 2 取整法,将十进制中的小数部分乘以 2 作为二进制的一位,然后继续取小数部分乘以 2 作为下一位,直到不存在小数为止。

话不多说,我们就以 8.625 转二进制作为例子,直接上图:

最后把「整数部分 + 小数部分」结合在一起后,其结果就是 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.95log10(2^24) 约等于 7.22 位,因此 double 的有效数字是 15~16 位,float 的有效数字是 7~8 位,这些有效位是包含整数部分和小数部分;
  • double 的指数部分是 11 位,而 float 的指数位是 8 位,意味着 double 相比 float 能表示更大的数值范围;

那二进制小数,是如何转换成二进制浮点数的呢?

我们就以 10.625 作为例子,看看这个数字在 float 里是如何存储的。

首先,我们计算出 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

在算指数的时候,你可能会有疑问为什么要加上偏移量呢?

前面也提到,指数可能是正数,也可能是负数,即指数是有符号的整数,而有符号整数的计算是比无符号整数麻烦的,所以为了减少不必要的麻烦,在实际存储指数的时候,需要把指数转换成无符号整数

float 的指数部分是 8 位,IEEE 标准规定单精度浮点的指数取值范围是 -126 ~ +127,于是为了把指数转换成无符号整数,就要加个偏移量,比如 float 的指数偏移量是 127,这样指数就不会出现负数了。

比如,指数如果是 8,则实际存储的指数是 8 + 127(偏移量)= 135,即把 135 转换为二进制之后再存储,而当我们需要计算实际的十进制数的时候,再把指数减去「偏移量」即可。

细心的朋友肯定发现,移动后的小数点左侧的有效位(即 1)消失了,它并没有存储到 float 里。

这是因为 IEEE 标准规定,二进制浮点数的小数点左侧只能有 1 位,并且还只能是 1,既然这一位永远都是 1,那就可以不用存起来了

于是就让 23 位尾数只存储小数部分,然后在计算时会自动把这个 1 加上,这样就可以节约 1 位的空间,尾数就能多存一位小数,相应的精度就更高了一点

总结

最后,再来回答开头的问题。

为什么负数要用补码表示?

负数之所以用补码的方式来表示,主要是为了统一和正数的加减法操作一样,毕竟数字的加减法是很常用的一个操作,就不要搞特殊化,尽量以统一的方式来运算。

十进制小数怎么转成二进制?

十进制整数转二进制使用的是「除 2 取余法」,十进制小数使用的是「乘 2 取整法」。

计算机是怎么存小数的?

计算机是以浮点数的形式存储小数的,大多数计算机都是 IEEE 754 标准定义的浮点数格式,包含三个部分:

  • 符号位:表示数字是正数还是负数,为 0 表示正数,为 1 表示负数;
  • 指数位:指定了小数点在数据中的位置,指数可以是负数,也可以是正数,指数位的长度越长则数值的表达范围就越大;
  • 尾数位:小数点右侧的数字,也就是小数部分,比如二进制 1.0011 x 2^(-2),尾数部分就是 0011,而且尾数的长度决定了这个数的精度,因此如果要表示精度更高的小数,则就要提高尾数位的长度;

用 32 位来表示的浮点数,则称为单精度浮点数,也就是我们编程语言中的 float 变量,而用 64 位来表示的浮点数,称为双精度浮点数,也就是 double 变量。

0.1 + 0.2 == 0.3 吗?

不是的,0.1 和 0.2 这两个数字用二进制表达会是一个一直循环的二进制数,比如 0.1 的二进制表示为 0.0 0011 0011 0011… (0011 无限循环),对于计算机而言,0.1 无法精确表达,这是浮点数计算造成精度损失的根源。

因此,IEEE 754 标准定义的浮点数只能根据精度舍入,然后用「近似值」来表示该二进制,那么意味着计算机存放的小数可能不是一个真实值。

0.1 + 0.2 并不等于完整的 0.3,这主要是因为这两个小数无法用「完整」的二进制来表示,只能根据精度舍入,所以计算机里只能采用近似数的方式来保存,那两个近似数相加,得到的必然也是一个近似数。

Last modification:November 2, 2023
如果觉得我的文章对你有用,请随意赞赏