摘要

本博文主要是介绍操作系统基础的相关知识和原理。

一、基本的计算机硬件知识

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

操作系统——Liunx系统基础知识_内存地址

内存:程序和数据都是存储在内存,存储的区域是线性的。内存的地址是从0 开始编号的,然后⾃增排列,最后⼀个地址为内存总字节数 - 1,这种结构好似我们程序 ⾥的数组,所以内存的读写任何⼀个数据的速度都是⼀样的。

CPU:CPU 中的寄存器主要作⽤是存储计算时的数据,CPU 内部还有⼀些组件,常⻅的有寄存器、控制单元和逻辑运算单元等。其中,控制单元负责控制 CPU ⼯作,逻辑运算单元负责计算,而寄存器可以分为多种类,每种寄存器的功能⼜不尽相同。

常见的寄存器种类:

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

总线:

总线是⽤于 CPU 和内存以及其他设备之间的通信,总线可分为 3 种:

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

当 CPU 要读写内存数据的时候,⼀般需要通过两个总线: ⾸先要通过地址总线来指定内存的地址; 再通过数据总线来传输数据

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

1.1 程序执行的基本过程

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

操作系统——Liunx系统基础知识_Line_02

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

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

简单总结⼀下就是:⼀个程序执行的时候,CPU 会根据程序计数器⾥的内存地址,从内存⾥⾯把需要执⾏ 的指令读取到指令寄存器⾥⾯执⾏,然后根据指令长度自增,开始顺序读取下⼀条指令。 CPU 从程序计数器读取指令、到执行、再到下⼀条指令,这个过程会不断循环,直到程序执行结束,这个 不断循环的过程被称为CPU 的指令周期。

现代⼤多数CPU 都使⽤来流水线的⽅式来执⾏指令,所谓的流水线就是把⼀个任务拆分成多个⼩任务,于是⼀条指令通常分为4个阶段,称为4 级流水线,如下图:

操作系统——Liunx系统基础知识_内存地址_03

操作系统——Liunx系统基础知识_内存地址_04

四个阶段的具体含义:

  • CPU 通过程序计数器读取对应内存地址的指令,这个部分称为 Fetch(取得指令);
  • CPU 对指令进⾏解码,这个部分称为 Decode(指令译码);
  • CPU 执⾏指令,这个部分称为 Execution(执⾏指令);
  • CPU 将计算结果存回寄存器或者将寄存器的值存⼊内存,这个部分称为 Store(数据回写);

上⾯这 4 个阶段,我们称为指令周期(Instrution Cycle),CPU 的⼯作就是⼀个周期接着⼀个周期,周而复始。

1.2 CPU Cache

CPU Cache ⽤的是⼀种叫 SRAM(Static Random-Access Memory,静态随机存储器)的芯⽚。 SRAM之所以叫静态存储器,是因为只要有电,数据就可以保持存在,而⼀旦断电,数据就会丢失 了。 在 SRAM ⾥⾯,⼀个 bit 的数据,通常需要 6 个晶体管,所以 SRAM 的存储密度不⾼,同样的物理空间 下,能存储的数据是有限的,不过也因为 SRAM 的电路简单,所以访问速度⾮常快。 CPU 的⾼速缓存,通常可以分为 L1、L2、L3 这样的三层⾼速缓存,也称为⼀级缓存、⼆级缓存、三级缓存。

操作系统——Liunx系统基础知识_数据_05

越靠近 CPU 核⼼的缓存其访问速度越快,CPU 访问 L1 Cache 只需要 2~4 个时钟周期,访问 L2 Cache ⼤约 10~20 个时钟周期,访问 L3 Cache ⼤约 20~60 个时钟周期,⽽访问内存速度⼤概在 200~300 个 时钟周期之间。如下表格:

操作系统——Liunx系统基础知识_数据_06

1.3 CPU Cache 的数据结构和读取过程

CPU Cache 的数据是从内存中读取过来的,它是以单元块的读取数据的,⽽不是按照单个数组元素来 读取数据的,在 CPU Cache 中的,这样单元块的数据,称为 Cache Line(缓存块)。

比如,有⼀个 int array[100] 的数组,当载入array[0] 时,由于这个数组元素的大小在内存只占 4 字节,不⾜64 字节,CPU 就会顺序加载数组元素到 array[15] ,意味着 array[0]~array[15] 数组元素都会被缓存在CPU Cache中了,因此当下次访问这些数组元素时,会直接从 CPU Cache 读取,而不⽤再从内 存中读取,⼤⼤提⾼了 CPU 读取数据的性能。

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

操作系统——Liunx系统基础知识_内存地址_07

1.4 CPU 如何读写数据的

操作系统——Liunx系统基础知识_数据_08

可以看到,⼀个 CPU ⾥通常会有多个 CPU 核⼼,⽐如上图中的 1 号和 2 号 CPU 核⼼,并且每个 CPU 核⼼都有⾃⼰的 L1 Cache 和 L2 Cache,⽽ L1 Cache 通常分为 dCache(数据缓存) 和 iCache(指令缓 存),L3 Cache 则是多个核⼼共享的,这就是 CPU 典型的缓存层次。

CPU 从内存中读取数据到 Cache 的时候,并不是⼀个字节⼀个字节读取,而是⼀块⼀块的⽅式来读取数 据的,这⼀块⼀块的数据被称为 CPU Line(缓存⾏),所以 CPU Line 是 CPU 从内存读取数据到 Cache 的单位。 ⾄于 CPU Line 大小,在 Linux 系统可以⽤下⾯的⽅式查看到,你可以看我服务器的 L1 Cache Line 大小 是 64 字节,也就意味着 L1 Cache ⼀次载⼊数据的⼤⼩是 64 字节。

1.5 Cache 伪共享是什么?如何避免这个问题?

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

操作系统——Liunx系统基础知识_Line_09

如果这两个不同核心的线程分别修改不同的数据,⽐如 1 号 CPU 核⼼的线程只修 改了 变量 A,或 2 号 CPU 核⼼的线程的线程只修改了变量 B,会发⽣什么呢?

现在我们结合保证多核缓存⼀致的 MESI 协议,来说明这⼀整个的过程:

步骤1、最开始变量 A 和 B 都还不在 Cache ⾥⾯,假设 1 号核⼼绑定了线程 A,2 号核心绑定了线程 B,线程 A 只会读写变量 A,线程 B 只会读写变量B。

操作系统——Liunx系统基础知识_数据_10

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

操作系统——Liunx系统基础知识_Line_11

步骤3、接着,2 号核心开始从内存⾥读取变量 B,同样的也是读取 Cache Line⼤⼩的数据到Cache 中,此Cache Line 中的数据也包含了变量A 和变量 B,此时1号和2号核⼼的Cache Line 状态变为共享状态。

操作系统——Liunx系统基础知识_java_12

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

操作系统——Liunx系统基础知识_数据_13

步骤5、2 号核心需要修改变量 B,此时 2 号核⼼的 Cache 中对应的 Cache Line 是已失效状态,另外由 于 1 号核⼼的 Cache 也有此相同的数据,且状态为已修改状态,所以要先把1号核⼼的 Cache 对应 的 Cache Line 写回到内存,然后 2 号核⼼再从内存读取 Cache Line ⼤⼩的数据到 Cache 中,最后把变 量 B 修改到 2 号核⼼的 Cache 中,并将状态标记为已修改状态。

操作系统——Liunx系统基础知识_java_14

所以,可以发现如果 1 号和 2 号 CPU 核⼼这样持续交替的分别修改变量 A 和 B,就会重复步骤4 和步骤5这两个步骤,Cache并没有起到缓存的效果,虽然变量 A 和 B 之间其实并没有任何的关系,但是因为同时归属于⼀个 Cache Line ,这个Cache Line 中的任意数据被修改后,都会相互影响,从⽽出现步骤4和步骤5 这两个步骤。 因此,这种因为多个线程同时读写同⼀个 Cache Line 的不同变量时,⽽导致CPU Cache 失效的现象称为伪共享(False Sharing)。

避免伪共享的方法:

因此,对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同⼀个 Cache Line 中,否则就会出现为伪共享的问题。 接下来,看看在实际项⽬中是用什么方式来避免伪共享的问题的。

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

#ifef CONFIG_SMP
#define __cacheline_aligned_in_smp __cacheline_aligned
#else
#define __cacheline_aligned_in_smp
#endif

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

  • 如果在多核(MP)系统⾥,该宏定义是 __cacheline_aligned ,也就是 Cache Line 的大小;
  • 而如果在单核系统⾥,该宏定义是空的;

因此,针对在同⼀个 Cache Line 中的共享的数据,如果在多核之间竞争⽐较严重,为了防⽌伪共享现象的发⽣,可以采⽤上⾯的宏定义使得变量在 Cache Line ⾥是对⻬的。

struct test{
    int a;
    int b;
}

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

操作系统——Liunx系统基础知识_数据_15

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

struct test{
    int a;
    int b __cacheline_aligned_in_smp;
}

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

操作系统——Liunx系统基础知识_Line_16

所以,避免Cache 伪共享实际上是⽤空间换时间的思想,浪费⼀部分Cache 空间,从⽽换来性能的提升。根据 JVM 对象继承关系中⽗类成员和⼦类成员,内存地址是连续排列布局的,因此 RingBufferPad 中的7个long类型数据作为 Cache Line 前置填充,⽽RingBuffer中的7个long 类型数据则作 Cache Line 后置填充,这14个long 变量没有任何实际⽤途,不会对它们进行读写操作。

操作系统——Liunx系统基础知识_数据_17

另外,RingBufferFelds ⾥⾯定义的这些变量都是 final 修饰的,意味着第⼀次加载之后不会再修改, 由于前后各填充了7个不会被读写的 long 类型变量,所以无论怎么加载Cache Line,这整个 Cache Line ⾥都没有会发⽣更新操作的数据,于是只要数据被频繁地读取访问,就⾃然没有数据被换出Cache的可能,也因此不会产⽣伪共享的问题。

二、线程与进程知识

2.1 CPU 如何选择线程

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

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

操作系统——Liunx系统基础知识_内存地址_18

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

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

2.2 中断原理

中断是⼀种异步的事件处理机制,可以提⾼系统的并发处理能⼒。操作系统收到了中断请求,会打断其他进程的运⾏,所以中断请求的响应程序,也就是中断处理程序,要 尽可能快的执⾏完,这样可以减少对正常进程运⾏调度地影响

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

2.3 软中断原理?

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

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

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

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

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

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

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

三、操作系统结构

3.1 内核基础概念

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

操作系统——Liunx系统基础知识_内存地址_19

现代操作系统,内核⼀般会提供 4 个基本能⼒:

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

内核是怎么⼯作的?

内核具有很⾼的权限,可以控制 cpu、内存、硬盘等硬件,⽽应⽤程序具有的权限很⼩,因此⼤多数操作 系统,把内存分成了两个区域: 内核空间,这个内存空间只有内核程序可以访问; ⽤户空间,这个内存空间专门给应⽤程序使用。

用户空间的代码只能访问⼀个局部的内存空间,⽽内核空间的代码可以访问所有内存空间。因此,当程序使⽤⽤户空间时,我们常说该程序在⽤户态执⾏,⽽当程序使内核空间时,程序则在内核态执⾏。

操作系统——Liunx系统基础知识_内存地址_20

内核程序执⾏在内核态,⽤户程序执⾏在⽤户态。当应⽤程序使⽤系统调⽤时,会产⽣⼀个中断。发⽣中断后, CPU会中断当前在执⾏的⽤户程序,转⽽跳转到中断处理程序,也就是开始执⾏内核程序。内核处理完后,主动触发中断,把CPU 执⾏权限交回给⽤户程序,回到用户态⼯作。

3.2 Linux内核设计

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

  • MutiTask,多任务
  • SMP,对称多处理
  • ELF,可执⾏⽂件链接格式
  • Monolithic Kernel,宏内核

3.2.1 MutiTask MutiTask

代表着Linux是⼀个多任务的操作系统。 多任务意味着可以有多个任务同时执⾏,这⾥的同时可以是并发或并⾏:对于单核 CPU 时,可以让每个任务执⾏⼀⼩段时间,时间到就切换另外⼀个任务,从宏观⻆度看, ⼀段时间内执⾏了多个任务,这被称为并发。 对于多核 CPU 时,多个任务可以同时被不同核心的CPU同时执⾏,这被称为并⾏。

3.2.2 SMP

SMP的意思是对称多处理,代表着每个 CPU的地位是相等的,对资源的使⽤权限也是相同的,多个CPU 共享同⼀个内存,每个 CPU 都可以访问完整的内存和硬件资源。 这个特点决定了 Linux 操作系统不会有某个 CPU 单独服务应⽤程序或内核程序,⽽是每个程序都可以被分 配到任意⼀个 CPU上被执行。

3.2.3 ELF

ELF的意思是可执⾏⽂件链接格式,它是 Linux 操作系统中可执⾏⽂件的存储格式,你可以从下图看到它的结构:

操作系统——Liunx系统基础知识_java_21

那 ELF⽂件是怎么被执⾏的呢? 执⾏ELF⽂件的时候,会通过装载器把 ELF⽂件装载到内存⾥,CPU读取内存中的指令和数据,于是程序就被执⾏起来了。

3.2.4 Monolithic Kernel

Monolithic Kernel 的意思是宏内核,Linux内核架构就是宏内核,意味着Linux 的内核是⼀个完整的可执⾏程序,且拥有最⾼的权限。宏内核的特征是系统内核的所有模块,比如进程调度、内存管理、⽂件系统、设备驱动等,都运⾏在内核态。

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

操作系统——Liunx系统基础知识_java_22

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

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

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

3.3 window内核设计

操作系统——Liunx系统基础知识_java_23

Windows和Linux⼀样,同样⽀持MutiTask和SMP,但不同的是,Window的内核设计是混合型内核, 在上图你可以看到内核中有⼀个MicroKernel 模块,这个就是最⼩版本的内核,⽽整个内核实现是⼀个完整的程序,含有⾮常多模块。

Windows 的可执⾏⽂件的格式与Linux 也不同,所以这两个系统的可执⾏⽂件是不可以在对⽅上运⾏的。 Windows 的可执⾏⽂件格式叫PE,称为可移植执⾏⽂件,扩展名通常是 .exe 、 .dll、sys 等。 PE的结构你可以从下图中看到,它与 ELF 结构有⼀点相似。

操作系统——Liunx系统基础知识_内存地址_24

对于内核的架构⼀般有这三种类型:

  • 宏内核,包含多个模块,整个内核像⼀个完整的程序;
  • 微内核,有⼀个最⼩版本的内核,⼀些模块和服务则由⽤户态管理;
  • 混合内核,是宏内核和微内核的结合体,内核中抽象出了微内核的概念,也就是内核中会有⼀个⼩型的内核,其他模块就在这个基础上搭建,整个内核是个完整的程序;

Linux的内核设计是采⽤了宏内核,Window 的内核设计则是采⽤了混合内核。 这两个操作系统的可执⾏⽂件格式也不⼀样, Linux 可执⾏⽂件格式叫作 ELF,Windows 可执⾏⽂件格式叫作PE。

四、虚拟内存

4.1 虚拟内存

操作系统为每个进程分配独⽴的⼀套虚拟地址,⼈⼈都有,⼤家⾃⼰玩⾃⼰的地址就⾏,互不⼲涉。但是有个前提每个进程都不能访问物理地址,⾄于虚拟地址最终怎么落到物理内存⾥,对进程来说是透明的,操作系统已经把这些都安排的明明⽩⽩了。操作系统会提供⼀种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运⾏的时候,写⼊ 的是不同的物理地址,这样就不会冲突了。

于是,这⾥就引出了两种地址的概念:

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

操作系统——Liunx系统基础知识_数据_25

操作系统引⼊了虚拟内存,进程持有的虚拟地址会通过CPU芯⽚中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:

操作系统——Liunx系统基础知识_java_26

主要有两种⽅式,分别是内存分段和内存分页,分段是比较早提出的,我们先来看看内存分段。

4.2 内存分段

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

分段机制下,虚拟地址和物理地址是如何映射的?分段机制下的虚拟地址由两部分组成,段选择⼦和段内偏移量。

操作系统——Liunx系统基础知识_Line_27

  • 段选择⼦就保存在段寄存器⾥⾯。段选择⼦⾥⾯最重要的是段号,⽤作段表的索引。段表⾥⾯保存的 是这个段的基地址、段的界限和特权等级等。
  • 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段 内偏移量得到物理内存地址。

在上⾯,知道了虚拟地址是通过段表与物理地址进⾏映射的,分段机制会把程序的虚拟地址分成 4 个段, 每个段在段表中有⼀个项,在这⼀项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址, 如下图:

操作系统——Liunx系统基础知识_Line_28

分段为什么会产生内存碎片的问题?

操作系统——Liunx系统基础知识_Line_29

这⾥的内存碎片的问题共有两处地⽅:

  • 外部内存碎片,也就是产⽣了多个不连续的⼩物理内存,导致新的程序⽆法被装载;
  • 内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使⽤,这也会导致内存的浪费;

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

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

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

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

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

因为硬盘的访问速度要⽐内存慢太多了,每⼀次内存交换,我们都需要把⼀⼤段连续的内存数据写到硬盘 上。 所以,如果内存交换的时候,交换的是⼀个占内存空间很⼤的程序,这样整个机器都会显得卡顿。 为了解决内存分段的内存碎⽚和内存交换效率低的问题,就出现了内存分⻚。

4.3 内存分页

分段的好处就是能产⽣连续的内存空间,但是会出现内存碎⽚和内存交换的空间太⼤的问题。要解决这些问题,那么就要想出能少出现⼀些内存碎⽚的办法。另外,当需要进⾏内存交换的时候,让需要交换写入或者从磁盘装载的数据更少⼀点,这样就可以解决问题了。这个办法,也就是内存分页。

分页是把整个虚拟和物理内存空间切成⼀段段固定尺⼨的大小。这样⼀个连续并且尺⼨固定的内存空间, 我们叫页(Page)。在 Linux 下,每⼀⻚的⼤⼩为 4KB 。虚拟地址与物理地址之间通过⻚表来映射如图所示:

操作系统——Liunx系统基础知识_Line_30

页表是存储在内存⾥的,内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的⼯作。⽽当进程访问的虚拟地址在⻚表中查不到时,系统会产⽣⼀个缺页异常,进⼊系统内核空间分配物理内存、更新进程页表,最后再返回⽤户空间,恢复进程的运⾏。

分页是怎么解决分段的内存碎片、内存交换效率低的问题?

由于内存空间都是预先划分好的,也就不会像分段会产⽣间隙⾮常⼩的内存,这正是分段会产⽣内存碎⽚ 的原因。⽽采⽤了分⻚,那么释放的内存都是以⻚为单位释放的,也就不会产⽣⽆法给进程使⽤的⼩内存。

如果内存空间不够,操作系统会把其他正在运⾏的进程中的最近没被使⽤的内存⻚⾯给释放掉,也就 是暂时写在硬盘上,称为换出(Swap Out)。⼀旦需要的时候,再加载进来,称为换⼊(Swap In)。所以,⼀次性写⼊磁盘的也只有少数的⼀个⻚或者⼏个⻚,不会花太多时间,内存交换的效率就相对⽐较⾼。

操作系统——Liunx系统基础知识_java_31

更进⼀步地,分⻚的⽅式使得我们在加载程序的时候,不再需要⼀次性都把程序加载到物理内存中。我们完全可以在进⾏虚拟内存和物理内存的⻚之间的映射之后,并不真的把⻚加载到物理内存⾥,⽽是只有在程序运⾏中,需要⽤到对应虚拟内存页⾥⾯的指令和数据时,再加载到物理内存⾥⾯去。

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

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

操作系统——Liunx系统基础知识_内存地址_32

总结⼀下,对于⼀个内存地址转换,其实就是这样三个步骤:

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

操作系统——Liunx系统基础知识_Line_33

简单的分页有什么缺陷吗?

有空间上的缺陷。 因为操作系统是可以同时运⾏⾮常多的进程的,那这不就意味着⻚表会⾮常的庞⼤。 在 32 位的环境下,虚拟地址空间共有 4GB,假设⼀个⻚的⼤⼩是 4KB(2^12),那么就需要⼤约 100 万 (2^20) 个⻚,每个「⻚表项」需要 4 个字节⼤⼩来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储⻚表。 这 4MB ⼤⼩的⻚表,看起来也不是很⼤。但是要知道每个进程都是有⾃⼰的虚拟地址空间的,也就说都有自己的页表。那么100 个进程的话,就需要 400MB 的内存来存储⻚表,这是⾮常⼤的内存了,更别说 64 位的环 境了。

4.4 多级页表

要解决上⾯的问题,就需要采⽤⼀种叫作多级⻚表(Multi-Level Page Table)的解决⽅案。在前⾯我们知道了,对于单⻚表的实现⽅式,在 32 位和页大小4KB 的环境下,⼀个进程的⻚表需要装 下 100多万个页表项,并且每个⻚表项是占⽤4字节大小的,于是相当于每个⻚表需占⽤4MB大小的空间。

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

操作系统——Liunx系统基础知识_数据_34

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

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

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

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

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

那么为什么不分级的页表就做不到这样节约内存呢?我们从页表的性质来看,保存在内存中的⻚表承担的 职责是将虚拟地址翻译成物理地址。假如虚拟地址在⻚表中找不到对应的⻚表项,计算机系统就不能⼯作 了。所以页表⼀定要覆盖全部虚拟地址空间,不分级的⻚表就需要有 100 多万个页表项来映射,⽽⼆级分⻚则只需要1024 个页表项(此时⼀级页表覆盖到了全部虚拟地址空间,⼆级页表在需要时创建)。

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

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

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

操作系统——Liunx系统基础知识_Line_35

4.5 TLB

多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了⼏道转换的⼯序,这显然就降 低了这俩地址转换的速度,也就是带来了时间上的开销。程序是有局部性的,即在⼀段时间内,整个程序的执⾏仅限于程序中的某⼀部分。相应地,执⾏所访问的 存储空间也局限于某个内存区域。

操作系统——Liunx系统基础知识_Line_36

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

操作系统——Liunx系统基础知识_Line_37

在CPU芯⽚⾥⾯,封装了内存管理单元(Memory Management Unit)芯⽚,它⽤来完成地址转换和 TLB 的访问与交互。 有了TLB 后,那么CPU在寻址时,会先查TLB,如果没找到,才会继续查常规的页表。 TLB 的命中率其实是很⾼的,因为程序最常访问的⻚就那么几个。

4.6 段页式内存管理

内存分段和内存分页并不是对⽴的,它们是可以组合起来在同⼀个系统中使⽤的,那么组合起来后,通常称为段页式内存管理。

操作系统——Liunx系统基础知识_Line_38

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

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

这样,地址结构就由段号、段内页号和⻚内位移三部分组成。⽤于段页式地址变换的数据结构是每⼀个程序⼀张段表,每个段⼜建⽴⼀张⻚表,段表中的地址是页表的起始地址,⽽页表中的地址则为某⻚的物理⻚号,如图所示:

操作系统——Liunx系统基础知识_java_39

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

  • 第⼀次访问段表,得到⻚表起始地址;
  • 第⼆次访问⻚表,得到物理⻚号;
  • 第三次将物理⻚号与⻚内位移组合,得到物理地址。

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

4.7 Linux 内存管理

页式内存管理的作⽤是在由段式内存管理所映射⽽成的地址上再加上⼀层地址映射。由于此时由段式内存管理映射⽽成的地址不再是“物理地址”了,Intel 就称之为“线性地址”(也称虚拟地 址)。于是,段式内存管理先将逻辑地址映射成线性地址,然后再由⻚式内存管理将线性地址映射成物理 地址。

操作系统——Liunx系统基础知识_数据_40

这⾥说明下逻辑地址和线性地址:

  • 程序所使⽤的地址,通常是没被段式内存管理映射的地址,称为逻辑地址;
  • 通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址;

逻辑地址是段式内存管理转换前的地址,线性地址则是⻚式内存管理转换前的地址。

Linux 内存主要采⽤的是⻚式内存管理,但同时也不可避免地涉及了段机制。Linux 系统中的每个段都是从0地址开始的整个4GB 虚拟空间(32 位环境下),也就是所有的段的起始 地址都是⼀样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应⽤程序代码,所⾯对的 地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被⽤于 访问控制和内存保护。

在 Linux 操作系统中,虚拟地址空间的内部⼜被分为内核空间和⽤户空间两部分,不同位数的系统,地址空间的范围也不同。

操作系统——Liunx系统基础知识_数据_41

虽然每个进程都各⾃有独⽴的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很⽅便地访问内核空间内存。

操作系统——Liunx系统基础知识_Line_42

操作系统——Liunx系统基础知识_Line_43

通过这张图你可以看到,用户空间内存,从低到⾼分别是 7 种不同的内存段:

  • 程序⽂件段,包括⼆进制可执⾏代码;
  • 已初始化数据段,包括静态常量;
  • 未初始化数据段,包括未初始化的静态变量;
  • 堆段,包括动态分配的内存,从低地址开始向上增⻓;
  • 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关);
  • 栈段,包括局部变量和函数调用的上下⽂等。栈的大小是固定的,⼀般是8MB 。当然系统也提供 了参数,以便我们⾃定义大小;

在这 7 个内存段中,堆和⽂件映射段的内存是动态分配的。⽐如说,使⽤ C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和⽂件映射段动态分配内存。

五、进程与线程

5.1 进程的状态

在⼀个进程的活动期间⾄少具备三种基本状态,即创建、运⾏状态、就绪状态、阻塞状态、结束状态。

操作系统——Liunx系统基础知识_数据_44

  • 运⾏状态(Runing):该时刻进程占用CPU;
  • 就绪状态(Ready):可运行,由于其他进程处于运行状态⽽暂时停⽌运⾏;
  • 阻塞状态(Blocked):该进程正在等待某⼀事件发⽣(如等待输⼊/输出操作的完成)⽽暂时停止运⾏,这时,即使给它CPU控制权,它也⽆法运⾏;
  • 创建状态(new):进程正在被创建时的状态;
  • 结束状态(Exit):进程正在从系统中消失时的状态;

操作系统——Liunx系统基础知识_java_45

5.2  进程的操作

操作系统允许⼀个进程创建另⼀个进程,⽽且允许⼦进程继承⽗进程所拥有的资源,当⼦进程被终⽌时, 其在⽗进程处继承的资源应当还给⽗进程。同时,终⽌⽗进程时同时也会终⽌其所有的子进程。注意:Linux 操作系统对于终⽌有⼦进程的⽗进程,会把⼦进程交给1号进程接管。本⽂所指出的进程终止概念是宏观操作系统的⼀种观点,最后怎么实现当然是看具体的操作系统。

创建进程的过程如下:

  • 为新进程分配⼀个唯⼀的进程标识号,并申请⼀个空⽩的PCB,PCB是有限的,若申请失败则创建失败;
  • 为进程分配资源,此处如果资源不⾜,进程就会进⼊等待状态,以等待资源;
  • 初始化 PCB;
  • 如果进程的调度队列能够接纳新进程,那就将进程插⼊到就绪队列,等待被调度运⾏;

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

终止进程的过程如下

  • 查找需要终⽌的进程的 PCB;
  • 如果处于执⾏状态,则⽴即终⽌该进程的执⾏,然后将 CPU 资源分配给其他进程;
  • 如果其还有⼦进程,则应将其所有⼦进程终⽌;
  • 将该进程所拥有的全部资源都归还给⽗进程或操作系统;
  • 将其从 PCB 所在队列中删除;

阻塞进程

当进程需要等待某⼀事件完成时,它可以调⽤阻塞语句把⾃⼰阻塞等待。⽽⼀旦被阻塞等待,它只能由另 ⼀个进程唤醒。

阻塞进程的过程如下:

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

唤醒进程

进程由运⾏转变为阻塞状态是由于进程必须等待某⼀事件的完成,所以处于阻塞状态的进程是绝 对不可能叫醒⾃⼰的。

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

唤醒进程的过程如下:

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

5.3 进程的上下文切换

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

⼤多数操作系统都是多任务,通常⽀持⼤于CPU 数量的任务同时运⾏。实际上,这些任务并不是同时运⾏的,只是因为系统在很短的时间内,让各个任务分别在CPU运⾏,于是就造成同时运⾏的错觉。任务是交给CPU运⾏的,那么在每个任务运⾏前,CPU 需要知道任务从哪⾥加载,⼜从哪⾥开始运⾏。所以,操作系统需要事先帮 CPU设置好CPU寄存器和程序计数器。

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

进程是由内核管理和调度的,所以进程的切换只能发⽣在内核态。 所以,进程的上下⽂切换不仅包含了虚拟内存、栈、全局变量等⽤户空间的资源,还包括了内核堆栈、寄 存器等内核空间的资源。通常,会把交换的信息保存在进程的 PCB,当要运⾏另外⼀个进程的时候,我们需要从这个进程的 PCB 取出上下⽂,然后恢复到 CPU 中,这使得这个进程可以继续执⾏,如下图所示:

操作系统——Liunx系统基础知识_内存地址_46

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

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

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

5.4 线程

线程是进程当中的⼀条执⾏流程。 同⼀个进程内多个线程之间可以共享代码段、数据段、打开的⽂件等资源,但每个线程各⾃都有⼀套独⽴ 的寄存器和栈,这样可以确保线程的控制流是相对独⽴的。

操作系统——Liunx系统基础知识_内存地址_47

线程的优点:

  • ⼀个进程中可以同时存在多个线程;
  • 各个线程之间可以并发执⾏;
  • 各个线程之间可以共享地址空间和⽂件等资源;

线程的缺点:

  • 当进程中的⼀个线程崩溃时,会导致其所属进程的所有线程崩溃。

5.5 线程的上下文切换

在前⾯我们知道了,线程与进程最⼤的区别在于:线程是调度的基本单位,⽽进程则是资源拥有的基本单位。 所以,所谓操作系统的任务调度,实际上的调度对象是线程,⽽进程只是给线程提供了虚拟内存、全局变 量等资源。

对于线程和进程,我们可以这么理解:

  • 当进程只有⼀个线程时,可以认为进程就等于线程;
  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下⽂切换 时是不需要修改的;
  • 另外,线程也有⾃⼰的私有数据,⽐如栈和寄存器等,这些在上下⽂切换时也是需要保存的。

这还得看线程是不是属于同⼀个进程: 当两个线程不是属于同⼀个进程,则切换的过程就跟进程上下文切换⼀样; 当两个线程是属于同⼀个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不 动,只需要切换线程的私有数据、寄存器等不共享的数据; 所以,线程的上下⽂切换相⽐进程,开销要⼩很多。

主要有三种线程的实现⽅式:

  • ⽤户线程(User Thread):在⽤户空间实现的线程,不是由内核管理的线程,是由⽤户态的线程库 来完成线程的管理;
  • 内核线程(Kernel Thread):在内核中实现的线程,是由内核管理的线程;
  • 轻量级进程(LightWeight Process):在内核中来⽀持⽤户线程;

在 LWP 之上也是可以使⽤⽤户线程的,那么 LWP 与⽤户线程的对应关系就有三种

  • 1 : 1 ,即⼀个 LWP 对应 ⼀个⽤户线程;
  • N : 1 ,即⼀个 LWP 对应多个⽤户线程;
  • M : N ,即多个 LMP 对应多个⽤户线程;

操作系统——Liunx系统基础知识_java_48

5.6 调度原理

进程都希望⾃⼰能够占⽤CPU 进行工作,那么这涉及到前⾯说过的进程上下文切换。 ⼀旦操作系统把进程切换到运⾏状态,也就意味着该进程占⽤着 CPU 在执⾏,但是当操作系统把进程切换到其他状态时,那就不能在 CPU中执⾏了,于是操作系统会选择下⼀个要运行的进程。选择⼀个进程运⾏这⼀功能是在操作系统中完成的,通常称为调度程序。

操作系统——Liunx系统基础知识_Line_49

5.6.1 调度时机

⽐如,以下状态的变化都会触发操作系统的调度:

  • 从就绪态 -> 运⾏态:当进程被创建时,会进⼊到就绪队列,操作系统会从就绪队列选择⼀个进程运⾏;
  • 从运⾏态 -> 阻塞态:当进程发⽣ I/O 事件⽽阻塞时,操作系统必须另外⼀个进程运⾏;
  • 从运⾏态 -> 结束态:当进程退出结束后,操作系统得从就绪队列选择另外⼀个进程运⾏;

因为,这些状态变化的时候,操作系统需要考虑是否要让新的进程给 CPU 运⾏,或者是否让当前进程从 CPU 上退出来⽽换另⼀个进程运⾏。 另外,如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断 ,把调度算法分为两类:

  • 非抢占式调度算法挑选⼀个进程,然后让该进程运⾏直到被阻塞,或者直到该进程退出,才会调⽤另外⼀个进程,也就是说不会理时钟中断这个事情。
  • 抢占式调度算法挑选⼀个进程,然后让该进程只运⾏某段时间,如果在该时段结束时,该进程仍然在 运⾏时,则会把它挂起,接着调度程序从就绪队列挑选另外⼀个进程。这种抢占式调度处理,需要在 时间间隔的末端发⽣时钟中断,以便把CPU 控制返回给调度程序进⾏调度,也就是常说的时间⽚机制。

5.6.2 调度原则

  • 原则⼀:如果运⾏的程序,发生了I/O 事件的请求,那 CPU 使⽤率必然会很低,因为此时进程在阻塞等待 硬盘的数据返回。这样的过程,势必会造成CPU 突然的空闲。所以,为了提⾼CPU利用,在这种发送I/O 事件致使CPU空闲的情况下,调度程序需要从就绪队列中选择⼀个进程来运行。
  • 原则⼆:有的程序执⾏某个任务花费的时间会⽐较⻓,如果这个程序⼀直占⽤着 CPU,会造成系统吞吐量 (CPU 在单位时间内完成的进程数量)的降低。所以,要提⾼系统的吞吐率,调度程序要权衡⻓任务和短 任务进程的运⾏完成数量。
  • 原则三:从进程开始到结束的过程中,实际上是包含两个时间,分别是进程运⾏时间和进程等待时间,这 两个时间总和就称为周转时间。进程的周转时间越⼩越好,如果进程的等待时间很⻓⽽运⾏时间很短,那 周转时间就很⻓,这不是我们所期望的,调度程序应该避免这种情况发⽣。
  • 原则四:处于就绪队列的进程,也不能等太久,当然希望这个等待的时间越短越好,这样可以使得进程更 快的在 CPU 中执⾏。所以,就绪队列中进程的等待时间也是调度程序所需要考虑的原则。
  • 原则五:对于鼠标、键盘这种交互式⽐较强的应⽤,我们当然希望它的响应时间越快越好,否则就会影响 ⽤户体验了。所以,对于交互式⽐较强的应⽤,响应时间也是调度程序需要考虑的原则。

操作系统——Liunx系统基础知识_java_50

针对上面的五种调度原则,总结成如下:

  • CPU 利⽤率:调度程序应确保 CPU 是始终匆忙的状态,这可提⾼ CPU 的利⽤率;
  • 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,⻓作业的进程会占⽤较⻓的 CPU 资 源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;
  • 周转时间:周转时间是进程运⾏和阻塞时间总和,⼀个进程的周转时间越⼩越好;
  • 等待时间:这个等待时间不是阻塞状态的时间,⽽是进程处于就绪队列的时间,等待的时间越⻓,⽤ 户越不满意;
  • 响应时间:⽤户提交请求到系统第⼀次产⽣响应所花费的时间,在交互式系统中,响应时间是衡量调 度算法好坏的主要标准。

5.7 调度算法

不同的调度算法适⽤的场景也是不同的。 说说在单核CPU系统中常见的调度算法。最简单的⼀个调度算法,就是

5.7.1 非抢占式的先来先服务First Come First Seved, FCFS算法

操作系统——Liunx系统基础知识_内存地址_51

顾名思义,先来后到,每次从就绪队列选择最先进⼊队列的进程,然后⼀直运⾏,直到进程退出或被阻塞,才会继续从队列中选择第⼀个进程接着运⾏。 这似乎很公平,但是当⼀个⻓作业先运行了,那么后⾯的短作业等待的时间就会很⻓,不利于短作业。 FCFS 对⻓作业有利,适⽤于 CPU 繁忙型作业的系统,⽽不适⽤于 I/O 繁忙型作业的系统

5.7.2 最短作业优先调度算法

最短作业优先(Shortest Job First, SJF)调度算法同样也是顾名思义,它会优先选择运⾏时间最短的进 程来运⾏,这有助于提⾼系统的吞吐量。

操作系统——Liunx系统基础知识_Line_52

这显然对⻓作业不利,很容易造成⼀种极端现象。 ⼀个⻓作业在就绪队列等待运⾏,⽽这个就绪队列有⾮常多的短作业,那么就会使得⻓作业不断的 往后推,周转时间变⻓,致使长作业长期不会被运⾏。

5.7.3 高响应比优先调度算法

每次进⾏进程调度时,先计算响应比优先级,然后把响应比优先级最⾼的进程投⼊运⾏,响应比优先级的计算公式:

操作系统——Liunx系统基础知识_java_53

从上⾯的公式,可以发现:

  • 如果两个进程的等待时间相同时,要求的服务时间越短,响应⽐就越⾼,这样短作业的 进程容易被选中运⾏;
  • 如果两个进程要求的服务时间相同时,等待时间越⻓,响应⽐就越⾼,这就兼顾到了⻓作业进程,因为进程的响应⽐可以随时间等待的增加⽽提⾼,当其等待时间⾜够⻓时,其响应⽐便可 以升到很⾼,从⽽获得运⾏的机会;

5.7.4 时间片轮转调度算法

操作系统——Liunx系统基础知识_数据_54

5.7.5 最高优先级调度算法

但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪 队列中选择最⾼优先级的进程进⾏运⾏,这称为最⾼优先级调度算法。

进程的优先级可以分为,静态优先级和动态优先级:

  • 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运⾏时间优先级都不会变化;
  • 动态优先级:根据进程的动态变化调整优先级,⽐如如果进程运⾏时间增加,则降低其优先级,如果 进程等待时间(就绪队列的等待时间)增加,则升⾼其优先级,也就是随着时间的推移增加等待进程 的优先级。

该算法也有两种处理优先级⾼的⽅法,⾮抢占式和抢占式:

  • ⾮抢占式:当就绪队列中出现优先级⾼的进程,运⾏完当前进程,再选择优先级⾼的进程。
  • 抢占式:当就绪队列中出现优先级⾼的进程,当前进程挂起,调度优先级⾼的进程运⾏。

5.7.6 多级反馈队列调度算法

多级反馈队列Multilevel Feedback Queue调度算法是时间⽚轮转算法和最⾼优先级算法综合发展。

  • 多级表示有多个队列,每个队列优先级从⾼到低,同时优先级越⾼时间⽚越短。
  • 反馈表示如果有新的进程加⼊优先级⾼的队列时,⽴刻停⽌当前正在运⾏的进程,转⽽去运⾏优 先级⾼的队列;

操作系统——Liunx系统基础知识_数据_55

5.8 进程通信

  • 管道
  • 消息队列
  • 共享内存
  • 信号量
  • socket

5.9 线程同步

  • 锁:加锁、解锁操作;
  • 信号量:P、V 操作;

六、死锁问题

在多线程编程中,我们为了防⽌多线程竞争共享资源⽽导致数据错乱,都会在操作共享资源之前加上互斥锁,只有成功获得到锁的线程,才能操作共享资源,获取不到锁的线程就只能等待,直到锁被释放。 那么,当两个线程为了保护两个不同的共享资源⽽使⽤了两个互斥锁,那么这两个互斥锁应⽤不当的时 候,可能会造成两个线程都在等待对⽅释放锁,在没有外⼒的作⽤下,这些线程会⼀直相互等待,就没办法继续运⾏,这种情况就是发⽣了死锁。

6.1 死锁条件

只有同时满⾜以下四个条件才会发⽣

  • 互斥条件;
  • 持有并等待条件;
  • 不可剥夺条件;
  • 环路等待条件;

互斥条件是指多个线程不能同时使⽤同⼀个资源。⽐如下图,如果线程 A 已经持有的资源,不能再同时被线程 B 持有,如果线程 B 请求获取线程 A 已经占 ⽤的资源,那线程 B 只能等待,直到线程 A 释放了资源。

操作系统——Liunx系统基础知识_内存地址_56

持有并等待条件:持有并等待条件是指,当线程A已经持有了资源 1,⼜想申请资源2,⽽资源2已经被线程C持有了,所以线程 A 就会处于等待状态,但是线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1。

操作系统——Liunx系统基础知识_数据_57

不可剥夺条件:不可剥夺条件是指,当线程已经持有了资源 ,在⾃⼰使⽤完之前不能被其他线程获取,线程 B 如果也想使 ⽤此资源,则只能在线程 A 使⽤完并释放后才能获取。

操作系统——Liunx系统基础知识_数据_58

环路等待条件环路等待条件指都是,在死锁发⽣的时候,两个线程获取资源的顺序构成了环形链。 ⽐如,线程 A 已经持有资源 2,⽽想请求资源 1, 线程 B 已经获取了资源 1,⽽想请求资源 2,这就形成 资源请求等待的环形图。

操作系统——Liunx系统基础知识_java_59

6.2 避免死锁问题

那么避免死锁问题就只需要破环其中⼀个条件就可以,最常⻅的并且可⾏的就是使⽤资源有序分配法,来破环环路等待条件

线程A和线程B获取资源的顺序要⼀样,当线程A是先尝试获取资源 A,然后尝试获取资源B的时候, 线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺 序申请⾃⼰想要的资源。 我们使⽤资源有序分配法的⽅式来修改前⾯发⽣死锁的代码,我们可以不改动线程 A 的代码。 我们先要清楚线程 A 获取资源的顺序,它是先获取互斥锁 A,然后获取互斥锁 B。 所以我们只需将线程 B 改成以相同顺序的获取资源,就可以打破死锁了。

操作系统——Liunx系统基础知识_java_60

6.3 悲观锁与乐观锁

最底层的两种就是会互斥锁和⾃旋锁,有很多⾼级的锁都是基于它们实现的,你可以认为它们是各种锁的地基,所以我们必须清楚它俩之间的区别和应⽤。 加锁的⽬的就是保证共享资源在任意时间⾥,只有⼀个线程访问,这样就可以避免多线程导致共享数据错乱的问题。

当已经有⼀个线程加锁后,其他线程加锁则就会失败,互斥锁和⾃旋锁对于加锁失败后的处理⽅式是不⼀ 样的:

  • 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
  • ⾃旋锁加锁失败后,线程会忙等待,直到它拿到锁;

互斥锁是⼀种独占锁,⽐如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有 释放⼿中的锁,线程B加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了CPU,⾃然线程 B 加锁的代码就会被阻塞。 对于互斥锁加锁失败⽽阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为睡 眠状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执⾏。如下图

操作系统——Liunx系统基础知识_java_61

所以,互斥锁加锁失败时,会从⽤户态陷⼊到内核态,让内核帮我们切换线程,虽然简化了使⽤锁的难 度,但是存在⼀定的性能开销成本。

那这个开销成本是什么呢?会有两次线程上下⽂切换的成本?

  • 当线程加锁失败时,内核会把线程的状态从运⾏状态设置为睡眠状态,然后把 CPU 切换给 其他线程运⾏;
  • 接着,当锁被释放时,之前睡眠状态的线程会变为就绪状态,然后内核会在合适的时间,把 CPU 切换给该线程运⾏。

线程的上下文切换的是什么?

当两个线程是属于同⼀个进程,因为虚拟内存是共享的,所以在切换时,虚 拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。 上下切换的耗时有⼤佬统计过,⼤概在⼏⼗纳秒到⼏微秒之间,如果你锁住的代码执⾏时间⽐较短,那可 能上下⽂切换的时间都⽐你锁住的代码执⾏时间还要⻓。 所以,如果你能确定被锁住的代码执⾏时间很短,就不应该⽤互斥锁,⽽应该选⽤⾃旋锁,否则使⽤互斥锁。

⾃旋锁是通过CPU提供的CAS函数(Compare And Swap),在⽤户态完成加锁和解锁操作,不 会主动产⽣线程上下⽂切换,所以相⽐互斥锁来说,会快⼀些,开销也⼩⼀些。

⼀般加锁的过程,包含两个步骤:

  • 第⼀步,查看锁的状态,如果锁是空闲的,
  • 第⼆步; 第⼆步,将锁设置为当前线程持有;

悲观锁做事⽐较悲观,它认为多线程同时修改共享资源的概率⽐较⾼,于是很容易出现冲突,所以访问共 享资源前,先要上锁。那相反的,如果多线程同时修改共享资源的概率⽐较低,就可以采⽤乐观锁。

乐观锁做事⽐较乐观,它假定冲突的概率很低,它的⼯作⽅式是:先修改完共享资源,再验证这段时间内 有没有发⽣冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资 源,就放弃本次操作。 放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很⾼,但是冲突的概率⾜够低的话,还是可以接受的。 可⻅,乐观锁的⼼态是,不管三七⼆⼗⼀,先改了资源再说。另外,你会发现乐观锁全程并没有加锁,所以它也叫⽆锁编程

七、零拷贝

磁盘可以说是计算机系统最慢的硬件之⼀,读写速度相差内存 10 倍以上,所以针对优化磁盘的技术⾮常的多,⽐如零拷⻉、直接 I/O、异步 I/O 等等,这些优化的⽬的就是为了提⾼系统的吞吐量,另外操作系统内 核中的磁盘⾼速缓存区,可以有效的减少磁盘的访问次数。

为什么要有 DMA 技术

在没有 DMA 技术前,I/O 的过程是这样的: CPU 发出对应的指令给磁盘控制器,然后返回; 磁盘控制器收到指令后,于是就开始准备数据,会把数据放⼊到磁盘控制器的内部缓冲区中,然后产⽣⼀个中断; CPU收到中断信号后,停下⼿头的⼯作,接着把磁盘控制器的缓冲区的数据⼀次⼀个字节地读进⾃⼰ 的寄存器,然后再把寄存器⾥的数据写⼊到内存,⽽在数据传输的期间 CPU 是⽆法执⾏其他任务 的。

操作系统——Liunx系统基础知识_java_62

可以看到,整个数据的传输过程,都要需要 CPU 亲⾃参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。 简单的搬运字符数据那没问题,但是如果我们⽤千兆⽹卡或者硬盘传输⼤量数据的时候,都⽤CPU来搬运的话,肯定忙不过来。 计算机科学家们发现了事情的严重性后,于是就发明了DMA 技术,也就是直接内存访问。

DMA技术就是在进⾏I/O 设备和内存的数据传输的时候,数据搬运的⼯作全部交给DMA 控制器,而CPU不再参与任何与数据搬运相关的事情,这样CPU 就可以去处理别的事务。

操作系统——Liunx系统基础知识_内存地址_63

具体过程:

  • ⽤户进程调⽤ read ⽅法,向操作系统发出 I/O 请求,请求读取数据到⾃⼰的内存缓冲区中,进程进 ⼊阻塞状态;
  • 操作系统收到请求后,进⼀步将 I/O 请求发送 DMA,然后让 CPU 执⾏其他任务;
  • DMA 进⼀步将 I/O 请求发送给磁盘;
  • 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知⾃⼰缓冲区已满;
  • DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷⻉到内核缓冲区中,此时不占⽤ CPU,CPU 可以执⾏其他任务;
  • 当DMA 读取了⾜够多的数据,就会发送中断信号给 CPU; CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷⻉到⽤户空间,系统调⽤返回;

可以看到, 整个数据传输的过程,CPU 不再参与数据搬运的⼯作,全程由DMA 完成,但是CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪⾥传输到哪⾥,都需要CPU来告诉 DMA 控制器。 早期DMA 只存在在主板上,如今由于I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备⾥⾯都有⾃⼰的 DMA 控制器。

传统的⽂件传输

如果服务端要提供⽂件传输的功能,我们能想到的最简单的⽅式是:将磁盘上的⽂件读取出来,然后通过⽹络协议发送给客户端。

如果服务端要提供⽂件传输的功能,我们能想到的最简单的⽅式是:将磁盘上的⽂件读取出来,然后通过⽹络协议发送给客户端。

操作系统——Liunx系统基础知识_内存地址_64

期间共发⽣了 4 次⽤户态与内核态的上下⽂切换,因为发生了两次系统调⽤,⼀次是 read() ,⼀ 次是 write() ,每次系统调⽤都得先从⽤户态切换到内核态,等内核完成任务后,再从内核态切换回⽤户 态。 上下文切换到成本并不小,⼀次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在⾼并发的场景下,这类时间容易被累积和放⼤,从⽽影响系统的性能。

其次,还发⽣了 4 次数据拷⻉,其中两次是 DMA 的拷⻉,另外两次则是通过 CPU 拷⻉的,下⾯说⼀下这 个过程:

  • 第⼀次拷⻉,把磁盘上的数据拷⻉到操作系统内核的缓冲区⾥,这个拷⻉的过程是通过 DMA 搬运 的。
  • 第⼆次拷⻉,把内核缓冲区的数据拷⻉到⽤户的缓冲区⾥,于是我们应⽤程序就可以使⽤这部分数据 了,这个拷⻉到过程是由 CPU 完成的。 read(file, tmp_buf, len); write(socket, tmp_buf, len);
  • 第三次拷⻉,把刚才拷⻉到⽤户的缓冲区⾥的数据,再拷⻉到内核的 socket 的缓冲区⾥,这个过程 依然还是由 CPU 搬运的。
  • 第四次拷⻉,把内核的 socket 缓冲区⾥的数据,拷⻉到⽹卡的缓冲区⾥,这个过程⼜是由 DMA 搬运 的。

这个⽂件传输的过程,我们只是搬运⼀份数据,结果却搬运了4次,过多的数据拷⻉⽆疑会消耗 CPU资源,⼤⼤降低了系统性能。 这种简单⼜传统的⽂件传输⽅式,存在冗余的上⽂切换和数据拷⻉,在⾼并发系统⾥是⾮常糟糕的,多了 很多不必要的开销,会严重影响系统性能。 所以要想提⾼⽂件传输的性能,就需要减少⽤户态与内核态的上下⽂切换和内存拷⻉的次数。

操作系统——Liunx系统基础知识_Line_65

操作系统——Liunx系统基础知识_数据_66

使⽤零拷⻉技术的项⽬事实上,Kafka 这个开源项⽬,就利⽤了零拷⻉技术,从⽽⼤幅提升了 I/O 的吞吐率,这也是 Kafka 在处理海量数据为什么这么快的原因之⼀。如果你追溯 Kafka ⽂件传输的代码,你会发现,最终它调⽤了 Java NIO 库⾥的 transferTo ⽅法:

@Overridepublic
long transferFrom(FileChannel fileChannel, long position, long count) throws
IOException {
     return fileChannel.transferTo(position, count, socketChannel);
}

如果 Linux 系统⽀持 sendfile() 系统调⽤,那么 transferTo() 实际上最后就会使⽤到 sendfile() 系统调用。

大文件传输方式

那针对⼤⽂件的传输,我们应该使⽤什么⽅式呢? 我们先来看看最初的例⼦,当调⽤ read ⽅法读取⽂件时,进程实际上会阻塞在read ⽅法调⽤,因为要等 待磁盘数据的返回,如下图:

操作系统——Liunx系统基础知识_java_67

  • 当调⽤read⽅法时,会阻塞着,此时内核会向磁盘发起 I/O 请求,磁盘收到请求后,便会寻址,当 磁盘数据准备好后,就会向内核发起 I/O 中断,告知内核磁盘数据已经准备好;
  • 内核收到 I/O 中断后,就将数据从磁盘控制器缓冲区拷⻉到 PageCache ⾥;
  • 最后,内核再把 PageCache 中的数据拷⻉到⽤户缓冲区,于是 read 调⽤就正常返回了。

对于阻塞的问题,可以⽤异步I/O 来解决,它⼯作⽅式如下图:

操作系统——Liunx系统基础知识_内存地址_68

它把读操作分为两部分:

  • 前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其 他任务;
  • 后半部分,当内核将磁盘中的数据拷⻉到进程缓冲区后,进程将接收到内核的通知,再去处理数据;

⽽且,我们可以发现,异步I/O 并没有涉及到 PageCache,所以使⽤异步 I/O 就意味着要绕开 PageCache。 绕开 PageCache 的 I/O 叫直接 I/O,使⽤PageCache的I/O 则叫缓存I/O。通常,对于磁盘,异步I/O只⽀持直接I/O。 前⾯也提到,⼤⽂件的传输不应该使⽤PageCache,因为可能由于PageCache被⼤⽂件占据,⽽导致热点⼩⽂件⽆法利⽤到 PageCache。于是,在⾼并发的场景下,针对⼤⽂件的传输的⽅式,应该使⽤异步I/O + 直接 I/O来替代零拷⻉技术。

直接 I/O 应⽤场景常⻅的两种:

  • 应⽤程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损 耗。在MySQL数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
  • 传输⼤⽂件的时候,由于⼤⽂件难以命中 PageCache 缓存,⽽且会占满 PageCache 导致热点⽂件⽆法充分利⽤缓存,从⽽增⼤了性能开销,因此,这时应该使⽤直接 I/O。