CPU是如何执行程序的呢?CPU又是如何调度任务的呢?一些关于CPU的知识总结

CPU是如何执行程序的呢?CPU又是如何调度任务的呢?一些关于CPU的知识总结

这题超纲了 柳筋

前言

欲速则不达,鼠鼠决定一星期写3篇文章就够了,其中一篇是拿来记录一下日常生活的,所以说只有两篇是拿来写一些几天内学习到的知识点,同时也可以更好地将学到的知识点串起来,所以就开始今天的主题叭,关于CPU的一些知识~

在去学习CPU前,我们先需要知道什么是冯诺依曼模型

冯诺依曼模型

在 1945 年冯诺依曼和其他计算机科学家们提出了计算机具体实现的报告,其遵循了图灵机的设计,而且还提出用电子元件构造计算机,并约定了用二进制进行计算和存储,还定义计算机基本结构为 5 个部分,分别是中央处理器(CPU)内存输入设备输出设备总线

中央处理器(CPU)

中央处理器,也就是我们常说的CPU,是一台计算机的运算核心和控制核心。其功能主要是解释计算机指令以及处理计算机软件中的数据。CPU由运算器、控制器、寄存器、高速缓存及实现它们之间联系的数据、控制及状态的总线构成

32位CPU和64位CPU的差别

32位CPU和64位CPU最主要的差别在于CPU一次能计算的字节数是多少

CPU一次能计算的字节数是多少
  1. 32位CPU一次性能计算的字节数为4个字节
  2. 64位CPU一次性能计算的字节数位8个字节
    (一个字节位8个比特,也就是8位)

我们说的CPU的32位和64位,一般指的是CPU的位宽

为什么要这样设计CPU呢

之所以要这样设计CPU,是为了一次性能够计算更多的数据。

  1. 假设有一个位宽为8的CPU(也就是8位的CPU),那么这个CPU一次能处理的最大整数就是(2^7+2^6+...+1)=255
  2. 此时我想要计算1000×500;由于CPU最多只能处理255以内的整数,所以此时就会分为好几次去计算1000×500;这样会很浪费CPU的时间,因为我们知道一个程序里不可能就一条指令,所以为了提高CPU的效率,科学家就将CPU分为32位和64位
  3. 32位的CPU可以计算的最大整数为4294967295
64位CPU相比于32位CPU的优势在哪里?64位CPU的性能一定比32位CPU的性能好嘛
  1. 上面我们知道64位的CPU能够比32位CPU多计算4个字节的数据,当我们计算超过32位的数据时,32位CPU就需要分步骤进行,而64位CPU不需要
  2. 64位CPU可以寻址更大的内存空间,32位CPU 大的寻址地址是4G,即使你加了8G大小的内存,也还是只能寻址到4G,而64位CPU最大寻址地址是2^64,远超于32位CPU最大寻址地址的2^32
  3. 由于我们大部分程序不需要计算超过32位的数据,所以其实64位CPU的优势没办法体现出来,所以和32位CPU的性能差不太多

CPU内部的组件

我们把CPU想象成一个工厂,那么一个核心(算术逻辑单元)就是一个车间,一个车间同一时间只能进行一个任务(进程),只有执行完了当前任务(进程),车间才能去执行其他任务,因为CPU处理任务的速度很快,快到我们人类无法感知,所以就会出现一种错觉,同一时间在执行很多进程,这也就是所谓的并发

我们知道,一个工厂里肯定不止一个车间的,所以说在现代的计算机里,大部分都是多核的,也就是说CPU可以在同一时间将不同的任务交给不同的核心进行处理,这就是并行

CPU由运算器寄存器控制器组成

运算器

在刚刚的比喻中,我们知道一个核心就是一个车间,车间里很定需要工人,运算器在这个车间里面就扮演这个角色,当有数据给他并告诉他要执行什么运算的时候,就由运算器进行运算并将结果给其他人,运算器只负责运算

寄存器

那么总得有人把东西交给普通工人吧,并且把处理好的东西拿走,那么寄存器就实现了这个功能,寄存器就是负责传递信息(数据)或者搬运信息(数据),主要作用是存储计算时的数据,需要时可以直接从寄存器中获取,寄存器也分很多种,不同的寄存器实现不同的功能

常见的寄存器种类
  1. 通用寄存器:用来存放需要进行运算的数据,比如需要进行加和运算的两个数据。
  2. 程序计数器:用来存储 CPU 要执行下一条指令「所在的内存地址」,注意不是存储了下一条要执行的指令,此时指令还在内存中,程序计数器只是存储了下一条指令的地址。
  3. 指令寄存器:用来存放程序计数器指向的指令,也就是指令本身,指令被执行完成之前,指令都存储在这里。
控制器

在一个工厂里,总得有人来控制整个工厂的运行,不能群龙无首啊,此时控制器就出现了,它的主要功能就是根据调度核心里的其他组件

内存

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

数据存储的单位是一个二进制位(bit),即 0 或 1。最小的存储单位是字节(byte),1 字节等于 8 位。

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

这里提一嘴,内存属于计算机中的存储设备,同样属于存储设备的还有我们熟知的硬盘,而硬盘又分为固态硬盘机械硬盘,这里先不说这么多,先往后看叭

总线

总线是用于CPU内存以及其他设备之间的通信

3种总线
  1. 地址总线,用于指定 CPU 将要操作的内存地址
  2. 数据总线,用于读写内存的数据
  3. 控制总线,用于发送和接收信号,比如中断、设备复位等信号,CPU 收到信号后自然进行响应,这时也需要控制总线
当 CPU 要读写内存数据的时候,一般需要通过两个总线
  1. 首先要通过地址总线来指定内存的地址
  2. 再通过数据总线来传输数据;

输入、输出设备

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

CPU位宽和线路位宽

我们知道,64位CPU和32位CPU其实说的是CPU的位宽,但是我们在去了解CPU是如何执行一个程序前,我们得先知道,数据在计算机是如何传输的呢?

线路位宽

  1. 其实计算机无论怎么样都是由一堆硬件组装起来的,所以想要传递数据肯定只能依靠这些硬件,也就是依靠一些线路来传输的,学过数字信号处理的同学应该都知道利用高电平来表示1,低电平来表示0,通过不同的组合,可以表示成不同的二进制数叭,所以其实我们的数据在线路中也是这样传递的,通过操控电压,高电压代表1,低电压代表0

  2. 我们想要传递数字5,转化为二进制也就是101,由于一条电线(一条线路)同一时间只能传递一个电流,来表示一种状态,所以101想要用一条线路来传递的话,我们就需要传递3次,当数字更大的时候,传递的次数也就更多了,这样显然是不高效的,所以我们需要增加线路来传输数据,就用101来说,如果又3条或者3条以上的线路的话,那么只需要一次就可以完成数据的传输。

  3. 上面那种只能一位一位来传输数据的情况,我们称为串行传输,下一个bit必须等待上一个bit传输完成才能进行传输。当然,想一次多传一些数据,增加线路即可,这时数据就可以并行传输。

  4. 线路的位宽简单来讲就是用来传递数据的线路的数量,为了避免低效率的串行传输的方式,线路的位宽最好一次就能访问到所有的内存地址。

  5. CPU想要操作一些地址空间就需要通过地址总线,如果只有一条的话,每次访问都只能操作2个地址空间(0或者1);如果CPU想要访问4G的地址空间的话,就需要32条地址总线,因为2^32=4G,也就是说线路位宽为32

CPU位宽

  1. CPU的位宽最好不要小于线路的位宽,比如32位CPU控制40位宽的地址总线和数据总线的话,工作起来就很麻烦,因为40位宽的总线能传输的最大数为2^40,远远大于32位CPU所能处理的最大整数2^32,这样CPU可能就需要分步骤来处理传输的数据,所以32位CPU最好和32位宽的线路搭配,因为32位CPU一次最多只能操作32位宽的地址总线和数据总线。

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

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

  4. 由于我们大部分程序不需要计算超过32位的数据,所以其实64位CPU的优势没办法体现出来,所以和32位CPU的性能差不太多

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

你知道软件的 32 位和 64 位之间的区别吗?再来 32 位的操作系统可以运行在 64 位的电脑上吗?64 位的操作系统可以运行在 32 位的电脑上吗?如果不行,原因是什么?

64位和32位软件,实际上代表指令是64位还是32位的:

  1. 如果 32 位指令在 64 位机器上(64位CPU)执行,需要一套兼容机制,就可以做到兼容运行了。但是如果 64 位指令在 32 位机器(32位CPU)上执行,就比较困难了,因为 32 位的寄存器存不下 64 位的指令
  2. 操作系统其实也是一种程序,我们也会看到操作系统会分成 32 位操作系统、64 位操作系统,其代表意义就是操作系统中程序的指令是多少位,比如 64 位操作系统,指令也就是 64 位,因此不能装在 32 位机器上

总之,硬件的 64 位和 32 位指的是 CPU 的位宽,软件的 64 位和 32 位指的是指令的位宽。

了解完这些,我们才算简单的了解了计算机的组成,接下来我们要知道CPU是如何执行一个程序的

程序执行的基本过程

程序其实是一条又一条的指令,所以程序的执行其实就是把每一条指令一步一步地执行起来,而负责执行指令的就是CPU了

CPU执行程序的基本过程
  1. CPU读取程序计数器的值,我们知道程序计数器的值为指令的内存地址
  2. CPU通过控制单元操控地址总线去访问CPU从程序计数器读取到的值,然后通知内存设备准备好数据
  3. 内存设备准备好数据后通过数据总线将指令数据传到CPU中,期间CPU会通过Catch Line来保存数据(这部分后面会详细讲解),这样等以后CPU还想访问该数据的时候就无需再去内存中获取了
  4. CPU将传递来的指令数据保存在指令寄存器
  5. CPU分析指令寄存器的指令,确定其指令的类型和参数,如果是计算类的指令,就将指令交给逻辑运算单元处理;如果是存储类型的指令,就交给控制单元处理
  6. CPU执行完指令后,程序计数器的值自动增加,表示指向下一条指令
自增的大小

程序计数器自增的大小是由CPU的位宽决定的,例如32为CPU,指令是4个字节,需要4个内存地址存放,因此程序计数器的值会自增4

简单总结

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

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

举个栗子(a=1+2)

我们知道一个程序想要运行,需要经过4个过程:预处理——>编译——>汇编——>链接

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

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

那么看看在32位CPU的环境下,(a=1+2)是如何执行的叭

  1. 在程序编译这个过程中,编译器发现12是数据,那么当程序运行的时候,在内存中专门有一块区域来存放这些数据(根据数据的类型来分配,有数据段(.data)、BSS段(.bss)、代码段、栈区、堆区),如下图所示

    1. 数据1被存放在了地址为0x104的位置
    2. 数据2被存放在了地址为0x100的位置
      注意,数据和指令是分开区域存放的,存放指令区域的地方称为「正文段」
  2. 编译器会把(a=1+2)编译成4条指令,存放到正文段中,如上图所示

    1. 0x200 的内容是 load 指令将 0x100 地址中的数据 1 装入到寄存器 R0;
    2. 0x204 的内容是 load 指令将 0x104 地址中的数据 2 装入到寄存器 R1;
    3. 0x208 的内容是 add 指令将寄存器 R0 和 R1 的数据相加,并把结果存放到寄存器 R2;
    4. 0x20c 的内容是 store 指令将寄存器 R2 中的数据存回数据段中的 0x108 地址中,这个地址也就是变量 a 内存中的地址;
  3. 编译完成后,具体执行程序的时候,程序计数器会被设置为 0x200 地址,然后依次执行这 4 条指令

  4. 由于是32位CPU,所以一条指令的大小为4个字节,所以每条指令间间隔4个字节,而数据的大小是根据你在程序中指定的变量类型,比如int类型的数据则占4个字节,char类型的数据则占1个字节。

以上就是CPU执行程序的过程,关于CPU的秘密还不止这一点,我们有没有想过,既然有了内存,为什么还需要寄存器来保存数据呢?硬盘和内存什么关系?CPU里面除了寄存器外还有没有可以存储数据的东西呢?如果有的话是如何实现的呢?实现的话又会带来什么问题呢?

别急,让我先急!

接下来慢慢看叭

计算机中的存储设备

鼠鼠毕业后找到工作了肯定要自己组装一台电脑的,那么鼠鼠除了CPU和显卡需要购买,还有显示屏等等,最重要的是,还需要买一些存储设备,不然怎么下载游戏?因为存储设备比较多,有内存和硬盘,硬盘又分为固态硬盘机械硬盘
当断电后,内存条中的数据是会丢失的,而硬盘中的数据并不会丢失,因为硬盘是持久化存储设备,同时也是一个 I/O 设备。后面我会写一篇文章就是关于数据是如何读写到硬盘上的,先写完今天再说

但其实 CPU 内部也有存储数据的组件,这个应该比较少人注意到,比如寄存器、**CPU L1/L2/L3 Cache **也都是属于存储设备,只不过它们能存储的数据非常小,但是它们因为靠近 CPU 核心,所以访问速度都非常快,快过硬盘好几个数量级别。

为何有了内存还需要寄存器?

我们知道,CPU是需要从内存从读取数据才能进行处理的,而寄存器也可以存储数据,只是存储空间比较小,但是由于CPU与内存的距离较远,读取速度相对于就在CPU内部的寄存器而言慢了很多,况且寄存器离控制单元逻辑运算单元都很近,所以计算速度会快很多。

存储器的层次结构

在所有的存储器中,我们按照CPU读取速度的快慢,将所有存储器分成以下级别:

  1. 速度最快的就是寄存器,但是能处理的数据量是最少的
  2. CPU Cache也是在CPU内部结构里的,所以它们的速度也很快,就比寄存器慢一点,但是存储的数据也更多了
  3. 内存,前面两个都属于CPU内部的存储器,在CPU外部,速度最快的就是内存了
  4. 固态硬盘和机械硬盘是最慢的,相对而言,固态硬盘又比机械硬盘快了一个档次

对于存储器而言,它的处理速度越快,耗能也就越多,而且制造材料就更贵,成本就更高,以至于速度快的存储器的存储空间都比较小
CPU 里的寄存器和 Cache,是整个计算机存储器中价格最贵的,虽然存储空间很小,但是读写速度是极快的,而相对比较便宜的内存和硬盘,速度肯定比不上 CPU 内部的存储器,但是能弥补存储空间的不足。

寄存器

最靠近控制单元逻辑运算单元的存储器就是寄存器了,它使用的制造材料也很贵但也是最快的,又因为它是在CPU里的,所以它的存储量也很小,并且数量也不是很多

寄存器的数量

寄存器的数量一般在几十到几百之间,每个寄存器存储一定字节的数据
例如:
1. 32位CPU的存储器一般存储4字节的数据
2. 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里面,一个比特的数据,通常需要6个晶体管,所以 SRAM 的存储密度不高,同样的物理空间下,能存储的数据是有限的,不过也因为 SRAM 的电路简单,所以访问速度非常快。

为什么要有CPU Cache?

根据摩尔定律,CPU 的访问速度每 18 个月就会翻倍,相当于每年增长 60% 左右,内存的速度当然也会不断增长,但是增长的速度远小于 CPU,平均每年只增长 7% 左右。于是,CPU 与内存的访问性能的差距不断拉大

到现在,一次内存访问所需时间是200300多个时钟周期,这意味着 CPU 和内存的访问速度已经相差200300 多倍了

为了弥补 CPU 与内存两者之间的性能差异,就在 CPU 内部引入了CPU Cache,也称高速缓存

CPU 的高速缓存(CPU Cache),通常可以分为 L1、L2、L3 这样的三层高速缓存,也称为一级缓存、二次缓存、三次缓存

L1 Cache

一级缓存的访问速度几乎和寄存器一样快,通常只需要 2~4 个时钟周期,而大小在几十 KB 到几百 KB 不等。
每个CPU核心都有一块属于自己的L1 Cache,并且L1 Cache通常分为数据缓存指令缓存
可以在Linux下通过以下命令查看L1 Cache的数据缓存和指令缓存的容量大小

1
2
$ cat /sys/devices/system/cpu/cpu0/cache/index0/size    //查看数据缓存
$ cat /sys/devices/system/cpu/cpu0/cache/index1/size //查看指令缓存

L2 Cache

二级缓存的速度就比一级缓存慢,因为它距离CPU核心更远,但同时,它的大小也比一级缓存大
同样的,二级缓存也是每个CPU都有的
可以在Linux下通过以下命令查看L2 Cache的容量大小

1
$ cat /sys/devices/system/cpu/cpu0/cache/index2/size

L3 Cache

L3 Cache与CPU核心又比L2 Cache更远,所以访问速度也比二级缓存更慢,但大小也更大
L2 Cache是所有CPU核心共享的,但每个CPU里面也只有一块
可以在Linux下通过以下命令查看L3 Cache的容量大小

1
$ cat /sys/devices/system/cpu/cpu0/cache/index3/size 

内存

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

相比 SRAM,DRAM 的密度更高,功耗更低,有更大的容量,而且造价比 SRAM 芯片便宜很多。

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

DRAM的数据访问电路和刷新电路比SRAM更复杂,所以内存的访问速度也更慢,但是大小也会大得多

硬盘

硬盘又分为固态硬盘(SSD)和机械硬盘(HDD)

固态硬盘(SSD)

结构和内存类似,但是硬盘的优点就在于在断电后,数据依旧保存在硬盘上的,意思就是说硬盘上的数据如果不是刻意去破坏,那么就是永久的。内存的读写速度大概是SSD的10-1000

机械硬盘(HDD)

机械硬盘是比较传统的硬盘,它是通过物理读写的方式来访问数据的,因此它的访问速度特别慢,内存的读写速度大概是它的10w

随着时间的发展,由于固态硬盘的价格越来越便宜,导致机械硬盘几乎没什么人会用了,因此HDD正在被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执行程序的时候是需要去从内存中获取数据的,假设CPU里面没有存储器,那么既不是每次获取数据都需要从内存中获取吗,准确的来说,即使是之前已经获取过的数据,想要再次获取就要重新去内存里面查找,这杨效率也太低了吧,所以就有了寄存器这个存储器,但是寄存器能存储的数据量实在是太小了,所以就有了CPU Cache的出现。

那么,CPU Cache是如何将内存中的数据保存下来的呢?

CPU Cache

CPU Cache的读取过程

CPU Cache的数据是从内存中读取过来的,它是以一小块一小块读取数据的,而不是按单个数据读取的,所以在CPU Cache中,这样一小块一小块的数据被称为Cache Line(缓存块)
在Linux下查看Cache Line的大小

1
$ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size     //查看L1 Cache一次性能载入的数据的大小,index1就是一次性能载入的指令的大小,index2就是L2 Cache,index3就是L3 Cache

我们假设在Linux下输入上面的命令得出的结果为64,意思就是说L1 Cache一次性能从内存中读取64字节的数据到L1 Cache中的数据缓存,什么意思呢?

假设我们内存中有一个数组int array[100],当我们CPU根据程序指数器的内容去访问array[0]的时候,由于是int类型的数组,一个元素占4个字节,而我们的CPU Cache一次性能载入的最大数据量为64字节,那么此时就会继续往后遍历到array[15],那么此时就刚好是64字节,那么**array[0]array[15]都会被缓存到CPU Cache中,这样等以后CPU还想要获取array[0]array[15]**中的数据时,就无需再去访问内存,直接访问CPU Cache就可以了,这也是CPU Cache的好处

事实上,CPU 读取数据的时候,无论数据是否存放到 Cache 中,CPU 都是先访问 Cache,只有当 Cache 中找不到数据时,才会去访问内存,并把内存中的数据读入到 Cache 中,CPU 再从 CPU Cache 读取数据

这样的访问机制,跟我们使用「内存作为硬盘的缓存」的逻辑是一样的,如果内存有缓存的数据,则直接返回,否则要访问龟速一般的硬盘。

那 CPU 怎么知道要访问的内存数据,是否在 Cache 里?如果在的话,如何找到 Cache 对应的数据呢?我们从最简单、基础的**直接映射 Cache(Direct Mapped Cache)**说起,来看看整个 CPU Cache 的数据结构和访问逻辑。

CPU Cache的数据结构和访问逻辑

我们前面提到了,CPU Cache去内存读取数据时,是一块一块地读取的,而读取的大小我们可以通过一些命令获取。
那么在内存中,这样一块的数据我们成为内存块,读取的时候,我们需要知道的是数据所在的内存块的地址。

直接映射 Cache

所谓直接映射Cache策略,就是将内存块始终映射到CPU Cache的某一块Cache Line,映射的方法则采用取模运算
当然了,这样做很容易出现类似哈希冲突一样的问题,就是多个内存块映射到同一块Cache Line

解决“哈希冲突”

为了区别不同的内存块,我们在对应的Cache Line中加入组标记(Tag),这个组标记会记录当前 CPU Line 中存储的数据对应的内存块,我们可以用这个组标记来区分不同的内存块
除此之外,Cache Line还有两个信息

  1. 从内存加载过来的实际存放数量
  2. 有效位,这个组标记会记录当前 CPU Line 中存储的数据对应的内存块,我们可以用这个组标记来区分不同的内存块

我们知道,我们CPU有时候需要读取的并不是一整个内存块,就那上面的数组例子来说,CPU想要获取array[2]的值,CPU Cache为了尽可能地将多的数据保存,所以就将array[0]~array[15]的数据全部保存在Cache Line中,但是CPU只需要array[2]的数据,所以我们就需要一个偏移量来帮助CPU定位到需要的数据

我们通常称CPU Cache上的一个数据片段,而在CPU Cache上找到CPU所需要的,就需要偏移量(offset)

因此,一个内存的访问地址,包括组标记、CPU Line 索引、偏移量这三种信息,于是 CPU 就能通过这些信息,在 CPU Cache 中找到缓存的数据。而对于 CPU Cache 里的数据结构,则是由索引 + 有效位 + 组标记 + 数据块组成。

如果内存中的数据已经在 CPU Cahe 中了,那 CPU 访问一个内存地址的时候,会经历这 4 个步骤:
  1. 根据内存地址中索引信息,计算在 CPU Cahe 中的索引,也就是找出对应的 CPU Line 的地址
  2. 找到对应 CPU Line 后,判断 CPU Line 中的有效位,确认 CPU Line 中数据是否是有效的,如果是无效的,CPU 就会直接访问内存,并重新加载数据,如果数据有效,则往下执行
  3. 对比内存地址中组标记和 CPU Line 中的组标记,确认 CPU Line 中的数据是我们要访问的内存数据,如果不是的话,CPU 就会直接访问内存,并重新加载数据,如果是的话,则往下执行
  4. 根据内存地址中偏移量信息,从 CPU Line 的数据块中,读取对应的字。

以上我们说的都是CPU访问内存的数据,但我们一直没说CPU是如何将数据写回到内存中的,现在我们就来说一下

CPU写数据操作

我们知道CPU内部有着CPU Cache,因为离 CPU 核心相当近,它的访问速度是很快的,于是它充当了 CPU 与内存之间的缓存角色。

如果CPU再修改完数据的时候收到指令需要把修改完的数据返回,那么此时CPU就需要把数据先写回CPU Cache中,那次是CPU Cache中的数据就和内存中的数据不一致了,那么我们要如何保持缓存的一致性呢?

有两种方法:写直达写回

写直达

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

顾名思义,写直达就是每次都将数据写回CPU Cache中和内存中,但这样的效率太低了,因为不是每次修改完数据用户都需要读取的,那此时写回内存将没有任何意义。

写回

为了提高效率,有发明了另外一种方法。
在写回机制中,当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。

写回的流程
  1. 当CPU发生写操作的时候,先检查CPU Cache中对应的Cache Line中是否有数据,有的话就将数据更改为修改后的数据,并且标记为,因为此时Cache Line中的数据和对应的内存中的数据是不一样的
  2. 当CPU发生写操作的时候,发现修改完的数据的对应的Cache Line中的保存的是别的内存地址的数据
    1. 如果该数据不是脏的,直接将修改后的数据进行覆盖,并且标记为
    2. 如果该数据是脏的,那么就先将该Cache Line上的数据写回到对应地址的内存上,然后将修改后的数据放入Cache Line中,并且标记为

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

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

缓存一致性问题

从上面的知识中,我们学到了很多关于CPU Cache的知识,但我们都是在单个CPU核心的情况下考虑问题的,这显然是不够的,因为现在的计算机都是多核的了,那么此时我们就要考虑,如何保证缓存的一致性

什么是缓存一致性?

举个栗子

我们知道,每个CPU核心都有自己的L1 Cache和L2 Cache,但是L3 Cache是共享的。
假设此时有两个CPU核心,分别为CPU1和CPU2,此时它们俩分别运行两个线程,但是都是拿来操作共享资源i的。
此时(假设i=0):

  1. CPU1读取i的值,并对i进行+1操作,我们知道CPU是采用写回机制的,所以此时CPU1的CPU Cache上i的值变为1,并且被标记为
  2. CPU2开始读取i的值,按道理来说,CPU2读取到的i值应该是1,但是由于写回的机制,CPU1并没有将修改完的i值写入内存中,这也就导致CPU2读取的i值还是0,这也就发生了错误

这个就是所谓的缓存一致性问题,1号核心2号核心的缓存,在这个时候是不一致,从而会导致执行结果的错误。

如何避免缓存一致性问题

要解决这一问题,就需要一种机制,来同步两个不同核心里面的缓存数据。要实现的这个机制的话,要保证做到下面这2点:

  1. 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(Wreite Propagation)
  2. 第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串形化(Transaction Serialization)
    写传播

    写传播就是当某个CPU核心更新了CPU Cache中的数据,就要把该事件广播给所有CPU核心,其中最常见的方法就是总线嗅探(Bus Snooping)

    以前面i的例子来说:
    当CPU1对i进行了+1操作后,修改了L1 Cache的数据,会通过总线把这个时间广播给其他CPU,然后每个CPU都会监听总线上的广播事件,然后检查自己L1 Cache上是否有该数据,如果有的话也将数据更新到自己的L1 Cache

    事务的串行化

    还是举个栗子叭:

    1. 假设我们有4个CPU核心,然后有一个数据为i
    2. CPU1将数据i修改成100
    3. CPU2将数据i修改成200
    4. 这两个操作都会通过写传播,用总线将数据更新的事件传递给其他CPU,在这里就是CPU3和CPU4
    5. 假设CPU3先收到了i修改成100的事件,然后再收到将i修改成200的事件,最后再CPU3的L1 Cache中,i的值为200
    6. 假设CPU4先收到了i修改成200的事件,然后再收到将i修改成100的事件,最后再CPU4的L1 Cache中,i的值为100

    显然,这里就又出现错误了,虽然我们通过写传播,将数据更新这个事件传输了,但由于其他CPU执行的顺序不同,也会出现数据不一致的情况,所以我们必须保证CPU3和CPU4都能看到相同顺序的数据变化,这就是数据的串行化

    要实现数据的串行化,必须满足以下两点:

    1. CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;
    2. 要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。

上面我们知道可以用总线嗅探来实现写传播,但是没有办法实现数据串行化,再加上虽然总线嗅探方法很简单,但是CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。

那么我们有没有什么方法,既可以减轻总线的负载来满足写传播,又能实现数据的串行化呢?

当然是有的啦,这是一个协议,基于总线嗅探机制实现了事务串形化,也用状态机机制降低了总线带宽压力,这个协议就是 MESI 协议,这个协议就做到了 CPU 缓存一致性

MESI协议

MESI协议其实是4个状态的英文首字母组合起来的:

  1. Modified 已修改
  2. Exclusive 独占
  3. Shared 共享
  4. Invalidated 已失效
    我们可以用这四个状态来标记 Cache Line 四个不同的状态
已修改

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

独占

独占状态代表 Cache Line 里的数据是干净的,也就是说,这个时候 Cache Line 里的数据和内存里面的数据是一致性的,同时也代表着只有一个CPU核心拥有该数据,这个时候无论如何修改这个数据,都不需要向其他CPU核心广播,这样就解决了总线嗅探导致的总线负载过大的问题

同样的,如果一个CPU核心处于独占状态的时候,有另外一个CPU核心读取了相同的数据,那么此时这两个CPU核心都会从独占变成共享

共享

共享状态代表 Cache Line 里的数据是干净的,也就是说,这个时候 Cache Line 里的数据和内存里面的数据是一致性的,也代表着不止一个CPU核心拥有这个数据。

当其中一个CPU核心想要修改共享的数据的时候,需要先向其他CPU核心广播消息,让其他CPU核心将共享的数据修改成已失效状态,然后该CPU核心才能对这个数据进行修改,修改完也要把状态变成已修改

已失效

代表该Cache Line中的数据已经失效了,不可以读取该状态的数据

还是举个栗子:

  1. CPU1读取内存中的A数据,那么此时A数据就被读取到CPU1的Cache Line中,由于CPU1的Cache Line并没有缓存该数据,所以此时将CPU1的Cache Line标记为独占
  2. CPU2也读取内存中的A数据,由于CPU1的Cache Line缓存了该数据,但是CPU2的Cache Line并没有缓存A数据,于是CPU2就将A数据缓存到自己的Cache Line中,并且CPU1和CPU2的Cache Line的状态都修改成共享
  3. 此时CPU1想要修改A数据,那么就需要先将想要修改A数据的信息传递给其他CPU核心,CPU2接收到该信息,检查自己的Cache Line,发现自己的Cache Line有A数据,于是将自身的Cache Line中的A数据标志为已失效状态,然后CPU1就开始修改A数据,修改结束后,将Cache Line的状态修改成已修改
  4. 如果CPU1继续修改Cache Line中A数据的值,由于此时的 Cache Line 是已修改状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可。
  5. 如果此时CPU3想要读取A数据,那么此时CPU1会先将已修改的A数据,写回到内存中,然后CPU3将更新完的A数据缓存到自己的Cache Line中,并且CPU1和CPU3的Cache Line状态都修改成共享

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

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

MESI 协议的四种状态之间的流转过程,如下表格,可以更详细的看到每个状态转换的原因:

同样的,当多个CPU核心读取同一块内存块的时候,还是会出现一个问题
那就是伪共享问题

伪共享问题

什么是伪共享问题?
让我们看看下面这个场景:

伪共享场景

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

然后我们根据上面说的MESI协议来分析一下什么是伪共享:

假设 CPU1绑定了线程 A,CPU2绑定了线程 B,线程 A 只会读写变量 A,线程 B 只会读写变量 B。

  1. 当我们的CPU核心1想要读取数据A,那么此时因为Cache Line最大能读取64个字节的数据,而一个A数据为long类型,也就是8个字节,所以Cache Line会继续往后读取,也就是说会把数据B也读取到CPU1的Cache Line中,并且检查是否有其他核心拥有该数据,检查完发现没有,于是CPU1在自己的Cache Line中将数据标记为独占
  2. 当CPU2想要读取数据B时,会发现CPU1的Cache Line已经有了该数据,那么CPU2的Cache Line就会把这一块数据标记为共享,同样的,CPU1的Cache Line也会把这一块数据标记为共享
  3. CPU1想要修改数据A,此时因为是共享状态,所以会先广播,CPU2接收到广播后就把共享的数据标记成已失效,然后CPU1就对数据A进行修改,然后将这一块数据标记为已修改
  4. CPU2想要修改数据B,于是会先读取数据B,因为此时数据B在CPU1的Cache Line中被标记为已修改(其实数据B并没有修改,只是因为数据B和已经修改的数据A是同一块内存块的),所以此时CPU1会先利用写回机制,把这一块数据写回内存,然后CPU2读取该内存块,并且进行修改,然后再CPU2的Cache Line中标记为已修改,而CPU1中的这一块数据就被标记为已失效
  5. 当CPU1又想要修改数据A时,会和上面的一样

那么,当我们一直重复步骤4步骤5的时候,一点也没体现出CPU Cache的优势,因为需要一直写回到内存中,就相当于直接从内存中进去读取和写入,虽然变量 A 和 B 之间其实并没有任何的关系,但是因为同时归属于一个 Cache Line ,这个 Cache Line 中的任意数据被修改后,都会相互影响

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

如何解决(避免)伪共享问题

在 Linux 内核中存在 __cacheline_aligned_in_smp 宏定义,是用于解决伪共享的问题。

1
2
3
4
5
#ifdef CONFIG_SMP
#define __cacheline_aligned_in_smp _-cacheline_aligned
#else
#define __cacheline_aligned_in_smp
#endif

从上面的宏定义,我们可以看到:

  1. 如果在多核(MP)系统里,该宏定义是 __cacheline_aligned,也就是 Cache Line 的大小
  2. 而如果在单核系统里,该宏定义是空的

因此,针对在同一个 Cache Line 中的共享的数据,如果在多核之间竞争比较严重,为了防止伪共享现象的发生,可以采用上面的宏定义使得变量在 Cache Line 里是对齐的

举个栗子

有这样一个结构体:

1
2
3
4
5
struct test
{
int a;
int b;
};

结构体里的两个成员变量 a 和 b 在物理内存地址上是连续的,于是它们可能会位于同一个 Cache Line 中,如下图

所以,为了防止前面提到的 Cache 伪共享问题,我们可以使用上面介绍的宏定义,将 b 的地址设置为 Cache Line 对齐地址,如下:

1
2
3
4
5
struct test
{
int a;
int b __cacheline_aligned_in_smp;
};

这样 a 和 b 变量就不会在同一个 Cache Line 中了,如下图:

所以,避免 Cache 伪共享实际上是用空间换时间的思想,浪费一部分 Cache 空间,从而换来性能的提升

我们知道,进程是系统分配资源的基本单位,线程是CPU执行调度的基本单位,我们也知道了进程的调度算法,但是线程呢?CPU还如何选择线程执行调度呢?接着往下看吧

线程调度

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

一般来说,没有创建线程的进程,是只有单个执行流,它被称为是主线程。如果想让进程处理更多的事情,可以创建多个线程分别去处理,但不管怎么样,它们对应到内核里都是tark_struct,所以,Linux 内核里的调度器,调度的对象就是 tark_struct,接下来我们就把这个数据结构统称为任务

任务

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

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

调度类

由于优先级的不同,Linux为了保障优先级高的任务尽可能地被执行,于是分为了以下几种调度类

几种调度类
  1. Deadline调度类,它的调度器为Deadline调度器,调度策略为SCHED_DEADLINE(按照deadline调度,意思就是说距离当前时间点最近的deadline的任务会先被调度),这种调度器一般是用来调度实时任务

  2. Realtime调度类,它的调度器为RT调度器,调度策略有两种

    1. SCHED_FIFO,对于相同优先级的任务,会将所有任务放入一个队列,然后根据队列的先进先出规则来调度任务,如果在一个任务被执行的过程中出现了优先级更高的任务,那么CPU就会停下当前手头上的任务,转而去执行优先级更高的那个任务
    2. SCHED_RR,对于相同优先级的任务,轮流着运行,每个任务都有一定的时间片,当用完时间片的任务会被放到队列尾部,以保证相同优先级任务的公平性,但是高优先级的任务依然可以抢占低优先级的任务

    这个调度类一般也用于实时任务

  3. Fair调度类,它的调度器为CFS调度器,调度策略也有两种

    1. SCHED_NORMAL,普通任务的调度策略
    2. SCHED_BATCH,后台任务的调度策略,不和终端进行交互,因此在不影响其他需要交互的任务,可以适当降低它的优先级

    这个调度类是用于普通任务

完全公平调度

在我们平时写代码时,一般的线程都属于普通任务普通任务的比例也是最大的,而对于普通任务而言,公平才是最重要的,Linux为了满足普通任务的公平性,基于CFS的调度算法应运而生,那就是完全公平调度算法

算法理念

这个算法的理念是想让分配给每个任务的 CPU 时间是一样,于是它为每个任务安排一个虚拟运行时间vruntime,如果一个任务在运行,其运行的越久,该任务的vruntime自然就会越大,而没有被运行的任务,vruntime是不会变化的

CFS调度算法中vruntime越小,就越会被选择,也就是说,CFS调度算法会优先选择vruntime小的任务

因为普通任务也会有优先级的区别,所以对于vruntime的计算,是需要考虑上普通任务的优先级的

vruntime的计算

虚拟运行时间 vruntime += 实际运行时间 delta_exec * NICE_0_LOAD/权重

NICE_0_LOAD:我们可以不用知道是什么,只需要知道它是一个常量
权重: 注意权重值并不是优先级的值,内核中会有一个 nice 级别与权重值的转换表,nice 级别越低的权重值就越大,至于 nice 值是什么,我们后面会提到

CPU运行队列

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

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

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

如何调整优先级

  1. 因为我们知道,实时任务肯定会比普通任务先进行调度,所以如果我们想要提高某个普通任务的优先级,那么方法之一就是将普通任务变成实时任务
  2. 如果你想让某个普通任务有更多的执行时间,可以调整任务的nice值,从而让优先级高一些的任务执行更多时间。nice的值能设置的范围是负20~19,值越低,表明优先级越高,因此负20是最高优先级,19则是最低优先级,默认优先级是 0
  3. 它与优先级(priority)的关系是这样的:priority(new) = priority(old) + nice,因为普通任务的优先值为100~139,又因为nice是调整普通任务的优先级,所以nice的值只能是负20~19
  4. 在前面我们提到了,权重值与 nice 值的关系的,nice 值越低,权重值就越大,计算出来的 vruntime 就会越少,由于 CFS 算法调度的时候,就会优先选择 vruntime 少的任务进行执行,所以 nice 值越低,任务的优先级就越高

结语

关于CPU的知识点总结就到这里了,鼠鼠写了好多字啊,虽然有的文字是直接复制小林coding公众号的内容,图片也都是出自这里的,但鼠鼠也有在认真思考总结的嘛,鼠鼠也没那么不堪对吧,虽然鼠鼠到现在还没收到一家公司的面试,有的公司可能都不看我一眼
(悲)我同学都收到腾讯的面试邀请了,我们读的不是一个大学吗,为什么我简历他们都不愿意看一眼(悲)

  • 标题: CPU是如何执行程序的呢?CPU又是如何调度任务的呢?一些关于CPU的知识总结
  • 作者: 这题超纲了
  • 创建于: 2023-03-03 11:22:22
  • 更新于: 2023-06-23 14:38:34
  • 链接: https://qx-gg.github.io/2023/03/03/blog9/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
推荐阅读
操作系统中的内存管理 操作系统中的内存管理 关于线程池的构建及理解 关于线程池的构建及理解 浙江宇视科技一面 浙江宇视科技一面
 评论