操作系统知识梳理
2025-04-30 12:24:32 # 面试

1.1 一个进程占用了系统中的哪些资源

  1. 内存资源:堆、栈、共享内存…
  2. CPU资源:时间片、上下文切换
  3. 文件和I/O资源:文件描述符、打开的文件
  4. 进程控制块(PCB)、信号量、互斥锁、管道、消息队列
  5. 网络资源:socket

1.2 linux/win 系统内存管理机制

1.3 进程中断时发生了什么

中断触发

  • 硬件中断:由外设(如键盘、硬盘、网卡)或 CPU 自身(如定时器)触发。
    • 示例:用户按下键盘,键盘控制器发送中断信号到 CPU。
  • 软件中断:通过系统调用(如int 0x80)或异常(如除以零)触发。
    • 示例:进程调用write()函数写入文件,触发软中断进入内核。
  • 信号:由其他进程或内核发送的异步通知(如SIGINT)。

中断响应

  • 关中断:CPU 立即停止响应其他中断(防止嵌套中断导致混乱)。
  • 保存当前上下文
    • 寄存器:保存通用寄存器(如 RAX、RBX)、段寄存器(CS、DS)等。
    • 程序计数器(PC):记录中断发生时的指令位置。
    • 栈指针(SP):保存当前栈的状态。
  • 识别中断源:通过中断向量表(Interrupt Vector Table)找到对应的中断处理程序入口地址。

中断处理

  • 执行中断服务程序(ISR):
    • 硬件中断:处理外设请求(如读取键盘输入、DMA 完成)。
    • 软件中断:执行系统调用(如分配内存、创建进程)。
    • 信号处理:执行用户自定义的信号处理函数(如捕获SIGINT时终止进程)。
  • 可屏蔽性:
    • 硬件中断可分为可屏蔽(如键盘)和不可屏蔽(如电源故障)。
    • 软中断通常不可屏蔽,必须立即处理。

上下文恢复与进程调度

  • 恢复上下文:从中断栈中弹出保存的寄存器值,恢复程序计数器。
  • 开中断:允许 CPU 再次响应新的中断。
  • 进程调度:
    • 如果中断处理改变了进程状态(如唤醒等待 I/O 的进程),可能触发调度。
    • 若原进程优先级未变,则继续执行;否则可能切换到更高优先级进程。

1.4 进程的控制方式有哪几种⭐

进程控制的主要功能是对系统中的所有进程实施有效的管理,它具有创建新进程、撤销已有进程、实现进程状态转换等功能。

简化理解:反正进程控制就是要实现进程状态转换

如何实现进程控制?

操作系统会把各个处于不同状态的进程对应的PCB挂到相应的一系列队列当中,用这种方式来管理组织进程的PCB(PCB通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列)。

在这里插入图片描述

进程控制过程如下:

在这里插入图片描述

进程控制是通过原语实现的(创建原语、撤销原语、阻塞原语、唤醒原语、切换原语),原语就是通过原子操作实现的指令行,无论哪个原语,要做的无非三类事情:(反正进程控制就是要实现进程状态转换

  1. 更新PCB中的信息(如修改进程状态标志、将运行环境保存到PCB、从PCB恢复运行环境)
    a.所有的进程控制原语一定都会修改进程状态标志
    b.剥夺当前运行进程的CPU使用权必然需要保存其运行环境
    c.某进程开始运行前必然要恢复期运行环境
  2. 将PCB插入合适的队列
  3. 分配/回收资源

比如当一些引起进程创建的事件发生时,系统会调用创建原语进行以下操作:

  1. 申请空白的PCB
  2. 为新进程分配所需资源
  3. 初始化PCB
  4. 将PCB插入就绪队列

引起进程创建的事件一般有:

  1. 用户登录:分时系统中,用户登录成功,系统会为其建立一个新的进程
  2. 作业调度:多道批处理系统中,有新的作业放入内存时,会为其建立一个新的进程
  3. 提供服务:用户向操作系统提出某些请求时,会新建一个进程处理该请求
  4. 应用请求:由用户进程主动请求创建一个子进程

操作系统还提供了用于进程终止的撤销原语,无非也就是更新一些PCB的内容,回收一些资源,如果一个处于运行态的进程,它运行正常结束或者运行过程中由于异常结束(如:整数除零这种错误),这种情况会使操作系统使用撤销原语来使进程从运行态转变为终止态(不是阻塞态),最后完成终止态的一系列相关操作,这个进程就被彻底撤销了。

另外,如果一个进程处于就绪态或阻塞态,此时如果有外界干预(如:用户主动请求撤销这个进程,如:windows下任务管理器直接杀进程正在操作),那操作系统也会使用撤销原语,来使这个进程直接从就绪态或阻塞态直接转变为终止态,然后完成一系列工作之后,最后结束进程。

总而言之,无论是进程正常结束、运行时遇到了异常而结束还是外界用户主动干预,操作系统都会调用撤消原语,将该进程切换至终止态。

在这里插入图片描述

操作系统还提供了进程的阻塞和进程的唤醒相关的原语就是阻塞原语唤醒原语。阻塞原语和唤醒原语所做的这一系列的工作,其实也是处理PCB的一些内容,或者把它插入到合适的队列,这样的一系列的工作,那么如果一个正在运行的进程,它需要等待系统分配某种资源,或者说需要等待某个事件的发生,操作系统就会使用阻塞原语把这个进程进行阻塞,由此这个进程会由运行态转变为阻塞态,那么当这个进程等待的事件发生之后,操作系统又会使用唤醒原语来把刚才阻塞的那个进程从阻塞态要转变为就绪态,那么需要注意的是,进程的唤醒,这个事件其实就是进程被阻塞的时候所等待的那个事件。因此,阻塞原语和唤醒原语应该是成对使用的,进程因为什么事件被阻塞,那么就应该因为什么事件被唤醒
在这里插入图片描述

另外呢,操作系统还提供了进程切换相关的原语。进程切换相关的切换原语,其实实现的也是一些PCB相关的一系列的操作。那么像当前正在运行的进程时间片到或者有更高的进程到达,然后抢占了CPU,或者说当前正在执行的进程,主动的申请阻塞,或者说当前进程终止,这些都有可能导致进程的切换。这个切换原语,会让当前处于运行态的进程变为阻塞态或者就绪态,另外,又会让处于一个处于就绪态的进程进入到运行态,所以这是切换原语的作用。

在这里插入图片描述

1.5 PCB⭐

进程控制块能对进程动态特征的集中反映。因为我们将程序运行起来,首先需要将代码和数据加载到内存中,然后cpu访问内存中的代码和数据。如果只有一个进程加载到内存中,cpu访问很简单,但如果由多个进程加载到内存中时,操作系统如何将这些加载到内存中的进程管理起来?

为了解决这个问题,操作系统会在内存中开辟一个结构体来存放相应的进程,即此时就需要引出关于程序控制块PCB。对于PCB结构体,我们即可为其命名为task/pcb struct里面存放了进程相关的所有属性

当程序被加载到内存运行时,操作系统会为其创建一个对应的 PCB 结构体,将进程的动态特征(如运行状态、资源占用)集中存储。多个进程的 PCB 结构体在内存中通过指针形成链表,就像一条锁链把所有进程串联起来。例如,每个 PCB 包含指向下一个 PCB 的指针,最终形成一个 “进程链表”。

操作系统通过遍历这条 “进程链表”,就能快速找到所有进程的 PCB 结构体,进而读取或修改进程属性。例如,当需要调度 CPU 执行某个进程时,操作系统只需遍历链表找到对应的 PCB,根据其中的内存地址和状态信息恢复进程运行。

PCB 的成员类型:

  • 进程标识符:进程 pid,用于系统区分不同进程。ppid,记录父进程的pid(如果是父进程通过fork创建的子进程)

  • 进程状态:运行、就绪、阻塞、僵尸等

  • 优先级: 相对于其他进程的优先级。

  • 程序计数器(PC): 程序中即将被执行的下一条指令的地址。

  • 内存指针: 指向进程代码段、数据段、堆、栈的内存地址,帮助操作系统定位进程在内存中的位置。

  • 上下文数据:保存 CPU 寄存器的当前值(如通用寄存器、状态寄存器),用于进程上下文切换时恢复现场。

  • 其他信息:比如进程启动时间

1.6 多线程如何同步、异步操作

同步操作:互斥锁、内存栅栏、条件变量、原子操作

异步操作:线程池(线程池将任务提交与任务执行解耦,任务被立即加入任务队列,无需等待任务完成,主线程可继续执行其他操作)、async、future、promise、异步回调

1.7 中断和回调的区别

中断和回调是异步事件处理的两种核心机制。中断由硬件或内核强制触发,用于响应外设事件或系统调用,具有高优先级且运行于内核态,需保存完整上下文;回调则是软件设计模式,通过函数调用实现异步通知,运行于用户态且依赖事件注册机制。两者核心区别在于控制权来源(硬件 / 内核 vs 用户代码)和执行场景(底层硬件响应 vs 上层业务逻辑)。

对比项 中断 回调
触发来源 硬件信号或内核指令 用户代码或库函数主动调用
执行态 内核态(特权模式) 用户态(非特权模式)
异步性 完全异步(事件不可预测) 同步或异步(取决于实现)
优先级 硬件固定优先级(高于进程) 取决于调用者执行优先级
资源开销 需保存 / 恢复完整上下文,开销大 仅函数调用开销,资源占用小
典型场景 外设数据接收、定时器超时 网络请求完成、GUI 事件响应
控制权 硬件 / 内核强制接管 用户代码主动控制调用时机

回调的同步异步区别:

  • 同步回调是回调在事件发生后立即执行,与主程序同步完成,与主程序共享同一调用栈,执行期间阻塞当前线程。
  • 异步回调在事件发生后不立即执行,而是回调加入任务队列,等待合适时机执行,可能在不同线程或事件循环中执行,不阻塞主程序。

1.8 fork()⭐

fork() 系统调用的主要功能是创建一个新的进程,这个新进程被称为子进程,而调用 fork() 的进程则被称为父进程。子进程是父进程的一个副本,它几乎复制了父进程的所有内容,包括代码段、数据段、堆、栈等。

  • 在父进程中,fork() 返回子进程的进程 ID(PID),这是一个正整数。
  • 在子进程中,fork() 返回 0。
  • 如果 fork() 调用失败,它会返回 -1,并设置相应的错误码。

fork() 被调用时,操作系统会为子进程分配新的进程控制块(PCB),并将父进程的大部分内容复制到子进程中。这包括代码段、数据段(全局变量、静态变量等)、堆、栈(子进程有自己独立的堆和栈空间,但初始时内容与父进程相同)等,但子进程有自己独立的内存空间,对一个进程的内存修改不会影响另一个进程。

fork() 调用之后,父进程和子进程会从 fork() 调用的下一条语句开始独立执行。它们的执行顺序是不确定的,取决于操作系统的调度算法。

如果父进程在子进程结束之前退出,子进程会成为孤儿进程,被 init 进程收养。如果子进程结束后,父进程没有及时回收子进程的资源,子进程会成为僵尸进程,占用系统资源。

实际上,无论是创建进程的fork,还是创建线程的 pthread create,底层实现都是调用同一个内核函数 clone

  1. 如果复制对方的地址空间,那么就产出一个“进程”;
  2. 如果共享对方的地址空间,就产生一个“线程”(同一进程中的线程之间共享代码段、数据段、打开的文件等资源,但是每个线程各自都有一套独立的寄存器和栈)

1.9 并发并行的区别

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

现在我们考虑有一个会读取硬盘文件数据的程序被执行了,那么当运行到读取文件的指令时,就会去从硬盘读取数据,但是硬盘的读写速度是非常慢的,那么在这个时候,如果 CPU 傻傻的等硬盘返回数据的话,那 CPU 的利用率是非常低的。
所以,当进程要从硬盘读取数据时,CPU不需要阻塞等待数据的返回,而是去执行另外的进程。当硬盘数据返回时,CPU 会收到个中断,于是 CPU 再继续运行这个进程。

进程 1 与进程 2 切换

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

对于一个支持多进程的系统,CPU 会从一个进程快速切换至另一个进程,其间每个进程各运行几十或几百个毫秒。

虽然单核的 CPU 在某一个瞬间,只能运行一个进程。但在1秒钟期间,它可能会运行多个进程,这样就产生并行的错觉,但实际上这是并发

并发与并行

1.10 上下文切换⭐

大多数操作系统都是多任务,通常支持大于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 上的进程会被中断挂起,转而执行内核中的中断服务程序

线程的上下文切换

线程与进程最大的区别在于:线程是调度的基本单位,而进程则是资源拥有的基本单位

所以,所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。

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

  • 当进程只有一个线程时,可以认为进程就等于线程;
  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时,是不需要修改的;

另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

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

这还得看线程是不是属于同一个进程:

  • 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
  • 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据;

所以,线程的上下文切换相比进程,开销要小很多。

1.11 进程和线程对比⭐

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

线程是进程当中的一条执行流程。同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的(当进程中只有一个线程时,这个线程其实可以看做是一个进程)。

所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。

当进程中的一个线程崩溃时,会导致其所属进程的所有线程都崩溃吗?

如果是因为非法访问内存引起的崩溃,那么进程肯定会崩溃,进而该进程下的所有线程也会崩溃。

这主要是是因为各个线程的地址空间是共享的,既然是共享,那么某个线程对地址的非法访问就这主要是因为在进程中,会导致内存的不确定性,进而可能会影响到其他线程,这种操作是危险的,操作系统会认为这很可能导致一系列严重的后果,于是干脆让整个进程崩溃。

如果是其他情况导致的崩溃,可以通过try-catch用户处理。

注意:栈溢出也属于非法访问内存。

现代操作系统为了保护进程之间不受影响,所以使用了虚拟地址空间来隔离进程,进程的寻址都是针对虚拟地址,每个进程的虚拟空间都是一样的,而线程会共用进程的地址空间。

以 32 位虚拟空间,进程的虚拟空间分布如下:

img

进程每调用一个函数,都会分配一个栈桢,然后在栈里会分配函数里定义的各种局部变量。

假设现在调用了一个无限递归的函数,那就会持续分配栈帧,但 stack 的大小是有限的(Linux 中默认为 8M,可以通过 uimit -a查看),如果无限递归很快栈就会分配完了,此时再调用函数试图分配超出栈的大小内存,就会发生段错误,也就是 stackoverflowError。

对比

线程与进程的比较如下:

  • 进程是资源(包括内存、打开的文件等)分配的单位,线程是CPU 调度的单位;
  • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
  • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
  • 线程能减少并发执行的时间和空间开销
  • 二者通信方式不同
  • 二者应用场景不同
  • 二者同步方式不同

对于,线程相比进程能减少开销,体现在:

  • 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
  • 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
  • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表(但注意,不同进程间的线程切换开销其实和进程切换基本相同)。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的
  • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;

所以,不管是时间效率,还是空间效率线程比进程都要高。

1.12 线程的实现⭐

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

  • 用户线程(User Thread):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;

  • 内核线程(Kernel Thread):在内核中实现的线程,是由内核管理的线程;

  • 轻量级进程(LightWeight Process):在内核中来支持用户线程(注意,是轻量级进程而不是线程)

用户线程是什么?

用户线程是基于用户态的线程管理库来实现的,那么线程控制块(TCB) 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。

所以,用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等

优点:

  • 线程切换不需要切换到内核空间中,节省了模式切换的开销
  • 调度算法可以是进程专用的,不同的进程可根据自身的需要,对自己的线程选择不同的调度算法
  • 用户级线程的实现与操作系统平台无关,对线程管理的代码是线程库函数完成的,可以用于不支持线程技术的操作系统

缺点:

  • 由于不由操作系统调度,一旦用户线程发起系统调用而阻塞,那么此进程下用户线程都无法运行
  • 一旦某个用户线程正在运行,只有当其交出CPU执行权,其他用户线程才可以运行,无法被打断,因为只有操作系统才有权限打断运行,但是操作系统不直接参与调度;
  • 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢;

内核线程是什么?

线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管内核线程是由操作系统管理的,理都是由操作系统负责。内核线程就是负责管理用户线程的,一个用户线程对应一个内核线程

内核线程的优点:

  • 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行
  • 分配给线程,多线程的进程获得更多的 CPU 运行时间

内核线程的缺点:

  • 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如 PCB 和 TCB;线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大;

轻量级进程

轻量级进程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理并像普通进程一样被调度

在大多数系统中,LWP与普通进程的区别也在于它只有一个最小的执行上下文调度程序所需的统计信息。一般来说,一个进程代表程序的一个实例,而 LWP 代表程序的执行线程,因为一个执行线程不像进程那样需要那么多状态信息,所以 LWP 也不带有这样的信息。

我们也可以通过 LWP创建多个用户线程,那么LWP与用户线程的对应关系有以下三种:

  • 1 :1 ,即一个 LWP 对应 一个用户线程
  • N:1,即一个 LWP 对应多个用户线程
  • M:N,即多个 LWP 对应多个用户线程

LWP 模型

1:1模式

一个线程对应到一个 LWP 再对应到一个内核线程,如上图的进程 4,属于此模型。

  • 优点:实现并行,当一个 LWP 阻塞,不会影响其他 LWP;
  • 缺点:每一个用户线程,就产生一个内核线程,创建线程的开销较大。

N:1模式

多个用户线程对应一个 LWP 再对应一个内核线程,如上图的进程 2,线程管理是在用户空间完成的,此模式中用户的线程对操作系统不可见

  • 优点:用户线程要开几个都没问题,且上下文切换发生用户空间,切换的效率较高;
  • 缺点:一个用户线程如果阻塞了,则整个进程都将会阻塞,另外在多核 CPU 中,是没办法充分利用CPU 的。

M:N 模式

根据前面的两个模型混搭一起,就形成M :N 模型,该模型提供了两级控制,首先多个用户线程对应到多个 LWP,LWP 再一一对应到内核线程,如上图的进程 3。

  • 优点:综合了前两种优点,大部分的线程上下文发生在用户空间,且多个线程又可以充分利用多核CPU 的资源。

1.13 调度⭐

1.13.1 进程调度

进程都希望自己能够占用 CPU 进行工作,那么这涉及到前面说过的进程上下文切换。一旦操作系统把进程切换到运行状态,也就意味着该进程占用着CPU 在执行,但是当操作系统把进程切换到其他状态时,那就不能在 CPU 中执行了,于是操作系统会选择下一个要运行的进程选择一个进程运行这一功能是在操作系统中完成的,通常称为调度程序(scheduler)。而进程状态的改变靠原语(1.4)实现。

我知道很多人会问,线程不是操作系统的调度单位吗?为什么这里参与调度的是进程?先提前说明,这里的进程指只有主线程的进程,所以调度主线程就等于调度了整个进程。那为什么干脆不直接取名线程调度?主要是操作系统相关书籍,都是用进程调度这个名字,所以我也沿用了这个名字。

进程调度方式

  • 非抢占调度方式:当一个进程正在处理中,即使有更为重要的进程进入到就绪队列中,仍然让正在执行的进程继续执行。
  • 抢占调度方式:当一个进程正在处理中,如果有更为重要的进程进入到就绪队列中,则允许调度程序根据某种原则去暂停正在执行的进程,将处理机分配给这个更为重要或紧迫的进程。

调度原则

  • CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率:

  • 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;

  • 周转时间:周转时间是进程运行+阻塞时间+等待时间的总和,一个进程的周转时间越小越好;

  • 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意;

  • 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。

说白了,这么多调度原则,目的就是要使得进程更快

调度算法

1)先来先服务调度算法(单核CPU系统常见的调度算法)

FCFS 调度算法

顾名思义,先来后到,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。

这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业

2)最短作业优先调度算法

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

这显然对长作业不利,很容易造成一种极端现象。

比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。

3)高响应比优先调度算法

高响应比优先调度算法主要是权衡了短作业和长作业。每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式:

img

但注意,一个进程要求服务的时间是不可预知的,所以,高响应比优先调度算法是「理想型」的调度算法,现实中是实现不了的

4)时间片轮转调度算法

RR 调度算法

每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。

  • 如果时间片用完,进程还在运行,那么将会把此进程从 CPU释放出来,并把CPU分配给另外一个进程;
  • 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;

另外,时间片的长度就是一个很关键的点“

  • 如果时间片设得太短会导致过多的进程上下文切换,降低了CPU效率,
  • 如果设得太长又可能引起对短作业进程的响应时间变长。

一般来说,时间片设为 20ms~50ms 通常是一个比较合理的折中值.

5)最高优先级调度算法

前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,大家的运行时间都-样。

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

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

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

注:进程在linux中的调度算法是完全公平调度器(CFS),和讲的这些调度算法不太同

1.13.2 内存页面置换算法

在了解内存页面置换算法前,我们得先谈一下缺页异常(缺页中断)。

当 CPU 访问的页面不在物理内存时,便会产生一个缺页中断,请求操作系统将所缺页调入到物理内存。那它与一般中断的主要区别在于:

  • 缺页中断在指令执行期间产生和处理中断信号,而一般中断在一条指令执行「完成」后检查和处理中断信号。
  • 缺页中断返回到该指令的开始重新执行「该指令」,而一般中断返回回到该指令的「下一个指令」执行。

我们来看一下缺页中断的处理流程,如下图:

缺页中断的处理流程

  1. 在 CPU 里访问一条 Load M 指令,然后 CPU 会去找 M 所对应的页表项。
  2. 如果该页表项的状态位是「有效的」,那 CPU 就可以直接去访问物理内存了,如果状态位是「无效的」,则 CPU 则会发送缺页中断请求。
  3. 操作系统收到了缺页中断,则会执行缺页中断处理函数,先会查找该页面在磁盘中的页面的位置
  4. 找到磁盘中对应的页面后,需要把该页面换入到物理内存中,但是在换入前,需要在物理内存中找空闲页,如果找到空闲页,就把页面换入到物理内存中。
  5. 页面从磁盘换入到物理内存完成后,则把页表项中的状态位修改为「有效的」
  6. 最后,CPU 重新执行导致缺页异常的指令。

上面所说的过程,第4步是能在物理内存找到空闲页的情况,那如果找不到呢?

找不到空闲页的话,就说明此时内存已满了,这时候,就需要「页面置换算法」选择一个物理页,如果该物理页有被修改过(脏页),则把它换出到磁盘,然后把该被置换出去的页表项的状态改成「无效的」最后把正在访问的页面装入到这个物理页中。

所以,页面置换算法的功能是,当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面,也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页。

那其算法目标则是,尽可能减少页面的换入换出的次数,常见的页面置换算法有如下几种:

  • 最佳页面置换算法(OPT)
  • 先进先出置换算法(FIFO)
  • 最近最久未使用的置换算法(LRU)
  • 时钟页面置换算法(Lock)
  • 最不常用置换算法(LFU)

1)最佳页面置换算法

最佳页面置换算法基本思路是,置换在「未来」最长时间不访问的页面

所以,该算法实现需要计算内存中每个逻辑页面的「下一次」访问时间,然后比较,选择未来最长时间不访问的页面。

这很理想,但是实际系统中无法实现,因为程序访问页面时是动态的,我们是无法预知每个页面在「下一次」访问前的等待时间。

所以,最佳页面置换算法作用是为了衡量你的算法的效率,你的算法效率越接近该算法的效率,那么说明你的算法是高效的

2)先进先出置换算法

既然我们无法预知页面在下一次访问前所需的等待时间,那我们可以选择在内存驻留时间很长的页面进行中置换,这个就是「先进先出置换」算法的思想。

3)最近最久未使用的置换算法(LRU)

最近最久未使用(LRU)的置换算法的基本思路是,发生缺页时,选择最长时间没有被访问的页面进行置换,也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。

这种算法近似最优置换算法,最优置换算法是通过[未来|的使用情况来推测要淘汰的页面,而 LRU 则是通过「历史」的使用情况来推测要淘汰的页面。

但是LRU开销比较大,实际应用中比较少使用

4)时钟页面置换算法

时钟页面置换算法就可以两者兼得,它跟 LRU 近似,又是对 FIFO 的一种改进。

该算法的思路是,把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面

当发生缺页中断时,算法首先检查表针指向的页面:

  • 如果它的访问位位是0就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置,
  • 如果访问位是1就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为0的页面为止;

时钟页面置换算法

5)最不常用算法(LFU)

最不常用(LFU)算法,这名字听起来很调皮,但是它的意思不是指这个算法不常用,而是当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰

它的实现方式是,对每个页面设置一个「访问计数器」,每当一个页面被访问时,该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。

看起来很简单,每个页面加一个计数器就可以实现了,但是在操作系统中实现的时候,我们需要考虑效率和硬件成本的。

要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高。

但还有个问题,LFU 算法只考虑了频率问题,没考虑时间的问题,比如有些页面在过去时间里访问的频率很高,但是现在已经没有访问了,而当前频繁访问的页面由于没有这些页面访问的次数高,在发生缺页中断时,就会可能会误伤当前刚开始频繁访问,但访问次数还不高的页面。

那这个问题的解决的办法还是有的,可以定期减少访问的次数,比如当发生时间中断时,把过去时间访问的页面的访问次数除以 2,也就说,随着时间的流失,以前的高访问次数的页面会慢慢减少,相当于加大了被置换的概率。

1.14 进程通信⭐⭐

进程通信指的是进程之间的信息交换,进程之间一般是相互独立的,但内核空间是每个进程都共享的,所以进程之
间要通信必须通过内核

img

进程间通信方式有:管道、消息队列、共享内存、信号量、信号、socket

1.14.1 管道

管道是指用于连接一个读进程和一个写进程以实现它们之间的通信的一个共享文件,又名pipe文件,向管道(共享文件)提供输入的发送进程(写进程),以字符流形式将大量的数据送入(写)管道;而接收管道输出的接收进程(即读进程)则从管道中接收(读)数据。

管道有匿名管道和命名管道两种:

1)匿名管道

匿名管道就是没有名字的管道,用完就销毁,Linux中的 | 就是一个匿名管道,上一个命令的输出作为下一个命令的输入,匿名管道只适用于父子进程之间的通信

匿名管道的创建,需要通过下面这个系统调用:

1
int pipe(int fd[2]);

这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1]。注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中

img

看到这,你可能会有疑问了,这两个描述符都是在一个进程里面,并没有起到进程间通信的作用,怎么样才能使得管道是跨过两个进程的呢?

我们可以使用 fork() 创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个[ fd[e]与 fd[1]」两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了

img

2)命名管道

命名管道就是提前创建了一个类型为管道的设备文件,在进程中只要使用了这个设备文件,就可以互相通信,所以它可以在不相关的进程之间进行通信

在使用命名管道前,先需要通过 mkfifo命令来创建,并且指定管道名字:

1
$ mkfifo myPipe

myPipe 就是这个管道的名称,基于 Linux 一切皆文件的理念,所以管道也是以文件的方式存在

接下来,我们往 myPipe 这个管道写入数据

1
2
$ echo "hello"> myPipe	// 将数据写进管道
//停住了

你操作了后,你会发现命令执行后就停在这了,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出

于是,我们执行另外一个命令来读取这个管道里的数据:

1
2
$ cat < myPipe// 读取管道里的数据
hello

可以看到,管道里的内容被读取出来了,并打印在了终端上,另外一方面,echo 那个命令也正常退出。

我们可以看出,管道这种通信方式效率低,不适合进程间频繁地交换数据。当然,它的好处,自然就是简单,同时也我们很容易得知管道里的数据已经被另一个进程读取了。

不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。

1.14.2 消息队列

前面说到管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据

对于这个问题,消息队列的通信模式就可以解决。比如,A 进程要给B进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B进程需要的时候再去读取数据就可以了。同理,B进程要给 A 进程发送消息也是如此。

再来,消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。

消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。

消息这种模型,两个进程之间的通信就像平时发邮件一样,你来一封,我回一封,可以频繁沟通了。

但邮件的通信方式存在不足的地方有两点,一是通信不及时,二是附件也有大小限制,这同样也是消息队列通信不足的点。

消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。在 Linux内核中,会有两个宏定义 MSGMAX和 MSGMNB,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。

消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。因此通信并不及时。

1.14.3 共享内存

消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。那共享内存的方式,就很好的解决了这一问题。

现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程A和 进程B的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响

共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。

img

1.14.4 信号量

用了共享内存通信方式,带来新的问题,那就是如果多个进程同时修改同一个共享内存,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。

为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制

信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据(互斥、条件变量才是作用于数据的)。

信号量表示资源的数量,控制信号量的方式有两种原子操作

  • 一个是P操作,这个操作会把信号量减去1,相减后如果信号量<0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量>=0,则表明还有资源可使用,进程可正常继续执行。
  • 另一个是V操作,这个操作会把信号量加上 1,相加后如果信号量<=0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量>0,则表明当前没有阻塞中的进程;

P操作是用在进入共享资源之前,V操作是用在离开共享资源之后,这两个操作是必须成对出现的。

1.14.5 信号

上面说的进程间通信,都是常规状态下的工作模式。对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。

信号跟信号量虽然名字相似度 66.66%,但两者用途完全不一样,就好像Java 和 JavaScript 的区别。

在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。

信号是进程间通信机制中异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。

  1. 执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。
  2. 捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
  3. 忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用它们用于在任何时候中断或结束某一进程。进程无法捕捉和忽略的,即SIGKILL和SEGSTOP,

1.14.6 Socket

前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,那要想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。

实际上,Socket 通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信

socket 的系统调用如下:

1
int socket(int domain, int type, int protocal)

三个参数分别代表:

  • domain 参数用来指定协议族,比如 AF _INET 用于 IPV4、AF_INET6 用于 IPV6、AF LOCAL/AF UNIX用于本机:
  • type 参数用来指定通信特性,比如 SOCK STREAM 表示的是字节流,对应 TCP、SOCK DGRAM 表示的是数据报,对应 UDP、SOCK RAW 表示的是原始套接字;
  • protocal 参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,protocol 目前一般写成0即可;

根据创建 socket 类型的不同,通信的方式也就不同!

  • 实现 TCP 字节流通信:socket 类型是 AF INET 和 SOCK STREAM;
  • 实现 UDP 数据报通信:socket 类型是 AF_INET和 SOCK DGRAM;
  • 实现本地进程间通信:「本地字节流 socket 」类型是 AF LOCAL和 SOCK STREAM,「本地数据报socket 」类型是 AF_LOCAL和 SOCK DGRAM。另外,AF UNIX和 AF_LOCAL 是等价的,所以AF_UNIX 也属于本地 socket;

针对TCP的socket编程模型

img

  • 服务端和客户端初始化socket,得到文件描述符
  • 服务端调用 bind,将绑定在IP 地址和端口;
  • 服务端调用listen进行监听;
  • 服务端调用accept等待客户端连接;
  • 客户端调用connect 向服务器端的地址和端口发起连接请求
  • 服务端 accept 返回用于传输的 socket 的文件描述符;
  • 客户端调用 write写入数据;服务端调用 read 读取数据;
  • read 读取数据的时候,就会读取到了 EOF,待处客户端断开连接时,会调用 close,那么服务端理完数据后,服务端调用close,表示连接关闭。

这里需要注意的是,服务端调用accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据

所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。

UDP的socket编程

img

UDP 是没有连接的,所以不需要三次握手,也就不需要像 TCP 调用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口号,因此也需要 bind。

对于 UDP 来说,不需要要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind

另外,每次通信时,调用 sendto 和 recvfrom,都要传入目标主机的 IP 地址和端口。

本地进程间的通信socket模型

本地 socket 被用于在同一台主机上进程间通信的场景:

  • 本地 socket 的编程接口和 IPv4、IPv6 套接字编程接口是一致的,可以支持「字节流」和「数据报」两种协议;
  • 本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现

对于本地字节流 socket,其 socket 类型是 AF LOCAL 和 SOCK STREAM。

对于本地数据报 socket,其 socket 类型是 AF_LOCAL和 SOCK DGRAM。

本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别

1.15 线程通信

线程间的通信目的主要是用于线程同步。所以线程没有像进程通信中的用于数据交换的通信机制。

同一进程的不同线程共享同一份内存区域,所以线程之间可以方便、快速地共享信息。只需要将数据复制到共享(全局或堆)变量中即可。但是需要避免出现多个线程试图同时修改同一份信息。

1.16 僵尸进程、孤儿进程⭐

如果父进程在子进程结束之前退出,子进程会成为孤儿进程,被 init 进程(进程号为1)收养,并由init进程对它们完成状态收集工作。

一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种子进程称之为僵尸进程

如何避免僵尸进程?

  1. 最简单的方法:父进程通过 wait() 和 waitpid()等函数等待子进程结束,但是,这会导致父进程挂起;
  2. 如果父进程要处理的事情很多,不能够挂起,通过 signal() 函数人为处理信号 SIGCHLD;
    • 只要有子进程退出自动调用指定好的回调函数,因为子进程结束后,父进程会收到该信号 SIGCHLD ,可以在其回调函数里调用 wait()或 waitpid() 回收;
  3. 如果父进程不关心子进程什么时候结束,那么可以用signal(SIGCHLD,SIGIGN)通知内核:自己对子进程的结束不感兴趣,父进程忽略此信号,那么子进程结束后,内核会回收,并不再给父进程发送信号;

1.17 一个进程最多可以创建多少线程?

在 32 位系统中,每个进程有4G的虚拟内存,但是内核空间需占用1G,只有3G是用户空间。线程数量其实与进程的虚拟内存空间上限系统参数有和,每创建一个线程,进程都需要为其分配一个栈空间,假如创建一个需要占用10M虚拟内存的线程,我们最多可以创建300个。

但在64位系统中,每个进程的虚拟内存最大值是128T,可以创建很多线程。但其实系统参数约束了线程数量的上限。

总结如下:

  • 32 位系统,用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 10M,那么一个进程最多只能创建 300 个左右的线程。
  • 64 位系统,用户态的虚拟空间大到有 128T,理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制。

1.18 用户态和内核态

用户态和内核态是操作系统为了保护系统资源和实现权限控制而设计的两种不同的CPU运行级别,可以控制进程或程序对计算机硬件资源的访问权限和操作范围

  • 用户态:在用户态下,进程或程序只能访问受限的资源和执行受限的指令集,不能直接访问操作系统的核心部分,也不能直接访问硬件资源。
  • 核心态:核心态是操作系统的特权级别,允许进程或程序执行特权指令和访问操作系统的核心部分。在核心态下,进程可以直接访问硬件资源,执行系统调用,管理内存、文件系统等操作。

什么时候发生用户态和内核态的切换?

  • 系统调用:当用户程序需要请求操作系统提供的服务时,会通过系统调用进入内核态。
  • 异常:当程序执行过程中出现错误或异常情况时,CPU会自动切换到内核态,以便操作系统能够处理这些异常。
  • 中断:外部设备(如键盘、鼠标、磁盘等)产生的中断信号会使CPU从用户态切换到内核态。操作系统会处理这些中断,执行相应的中断处理程序,然后再将CPU切换回用户态。

1.19 虚拟内存⭐⭐

1.19.1 为什么要有虚拟内存

单片机是没有操作系统的,每次修改完代码,我们必须借助工具将代码烧进去,因为单片机没有操作系统,所以烧进去的代码其实是单片机的CPU直接操作内存的物理地址的,而要想在内存中同时运行两个程序是不可能的。如果第一个程序在 2000 的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容,所以同时运行两个程序是根本行不通的,这两个程序会立刻崩溃。

为了同时运行多个程序,操作系统需要把不同程序引用的绝对物理地址隔开,即让操作系统为每个进程分配独立的一套虚拟地址。注意:每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程是透明的,这部分工作交给操作系统来完成。

总结:

  • 第一,虚拟内存可以使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
  • 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题隔离不同进程的访问权限
  • 第三,虚拟内存可以为进程提供独立的内存空间并引入多层的页表结构将虚拟内存翻译成物理内存,进程之间可以共享物理内存减少开销,也能简化程序的链接、装载以及内存分配过程;
  • 第四,有了虚拟内存机制后,操作系统可以同时运行多个程序

1.19.2 虚拟地址如何映射至物理地址

进程的中间层

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

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

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

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

操作系统会提供一种机制,在物理内存和虚拟内存之间建立一个地址映射表,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:

img

而操作系统主要通过内存分段内存分页两种方式来管理虚拟地址和物理地址之间的关系。

1.19.2.1 内存分段

内存分段将物理内存划分成不同的逻辑段或区域,每个段用于存储特定类型的数据或执行特定类型的任务。每个段具有不同的大小和属性。常见的段包括代码段(存储程序执行代码),数据段(存储程序数据)、堆栈段(存储函数调用和局部变量),以及其他自定义段,如共享库段等。

分段机制下的虚拟地址由两部分组成,段选择因子段内偏移量,而虚拟地址是通过段表与物理地址进行映射的。

img

段选择因子中的段号与段表对应,作为段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。虚拟地址中的段内偏移量应该位于0和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址

img

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

但分段也有一些不足之处:

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

什么是内存碎片

这里是因为段与段之间会产生间隙非常小的内存,所以导致内存碎片的产生。

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

系统中有一个空闲链表,当有程序申请的时候,系统遍历空闲链表找到第一个大于等于申请大小的空间分配给程序,但频繁的分配和释放内存,可能会导致内存碎片的产生(比如,一开始分配了几个小块内存,后来又释放了其中几块内存,这样就会在已分配的内存之间形成一些小块的空闲空间,这些空闲空间由于不连续,可能无法满足后续较大内存块的分配需求,就像市场中一些摊位被拆除后,剩下的一些小空间无法再容纳大型的摊位一样)。虽然整体内存足够,但是无法为较大的内存请求分配连续的内存块,这就是外部碎片。

内部内存碎片是有时分配的内存块大于进程实际需要的内存量,这意味着有一些内存浪费在内部碎片中。

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

如何解决内存碎片?

解决「外部内存碎片」的问题就是内存交换(也可以通过内存池,STL空间配置器的思想解决)

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

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

内存交换的效率低的问题

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

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

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

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

1.19.2.2 内存分页

分段的好处就是能产生连续的内存空间,但是会出现「外部内存碎片和内存交换的空间太大」的问题。

要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是内存分页

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

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

img

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

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

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

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

但是,因为内存分页机制分配内存的最小单位是一页,即使程序不足一页大小,我们最少只能分配一个页,所以页内会出现内存浪费,所以针对内存分页机制会有内部内存碎片的现象

如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap ln)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高

img

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

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

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

img

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

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

但这种一级页表会有缺陷

空间上的缺陷

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

在 32 位的环境下,虚拟地址空间共有 4GB(2^32),假设一个页的大小是 4KB(2^12),那么就需要大约 100 万(2^20)个页。

每个「页表项」需要4个字节大小来存储,因为32位系统下,32位虚拟地址分为两部分,前二十位是物理页号(4G/4KB=2^20,这样才能从任意虚拟页对应到物理页中,4GB也需要2^20个物理页存储,所以每一个表项中必须能有20位地址存储物理页的表项),剩余12位用作页内偏移或者各种标志位(4KB=2^12)。因此在一个页表项中,前二十位其实是用于对应物理页的索引,而不是对应虚拟页的索引。而32位需要4字节大小存储,因此一个页表项4字节,而一个页表中存在有非常多的页表项,一个页表项需要对应一个虚拟页,这样才能从一个虚拟页对应到一个页表项,一个页表项对应到2^20个物理页。

那么整个 4GB 空间的映射就需要有 4MB(2^20 * 4字节)的内存来存储页表,页表中有2^20个页表项。

这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。那么, 100 个进程的话,就需要400MB的内存来存储页表,这是非常大的内存了,更别说 64 位的环境

多级页表

在前面我们知道了,对于单页表的实现方式,在 32 位和页大小 4K8的环境下,一个进程的页表需要装下 100 多万个「页表项」,并且每个页表项是占用4字节大小的,于是相当于每个页表需占用 4MB 大小的空间。

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

img

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

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

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

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

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

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

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

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

1.19.2.3 段页式内存管理

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

img

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

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

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

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

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

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

1.19.3 Linux内存分布

Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制。

这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址先进行段式映射,然后才能进行页式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel的选择。

但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用。也就是说,”上有政策下有对策”,若惹不起就躲着走。

Linux 系统中的每个段都是从0地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。(分段其实是将栈、堆这些区域分开,但如果段是从0~4GB那么其实和没分差不多)。

在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地堆空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示:

img

通过这里可以看出:

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

再来说说,内核空间与用户空间的区别:

  • 进程在用户态时,只能访问用户空间内存,
  • 只有进入内核态后,才可以访问内核空间的内存;

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

img

1.19.4 内存满了会发生什么?

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

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

缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。

如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作(其实就是将一些进程中不被常用的页内存回收,将其暂时存放至硬盘,如果有使用在重新读取回来,释放的页可用于被分配),回收的方式主要是两种:直接内存回收和后台内存回收。

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

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

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

8

1.19.5 在4G物理内存的机器上申请8G会发生什么

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

  • 在 64位 位操作系统,因为进程理论上最大能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。如果这块虚拟内存被访问了,要看系统有没有Swap 分区:

    • 如果没有 Swap 分区,因为物理空间不够,进程会被操作系统杀掉,原因是 OOM(内存溢出);

    • 如果有 Swap 分区,即使物理内存只有 4GB,程序也能正常使用 8GB 的内存,进程可以正常运行

1.20 进程/线程内存布局

在 32 位机器上,指针的寻址范围为 2^32,所能表达的虚拟内存空间为4 GB。所以在 32 位机器上进程的虚拟内存地址范围为:0x0000 0000-0xFFFF FFFF。

其中用户态虚拟内存空间为3GB,虚拟内存地址范围为:0x0000 0000-0xC00 0000。

内核态虚拟内存空间为1GB,虚拟内存地址范围为:0xC00 0000-0xFFFF FFFF。

image.png

但是用户态虚拟内存空间中的代码段并不是从 0x0000 0000 地址开始的,而是从 0x0804 8000 地址开始。

0x0000 0000 到 0x0804 8000 这段虚拟内存地址是一段不可访问的保留区,因为在大多数操作系统中,数值比较小的地址通常被认为不是一个合法的地址,这块小地址是不允许访问的。比如在C语言中我们通常会将一些无效的指针设置为 NULL,指向这块不允许访问的地址。

保留区的上边就是代码段和数据段,它们是从程序的二进制文件中直接加载进内存中的,BSS 段中的数据也存在于二进制文件中,因为内核知道这些数据是没有初值的,所以在二进制文件中只会记录 BSS 段的大小,在加载进内存时会生成一段 0 填充的内存空间。

紧挨着 BSS 段的上边就是我们经常使用到的堆空间,从图中的红色箭头我们可以知道在堆空间中地址的增长方向是从低地址到高地址增长。

堆空间的上边是一段待分配区域,用于扩展堆空间的使用。接下来就来到了内存映射段。进程运行时所依赖的动态链接库中的代码段,数据段,BSS 段就加载在这里。还有我们调用 mmap 映射出来的一段虚拟内存空间也保存在这个区域。注意:在文件映射与匿名映射区的地址增长方向是从高地址向低地址增长。

内存映射段的作用:

  1. 文件映射:将磁盘文件直接映射到进程内存空间,进程可通过指针直接访问文件数据(无需频繁调用 read/write 系统调用),减少 I/O 开销。例如,读取大文件时,只需映射部分区域,提升访问效率。
  2. 动态库加载:进程运行时,动态链接库(如 C 语言的 libc.so、Java 的 JRE 库)会通过内存映射段加载到进程地址空间(多个进程共享同一份库代码的物理内存,节省内存空间。)
  3. 共享内存:进程通信
  4. 匿名内存映射:通过malloc()请求一大块内存,C运行库将会创建这样一个匿名映射而不是使用堆内存。“大块”意味着比MMAP_THRESHOLD还大,缺省128KB,可以通过mallocp()调整。

接下来用户态虚拟内存空间的最后一块区域就是栈空间了,在这里会保存函数运行过程所需要的局部变量以及函数参数等函数调用信息。栈空间中的地址增长方向是从高地址向低地址增长。每次进程申请新的栈地址时,其地址值是在减少的。

在栈空间的下边也有一段待分配区域用于扩展栈空间,在栈空间的上边就是内核空间了,进程虽然可以看到这段内核空间地址,但是就是不能访问。这就好比我们在饭店里虽然可以看到厨房在哪里,但是厨房门上写着“厨房重地,闲人免进”,我们就是进不去。

而在线程中,同一进程的所有线程共享进程的代码段、数据段、内存映射区,但是每个线程都有独立的线程栈、TCB、线程私有(全局 / 静态变量)。

1
2
3
4
5
6
7
8
9
10
11
12
13
进程虚拟地址空间(32位/64位)
┌───────────────────────────────────────────────────┐
│ 代码段(Text) ←─ 共享(所有线程) │
│ 数据段(Data) ←─ 共享(全局/静态变量) │
│ 堆(Heap) ←─ 共享(动态内存) │
│ 内存映射区(MMAP) ←─ 共享(文件/库映射) │
│ 栈(Stack,线程1) ←─ 私有(独立栈空间) │
│ ▼(向下增长) │
│ 栈(Stack,线程2) ←─ 私有(独立栈空间) │
│ ▼(向下增长) │
│ ...(其他线程栈) │
│ 内核空间(Kernel) ←─ 内核态内存(TCB等) │
└───────────────────────────────────────────────────┘

1.21 mmap() 函数原理

mmap 即 memory map,也就是内存映射。mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read、write 等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享

零拷贝中可通过mmap() + write 的方法减少内核区的数据拷贝

img

mmap的作用,在应用这一层,是让你把文件的某一段,当作内存一样来访问。将文件映射到物理内存,将进程虚拟空间映射到那块内存。这样,进程不仅能像访问内存一样读写文件,多个进程映射同一文件,还能保证虚拟空间映射到同一块物理内存,达到内存共享的作用

mmap 是 Linux 中用处非常广泛的一个系统调用,它将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。mmap 必须以 PAGE_SIZE 为单位进行映射,而内存也只能以页为单位进行映射,若要映射非 PAGE_SIZE 整数倍的地址范围,要先进行内存对齐,强行以 PAGE_SIZE 的倍数大小进行映射。

内存映射文件的原理

首先创建虚拟区间并完成地址映射,此时还没有将任何文件数据拷贝至主存。当进程发起读写操作时,会访问虚拟地址空间,通过查询页表,发现这段地址不在物理页上,因为只建立了地址映射,真正的数据还没有拷贝到内存,因此引发缺页异常。缺页异常经过一系列判断,确定无非法操作后,内核发起请求调页过程。

最终会调用nopage函数把所缺的页从文件在磁盘里的地址拷贝到物理内存。之后进程便可以对这片主存进行读写,如果写操作修改了内容,一定时间后系统会自动回写脏页面到对应的磁盘地址,完成了写入到文件的过程。另外,也可以调用msync()来强制同步,这样所写的内存就能立刻保存到文件中。

mmap内存映射的实现过程,总的来说可以分为三个阶段

⑴进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域

  • 进程在用户空间调用库函数mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
  • 在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址
  • 为此虚拟区分配一个vm_area_struct结构,接着对这个结构的各个域进行了初始化
  • 将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中

⑵调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系

  • 为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。
  • 通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。
  • 内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。
  • 通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。

⑶进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝

注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。

  • 进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。
  • 缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。
  • 调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。
  • 之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。

注:修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用msync()来强制同步, 这样所写的内容就能立即保存到文件里了。

1.22 管程

在信号量机制中,每个要访问临界资源的进程都必须自备同步的PV操作,大量分散的同步操作会给系统管理带来麻烦,且容易因为同步操作不当而导致系统死锁。于是便产生了一种新的进程同步工具——管程(Monitors)。

管程(Monitors):是一个资源管理模块,其中包含了共享资源的数据结构,以及由对该共享数据结构实施操作的一组过程(方法)所组成的资源管理程序。

管程中包含条件变量,用于管理进程的阻塞和唤醒。其形式为 condition x;对它的操作仅有wait和signal。

  • x.wait:正在调用管程的进程因 x 条件需要被阻塞或挂起,则调用 x.wait 将自己插入到 x 条件的等待队列上,并释放管程,直到 x 条件变化。此时其它进程可以使用该管程。
  • x.signal:正在调用管程的进程发现 x 条件发生了变化,则调用 x.signal,重新启动一个因 x 条件而阻塞或挂起的进程。(与信号量的signal不同,没有s:=s+1的操作)

个人理解:

  1. 管程相当于把对临界资源的操作封装了起来,当进程要对资源进行操作时,只要调用管程中的方法就可以了,而不用进程自己担心同步和互斥的问题,管程的内部有自己的一套机制进行同步与互斥
  2. 管程中每次只允许一个进程进入管程。
  3. 当调用管程的进程因为某原因阻塞或者挂起时,把这个原因定义为一个条件变量x。
  4. x.wait操作就是把自己放到一个队列上,这个队列上的进程都是因为x原因而阻塞的。
  5. x.signal操作就是让在x阻塞队列上的一个进程重新启动。

相对形象的比喻:假如一个管程叫ATM(取款机),其包含两个方法:存款和取款,不同的人代表不同的进程,但是ATM只允许一个人在一个时间段中进行操作,当一个人在使用时,其他的人只能wait。此外,一个人如果使用的时间太长也不行,所以需要一个条件变量来约束他。

比如一个人在操作ATM时突然接电话了,没法继续操作,把这个原因记为x,执行x.wait,让他离开ATM机,去接电话的队列中等待。等到打完电话,即调用了x.signal后,他就可以继续操作ATM了(一般令正在操作ATM的人操作完后,他才能重新进去)。

管程就是将共享资源及其操作函数封装在一个抽需数据类中,保证同一时刻仅有一个线程能进入管程(互斥性),内部通过类似于C++条件变量的思想保证线程的同步。