操作系统

硬件结构

该部分主要接收一些计算机中的硬件知识。

CPU 是如何执行程序的?

冯诺依曼模型

冯诺依曼模型定义了计算机基本结构的 5 个部分,分别是运算器、控制器、存储器、输入设备、输出设备

img

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

存储单元和输入/输出设备要与中央处理器打交道的话,离不开总线。它们的关系如下。

下面,我们分别介绍内存、中央处理器、总线、输入输出设备。


内存

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

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

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

中央处理器

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

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

这里的 32 位和 64 位,通常称为 CPU 的位宽。

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

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

CPU 中的寄存器主要作用是存储计算时的数据,因为内存离 CPU 太远了,而寄存器就在 CPU 里,还紧挨着控制单元和逻辑运算单元,自然计算时速度会比内存快很多。

常见的寄存器种类:

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

总线用于 CPU 和内存以及其他设备之间的通信,总线分为 3 中:

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

CPU 要读写内存数据时,需要经过下面三个总线:

  1. 通过「地址总线」来指定内存的地址;
  2. 通过「控制总线」控制是读或写命令;
  3. 通过「数据总线」来传输数据;
输入/输出设备

输出设备向计算机输入数据,计算机经过处理计算后,把数据输出给输出设备。期间如果输入设备是键盘,按下按键时是需要和 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 了。

img

CPU 执行程序的过程如下:

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

流程可以简单地描述为:一个程序执行的时候,CPU 会根据程序计数器里面的内存地址,把内存里面需要执行的指令读取到指令寄存器里面执行,然后根据指令长度增长,程序计数器自增指向下一条执行的指令的内存地址,开始顺序读取下一条指令。

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

a = 1 + 2 执行具体过程

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

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

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

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

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

  • 数据 1 被存放到 0x100 位置;
  • 数据 2 被存放到 0x104 位置;

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

img

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

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

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

上面的例子中,由于是在 32 位 CPU 执行的,因此一条指令是占 32 位大小,所以你会发现每条指令间隔 4 个字节。(单字长指令 = 机器字长 = 计算机能直接处理的二进制数据的位数。机器字长通常与主存单元的位数一致。)

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

操作系统结构

内核

计算机是由这种外部硬件设备组成的,比如内存、CPU、硬盘等,如果每个应用都要和这些硬件对接通信协议,那这样太累了,所以这个中间人就由内核来充当,让内核作为应用连接硬件设备的桥梁,应用程序只需要关心与内核的交互,不用关心硬件的细节。

内核

内核的能力

现代的操作系统,内核一般会提供 4 个基本能力:

  • 管理进程、线程,决定那个进程、线程使用 CPU,也就是进程调度的能力;
  • 内存管理,决定内存的分配与回收,也就是内存管理的能力;
  • 管理硬件设备,为进程与硬件设备之间提供通信能力,也就是硬件通信能力。
  • 提供系统调用,如果应用程序要运行更高权限的服务,那么就需要有系统调用,它是用户程序与操作系统之间的接口。

内核怎样工作

内核具有很高的权限,可以控制 CPU、内存等硬件,而用户层面的应用程序具有操作系统的权限很小,因此多少操作系统,会把内存分为内核空间和用户空间

  • 内核空间:这个内存空间只有内核程序可以访问;
  • 用户空间:这个内存空间专门给应用程序使用;

用户空间的代码只能访问一个局部的内存空间,而内核空间的代码可以访问所有内存空间。因此当程序使用用户空间时,常指该程序在用户态执行,而当程序使用内核空间时,程序则在内核态执行。

应用程序需要进入内核空间,就需要通过系统调用,下面来看看系统调用的过程:

img

内核程序执行在内核态,用户程序执行在内核态。当应用程序使用系统调用,会产生一个中断。发生中断后,CPU 会中断当前在执行的应用程序,转而跳转到中断处理程序,也就是开始执行内核程序。内核处理完后,主动触发中断,把 CPU 执行权限交回给应用程序,回到用户态继续工作。

Linux 的设计

Linux 内核设计的理念主要有这么几点:

  • MultiTask:多任务
  • SMP:对称多处理
  • ELF:可执行文件链接格式
  • Monolithic Kernel:宏内核

MultiTask 多任务

代表着 Linux 是一个多任务的操作系统。

多任务意味着可以同时有多个任务同时执行,这里的「同时」可以是并发或并行:

  • 对于单核 CPU 时,可以让每个任务执行一小段时间,时间到就切换到另外一个任务,从宏观角度看,一段时间内执行了多个任务,这被称为并发
  • 对于多核 CPU 时,多个任务可以同一时刻被不同核心的 CPU 同时执行,这被称为并行

SMP 对称多处理

对称多处理,代表着每个 CPU 的地位是相等的,对资源的使用权限也是相同的,多个 CPU 共享一个内存,每个 CPU 都可以访问完整的内存和硬件资源。

这个特点决定了 Linux 操作系统不会有某个 CPU 单独服务应用程序或内核程序,而是每个程序都可以被分配到任意一个 CPU 上被执行。

ELF

ELF 意思是可执行文件链接格式,它是 Linux 操作系统中可执行文件的存储格式。

ELF 文件格式

ELF 把文件分成了一个个分段,每一个段都有自己的作用。

另外,ELF 文件有两种索引,Program header table 中记录了「运行时」所需的段,而 Section header table 记录了二进制文件中各个「段的首地址」。

那 ELF 文件怎么生成的呢?

我们编写的代码,首先通过「编译器」编译成汇编代码,接着通过「汇编器」变成目标代码,也就是目标文件,最后通过「链接器」把多个目标文件以及调用的各种函数库链接起来,形成一个可执行文件,也就是 ELF 文件。

那 ELF 文件是怎么被执行的呢?

执行 ELF 文件的时候,会通过「装载器」把 ELF 文件装载到内存里,CPU 读取内存中的指令和数据,于是程序就被执行起来了。

Monolithic Kernel

宏内核 ,Linux 内核架构就是宏内核,意味着 Linux 内核是一个完整的可执行程序,且拥有最高权限

宏内核的特征是系统内核的所有模块,比如进程调度、内存管理、文件系统、设备驱动等,都运行在内核态。

Linux 也实现了动态加载内核模块的功能,例如大部分设备是以可加载模块的形式存在的,与内和其他模块解耦,让驱动开发和驱动加载更为方便、灵活。

分别为宏内核、微内核、混合内核的操作系统结构

与宏内核相反的是微内核,微内核架构的内核只保留最基本的能力,比如进程调度、虚拟机内存、中断等,把一些应用放到了用户空间,比如驱动程序、文件系统等。这样服务与服务之间是隔离的,单个服务出现故障或者完全攻击,也不会导致整个操作系统挂掉,提高了操作系统的稳定性和可靠性。

微内核内核功能少,可移植性高,相比宏内核有一点不好的地方在于,由于驱动程序不在内核中,而且驱动程序一般会频繁调用底层能力的,于是驱动和硬件设备交互就需要频繁切换到内核态,这样会带来性能损耗。华为的鸿蒙操作系统的内核架构就是微内核。

还有一种内核叫混合类型内核,它的架构有点像微内核,内核里面会有一个最小版本的内核,然后其他模块会在这个基础上搭建,然后实现的时候会跟宏内核类似,也就是把整个内核做成一个完整的程序,大部分服务都在内核中,这就像是宏内核的方式包裹着一个微内核。

Windows 设计

当今 Windows 7、Windows 10 使用的内核叫 Windows NT,NT 全称叫 New Technology。

结构图:

Windows NT 的结构

与 Linux 一样支持 MultiTask(多任务)SMP(对称多处理),但不同的是,Windows 的内核设计是混合型内核,上图中的 MicroKernel 模块,就是这个最小版本的内核,而整个内核实现是一个完成的可执行程序,含有非常多的模块。

Windows 的可执行文件的格式与 Linux 也不同,所以两个系统的可执行文件不能在对方的系统上运行。

Windows 的可执行文件格式叫 PE,称为可移植执行文件,拓展名如:.exe.dll.sys 等。

PE 结构:

PE 文件结构

内存管理

为什么要有虚拟内存?

什么是虚拟内存?

对于单片机这样没有操作系统的,每次写完代码,都需要借助工具将程序烧录进去,这样程序才能跑起来。单片机的 CPU 是直接操作内存的「物理地址」

img

这种情况下,如果程序占用的内存有叠加,要想同时运行两个程序是不可能的。

例如:如果第一个程序在 2000 的位置写入一个新的值,将会擦除掉第二个程序存放在相同位置上的所有内容。

所以同时运行两个程序根本行不通,这两个程序会立刻崩溃。

因此有了虚拟内存。每个进程分配独立的一套虚拟地址,互不干涉。(虚拟地址由操作系统负责映射到物理内存)

进程的中间层

操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。

如果程序要访问虚拟地址的时候,由于操作系统会将虚拟地址映射到不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。

两种地址的概念:

  • 我们程序所使用的内存地址叫做虚拟内存地址(Virtual Memory Address)
  • 实际存在硬件里面的空间地址叫做物理内存地址(Physical Memory Address)

操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转变为物理地址,然后再通过物理地址访问内存

CPU 执行程序命令时:虚拟地址先通过 MMU 转变为实际的物理内存地址,然后再通过物理地址访问。

img

操作系统通过内存分段内存分页来管理虚拟地址和物理地址之间的映射关系。

内存分段

程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。

分段机制下的虚拟地址由两部分组成,段选择因子段内偏移量

映射关系如图:

img

段选择因子和段内偏移量:

  • 段选择因子保存在段寄存器里面。段选择子里面最重要的就是段号,用作段的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。段号用于再段表中查出段内标识符,标识符中就包含了这个段的信息。
  • 虚拟地址中的段内偏移量应该位于 0 和 段界限之间,如果段内偏移量是合法的话,就将段基地址加上段内偏移量得到物理内存地址

物理地址 = 虚拟地址段选择因子->段内描述符->段基地址 + 段内偏移量

虚拟地址通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个 段(分别为 栈、堆、数据、代码 四个段),每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图。

img

如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。

分段虽然解决了程序本身不需要关系具体的物理内存地址的问题,但它也有一些不足之处:

  • 第一个就是内存碎片的问题。
  • 第二个就是内存交换效率低的问题。

接下来,说说为什么会有这两个问题。

我们先来看看,分段为什么会产生内存碎片的问题?

我们来看看这样一个例子。假设有 1G 的物理内存,用户执行了多个程序,其中:

  • 游戏占用了 512MB 内存
  • 浏览器占用了 128MB 内存
  • 音乐占用了 256 MB 内存。

这个时候,如果我们关闭了浏览器,则空闲内存还有 1024 - 512 - 256 = 256MB。

如果这个 256MB 不是连续的,被分成了两段 128 MB 内存,这就会导致没有空间再打开一个 200MB 的程序。

img

内存分段会出现内存碎片吗?

内存碎片主要分为,内部内存碎片外部内存碎片

内存分段管理可以做到段根据实际需求分配内存,所以有多少需求就分配多大的段,所以不会出现内部内存碎片

但是由于每个段的长度不固定,所以多个段未必能恰好使用所有的内存空间,会产生了多个不连续的小物理内存,导致新的程序无法被装载,所以会出现外部内存碎片的问题。(如上图类似)

解决「外部内存碎片」的问题就是内存交换

可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。

这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换

再来看看,分段为什么会导致内存交换效率低的问题?

对于多进程的系统来说,用分段的方式,外部内存碎片是很容易产生的,产生了外部内存碎片,那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。

因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。

所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。

为了解决内存分段的「外部内存碎片和内存交换效率低」的问题,就出现了内存分页

内存分页

上述我们已经讨论了分段的好处是能够产生连续的空间,但是会出现「外部内存碎片和内存交换的空间太大」的问题。

内存分页可以使内存碎片出现的更少,另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘预装的数据更少一点。

分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫Page)。在 Linux 下,每一页的大小为 4KB

虚拟地址与物理地址之间通过页表来映射,如图:

img

页表是存储在内存里的,内存管理单元MMU)就做将虚拟内存地址转换为物理地址的工作。

当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新页表,最后再返回用户空间,恢复进程的运行。


分页是怎么解决分段的「外部内存碎片和内存交换效率低」的问题?

内存分页由于内存空间都是预先分配好的,也就不会像内存分段一样,在段与段之间产生间隙非常小的内存,这正是分段会产生外部碎片的原因,而采用了分页,页与页之间是紧密排列的,所以不会有外部碎片。

但,由于内存分页机制分配内存的最小单位是一页,即使程序不足一页大小,我们还是最少只能分配一页所以会出现内存浪费的情况,所以内存分页会有内部内存碎片的情况。

内存交换:如果内存空间不够,操作系统会把其他正在运行的进程中「最近没被使用」的内存页面给释放掉,将其暂时写入硬盘,称之为换出(Swap Out)。一旦需要的时候,再加载进来,成为换入(Swap In)

针对内存分页的内存交换,一次性写入磁盘的也只有少数的一个页或者几个页,相较于分段内存每次交换都是交换内存较大的段而言效率很高。

img

更进一步的,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页都加载到物理内存里,而是只在有程序运行中,需要用到对应的内存页里面的指令和数据时,再加载到物理内存里面去。


分页机制下,虚拟地址和物理地址如何映射的?

先看下面的图:

img

在分页机制下,虚拟地址可以分为两个部分,页号和页内偏移。也好作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移组成就形成了物理内存地址。

内存地址转换三大步:

  • 把虚拟内存地址,切分为页号和偏移量;
  • 根据页号,从页表里面,查询对应的物理页号;
  • 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。

img


简单分页的缺陷

上述这种分页是简单的分页,有空间上的缺陷。

每个进程都有自己的页表,存放在内存里。

因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。

例如:在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。

这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。

那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。

多级分页

为了解决简单分页上空间上的缺陷,就需要采用一种叫做多级页表(Multi-Level Page Table)的解决方案了。

根据前面对于简单页表的缺陷分析,我们知道如果运行100个进程,那么一个进程的页表需要装下 100 多万个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。

我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024 个页表(二级页表),每个表(二级页表)中包含 1024 个「页表项」,形成二级分页。如下图所示:

你可能会问,分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗?

当然如果 4GB 的虚拟地址全部都映射到了物理内存上的话,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。

其实我们应该换个角度来看问题,还记得计算机组成原理里面无处不在的局部性原理么?

每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。

如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB,这对比单级页表的 4MB 是不是一个巨大的节约?

那么为什么不分级的页表就做不到这样节约内存呢?

我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。

我们把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。

在一级页表中只有20%被用到了,在二级页表中也只有20%被用到了,在三级页表中。。。。。这样依次类推就是多级页表内存空间占用更少的原因。

对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:

  • 全局页目录项 PGD(Page Global Directory);
  • 上层页目录项 PUD(Page Upper Directory);
  • 中间页目录项 PMD(Page Middle Directory);
  • 页表项 PTE(Page Table Entry);
  • img

TLB

多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。

程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。

我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer ,通常称为页表缓存、转址旁路缓存、快表等。

img

在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。

有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。

TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。


段页式内存管理

内存分段和内存分页是可以组合在一起使用的,通常称这种组合为段页式内存管理

img

段页式内存管理实现的方式:

  • 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
  • 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;

这样,地址结构就由段号、段内页号和页内位移三部分组成。

用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:

img

段页式地址变换中要得到物理地址需要经过三次内存访问:

  • 第一次访问段表,得到页表其实位置
  • 第二次访问页表,得到物理页号
  • 第三次将物理页号与页内位移组合,得到物理地址。

可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。

总结

虚拟内存的作用:

  • 第一,虚拟内存可以使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
  • 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
  • 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。

系统内存紧张时,会发生什么?

当应用程序通过 malloc 函数申请内存时,实际上申请的是虚拟内存,此时并不会分配物理内存。

当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存,这时会发现这个虚拟内存没有映射到物理内存,CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页交给内核的 Page Fault Handler (缺页中断函数)处理。

缺页中断函数,会判断当前是否还有空闲的物理内存,如果有,直接分配,并建立与虚拟内存与物理内存之间的关系。

如果没有,那么内核就会开始进行内存回收的工作,回收的方式主要有两种:直接内存回收和后台内存回收。

  • 后台内存回收kswapd):在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。
  • 直接内存回收direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。

这里的阻塞是针对申请内存的进程

如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ——触发 OOM (Out of Memory)机制

机制讲解:OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。

优先级:后台内存回收 > 直接内存回收 > OOM 机制

申请物理内存的过程如下图:

img

那些内存可以被回收?

主要有两类可以被回收,它们的回收方式也不同。

  • 文件页(File-backed Page):内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页。大部分文件页,都可以直接释放内存,以后有需要时,再从磁盘重新读取就可以了。而那些被应用程序修改过,并且暂时还没写入磁盘的数据(也就是脏页),就得先写入磁盘,然后才能进行内存释放。所以,回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存
  • 匿名页(Anonymous Page):这部分内存没有实际载体,不像文件缓存有硬盘文件这样一个载体,比如堆、栈数据等。这部分内存很可能还要再次被访问,所以不能直接释放内存,它们回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。

文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。LRU 回收算法,实际上维护着 active 和 inactive 两个双向链表,其中:

  • active_list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;
  • inactive_list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;

越接近链表尾部,就表示内存页越不常访问。这样,在回收内存时,系统就可以根据活跃程度,优先回收不活跃的内存。

活跃和非活跃的内存页,按照类型的不同,又分别分为文件页和匿名页。可以从 /proc/meminfo 中,查询它们的大小,比如:

# grep表示只保留包含active的指标(忽略大小写)
# sort表示按照字母顺序排序
[root@xiaolin ~]# cat /proc/meminfo | grep -i active | sort
Active: 901456 kB
Active(anon): 227252 kB
Active(file): 674204 kB
Inactive: 226232 kB
Inactive(anon): 41948 kB
Inactive(file): 184284 kB

回收带来的性能影响

根据回收机制来说:

  • 后台内存回收,唤醒 kswaped 内核线程,这种方式是异步回收,不会阻塞进程,性能较好。
  • 直接内存回收,这种方式是同步回收的,会阻塞进程,这样就会造成很长时间的延迟,以及系统 CPU 利用率会升高,最终引起系统负荷飙高。

根据可被回收的内存类型来说:

  • 文件页的回收:对于干净页是直接释放内存,这个操作不会影响性能,而对于脏页会先写到磁盘再释放内存,这个操作会发生磁盘 I/O 的,所以脏页的回收是会影响系统性能的。
  • 匿名页的回收:如果开启了 Swap 机制,那么 Swap 机制会将不常访问的匿名页换出到磁盘中,下次访问时,再从磁盘换出到内存中,这个操作是会影响系统性能的(磁盘 I/O)。

可以看到,回收内存的操作基本都会发生磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能。

既然频繁的内存回收会影响性能,那下面介绍几个常见的解决方式。

调整文件页和匿名页的回收倾向

从上述性能分析来看,文件页的回收操作对系统的影响相比匿名页的回收操作会少一点,因为文件页对于干净页来看是不会发生磁盘 I/O,而匿名页的 Swap 换入换出这两个操作都会发生磁盘 I/O。

Linux 提供了一个 /proc/sys/vm/swappiness 选项,用来调整文件页和匿名页的回收倾向。

swappiness 的范围是 0-100,数值越大,越积极使用 Swap,也就是更倾向于回收匿名页;数值越小,越消极使用 Swap,也就是更倾向于回收文件页。

[root@xiaolin ~]# cat /proc/sys/vm/swappiness
0

一般建议 swappiness 设置为 0(默认值是 60),这样在回收内存的时候,会更倾向于文件页的回收,但是并不代表不会回收匿名页。


尽早触发 kswapd 内核线程异步回收内存

如何查看系统的直接内存回收和后台内存回收的指标?

我们可以使用 sar -B 1 来观察:

image-20221105174012664

如果输入命令后 not found commnd! , 可能是系统没有安装这个工具,centOS 下 sar 工具命令为 :

#CentOS
yum install sysstat

图中红色框住的就是后台内存回收和直接内存回收的指标,它们分别表示:

  • pgscank/s : kswapd(后台回收线程) 每秒扫描的 page 个数。
  • pgscand/s: 应用程序在内存申请过程中每秒直接扫描的 page 个数。
  • pgsteal/s: 扫描的 page 中每秒被回收的个数(pgscank+pgscand)。

如果系统时不时发生抖动,并且在抖动的时间段里如果通过 sar -B 观察到 pgscand 数值很大,那大概率是因为「直接内存回收」导致的。

针对这个问题,解决的办法就是,可以通过尽早的触发「后台内存回收」来避免应用程序进行直接内存回收。

什么条件下才能触发 kswapd 内核线程回收内存呢?

内核定义了三个内存阈值(watermark,也称为水位),用来衡量当前剩余内存(pages_free)是否充裕或者紧张,分别是:

  • 页最小阈值(pages_min);
  • 页低阈值(pages_low);
  • 页高阈值(pages_high);

这三个内存阈值会划分为四种内存使用情况,如下图:

img

kswapd 会定期扫描内存的使用情况,根据剩余内存(pages_free)的情况来进行内存回收的工作。

  • 图中绿色部分:如果剩余内存(pages_free)大于 页高阈值(pages_high),说明剩余内存是充足的;
  • 图中蓝色部分:如果剩余内存(pages_free)在页高阈值(pages_high)和页低阈值(pages_low)之间,说明内存有一定压力,但还可以满足应用程序申请内存的请求;
  • 图中橙色部分:如果剩余内存(pages_free)在页低阈值(pages_low)和页最小阈值(pages_min)之间,说明内存压力比较大,剩余内存不多了。这时 kswapd0 会执行内存回收,直到剩余内存大于高阈值(pages_high)为止。虽然会触发内存回收,但是不会阻塞应用程序,因为两者关系是异步的。
  • 图中红色部分:如果剩余内存(pages_free)小于页最小阈值(pages_min),说明用户可用内存都耗尽了,此时就会触发直接内存回收,这时应用程序就会被阻塞,因为两者关系是同步的。

可以看到,当剩余内存页(pages_free) 小于页低阈值(pages_lows),就会触发 kswapd 进行后台回收,然后 kswapd 会一直回收到剩余内存页(pages_free)大于页高阈值(pages_high)

也就是说 kswapd 的活动空间只有 pages_low 与 pages_min 之间的这段区域,如果剩余内存低于了 pages_min 会触发直接内存回收,高于了 pages_high 又不会唤醒 kswapd。

页低阈值(pages_low)可以通过内核选项 /proc/sys/vm/min_free_kbytes (该参数代表系统所保留空闲内存的最低限)来间接设置。

min_free_kbytes 虽然设置的是页最小阈值(pages_min),但是页高阈值(pages_high)和页低阈值(pages_low)都是根据页最小阈值(pages_min)计算生成的,它们之间的计算关系如下:

pages_min = min_free_kbytes
pages_low = pages_min*5/4
pages_high = pages_min*3/2

如果系统时不时发生抖动,并且通过 sar -B 观察到 pgscand 数值很大,那大概率是因为直接内存回收导致的,这时可以增大 min_free_kbytes 这个配置选项来及早地触发后台回收,然后继续观察 pgscand 是否会降为 0。

增大了 min_free_kbytes 配置后,这会使得系统预留过多的空闲内存,从而在一定程度上降低了应用程序可使用的内存量,这在一定程度上浪费了内存极端情况下设置 min_free_kbytes 接近实际物理内存大小时,留给应用程序的内存就会太少而可能会频繁地导致 OOM 的发生

所以在调整 min_free_kbytes 之前,需要先思考一下,应用程序更加关注什么,如果关注延迟那就适当地增大 min_free_kbytes,如果关注内存的使用量那就适当地调小 min_free_kbytes。


NUMA 架构下的内存回收策略

什么是 NUMA 架构?

SMP 指的是一种多个 CPU 处理器共享资源的电脑硬件架构,也就是说每个 CPU 地位平等,它们共享相同的物理资源,包括总线、内存、IO、操作系统等。每个 CPU 访问内存所用时间都是相同的,因此,这种系统也被称为一致存储访问结构(UMA,Uniform Memory Access)。

随着 CPU 处理器核数的增多,多个 CPU 都通过一个总线访问内存,这样总线的带宽压力会越来越大,同时每个 CPU 可用带宽会减少,这也就是 SMP 架构的问题。

SMP 与 NUMA 架构

为了解决 SMP 架构的问题,就研制出了 NUMA 结构,即非一致存储访问结构(Non-uniform memory access,NUMA)。

NUMA 架构将每个 CPU 进行了分组,每一组 CPU 用 Node 来表示,一个 Node 可能包含多个 CPU 。

每个 Node 有自己独立的资源,包括内存、IO 等,每个 Node 之间可以通过互联模块总线(QPI)进行通信,所以,也就意味着每个 Node 上的 CPU 都可以访问到整个系统中的所有内存。但是,访问远端 Node 的内存比访问本地内存要耗时很多。

NUMA 架构跟回收内存有什么关系?

在 NUMA 架构下,当某个 Node 内存不足时,系统可以从其他 Node 寻找空闲内存,也可以从本地内存中回收内存。

具体选哪种模式,可以通过 /proc/sys/vm/zone_reclaim_mode 来控制。它支持以下几个选项:

  • 0 (默认值):在回收本地内存之前,在其他 Node 寻找空闲内存;
  • 1:只回收本地内存;
  • 2:只回收本地内存,在本地回收内存时,可以将文件页中的脏页写回硬盘,以回收内存。
  • 4:只回收本地内存,在本地回收内存时,可以用 swap 方式回收内存。

在使用 NUMA 架构的服务器,如果系统出现还有一半内存的时候,却发现系统频繁触发「直接内存回收」,导致了影响了系统性能,那么大概率是因为 zone_reclaim_mode 没有设置为 0 ,导致当本地内存不足的时候,只选择回收本地内存的方式,而不去使用其他 Node 的空闲内存。

虽然说访问远端 Node 的内存比访问本地内存要耗时很多,但是相比内存回收的危害而言,访问远端 Node 的内存带来的性能影响还是比较小的。因此,zone_reclaim_mode 一般建议设置为 0。

image-20221105181039326

如何保护一个进程不被 OOM 杀掉呢

在系统空闲内存不足的情况,进程申请了 一个很大的内存,如果直接回收都无法回收出足够大的内存,那么就会会触发 OOM 机制,内核就会根据算法选择一个进程杀掉(可能和手机系统的杀后台差不多)。

这就要提到一个在 Linux 内核里有一个 oom_badness() 函数,它会把系统中可以被杀掉的进程扫描一遍,并对每个进程打分,得分最高的进程就会被首先杀掉。

进程得分的结果受下面这两个方面影响:

  • 第一,进程已经使用的物理内存页面数。
  • 第二,每个进程的 OOM 校准值 oom_score_adj。它是可以通过 /proc/[pid]/oom_score_adj 来配置的。我们可以在设置 -1000 到 1000 之间的任意一个数值,调整进程被 OOM Kill 的几率。

函数 oom_badness() 里的最终计算方法是这样的:

// points 代表打分的结果
// process_pages 代表进程已经使用的物理内存页面数
// oom_score_adj 代表 OOM 校准值
// totalpages 代表系统总的可用页面数
points = process_pages + oom_score_adj*totalpages/1000

用「系统总的可用页面数」乘以 「OOM 校准值 oom_score_adj」再除以 1000,最后再加上进程已经使用的物理页面数,计算出来的值越大,那么这个进程被 OOM Kill 的几率也就越大

每个进程的 oom_score_adj 默认值都为 0,所以最终得分跟进程自身消耗的内存有关,消耗的内存越大越容易被杀掉。我们可以通过调整 oom_score_adj 的数值,来改成进程的得分结果:

  • 如果你不想某个进程被首先杀掉,那你可以调整该进程的 oom_score_adj,从而改变这个进程的得分结果,降低该进程被 OOM 杀死的概率。
  • 如果你想某个进程无论如何都不能被杀掉,那你可以将 oom_score_adj 配置为 -1000。

我们最好将一些很重要的系统服务的 oom_score_adj 配置为 -1000,比如 sshd,因为这些系统服务一旦被杀掉,我们就很难再登陆进系统了。

注意:但是,不建议将我们自己的业务程序的 oom_score_adj 设置为 -1000,因为业务程序一旦发生了内存泄漏,而它又不能被杀掉,这就会导致随着它的内存开销变大,OOM killer 不停地被唤醒,从而把其他进程一个个给杀掉。

在 4GB 物理内存的机器上,申请 8GB 内存会怎么样?

讨论这个问题,需要在有前置条件下,不然说出答案是经不起推敲的。

这个问题需要讨论三个前置条件:

  • 操作系统是 32 位的,还是 64 位的?
  • 申请完 8G 内存后会不会被使用?
  • 操作系统有没有使用 Swap 机制?

操作系统虚拟内存大小

32 位操作系统和 64 位操作系统的虚拟地址空间大小是不同的,在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,如下所示:

img

通过这里可以看出:

  • 32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;
  • 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。

在 32 位的操作系统上,进程最多只能分配 3 G 大小的虚拟内存空间,所以进程申请 8 GB 内存的话,在申请虚拟内存阶段就会失败。

对于 64 位操作系统,进程可以使用 128 TB 大小的虚拟内存空间,所以进程申请 8 GB 是没有问题的,因为进程申请内存是虚拟内存,只要不适用这个虚拟内存,操作系统就不会分配物理内存。

Swap 机制的作用

如果申请物理内存大小超过了空闲物理内存大小,就要看操作系统有没有开启 Swap 机制:

  • 如果没有开启 Swap 机制,程序就会直接 OOM;
  • 如果有开启 Swap 机制,程序可以正常运行。

Swap 机制讲解:

当系统的物理内存不够用的时候,就需要将物理内存中的一部分空间释放出来,以供当前运行的程序使用。那些被释放的空间可能来自一些很长时间没有什么操作的程序,这些被释放的空间会被临时保存到磁盘,等到那些程序要运行时,再从磁盘中恢复保存的数据到内存中

另外,当内存使用存在压力的时候,会开始触发内存回收行为,会把这些不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了

这种。将内存数据换出到磁盘,又从磁盘中恢复数据到内存的过程,就是 Swap 机制负责的。

Swap 就是把一块磁盘空间或者本地文件,当成内存来使用,它包含换出换入两个过程。

  • 换出(Swap Out) ,是把进程暂时不用的内存数据存储到磁盘中,并释放这些数据占用的内存;
  • 换入(Swap In),是在进程再次访问这些内存的时候,把它们从磁盘读到内存中来;

如图:

img

使用 Swap 机制优点是,应用程序实际可用使用的内存空间将远远超过系统的物理内存。由于硬盘空间的价格远比内存低,这种方式无疑是经济实惠的。但频繁地读写磁盘会显著减低操作系统的运行速度,这也是 Swap 的弊端。

Linux 中的 Swap 会在内存不足和内存闲置两种场景下触发:

  • 内存不足:当系统需要的内存超过了可用的物理内存时,内核就会将内存中不常用的内存页换出到磁盘上为当前进程让出内存,保证了正在执行的进程的可用性,这个内存回收的过程是强制的直接内存回收(Direct Page Reclaim)直接内存回收时同步的过程,会阻塞当前申请内存的进程。
  • 内存闲置:应用程序在启动阶段使用的大量内存在启动后往往都不会使用,通过后台运行的守护进程(kSwapd),我们可以将这部分只使用一次的内存交换到磁盘上为其他内存的申请预留空间。kSwapd 是 Linux 负责页面置换(Page replacement)的守护进程,它也是负责交换闲置内存的主要进程,它会在空闲内存低于一定水位 (opens new window)时,回收内存页中的空闲内存保证系统中的其他进程可以尽快获得申请的内存。**kSwapd 是后台进程,所以回收内存的过程是异步的,不会阻塞当前申请内存的进程。**

Linux 提供了两种不同的方法启用 Swap,分别是 Swap 分区(Swap)和 Swap 文件(Swapfile),开启方法可以看这个资料

  • Swap 分区是硬盘上的独立区域,该区域只会用于交换分区,其他的文件不能存储在该区域上,我们可以使用 swapon -s 命令查看当前系统上的交换分区;
  • Swap 文件是文件系统中的特殊文件,它与文件系统中的其他文件也没有太多的区别;

Swap 换入换出的是什么类型的内存?

内核缓存的文件数据, 因为都有对应的磁盘文件,所以在回收文件数据的时候,直接写回到对应文件就可以了。

但是像进程的堆,栈数据等,它们是没有实际载体的,这部分内存称为匿名页。而且这部分内存很可能还要再次被访问,所以不能直接释放内存,于是就需要有一个能保存匿名页的磁盘载体,这个载体就是 Swap 分区。

匿名页回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。

总结

  • 在 32 位操作系统中,因为进程最大只能申请 3 GB 大小的虚拟内存,所以直接申请 8 GB 内存,会申请失败。

  • 在 64 位操作系统中,因为进程最大能申请 128 TB 大小的虚拟内存,即使物理内存只有 4 GB,直接申请 8 GB 内存也是没有问题的,因为申请的内存是虚拟内存(如果没有被使用是不会为其真正分配物理内存的)。如果这块虚拟内存被访问了,要看系统有没有 Swap 分区:

    • 如果没有 Swap 分区,因为物理空间不够,进程会被操作系统杀掉,原因是 OOM(内存溢出)。
    • 如果由 Swap 分区,即使物理内存只有 4 GB,程序也能正常使用 8 GB 的内存,进程可以正常运行。

什么是 OOM?

内存溢出(Out Of Memory,简称OOM)是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于能提供的最大内存。此时程序就运行不了,系统会提示内存溢出。

如何避免预读失效和缓存污染问题?

Redis 的缓存淘汰算法则是通过实现 LFU 算法来避免「缓存污染」而导致缓存命中率下降的问题(Redis 没有预读机制)。

MySQL 和 Linux 操作系统是通过改进 LRU 算法来避免「预读失效和缓存污染」而导致缓存命中率下降的问题。

该模块主要说说 Linux 和 Mysql 是如何改进 LRU 算法的。

Linux 和 Mysql 的缓存

Linux 操作系统的缓存

在应用程序读取文件的数据的时候,Linux 操作系统会对读取的文件进行缓存,会缓存在文件系统中的 Page Chche(如下图中的页缓存):

img

Page Cache 属于内存空间里的数据,由于内存访问比磁盘访问快很多,在下一次访问相同的数据就不需要磁盘 I/O了,命中缓存直接返回数据即可。

因此,Page Cache(缓存) 起到了加速访问数据的作用。

MySql 的缓存

MySql 的数据是存储在磁盘里的,为了提升数据库的读写性能,Innodb 存储引擎设计了一个缓冲池(Buffer Pool)Buffer Pool 属于内存空间里的数据

img

有了缓冲池之后:

  • 读取数据时,如果数据存储在 Buffer Pool 中,客户端就会直接读取 Buffer Pool 中的数据,否则再去磁盘中读取。
  • 修改数据时,首先是修改 Buffer Pool 中的数据所在的页,然后将其设置为脏页,最后由后台线程将脏页写入到磁盘。

传统 LRU 是如何管理内存数据的?

无论是 Linux 的 Page Cache (缓存页)还是 MySqlBuffer Pool(缓冲池),容量都是有限的,并不能无限地存放缓存数据,对于一些频繁访问的数据我们希望可以长时间(或一直)留在内存中,而一些很少访问的数据我们希望在某些时机可以淘汰掉,从而保证内存不会因为满了而导致无法再缓存新的数据,同时还能保证常用数据留在内存中。

LRU(Least recently used)算法,就可以实现上述机制。

LRU 算法一般是用「链表」作为数据结构来实现的,链表头部的数据是最近使用的,而链表末尾的数据是最久没被使用的。那么,当空间不够了,就淘汰最久没被使用的节点,也就是链表末尾的数据,从而腾出内存空间。

因为 Linux 的 Page Cache 和 MySQL 的 Buffer Pool 缓存的基本数据单位都是页(Page)单位,所以后续以「页」名称代替「数据」

LRU 算法原理

可以用一个特殊的栈来保存当前正在使用的各个页面的页面号。当一个新的进程访问某页面时,便将该页面号压入栈顶,其他的页面号往栈底移,如果内存不够,则将栈底的页面号移除。这样,栈顶始终是最新被访问的页面的编号,而栈底则是最近最久未访问的页面的页面号。

在一般标准的操作系统教材里,会用下面的方式来演示 LRU 原理,假设内存只能容纳3个页大小,按照 7 0 1 2 0 3 0 4 的次序访问页。假设内存按照栈的方式来描述访问时间,在上面的,是最近访问的,在下面的是,最远时间访问的,LRU就是这样工作的。

在这里插入图片描述

传统的 LRU 算法的实现思路是这样的:

  • 当访问的页在内存里,就直接把该页对应的 LRU 链表节点移动到链表的头部。
  • 当访问的页不在内存里,除了要把该页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的页(如果满了的情况)。

比如下图,假设 LRU 链表长度为 5,LRU 链表从左到右有编号为 1,2,3,4,5 的页。

img

如果访问了 3 号页,因为 3 号页已经在内存了,所以把 3 号页移动到链表头部即可,表示最近被访问了。

img

而如果接下来,访问了 8 号页,因为 8 号页不在内存里,且 LRU 链表长度为 5,所以必须要淘汰数据,以腾出内存空间来缓存 8 号页,于是就会淘汰末尾的 5 号页,然后再将 8 号页加入到头部。

img

传统的 LRU 算法并没有被 Linux 和 MySQL 使用,因为传统的 LRU 算法无法避免下面这两个问题:

  • 预读失效导致缓存命中率下降;
  • 缓存污染导致缓存命中率下降;

预读机制

Linux 操作系统为基于 Page Cache 的读缓存机制提供预读机制,一个例子是:

  • 应用程序只想读取磁盘上文件 A 的 offset 为 0-3 KB 范围内的数据,由于磁盘的基本读写单位为 block(4 KB),于是操作系统至少会读 0-4 KB 的内容,这恰好可以在一个 page 中装下。
  • 但是操作系统出于空间局部性原理(靠近当前被访问数据的数据,在未来很大概率会被访问到),会选择将磁盘块 offset [4 KB,8 KB)、[8 KB,12 KB) 以及 [12 KB,16 KB) 都加载到内存,于是额外在内存中申请了 3 个 page;

下图代表了操作系统的预读机制:

img

上图中,应用程序利用 read 系统调动读取 4 KB 数据,实际上内核使用预读机制(ReadaHead) 机制完成了 16 KB 数据的读取,也就是通过一次磁盘顺序读将多个 Page 数据装入 Page Cache

这样下次读取 4 KB 数据后面的数据的时候,就不用从磁盘读取了,直接在 Page Cache 即可命中数据。因此,预读机制带来的好处就是减少了 磁盘 I/O 次数,提高系统磁盘 I/O 吞吐量

MySQL Innodb 存储引擎的 Buffer Pool 也有类似的预读机制,MySQL 从磁盘加载页时,会提前把它相邻的页一并加载进来,目的是为了减少磁盘 IO。


预读失败的后果

预读失败:如果这些被提前加载进来的页,并没有被访问,相当于这个预读工作是白做了,这就是预读失败。

问题分析:如果使用传统的 LRU 算法,就会把「预读页」放到 LRU 链表头部,而当内存空间不够的时候,还需要把末尾的页淘汰掉。

后果:如果这些「预读页」如果一直不会被访问到,就会出现一个很奇怪的问题,不会被访问的预读页却占用了 LRU 链表前排的位置,而末尾淘汰的页,可能是热点数据,这样就大大降低了缓存命中率


如何避免预读失败造成的影响?

虽然存在预读失败的情况,而这种情况也确实对系统存在影响,但是凡事都有利弊,我们不能因为害怕出现预读失败的情况,而将预读机制去掉,大部分情况下,空间局部性原理还是成立的。

要避免预读失效带来的影响,最好的办法就是让预读页停留在内存里的时间尽可能的短,让真正被访问的页才移动到 LRU 链表的头部,从而保证真正被读取的热数据留在内存中里的时间尽可能长。

那到底怎么才能避免呢?

Linux 操作系统和 MySQL Innodb 通过改进传统 LRU 链表来避免预读失效带来的影响,具体的改进分别如下:

  • Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)
  • MySQLInnodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:young 区域 和 old 区域

这两个改进方式,设计思想都是类似的,都是将数据分为了冷数据和热数据,然后分别进行 LRU 算法。不再像传统的 LRU 算法那样,所有数据都只用一个 LRU 算法管理。

Linux 是如何避免预读失效带来的影响?

Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)

  • active list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;
  • inactive list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;

有了这两个 LRU 链表后,预读页就只需要加入到 inactive list 区域的头部,当页被真正访问的时候,才将页插入 active list 的头部。如果预读的页一直没有被访问,就会从 inactive list 移除,这样就不会影响 active list 中的热点数据。

接下来,给大家举个例子。

假设 active list 和 inactive list 的长度为 5,目前内存中已经有如下 10 个页:

img

现在有个编号为 20 的页被预读了,这个页只会被插入到 inactive list 的头部,而 inactive list 末尾的页(10号)会被淘汰掉。

img

即使编号为 20 的预读页一直不会被访问,它也没有占用到 active list 的位置,而且还会比 active list 中的页更早被淘汰出去。

如果 20 号页被预读后,立刻被访问了,那么就会将它插入到 active list 的头部, active list 末尾的页(5号),会被降级到 inactive list ,作为 inactive list 的头部,这个过程并不会有数据被淘汰

img

MySQL 是如何避免预读失效带来的影响?

MySQLInnodb 存储引擎是在一个 LRU 链表上划分来 2 个区域,young 区域 和 old 区域

young 区域在 LRU 链表的前半部分,old 区域则是在后半部分,这两个区域都有各自的头和尾节点,如下图:

img

young 区域与 old 区域在 LRU 链表中的占比关系并不是一比一的关系,而是 63:37默认比例)的关系。

划分这两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。

接下来,给大家举个例子。

假设有一个长度为 10 的 LRU 链表,其中 young 区域占比 70 %,old 区域占比 30 %。

img

现在有个编号为 20 的页被预读了,这个页只会被插入到 old 区域头部,而 old 区域末尾的页(10号)会被淘汰掉。

img

如果 20 号页一直不会被访问,它也没有占用到 young 区域的位置,而且还会比 young 区域的数据更早被淘汰出去

如果 20 号页被预读后,立刻被访问了,那么就会将它插入到 young 区域的头部,young 区域末尾的页(7号),会被挤到 old 区域,作为 old 区域的头部,这个过程并不会有页被淘汰。

img


缓存污染

虽然上述办法避免了去读失效带来的影响。但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」 这种方式的话,那么还存在缓存污染的问题。


缓存污染:当我们在批量读取数据的时候,由于数据被访问了一次,这些大量数据都会被加入到「活跃 LRU 链表」里,然后之前缓存在活跃 LRU 链表(或者 young 区域)里的热点数据全部都被淘汰了,如果这些大量的数据在很长一段时间都不会被访问的话,那么整个活跃 LRU 链表(或者 young 区域)就被污染了


缓存污染会带来什么问题?

缓存污染带来的影响就是很致命的,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,系统性能就会急剧下降。

我以 MySQL 举例子,Linux 发生缓存污染的现象也是类似。

当某一个 SQL 语句扫描了大量的数据时,在 Buffer Pool 空间比较有限的情况下,可能会将 Buffer Pool 里的所有页都替换出去,导致大量热数据被淘汰了,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,MySQL 性能就会急剧下降。

注意, 缓存污染并不只是查询语句查询出了大量的数据才出现的问题,即使查询出来的结果集很小,也会造成缓存污染。

比如,在一个数据量非常大的表,执行了这条语句:

select * from t_user where name like "%xiaolin%";

可能这个查询出来的结果就几条记录,但是由于这条语句会发生索引失效,所以这个查询过程是全表扫描的,接着会发生如下的过程:

  • 从磁盘读到的页加入到 LRU 链表的 old 区域头部;
  • 当从页里读取行记录时,也就是页被访问的时候,就要将该页放到 young 区域头部
  • 接下来拿行记录的 name 字段和字符串 xiaolin 进行模糊匹配,如果符合条件,就加入到结果集里;
  • 如此往复,直到扫描完表中的所有记录。

经过这一番折腾,由于这条 SQL 语句访问的页非常多,每访问一个页,都会将其加入 young 区域头部,那么原本 young 区域的热点数据都会被替换掉,导致缓存命中率下降。那些在批量扫描时,而被加入到 young 区域的页,如果在很长一段时间都不会再被访问的话,那么就污染了 young 区域。

举个例子,假设需要批量扫描:21,22,23,24,25 这五个页,这些页都会被逐一访问(读取页里的记录)。

img

在批量访问这些页的时候,会被逐一插入到 young 区域头部。

img

可以看到,原本在 young 区域的 6 和 7 号页都被淘汰了,而批量扫描的页基本占满了 young 区域,如果这些页在很长一段时间都不会被访问,那么就对 young 区域造成了污染。

如果 6 和 7 号页是热点数据,那么在被淘汰后,后续有 SQL 再次读取 6 和 7 号页时,由于缓存未命中,就要从磁盘中读取了,降低了 MySQL 的性能,这就是缓存污染带来的影响。


怎么避免缓存污染造成的影响?

前面的 LRU 算法只要数据被访问一次,就将数据加入活跃 LRU 链表(或者 young 区域),这种 LRU 算法进入活跃 LRU 链表的门槛太低了!正式因为门槛太低,才导致在发生缓存污染的时候,很容就将原本在活跃 LRU 链表里的热点数据淘汰了。

所以,只要我们提高进入到活跃 LRU 链表(或者 young 区域)的门槛,就能有效地保证活跃 LRU 链表(或者 young 区域)里的热点数据不会被轻易替换掉

Linux 操作系统和 MySQL Innodb 存储引擎分别是这样提高门槛的:

  • Linux 操作系统:在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里。
  • MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行停留在 old 区域的时间判断:
    • 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域;
    • 如果第二次的访问时间与第一次访问的时间超过 1 秒,那么该页就从 old 区域升级到 young 区域;

提高了进入活跃 LRU 链表(或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响。

在批量读取数据时候,如果这些大量数据只会被访问一次,那么它们就不会进入到活跃 LRU 链表(或者 young 区域),也就不会把热点数据淘汰,只会待在非活跃 LRU 链表(或者 old 区域)中,后续很快也会被淘汰。

进程与线程

进程、线程基础知识

进程

我们编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制文件,当我们运行这个二进制文件后,它会被装载到内存,接着 CPU 会执行程序中的每一条指令,那么这个运行中的程序,就被称为「进程」(Process)

狭义定义:进程是正在运行的程序的实例(an instance of a computer program that is being executed)。

广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。


考虑一个问题,现在我们有一个会读取硬盘文件数据的程序被执行了,那么当运行到读取文件的指令时,就会去从硬盘读取数据,但是硬盘的读写速度是非常慢的,那么在这个时候,如果 CPU 傻等硬盘返回数据的话,那么 CPU 的利用率是非常低的。

因此,当进程从硬盘读取数据时,CPU 不需要阻塞等待数据的返回,而是去执行另外的进程。当磁盘数据返回时,CPU 会收到个 中断,于是 CPU 再继续执行这个进程。

进程 1 与进程 2 切换

这种多个程序、交替执行的思想,就有了 CPU 管理多个进程的初步想法。

实际上,现代的操作系统如:Linux、windows 都是支持多进程的系统,CPU 会从一个进程快速切换到另一个进程,其间每个进程各运行几十或几百个毫秒,这大大地提高了 CPU 的利用率。

对于单核 CPU 在某一个时刻,只能运行一个程序,但是在一段时间内,他可能会运行多个进程。在宏观上来看,CPU 似乎是在并行执行多个程序,但在微观上 CPU 其实是在多个进程之间来回切换的。

并行与并发

图解:

并发与并行

进程与程序的关系的类比

进程就是具有一定独立功能的程序关于某一个数据集合的一次运行活动,亦或是正在运行的程序的实例

img


进程的状态

进程有着 「运行 - 暂停 - 运行」的活动规律。一般来说,一个进程并不是自始自终连续不断地运行的,它与并发执行中的其他进程的执行是相互制约的。

在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。

上图中各个状态的意义:

  • 运行状态(Running):该时刻进程占用 CPU 运行。
  • 就绪状态(Ready): 可运行,由于其他进程处于运行状态而暂时停止运行
  • 阻塞状态(Blocked): 该进程正在等待某一事件的发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它 CPU 控制权,它也无法运行;

除此之外进程还有另外两个基本状态:

  • 创建状态(new): 进程正在被创建时的状态;
  • 结束状态(Exit):进程正在从系统中消失时的状态;

一个完整的进程状态变迁图如下:

进程五种状态的变迁

进程的状态变迁详细说明:

  • NULL -> 创建状态 :一个新进程被创建时的第一个状态;
  • 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的。
  • 就绪态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程;
  • 运行状态 -> 结束状态:当进程已经完成或出错时,会被操作系统作结束状态处理;
  • 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行。
  • 运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件;
  • 阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态;

如果有大量处于阻塞的进程,进程可能会占用着物理内存空间,显然不是我们所希望的,比较物理内存空间是有限的,被阻塞状态的进程占用着物理内存就是一种浪费物理内存的行为。

所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存

虚拟内存管理-换入换出

那么就需要有一个新的状态,来描述进程没有占用实际物理内存空间的情况,这个状态就是挂起状态。这跟阻塞状态是不一样的,阻塞状态是等待某个事件的返回。

挂起状态还可以细分为两种:

  • 阻塞挂起状态:进程在外存(硬盘)并等待某个事件出现;
  • 就绪挂起状态:进程在外存(硬盘),但只要进入内存,立刻运行;

现在我们得到一个完整的状态变迁图(一共七种状态)如下图:

导致进程挂起的原因不只是因为进程所使用的内存空间不在物理内存,还包括如下情况:

  • 通过 sleep 让进程间歇性挂起,其工作原理是设置一个定时器,到期后唤醒进程。
  • 用户希望挂起一个程序的执行,比如 Linux 中使用 Ctrl+Z 挂起进程;

进程的控制结构

在操作系统中,使用进程控制块(PCB Process Control Block)数据结构来描述进程。

PCB 是进程存在的唯一标识,这意味着一个进程的存在,比如会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。

PCB 具体包含的信息:

进程描述信息:

  • 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符:
  • 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务;

进程控制和管理信息:

  • 进程当前状态:如 new、ready、running、waiting 或 blocked 等;
  • 进程优先级:进程抢占 CPU 时的优先级;

资源分配清单:

  • 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。

CPU 相关信息:

  • CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行。

每个 PCB 是如何组织的呢?

通常是通过 链表 的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:

  • 把所有处于就绪状态的进程链在一起,称为就绪队列
  • 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列
  • 另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时刻,只能运行一个程序。

阻塞队列和就绪队列链表的组织形式如下图:

除了链表的组织方式,还有索引方式,它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的 PCB,不同状态对应不同的索引表。

一般会选择链表,因为可能面临进程创建,销毁等调度导致进程状态发生变化,所以链表能够更加灵活的插入和删除。

进程的控制

熟悉了进程的状态变迁和进程的数据结构 PCB 后,来看看进程的创建、终止、阻塞、唤醒的过程,也就是进程的控制。

01 创建进程

操作系统运行一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源。

创建进程的过程如下:

  • 申请一个空白的 PCB,并向 PCB 中填写一些控制和管理进程的信息,比如进程的唯一标识符等;
  • 为该进程分配运行时所必需的资源,比如内存资源;
  • 将 PCB 插入到就绪队列,等待被调度运行;

02 终止进程

进程的终止可以有三种方式:正常结束、异常结束以及外界干预(信号 kill 掉)

当子进程被终止时,其在父进程处继承的资源应当还给父进程。而当父进程被终止时,该父进程的子进程就变为孤儿进程,会被 1 号进程收养,并由 1 号进程对它们完成状态收集工作。

终止的过程如下:

  • 查找需要终止的进程的 PCB;
  • 如果处于执行状态,则立刻终止该进程的执行,然后将 CPU 资源分配给其他进程;
  • 如果其还有子进程,则应将该进程的子进程交给 1 号进程接管;
  • 将该进程所拥有的全部资源都归还给操作系统;
  • 将其从 PCB 所在队列中删除;

03 阻塞进程

当进程需要等待某一事件完成时,它可以调用阻塞语句将自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。

阻塞的过程如下:

  • 找到将要被阻塞进程标识号对应的 PCB;
  • 如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行;
  • 将该 PCB 插入到阻塞队列中去;

04 唤醒进程

处于阻塞状态的进程时绝无可能叫醒自己的,它只能靠其他线程来唤醒。

如果某进程正在等待 I/O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者(另一个进程)用唤醒语句叫醒它。

唤醒的过程如下:

  • 在该事件的阻塞队列中找到相应进程的 PCB;
  • 将其从阻塞队列中移出,并置其状态为就绪状态;
  • 把该 PCB 插入到就绪队列中,等待调度程序调度;

进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。

进程的上下文切换

各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行,称为进程的上下文切换。

在详细说进程上下文切换前,我们先来看看 CPU 上下文切换

大多数操作系统都是多任务,通常支持大于 CPU 数量的任务同时运行。实际上,这些任务并不是同时运行的,只是因为系统在很短的时间内,让各个任务分别在 CPU 运行,于是就造成同时运行的错觉。

任务是交给 CPU 运行的,那么在每个任务运行前,CPU 需要知道任务从哪里加载,又从哪里开始运行。

所以,操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器

CPU 寄存器是 CPU 内部一个容量小,但是速度极快的内存(缓存)。我举个例子,寄存器像是你的口袋,内存像你的书包,硬盘则是你家里的柜子,如果你的东西存放到口袋,那肯定是比你从书包或家里柜子取出来要快的多。

再来,程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。

所以说,CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文

既然知道了什么是 CPU 上下文,那理解 CPU 上下文切换就不难了。

CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。

上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换

进程的上下文切换到底是切换什么呢?

进程是由内核管理和调度的,所以进程的切换只能发生在内核态。

所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。

通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行,如下图所示:

进程上下文切换

大家需要注意,进程的上下文开销是很关键的,我们希望它的开销越小越好,这样可以使得进程可以把更多时间花费在执行程序上,而不是耗费在上下文切换。

发生进程上下文切换有哪些场景?

  • 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行;
  • 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;
  • 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;
  • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
  • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;

以上,就是发生进程上下文切换的常见场景了。