CPU是如何执行程序的呢?CPU又是如何调度任务的呢?一些关于CPU的知识总结
前言
欲速则不达,鼠鼠决定一星期写3篇文章就够了,其中一篇是拿来记录一下日常生活的,所以说只有两篇是拿来写一些几天内学习到的知识点,同时也可以更好地将学到的知识点串起来,所以就开始今天的主题叭,关于CPU的一些知识~
在去学习CPU前,我们先需要知道什么是冯诺依曼模型
冯诺依曼模型
在 1945 年冯诺依曼和其他计算机科学家们提出了计算机具体实现的报告,其遵循了图灵机的设计,而且还提出用电子元件构造计算机,并约定了用二进制进行计算和存储,还定义计算机基本结构为 5 个部分,分别是中央处理器(CPU)
、内存
、输入设备
、输出设备
、总线
中央处理器(CPU)
中央处理器,也就是我们常说的CPU,是一台计算机的运算核心和控制核心。其功能主要是解释计算机指令以及处理计算机软件中的数据。CPU由运算器、控制器、寄存器、高速缓存及实现它们之间联系的数据、控制及状态的总线构成
32位CPU和64位CPU的差别
32位CPU和64位CPU最主要的差别在于CPU一次能计算的字节数是多少
CPU一次能计算的字节数是多少
- 32位CPU一次性能计算的字节数为4个字节
- 64位CPU一次性能计算的字节数位8个字节
(一个字节位8个比特,也就是8位)
我们说的CPU的32位和64位,一般指的是CPU的位宽
为什么要这样设计CPU呢
之所以要这样设计CPU,是为了一次性能够计算更多的数据。
- 假设有一个位宽为8的CPU(也就是8位的CPU),那么这个CPU一次能处理的最大整数就是
(2^7+2^6+...+1)=255
- 此时我想要计算1000×500;由于CPU最多只能处理255以内的整数,所以此时就会分为好几次去计算1000×500;这样会很浪费CPU的时间,因为我们知道一个程序里不可能就一条指令,所以为了提高CPU的效率,科学家就将CPU分为32位和64位
- 32位的CPU可以计算的最大整数为4294967295
64位CPU相比于32位CPU的优势在哪里?64位CPU的性能一定比32位CPU的性能好嘛
- 上面我们知道64位的CPU能够比32位CPU多计算4个字节的数据,当我们计算超过32位的数据时,32位CPU就需要分步骤进行,而64位CPU不需要
- 64位CPU可以寻址更大的内存空间,32位CPU 大的寻址地址是4G,即使你加了8G大小的内存,也还是只能寻址到4G,而64位CPU最大寻址地址是2^64,远超于32位CPU最大寻址地址的2^32
- 由于我们大部分程序不需要计算超过32位的数据,所以其实64位CPU的优势没办法体现出来,所以和32位CPU的性能差不太多
CPU内部的组件
我们把CPU想象成一个工厂,那么一个核心(算术逻辑单元)就是一个车间,一个车间同一时间只能进行一个任务(进程),只有执行完了当前任务(进程),车间才能去执行其他任务,因为CPU处理任务的速度很快,快到我们人类无法感知,所以就会出现一种错觉,同一时间在执行很多进程,这也就是所谓的并发
我们知道,一个工厂里肯定不止一个车间的,所以说在现代的计算机里,大部分都是多核的,也就是说CPU可以在同一时间将不同的任务交给不同的核心进行处理,这就是并行
CPU由运算器、寄存器和控制器组成
运算器
在刚刚的比喻中,我们知道一个核心就是一个车间,车间里很定需要工人,运算器在这个车间里面就扮演这个角色,当有数据给他并告诉他要执行什么运算的时候,就由运算器进行运算并将结果给其他人,运算器只负责运算
寄存器
那么总得有人把东西交给普通工人吧,并且把处理好的东西拿走,那么寄存器就实现了这个功能,寄存器就是负责传递信息(数据)或者搬运信息(数据),主要作用是存储计算时的数据,需要时可以直接从寄存器中获取,寄存器也分很多种,不同的寄存器实现不同的功能
常见的寄存器种类
- 通用寄存器:用来存放需要进行运算的数据,比如需要进行加和运算的两个数据。
- 程序计数器:用来存储 CPU 要执行下一条指令「所在的内存地址」,注意不是存储了下一条要执行的指令,此时指令还在内存中,程序计数器只是存储了下一条指令的地址。
- 指令寄存器:用来存放程序计数器指向的指令,也就是指令本身,指令被执行完成之前,指令都存储在这里。
控制器
在一个工厂里,总得有人来控制整个工厂的运行,不能群龙无首啊,此时控制器就出现了,它的主要功能就是根据调度核心里的其他组件
内存
我们的程序和数据都是存储在内存,存储的区域是线性的。
数据存储的单位是一个二进制位(bit),即 0 或 1。最小的存储单位是字节(byte),1 字节等于 8 位。
内存的地址是从 0 开始编号的,然后自增排列,最后一个地址为内存总字节数 - 1,这种结构好似我们程序里的数组,所以内存的读写任何一个数据的速度都是一样的。
这里提一嘴,内存属于计算机中的存储设备,同样属于存储设备的还有我们熟知的硬盘,而硬盘又分为固态硬盘和机械硬盘,这里先不说这么多,先往后看叭
总线
总线是用于CPU和内存以及其他设备之间的通信
3种总线
- 地址总线,用于指定 CPU 将要操作的内存地址
- 数据总线,用于读写内存的数据
- 控制总线,用于发送和接收信号,比如中断、设备复位等信号,CPU 收到信号后自然进行响应,这时也需要控制总线
当 CPU 要读写内存数据的时候,一般需要通过两个总线
- 首先要通过地址总线来指定内存的地址
- 再通过数据总线来传输数据;
输入、输出设备
输入设备(鼠标、键盘、摄像头等等)向计算机输入数据,计算机经过计算后,把数据输出给输出设备(屏幕)。期间,如果输入设备是键盘,按下按键时是需要和 CPU 进行交互的,这时就需要用到控制总线了。
CPU位宽和线路位宽
我们知道,64位CPU和32位CPU其实说的是CPU的位宽,但是我们在去了解CPU是如何执行一个程序前,我们得先知道,数据在计算机是如何传输的呢?
线路位宽
其实计算机无论怎么样都是由一堆硬件组装起来的,所以想要传递数据肯定只能依靠这些硬件,也就是依靠一些线路来传输的,学过数字信号处理的同学应该都知道利用高电平来表示1,低电平来表示0,通过不同的组合,可以表示成不同的二进制数叭,所以其实我们的数据在线路中也是这样传递的,通过操控电压,高电压代表1,低电压代表0
我们想要传递数字5,转化为二进制也就是101,由于一条电线(一条线路)同一时间只能传递一个电流,来表示一种状态,所以101想要用一条线路来传递的话,我们就需要传递3次,当数字更大的时候,传递的次数也就更多了,这样显然是不高效的,所以我们需要增加线路来传输数据,就用101来说,如果又3条或者3条以上的线路的话,那么只需要一次就可以完成数据的传输。
上面那种只能一位一位来传输数据的情况,我们称为串行传输,下一个bit必须等待上一个bit传输完成才能进行传输。当然,想一次多传一些数据,增加线路即可,这时数据就可以并行传输。
线路的位宽简单来讲就是用来传递数据的线路的数量,为了避免低效率的串行传输的方式,线路的位宽最好一次就能访问到所有的内存地址。
CPU想要操作一些地址空间就需要通过地址总线,如果只有一条的话,每次访问都只能操作2个地址空间(0或者1);如果CPU想要访问4G的地址空间的话,就需要32条地址总线,因为2^32=4G,也就是说线路位宽为32
CPU位宽
CPU的位宽最好不要小于线路的位宽,比如32位CPU控制40位宽的地址总线和数据总线的话,工作起来就很麻烦,因为40位宽的总线能传输的最大数为2^40,远远大于32位CPU所能处理的最大整数2^32,这样CPU可能就需要分步骤来处理传输的数据,所以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 位数字的计算。
由于我们大部分程序不需要计算超过32位的数据,所以其实64位CPU的优势没办法体现出来,所以和32位CPU的性能差不太多
32 位 CPU 最大只能操作 4GB 内存,就算你装了 8 GB 内存条,也没用。而 64 位 CPU 寻址范围则很大,理论最大的寻址空间为 2^64
你知道软件的 32 位和 64 位之间的区别吗?再来 32 位的操作系统可以运行在 64 位的电脑上吗?64 位的操作系统可以运行在 32 位的电脑上吗?如果不行,原因是什么?
64位和32位软件,实际上代表指令是64位还是32位的:
- 如果 32 位指令在 64 位机器上(64位CPU)执行,需要一套兼容机制,就可以做到兼容运行了。但是如果 64 位指令在 32 位机器(32位CPU)上执行,就比较困难了,因为 32 位的寄存器存不下 64 位的指令
- 操作系统其实也是一种程序,我们也会看到操作系统会分成 32 位操作系统、64 位操作系统,其代表意义就是操作系统中程序的指令是多少位,比如 64 位操作系统,指令也就是 64 位,因此不能装在 32 位机器上
总之,硬件的 64 位和 32 位指的是 CPU 的位宽,软件的 64 位和 32 位指的是指令的位宽。
了解完这些,我们才算简单的了解了计算机的组成,接下来我们要知道CPU是如何执行一个程序的
程序执行的基本过程
程序其实是一条又一条的指令,所以程序的执行其实就是把每一条指令一步一步地执行起来,而负责执行指令的就是CPU了
CPU执行程序的基本过程
- CPU读取程序计数器的值,我们知道程序计数器的值为指令的内存地址
- CPU通过控制单元操控地址总线去访问CPU从程序计数器读取到的值,然后通知内存设备准备好数据
- 内存设备准备好数据后通过数据总线将指令数据传到CPU中,期间CPU会通过Catch Line来保存数据(这部分后面会详细讲解),这样等以后CPU还想访问该数据的时候就无需再去内存中获取了
- CPU将传递来的指令数据保存在指令寄存器中
- CPU分析指令寄存器的指令,确定其指令的类型和参数,如果是计算类的指令,就将指令交给逻辑运算单元处理;如果是存储类型的指令,就交给控制单元处理
- 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和2是数据,那么当程序运行的时候,在内存中专门有一块区域来存放这些数据(根据数据的类型来分配,有数据段(.data)、BSS段(.bss)、代码段、栈区、堆区),如下图所示
- 数据1被存放在了地址为0x104的位置
- 数据2被存放在了地址为0x100的位置
注意,数据和指令是分开区域存放的,存放指令区域的地方称为「正文段」
编译器会把(a=1+2)编译成4条指令,存放到正文段中,如上图所示
- 0x200 的内容是 load 指令将 0x100 地址中的数据 1 装入到寄存器 R0;
- 0x204 的内容是 load 指令将 0x104 地址中的数据 2 装入到寄存器 R1;
- 0x208 的内容是 add 指令将寄存器 R0 和 R1 的数据相加,并把结果存放到寄存器 R2;
- 0x20c 的内容是 store 指令将寄存器 R2 中的数据存回数据段中的 0x108 地址中,这个地址也就是变量 a 内存中的地址;
编译完成后,具体执行程序的时候,程序计数器会被设置为 0x200 地址,然后依次执行这 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读取速度的快慢,将所有存储器分成以下级别:
- 速度最快的就是寄存器,但是能处理的数据量是最少的
- CPU Cache也是在CPU内部结构里的,所以它们的速度也很快,就比寄存器慢一点,但是存储的数据也更多了
- 内存,前面两个都属于CPU内部的存储器,在CPU外部,速度最快的就是内存了
- 固态硬盘和机械硬盘是最慢的,相对而言,固态硬盘又比机械硬盘快了一个档次
对于存储器而言,它的处理速度越快,耗能也就越多,而且制造材料就更贵,成本就更高,以至于速度快的存储器的存储空间都比较小
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 | cat /sys/devices/system/cpu/cpu0/cache/index0/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还有两个信息
- 从内存加载过来的实际存放数量
- 有效位,这个组标记会记录当前 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 个步骤:
- 根据内存地址中索引信息,计算在 CPU Cahe 中的索引,也就是找出对应的 CPU Line 的地址
- 找到对应 CPU Line 后,判断 CPU Line 中的有效位,确认 CPU Line 中数据是否是有效的,如果是无效的,CPU 就会直接访问内存,并重新加载数据,如果数据有效,则往下执行
- 对比内存地址中组标记和 CPU Line 中的组标记,确认 CPU Line 中的数据是我们要访问的内存数据,如果不是的话,CPU 就会直接访问内存,并重新加载数据,如果是的话,则往下执行
- 根据内存地址中偏移量信息,从 CPU Line 的数据块中,读取对应的字。
以上我们说的都是CPU访问内存的数据,但我们一直没说CPU是如何将数据写回到内存中的,现在我们就来说一下
CPU写数据操作
我们知道CPU内部有着CPU Cache,因为离 CPU 核心相当近,它的访问速度是很快的,于是它充当了 CPU 与内存之间的缓存角色。
如果CPU再修改完数据的时候收到指令需要把修改完的数据返回,那么此时CPU就需要把数据先写回CPU Cache中,那次是CPU Cache中的数据就和内存中的数据不一致了,那么我们要如何保持缓存的一致性呢?
有两种方法:写直达和写回
写直达
- 如果数据已经在 Cache 里面,先将数据更新到 Cache 里面,再写入到内存里面;
- 如果数据没有在 Cache 里面,就直接把数据更新到内存里面。
顾名思义,写直达就是每次都将数据写回CPU Cache中和内存中,但这样的效率太低了,因为不是每次修改完数据用户都需要读取的,那此时写回内存将没有任何意义。
写回
为了提高效率,有发明了另外一种方法。
在写回机制中,当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。
写回的流程
- 当CPU发生写操作的时候,先检查CPU Cache中对应的Cache Line中是否有数据,有的话就将数据更改为修改后的数据,并且标记为脏,因为此时Cache Line中的数据和对应的内存中的数据是不一样的
- 当CPU发生写操作的时候,发现修改完的数据的对应的Cache Line中的保存的是别的内存地址的数据时
- 如果该数据不是脏的,直接将修改后的数据进行覆盖,并且标记为脏
- 如果该数据是脏的,那么就先将该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):
- CPU1读取i的值,并对i进行+1操作,我们知道CPU是采用写回机制的,所以此时CPU1的CPU Cache上i的值变为1,并且被标记为脏
- CPU2开始读取i的值,按道理来说,CPU2读取到的i值应该是1,但是由于写回的机制,CPU1并没有将修改完的i值写入内存中,这也就导致CPU2读取的i值还是0,这也就发生了错误
这个就是所谓的缓存一致性问题,1号核心和2号核心的缓存,在这个时候是不一致,从而会导致执行结果的错误。
如何避免缓存一致性问题
要解决这一问题,就需要一种机制,来同步两个不同核心里面的缓存数据。要实现的这个机制的话,要保证做到下面这2点:
- 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(Wreite Propagation)
- 第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串形化(Transaction Serialization)
写传播
写传播就是当某个CPU核心更新了CPU Cache中的数据,就要把该事件广播给所有CPU核心,其中最常见的方法就是总线嗅探(Bus Snooping)
以前面i的例子来说:
当CPU1对i进行了+1操作后,修改了L1 Cache的数据,会通过总线把这个时间广播给其他CPU,然后每个CPU都会监听总线上的广播事件,然后检查自己L1 Cache上是否有该数据,如果有的话也将数据更新到自己的L1 Cache事务的串行化
还是举个栗子叭:
- 假设我们有4个CPU核心,然后有一个数据为i
- CPU1将数据i修改成100
- CPU2将数据i修改成200
- 这两个操作都会通过写传播,用总线将数据更新的事件传递给其他CPU,在这里就是CPU3和CPU4
- 假设CPU3先收到了i修改成100的事件,然后再收到将i修改成200的事件,最后再CPU3的L1 Cache中,i的值为200
- 假设CPU4先收到了i修改成200的事件,然后再收到将i修改成100的事件,最后再CPU4的L1 Cache中,i的值为100
显然,这里就又出现错误了,虽然我们通过写传播,将数据更新这个事件传输了,但由于其他CPU执行的顺序不同,也会出现数据不一致的情况,所以我们必须保证CPU3和CPU4都能看到相同顺序的数据变化,这就是数据的串行化
要实现数据的串行化,必须满足以下两点:
- CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;
- 要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。
上面我们知道可以用总线嗅探来实现写传播,但是没有办法实现数据串行化,再加上虽然总线嗅探方法很简单,但是CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。
那么我们有没有什么方法,既可以减轻总线的负载来满足写传播,又能实现数据的串行化呢?
当然是有的啦,这是一个协议,基于总线嗅探机制实现了事务串形化,也用状态机机制降低了总线带宽压力,这个协议就是 MESI 协议,这个协议就做到了 CPU 缓存一致性
MESI协议
MESI协议其实是4个状态的英文首字母组合起来的:
- Modified 已修改
- Exclusive 独占
- Shared 共享
- 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中的数据已经失效了,不可以读取该状态的数据
还是举个栗子:
- CPU1读取内存中的A数据,那么此时A数据就被读取到CPU1的Cache Line中,由于CPU1的Cache Line并没有缓存该数据,所以此时将CPU1的Cache Line标记为独占
- CPU2也读取内存中的A数据,由于CPU1的Cache Line缓存了该数据,但是CPU2的Cache Line并没有缓存A数据,于是CPU2就将A数据缓存到自己的Cache Line中,并且CPU1和CPU2的Cache Line的状态都修改成共享
- 此时CPU1想要修改A数据,那么就需要先将想要修改A数据的信息传递给其他CPU核心,CPU2接收到该信息,检查自己的Cache Line,发现自己的Cache Line有A数据,于是将自身的Cache Line中的A数据标志为已失效状态,然后CPU1就开始修改A数据,修改结束后,将Cache Line的状态修改成已修改
- 如果CPU1继续修改Cache Line中A数据的值,由于此时的 Cache Line 是已修改状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可。
- 如果此时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。
- 当我们的CPU核心1想要读取数据A,那么此时因为Cache Line最大能读取64个字节的数据,而一个A数据为long类型,也就是8个字节,所以Cache Line会继续往后读取,也就是说会把数据B也读取到CPU1的Cache Line中,并且检查是否有其他核心拥有该数据,检查完发现没有,于是CPU1在自己的Cache Line中将数据标记为独占
- 当CPU2想要读取数据B时,会发现CPU1的Cache Line已经有了该数据,那么CPU2的Cache Line就会把这一块数据标记为共享,同样的,CPU1的Cache Line也会把这一块数据标记为共享
- CPU1想要修改数据A,此时因为是共享状态,所以会先广播,CPU2接收到广播后就把共享的数据标记成已失效,然后CPU1就对数据A进行修改,然后将这一块数据标记为已修改
- CPU2想要修改数据B,于是会先读取数据B,因为此时数据B在CPU1的Cache Line中被标记为已修改(其实数据B并没有修改,只是因为数据B和已经修改的数据A是同一块内存块的),所以此时CPU1会先利用写回机制,把这一块数据写回内存,然后CPU2读取该内存块,并且进行修改,然后再CPU2的Cache Line中标记为已修改,而CPU1中的这一块数据就被标记为已失效
- 当CPU1又想要修改数据A时,会和上面的一样
那么,当我们一直重复步骤4和步骤5的时候,一点也没体现出CPU Cache的优势,因为需要一直写回到内存中,就相当于直接从内存中进去读取和写入,虽然变量 A 和 B 之间其实并没有任何的关系,但是因为同时归属于一个 Cache Line ,这个 Cache Line 中的任意数据被修改后,都会相互影响
因此,这种因为多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象称为伪共享(False Sharing)
如何解决(避免)伪共享问题
在 Linux 内核中存在 __cacheline_aligned_in_smp 宏定义,是用于解决伪共享的问题。
1 | ifdef CONFIG_SMP |
从上面的宏定义,我们可以看到:
- 如果在多核(MP)系统里,该宏定义是 __cacheline_aligned,也就是 Cache Line 的大小
- 而如果在单核系统里,该宏定义是空的
因此,针对在同一个 Cache Line 中的共享的数据,如果在多核之间竞争比较严重,为了防止伪共享现象的发生,可以采用上面的宏定义使得变量在 Cache Line 里是对齐的
举个栗子
有这样一个结构体:
1 | struct test |
结构体里的两个成员变量 a 和 b 在物理内存地址上是连续的,于是它们可能会位于同一个 Cache Line 中,如下图
所以,为了防止前面提到的 Cache 伪共享问题,我们可以使用上面介绍的宏定义,将 b 的地址设置为 Cache Line 对齐地址,如下:
1 | struct test |
这样 a 和 b 变量就不会在同一个 Cache Line 中了,如下图:
所以,避免 Cache 伪共享实际上是用空间换时间的思想,浪费一部分 Cache 空间,从而换来性能的提升
我们知道,进程是系统分配资源的基本单位,线程是CPU执行调度的基本单位,我们也知道了进程的调度算法,但是线程呢?CPU还如何选择线程执行调度呢?接着往下看吧
线程调度
在 Linux 内核中,进程和线程都是用tark_struct结构体表示的,区别在于线程的 tark_struct 结构体里部分资源是共享了进程已创建的资源,比如内存地址空间、代码段、文件描述符等,所以 Linux 中的线程也被称为轻量级进程,因为线程的tark_struct相比进程的tark_struct承载的 资源比较少,因此以轻得名。
一般来说,没有创建线程的进程,是只有单个执行流,它被称为是主线程。如果想让进程处理更多的事情,可以创建多个线程分别去处理,但不管怎么样,它们对应到内核里都是tark_struct,所以,Linux 内核里的调度器,调度的对象就是 tark_struct,接下来我们就把这个数据结构统称为任务
任务
在Linux系统中,我们根据任务的优先级以及响应要求,将任务分为两种,其中优先级数值越小,优先级越高
- 实时任务,对系统的响应时间要求很高,也就是要尽可能快的执行实时任务,优先级在 0~99 范围内的就算实时任务
- 普通任务,响应时间没有很高的要求,优先级在 100~139 范围内都是普通任务级别
调度类
由于优先级的不同,Linux为了保障优先级高的任务尽可能地被执行,于是分为了以下几种调度类
几种调度类
Deadline调度类,它的调度器为Deadline调度器,调度策略为SCHED_DEADLINE(按照deadline调度,意思就是说距离当前时间点最近的deadline的任务会先被调度),这种调度器一般是用来调度实时任务的
Realtime调度类,它的调度器为RT调度器,调度策略有两种
- SCHED_FIFO,对于相同优先级的任务,会将所有任务放入一个队列,然后根据队列的先进先出规则来调度任务,如果在一个任务被执行的过程中出现了优先级更高的任务,那么CPU就会停下当前手头上的任务,转而去执行优先级更高的那个任务
- SCHED_RR,对于相同优先级的任务,轮流着运行,每个任务都有一定的时间片,当用完时间片的任务会被放到队列尾部,以保证相同优先级任务的公平性,但是高优先级的任务依然可以抢占低优先级的任务
这个调度类一般也用于实时任务
Fair调度类,它的调度器为CFS调度器,调度策略也有两种
- SCHED_NORMAL,普通任务的调度策略
- 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 里选择任务
因此,实时任务总是会比普通任务优先被执行
如何调整优先级
- 因为我们知道,实时任务肯定会比普通任务先进行调度,所以如果我们想要提高某个普通任务的优先级,那么方法之一就是将普通任务变成实时任务
- 如果你想让某个普通任务有更多的执行时间,可以调整任务的nice值,从而让优先级高一些的任务执行更多时间。nice的值能设置的范围是负20~19,值越低,表明优先级越高,因此负20是最高优先级,19则是最低优先级,默认优先级是 0
- 它与优先级(priority)的关系是这样的:priority(new) = priority(old) + nice,因为普通任务的优先值为100~139,又因为nice是调整普通任务的优先级,所以nice的值只能是负20~19
- 在前面我们提到了,权重值与 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 进行许可。