admin管理员组

文章数量:1598864

目录

前置知识

计算机的结构模型

操作系统的理解

进程的概念

进程和PCB

查看进程信息的指令 - ps

进程的一些特性

进程标识符 - PID和PPID

进程状态

进程状态的概念

Linux下的进程状态

父子进程

子进程的创建 - fork

僵尸进程

孤儿进程

进程切换

CPU上下文切换

内核态和用户态

进程上下文切换

进程优先级与进程调度

进程优先级

进程调度算法

Linux具体的进程调度

虚拟地址空间

虚拟内存分段(分区)

分页与内存映射

内容补充

页表相关

内存相关

进程相关

缺页中断

内存碎片问题


前置知识

计算机的结构模型

早期冯诺依曼模型

美籍匈牙利科学家冯·诺依曼最先提出“程序存储”的思想,并成功将其运用在计算机的设计之中,根据这一原理制造的计算机被称为冯·诺依曼结构计算机。由于他对现代计算机技术的突出贡献,因此冯·诺依曼又被称为“现代计算机之父”。程序存储是指:指令以代码的形式事先输入到计算机的主存储器中,然后按其在存储器中的首地址执行程序的第一条指令,以后就按该程序的规定顺序执行其他指令,直至程序执行结束。结构示意图如下

早期的冯诺依曼模型是以运算器为核心,由输入设备将数据和相关的指令传递给运算器,之后先将数据放到存储器,然后运算器将数据处理完成之后再传递给输出设备。而这整个过程的协调和分配就是由控制器来控制的。


现代计算机的结构模型

但是这样会有一个很严重的缺陷,就是运算器的速率会被输入输出设备大大的影响。在微处理器问世之前,运算器与控制器分离。而且存储器容量小,因此设计成以运算器为中心的结构,其他部件都通过运算器完成信息的传递,也就是上图的样子。
而随着微电子技术的进步,同时计算机需要处理的信息也越来越多,大量I/O设备的速度和CPU的速度差距悬殊,因此需要更新换代计算机的组织结构以适应新的需求。计算机发展为了以存储器为中心,使I/O设备尽可能的绕过CPU,直接在I/O设备与存储器之间完成操作,以提高整体效率。由此便产生了现代主流的计算机结构,结构示意图如下

可以看到,当代的计算机结构是以存储器为核心的,输入设备将信息传递给存储器,然后由存储器将信息传递给运算器,运算器处理完数据之后再传回存储器,最后由存储器将数据传递到输出设备。其中,控制器和运算器共同组成我们现在耳熟能详的CPU。
可以看到,现代的计算机结构是以存储器为核心的,这样就实现了运算器只与存储器之间进行交互,大大提高了运算器的效率。

我们可以把上面的结构抽象为下面的简化图,这样CPU和主存储器(也就是我们的内存)就组成了我们专业术语上的主机,而其它的任何输入输出设备都叫做外设。

其次,我们还需要介绍一下存储相关的知识的,首先是存储设备的层级结构:

下面的内容选自《深入理解计算机系统(第三版)》


实际上,每个计算机系统中的存储设备都被组织成了一个存储器层次结构,如图下图所示。在这个层次结构中,从上至下,设备的访问速度越来越慢、容量越来越大、离CPU越来越远,并且每字节的造价也越来越便宜。寄存器文件在层次结构中位于最顶部,也就是第0级或记为L0。这里我们展示的是三层高速缓存L1到L3,占据存储器层次结构的第1层到第3层。主存在第4层,以此类推。

特别的,主存储器主要由RAM和ROM组成,而RAM主要有DRAM和SRAM两种。其中,内存条就是DRAM集成的,也就是俗称的运行内存。而像SRAM、CACHE等高速缓存和寄存器一般是被集成在CPU中的。至于ROM,一般被嵌入在主板中,存储了BIOS等一些很重要的程序。关于RAM和ROM的一些区别,感兴趣的可以参考这篇文章:RAM和ROM的区别(转) - 知乎 (zhihu)

操作系统的理解

操作系统所处的位置大致如下图所示

可以看到,操作系统是处于一个“承上启下”的位置,用于处理用户和硬件程序之间的交互功能。

首先,操作系统的本质其实就是一款软件,它是我们的计算机启动时第一个启动的软件。如果细究的话,操作系统其实是由主板上ROM存储中的或是其它硬件中的一些程序启动的。感兴趣的话可以试着阅读这篇文章:操作系统是如何启动起来的? - 知乎 (zhihu)

由于操作系统的本质其实就是一款很大的软件,所以是无法直接在操作系统上操作键盘、鼠标、音响(甚至是主存储器和CPU)等硬件的。那么为了实现硬件与操作系统这款软件之间的交互操作,于是便有了”驱动程序“这个中介,驱动程序将硬件的信息的方法等组织封装起来然后打包提供给操作系统(这些一般是嵌入式的活)。正是由于驱动程序的介入,使得操作系统可以像处理文件那样与我们的硬件进行交互,同时这也应证了”Linux下一切皆文件“的这句经典名言。通常情况下,很多人认为主存储器和CPU与操作系统之间没有驱动,实际上并不是没有驱动,而是由于安全和便捷等因素的考虑,一般把CPU和主存储器的驱动程序内嵌在主板和操作系统中(这也就是为什么有些CPU或内存条在某些主板上不适配的原因之一)。所以,有了驱动程序这个中介,操作系统就可以按照我们的需求与硬件之间进行交互了。

那么操作系统又是怎么和用户或者应用软件之间进行交互的呢?这里就不得不提一下“系统调用”了。虽然操作系统能够以它能理解的方式操作硬件了,但是为了防止用户的一些危险操作,并且使得用户/程序员能够以一种方便、易于理解的方式来操作这些硬件设备,最终操作系统会提供给我们一系列的操作接口,这些接口就是所谓的“系统调用”。系统调用给了上层程序一个清晰的接口,隐藏了内核的复杂结构,这些接口使得我们能够以一种简单并且安全的方式访问我们的硬件程序。特别的,我们没有能够与CPU之间进行交互的系统调用,只有与内存之间进行交互的系统调用,这是因为CPU是严格按照一些既定的规则来处理日常的这些指令、操作的(些指令基本上都来源于内存或者寄存器),所以我们根本就不能而且完全没必要与CPU之间进行交互。可以理解为CPU就是一个计算机的大脑,他有自己的想法,不需要我们去教它做事。

不过由于系统调用非常的基础,所以有时使用起来也是很繁杂的,比如一个简单的给变量分配内存空间的操作,就需要动用多个系统调用。所以在系统调用的基础上又有了库函数和外壳程序等工具。操作系统会将一些编程中常用操作所对应的系统调用封装成对应的库函数,以供开发者的使用,这大大的提高了编程效率和学习成本,比如C语言中的malloc函数,看似只是一个函数,实际上却调用了大量的系统调用的接口。(实际上,一个操作系统只要称得上是Linux系统,必须要拥有一些库函数,比如ISO C标准库,POSIX标准库等)。至于外壳程序(shell),如果我们在使用操作系统的时候,每执行一个简单的操作都需要我们手动的去调用大量的系统调用的话,那么即使是一些很专业的开发者也会不堪重负。因此,便有了Windows下的图形化界面和Linux下像bash这样的命令行解释器,每当我们在Windows下点击鼠标或者是在Linux下输入以下指令,其背后都是调用了大量的系统调用的。其实,shell外壳程序可以看作是一种特殊的软件,它为我们封装了大量的系统调用,为我们提供了一种高效、稳定、安全的操作环境。

我们日常生活中说的操作系统是包含了系统内核、系统调用、shell外壳这些内容的。而严格意义上讲,操作系统的内核不包含所谓的系统接口、库函数等内容,如下图所示。也就是说,我们日常在Windows下看到的那个图形化界面并不属于操作系统的内核,他只是一种套在系统内核上让用户可以快速上手的外壳程序。正是因为有了这个图形化界面,于是便有了一系列的可以运行在这图形化界面环境下的应用程序,诸如我们日常使用的QQ、bilibili、Steam等。我们可以在外壳程序的基础之上快速上手使用这些应用程序,而不是打开一个软件之前还需要我们手动的去调用大量系统接口。

至此,我们知道了操作系统对下是通过一系列的驱动程序来调度硬件设备的,对上提供一系列安全、稳定的系统调用。人们又将这些系统调用封装为一些库函数或者便于使用的shell外壳,而在外壳程序之上又衍生了一大批的应用程序。而操作系统的一个很重要的任务就是管理和调度这些上下层以及操作系统本身的软硬件资源。我们人为地将操作系统的管理功能进行分类,其中较为常见的有内存管理、进程管理、文件管理和驱动管理这么几类。下面我就就先针对进程管理进行介绍。

进程的概念

进程和PCB

可执行程序的运行首先是需要加载或者说是拷贝到内存中的,然后由CPU去读取和处理内存中对应区域的二进制指令。广义上讲,进程就是程序加载到内存。也就是说可以认为我们平时运行的可执行程序和控制台窗口执行的指令(指令也是进程)加载到内存之后就叫做进程。但严格意义上讲,并没有这么简单,一个的描述是:进程=可执行程序+内核数据结构,其中这个内核数据结构中就包含了进程的PCB,进程PCB被称为"操作系统感知进程的唯一实体"。那么何为PCB呢?下面是一段对PCB的介绍(摘自百度百科):

为了描述控制进程的运行,系统中存放进程的管理和控制信息的数据结构称为进程控制块(PCB Process Control Block),它是进程实体的一部分,是操作系统中最重要的记录性数据结构。它是进程管理和控制的最重要的数据结构,每一个进程均有一个PCB,在创建进程时,建立PCB,伴随进程运行的全过程,直到进程撤消而撤消。

那么为什么要引入这个PCB呢?简单来说就是为了便于对进程进行统一的管理和描述。因为可执行程序的具体实现五花八门,操作系统无法对每一个进程做到做监控,而操作系统又需要对进程进行统一的管理,于是PCB便诞生了。PCB就相当于是一个信息收集表,表中是和进程相关的一些数据,如进程标识符、进程状态等信息。这样操作系统就不需要对每一个进程做到实时监控,而是只需要通过分析处理进程所对应的PCB中的信息即可,例如下图所示。这就像学校为了管理学生的体质情况会进行体测,最终的体测结果填入统一的项目表中,最终学校只需要分析和处理我们对应的体测表信息就可以了,而不是每次想要查看某个同学的体测结果时都要去调监控。

而PCB是进程控制块的一个统称,不是具体的一个实例。我们知道,Linux是用C语言写的,所以在Linux下PCB其实就是一个名为task_struct的结构体。而PCB与task_struct的关系,就和shell与bash的关系一样,是一类东西的名称和具体实例的关系。

查看进程信息的指令 - ps

在Linux下我们通常使用ps指令来获取一个进程的各种信息,具体使用细节见ps命令 – 显示进程状态 – Linux命令大全(手册) (linuxcool)https://www.linuxcool/ps通常使用组合就是 ps -ajx、ps -aux、ps -al。用法示例如下:

ps aux | grep test  # 列出所有进程,并筛选含有"test"条目的信息

需要注意的是,ps指令带不带杠是不一样的,官方的解释如下:

Note that "ps -aux" is distinct from "ps aux".  The POSIX and UNIX standards require that "ps -aux" print all processes owned by a user named "x", as well as printing all processes that would be selected by the -a option.  If the user named "x" does not exist, this ps may interpret the command as "ps aux" instead and print a warning.  This behavior is intended to aid in transitioning old scripts and habits.  It is fragile, subject to change, and thus should not be relied upon.

大致意思就是:

带扛的(例如ps -al),筛选所有终端机下执行的程序,除了阶段作业领导者之外。
不带杠的(例如ps al),筛选现行终端机下的所有程序,包括其他用户的程序。

进程的一些特性

如下是与进程相关的一些特性理解:

  1. 并发:多个进程在同一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,使得我们从宏观上感受这几个进程是在同时进行的,这就叫做进程并发。
  2. 并行:多个进程在多个CPU下分别同时执行,这称之为并行。注意,并行只有多个CPU的情况下才行,CPU的多核是与多线程有关的,与并行无关。
  3. tips - 并发和并行的区别:并发是单个CPU频繁切换进程,使得我们认为感受上是多个进程在同时运行,但实际上每个时间只有一个进程在运行。而并行就是真正的多个进程同时运行,其中,有多少个CPU就可以同时运行几个进程。
  4. 进程的竞争性:一般情况下操作系统中的进程数量非常的多,而CPU资源却是有限的(家用电脑通常只有一个CPU),所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了进程优先级、调度算法等一系列分配规则。
  5. 进程的独立性:多进程运行时,需要独享各种资源,保证运行期间每个进程都是相互独立、互不干扰。通常是用一些虚拟技术来实现每个进程的独立性。
  6. 进程的动态性:进程的动态性通常体现在如下两个方面。首先,进程是动态产生、动态消亡的。其次,在进程的生存期内,其状态是处于经常性的动态变化之中的。
  7. 进程的异步性:每个进程都以其相对独立、不可预知的速度向前推进。至于进程的异步性是如何体现的可以参考:进程的异步性-CSDN博客
  8. 进程的结构性:每个进程都有一些抽象化的内核数据结构(例如PCB),以便操作系统统一化的管理。

进程标识符 - PID和PPID

进程标识符,又名pid、进程号等,是当前OS中每个进程唯一的标识符。PID是一个正整数,取值范围一般是 2—32768, /proc/sys/kernel/pid_max 文件中的内容就是最大可支持的进程个数。

我们知道,Linux是用C语言写的,所以在Linux下,我们可以在使用对应的getpid这个系统调用来获取一个进程的pid。

其中, sys/types.h 是 pid_t 这个类型声明的头文件,而getpid()函数是在 unistd.h 头文件中的。getpid函数直接返回一个pid_t类型的当前进程号,这个pid_t就是某个整型类型的声明,因为这是封装好的库函数,所以用法上就和C语言中普通的函数调用一样。

在前面我们知道,PID是一个进程的进程标识符。其实,还有一个对应的PPID,表示这个进程的父进程的进程标识符,也就是其父进程的PID,在Linux中我们通常用getppid()系统调用来获取一个进程的PPID信息,其用法和getpid()大同小异,这里就不再过多赘述了。


其中,在Linux中内存管理的一系列进程的PCB信息都是统一存放在 ./proc 目录中的,例如:

这些数字形式的目录就是对应的进程,而且这些目录的中的内容格式都是一致的,例如下面是目录内容的部分截图:

值得一提的是,cwd和exe这两个链接文件分别指向自己所在的工作目录和进程所在的路径位置,这也就是为什么我们可以采取相对路径的方式输入指令,因为指令创建的进程所在的地址是可以通过对应的链接符直接找到的。

其中,可以使用chdir接口实现改变工作目录,参考:getcwd、chdir、fchdir函数的基本使用。

暂且可以认为这些目录中的信息就是进程所对应的PCB信息。与一般目录不同的是, /proc 目录下的信息内容是动态的,当有进程被创建时,就会向其中增加对应的目录信息,文件名一般就是其对应的PID。而有进程结束时, /proc 目录下就会随之删除对应的信息。

进程状态

进程状态的概念

想要描述一个进程,一个必不可少的信息就是进程状态,我们需要知道进程当前是在运行、还是被终止了,亦或是其它状态,所以PCB中一个必不可少的信息就是进程的状态。那么以Linux为例,Linux中对上述描述的具体实现就是 task_struct 结构体中的 state 变量,如下图所示,task_struct中的第一个就是state。

参考 Linux 2.6.39 版本

而PCB对进程各种状态的描述,就是state对应了不同的变量,例如:

参考 Linux 5.2 版本

也就是说,仅从PCB的角度来看待进程的话,那么所谓的进程状态,其实就是一些提前定义好的数据,进程处在哪个状态,对应的state变量就切换成对应的值。进程状态切换时,PCB中其实就只是修改了state变量的值而已。

那么从操作系统的角度来看,进程状态又是怎么一回事呢?以操作系统的视角来看,进程大致有如下新建、就绪、运行、阻塞、挂起、结束等几个状态。

下面我们就简单的从概念层面上介绍这几个进程状态

  1. 新建状态:新建状态也常被称作创建状态,顾名思义,新建状态就是执行进程创建相关的工作。此时,操作系统会执行但不限于如下操作:为新进程申请一个空白PCB、为新进程分配其运行所需的资源、初始化PCB中的相关信息(例如PCB的state变量)。
  2. 就绪状态:当进程的创建操作完成之后,并不是马上将其PCB放到运行队列中,而是先把它放到就绪队列中(并修改PCB的state变量),等待操作系统的调度分配(具体是如何调度的与进程的优先级和系统的调度算法等有关,暂时不展开讨论),此时进程的状态就是就绪状态。
  3. 运行状态:一般情况下,进入到就绪队列中的PCB基本上都会被调度到CPU的运行队列中(并修改PCB的state变量),此时CPU会逐条执行处于运行队列中PCB所对应可执行程序中的二进制代码。那么此时进程所处的状态就是运行状态。值得注意的是,一个CPU只有一个运行队列,多个CPU可以有多个运行队列。
  4. 阻塞状态:一般来说,如果进程当前要获取的资源(软硬件资源)没有就绪,或者说进程当前无法获取到目标资源。那么此时这个进程的PCB就会被转移到对应资源的等待队列中,对应的state变量也会被修改。此时CPU的运行队列就没有了这个进程的PCB,那么也就无法去调度执行这个进程。那么此时的进程状态就叫做阻塞状态。
  5. 挂起状态:当系统资源紧张的时候,操作系统会对在内存中的资源进行更合理的安排,这时就会将将某些优先级不高的进程设为挂起状态,并将其移到内存外面,一段时间内不对其进行任何操作,当条件允许的时候,会被操作系统再次调回内存,重新进入等待被执行的状态即就绪态。
  6. 结束状态:顾名思义,进程执行结束,操作系统为其释放对应的系统资源和执行一些清理工作的过程就叫做结束状态。

下面是与进程状态一些相关的内容:(有点杂乱,可以直接跳过)

  1. 操作系统在管理硬件资源时,也有对应的硬件信息队列(只是提一嘴,不细究),这些硬件资源的管理和PCB的运行队列类似(虽然实际情况肯定不是这样的,但却是以这种形式为基础的,所以可以这样描述),当硬件资源就绪时,进程就可以正常访问、获取进程想要的资源,而如果硬件资源没有就绪时,那么前来访问资源的进程就会被安排在对应资源的等待队列中,当资源就绪时,进程获取资源,对应的PCB再回到CPU的运行队列,就又可以被CPU重新调度。其中,每一个硬件都有其对应的等待队列,所以并不是只有一个等待队列。所以当我们运行了多个进程,这些进程有时可能会访问一些共同的资源时,就可能会出现我们感受到的卡顿现象。而且,操作系统中,存在着各种各样的队列,不止CPU的运行队列和各种硬件的等待队列。
  2. 挂起状态的实例 —— 阻塞挂起:阻塞挂起的操作是,将内存中的代码和数据先暂存到外存设备的某些特定区域(如硬盘的swap区),只留一个很小的PCB放在内存中(当然也会修改对应的state变量),当进程被唤醒时再次重新被加载到内存。这样做肯定会造成速度降低,但有时却不得不这样做。例如当所剩的内存资源严重不足时,操作系统就不得不暂时挂起一些阻塞队列中的进程来缓解内存的压力。
  3. 阻塞和挂起的区别:(参考:一文助你理解进程七态及挂起与阻塞 - 掘金)

    1、挂起是一个行为,而阻塞是进程的一种状态。

    2、进程的位置不同:挂起是将进程移到外存中,而处于阻塞状态的进程还是在内存的对应资3、源的等待队列中。

    4、产生的原因不同:挂起一般是由于可分配资源不足,迫使操作系统对一些进程采取挂起操作,抑或是用户指定某个进程挂起。而阻塞是进程正在等待某一事件发生,一般是等待资源或者响应等而暂时停止运行的状态。

    5、挂起是被动的行为,进程被迫从内存中移至外存中。而阻塞可以看成是一个主动的行为,主动的进入到对应资源的等待队列。

Linux下的进程状态

至此,我们简单的从概念的层面认识了进程的几种状态,那么接下来我们就以Linux为例,介绍一些具体的进程状态。(内容参考:Linux系统之进程状态-腾讯云开发者社区-腾讯云)

R:running or runnable (on run queue)
S:interruptible sleep (waiting for an event to complete)
D:uninterruptible sleep (usually IO)
T:stopped by job control signal
t:stopped by debugger during the tracing
W:paging (not valid since the 2.6.xx kernel)
X:dead (should never be seen)
Z:defunct ("zombie") process, terminated but not - reaped by its parent

1、R (TASK_RUNNING):运行或可执行状态

TASK_RUNNING,运行态和就绪态的合并,表示进程正在运行或准备运行,因为CPU的运行速度非常快,所以运行状态和就绪状态之间的空隙时间很短,所以就统一用 R 状态表示了。

补充:很多教科书将正在CPU上执行的进程定义为RUNNING状态、而将可执行但是尚未被调度执行的进程定义为READY状态,这两种状态在linux下统一为 TASK_RUNNING状态

2、S (TASK_INTERRUPTIBLE):可中断睡眠状态

TASK_INTERRUPTIBLE,此时进程被阻塞,当等待的资源到来时即可唤醒。或者也可以通过其他进程信号或时钟中断唤醒,进入运行队列。这种睡眠状态是可中断的,即可以被kill掉的。

补充:通过ps命令会看到,一般情况下,进程列表中的绝大多数进程都处于 S 状态(除非机器的负载很高)。毕竟CPU就这么几个,进程动辄几十上百个,如果不是绝大多数进程都在睡眠,CPU又怎么响应得过来。

3、D (TASK_UNINTERRUPTIBLE):不可中断的睡眠状态

与TASK_INTERRUPTIBLE状态类似,进程处于睡眠状态,但是此刻进程是不可中断的。不可中断,也就是说处于这种睡眠状态的进程,哪怕是用户强制kill都不能让其强行结束。

补充说明:不可中断睡眠状态存在的意义就在于,内核的某些处理流程是不能被打断的。如果响应异步信号,程序的执行流程中就会被插入一段用于处理异步信号的流程(这个插入的流程可能只存在于内核态,也可能延伸到用户态),于是原有的流程就被中断了。 例如进程在对某些硬件进行操作时(比如进程调用read系统调用对某个设备文件进行读操作,而read系统调用最终执行到对应设备驱动的代码,并与对应的物理设备进行交互),可能需要使用TASK_UNINTERRUPTIBLE状态对进程进行保护,以避免进程与设备交互的过程被打断,造成设备陷入不可控的状态。这种情况下的TASK_UNINTERRUPTIBLE状态总是非常短暂的,通过ps命令基本上不可能捕捉到。

4、T (TASK_STOPPED):暂停状态

TASK_STOPPED,进程暂停执行,处于挂起状态,而不是阻塞状态。 例如通过向进程发送一个SIGSTOP信号(kill -19),它就会因响应该信号而进入暂停状态(除非该进程本身处于 D 状态而不响应信号)。

5、t (TASK_TRACED):跟踪状态

TASK_TRACED,此时进程被跟踪,“正在被跟踪” 指的是进程暂停下来,等待跟踪它的进程对它进行操作。比如在gdb调试中对进程打一个断点,进程在断点处停下来的时候就处于跟踪状态。而在其他时候,进程就还是处于正常情况,不是跟踪状态。

补充说明:对于进程本身来说,暂停状态和跟踪状态很类似,都是表示进程暂停下来。只不过跟踪状态相当于在暂停状态之上多了一层保护,处于跟踪状态的进程不能响应SIGCONT信号而被唤醒。只能等到调试进程通过一些系统调用执行对应的操作操作或是调试进程退出,被调试的进程才能恢复到其它状态。

6、Z (TASK_DEAD - EXIT_ZOMBIE):退出状态 - 进程成为僵尸进程

进程在退出的过程中,是处于TASK_DEAD状态的。在这个退出过程中,除了task_struct(PCB)之外,操作系统会将进程占有的所有资源进行回收。那么最后进程就只剩下task_struct这个空壳,所以被称为僵尸进程。

补充说明:之所以保留task_struct,是因为task_struct里面保存了进程的退出码、以及一些统计信息。而其父进程很可能会关心这些信息。例如父进程可以通过wait系列的系统调用(如wait4、waitid等)来等待某个或某些子进程的退出,并获取它的退出信息,而这个退出信息就是被保存在task_struct中的,之后wait系列的系统调用会顺便将子进程的尸体(task_struct)也释放掉。

6、X (TASK_DEAD - EXIT_DEAD),死亡状态 - 进程即将被销毁

一个进程即将退出,PCB随之也会销毁。所以死亡状态是非常短暂的,几乎不可能通过ps命令捕捉到,这里仅作为概念了解,日常生活中很少遇到。


案例分析:当编写如下的C语言的代码,并监视对应的进程时会发现,大多数情况捕获到的都是S状态。是因为CPU的执行速度非常快,而访问硬件资源或是文件等速度相对会很慢(例如代码中的printf函数),所以实际上处于R状态的时间是微乎其微的。当我们把printf和sleep都注释掉之后再去捕获进程状态,就会发现此时都是R状态了。

int main()
{

    while(1)
    {
       printf("我是一个进程,我的pid是: %d\n", getpid());
       sleep(1);
    }

    return 0;
}

内容补充:前台进程和后台进程

不难发现,有时进程状态后面还会跟一个加号 '+',这表示的是当前的进程是一个前台进程,前台进程占用着前台资源,所以此时是无法进行输入指令等操作的。而后台进程状态信息之后是没有那个加号的,后台进程不影响前台操作(后台进程无法Ctrl+C终止掉),所以还是可以进行输入指令等操作的。在Linux下,我们只需要在指令后加一个 '&',那么对应的进程就是后台进程。

父子进程

子进程的创建 - fork

在Linux中,我们通常使用一个系统调用fork()来创建一个进程。其中,这个被创建的进程叫做当前进程的子进程,那么这个当前进程就是被fork创建的进程的父进程。

fork的用法示例如下:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
    fork();  
    printf("我是一个进程,我的进程PID是:%d 我的PPID是:%d\n", getpid(), getppid());

    return 0;
}

运行结果如下:

要点概述:

  1. fork的返回值是一个pid_t整型的值,根据不同的情况返回值是不同的。如果进程创建成功,那么父进程返回子进程的pid,子进程返回0。如果进程创建失败则返回一个负数。其中给父进程返回子进程的PID的原因是,这样做便于父进程对子进程的管理和监控。
  2. fork之后生成的子进程与其父进程谁先执行谁后执行是不确定的,这与操作系统的进程调度策略有关,不能一概而论。
  3. fork()的执行过程:申请PID → 申请PCB结构 → 复制父进程的PCB → 将子进程的运行状态设置为不可执行的 → 将子进程中的某些属性清零,某些保留,某些修改 → 复制父进程的页(用到了写时拷贝技术)。
  4. 有些地方会讲,“通过fork创建的子进程会拷贝父进程的代码段、数据段、静态数据段、堆、栈、IO缓冲区等信息”。但严格意义上讲并不是这么简单的,虽然从虚拟内存的角度来看,看似是将这些信息直接拷贝给子进程的。 但实际上这些内容并不是直接拷贝过去的,而是用到了一个“写时拷贝”技术:父子进程在初始阶段共享所有的数据(全局、 栈区、 堆区、 代码), 内核会将所有的区域设置为只读。 当父子进程中任意一个进程试图修改其中的数据时, 内核才会将要修改的数据所在的区域(页)拷贝一份。
  5. 父子进程代码共享,数据以写时拷贝的方式私有一份。父子进程虽然代码共享,但数据是各自独有的。这与操作系统的虚拟内存机制有关,这样的机制保证了父子进程独立运行互不干扰。
  6. 为什么子进程在创建之后是在fork之后的位置开始的,而不是从代码开始的位置开始的呢?原因也很好理解:子进程拷贝父进程的PCB,而父进程在执行时,PCB中存储了程序计数器与上下文信息等内容,因此虽然父子进程代码共享,但子进程并不会从main函数的入口处开始执行,而是同父进程的运行位置开始执行。
  7. 一般来说,所有命令行下执行的指令都是 shell/bash 的子进程。
  8. 虽然子进程完全拷贝父进程的PCB,但这父子进程的PCB并不是同一个。
  9. 为什么有父子进程?也就是说,创建子进程有什么作用?子进程与父进程有着很强的关联,但其运行过程并不影响父进程,因此子进程也被称为父进程的守护进程。当一个进程需要做一些可能发生阻塞或中断的任务,父进程可通过子进程去做,来避免自己进程的崩溃。
  10. 父进程只对子进程负责(执行回收PCB等操作),不对孙子进程负责。

内容参考:

  1. Linux 进程概念——父子进程、僵尸进程和孤儿进程-CSDN博客
  2. 通过fork创建的子进程会拷贝父进程的……
  3. Linux系统——fork()函数详解(看这一篇就够了!!!)_fork函数-CSDN博客

僵尸进程

一个进程(一般是指子进程)在退出的过程中,会先将将系统资源(内存、外设等)归还给操作系统,然后只留下一个进程的PCB信息。也就是说此时的进程在内存中只剩下的PCB这一个空壳了,那么进程此时的状态就叫做僵尸状态,那么这个进程就是一个僵尸进程(进程只剩下了一具躯壳,就像一个僵尸一样,所以叫僵尸进程)。简单来说,僵尸进程即:子进程资源已经释放,但其父进程还没有回收这个进程的PCB,这个期间的进程就叫做僵尸进程。网上常见的解释是:

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

此时这个PCB不能立即释放,因为里面还存放着进程的退出状态等信息,这些PCB信息需要由其父进程回收之后才会释放。父进程在读取了PCB中的相关退出信息或者说回收了对应的PCB之后,会先把PCB中的进程状态信息状态改为X,然后才会把PCB退出。而且,僵尸进程是无法被终止的,那么是通过kill指令,因为谁也无法杀掉一个死去的进程!所以,如果不及时对僵尸进程的PCB进行回收,那么就需要一直维护着这个PCB,进而会造成内存泄露。
而且,子进程退出时,会为父进程发送一个信号,父进程需要主动捕捉这个信号才能回收子进程的PCB信息,但是当父进程正处于运行状态(R)或睡眠状态(S)时,是无法接收子进程的退出信号的, 那么子进程的退出状态信息也就无法被回收,所以对应子进程的PCB可能会长时间占用内存资源,这就可能导致内存泄露的问题。实例演示如下:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>


int main()
{
    int fid = fork();

    if(fid != 0)
        sleep(1000);
    else   
        _exit(2);

    return 0;
}

监测其进程状态为:

孤儿进程

孤儿进程很好理解,就是如果父进程比子进程先退出,那么这个子进程就变成了孤儿进程。而在Linux中,孤儿进程都会被操作系统的1号进程(init)领养,这个1号进程(init)是所有用户的所有进程的共同祖先,暂且认为这个1号进程(init)就相当于是进程中的“造物主”。想了解更多关于这个1号进程的内容,可以参考这篇博客:linux的0号进程和1号进程 - AlanTu - 博客园 (cnblogs)

注意事项:

  1. 与僵尸进程不同的是,孤儿进程是没有孤儿状态一说的。
  2. 如果父进程提前退出,那么其子进程(孤儿进程)会自动变成后台进程。即孤儿进程运行在后台。
  3. 孤儿进程一定会被1号进程领养,不存在无主的孤儿进程的这种情况。这是因为:如果不对孤儿进程领养,就没有进程对这个孤儿进程回收,那么必然就会导致内存泄露。
  4. 因为孤儿进程一定会被领养,所以孤儿进程是不会由内存泄露的危险的。

进程切换

CPU上下文切换

Linux是一个多任务操作系统,它支持远大于CPU核心数的任务同时进行。当然,这些任务并不是真的同时在运行,而是因为系统在很短的时间内(一般人无法感觉到的毫秒级别的时间),将CPU资源轮流分配给它们,造成多任务同时运行的错觉(也就是进程的并发)。
每个任务在运行前,CPU都需要知道任务从哪来加载,又从哪里开始运行,也就是说,需要事先帮它们设置好CPU寄存器和程序计数器。关于CPU寄存器和程序计数器的相关内容如下:

  • CPU寄存器:是CPU内置的容量小、但速度快的内存,用来临时存放指令执行运行过程中的操作数和中间(最终)的操作结果。
  • 程序计数器:是用来存储CPU正在运行的指令位置、或者即将执行的下一条指令位置。

 由于寄存器的种类繁多,所以这里就不再细说了,对此感兴趣的话可以参考下面这篇博客:一口气看完45个寄存器,CPU核心技术大揭秘 - 知乎 (zhihu)

因此我们知道了:CPU寄存器和程序计数器,是CPU运行任何任务时必须依赖的环境。同时也被称作CPU上下文。那么,“寄存器只有一个,但寄存器数据却可以有多组”这句话就很好理解了。所以,CPU上下文切换就是把CPU寄存器和程序计数器中的内容进行切换,大致过程如下:

把前一个任务的CPU上下文(CPU寄存器和程序计数器的内容)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器中(通常是以覆盖的方式加载),最后再跳转到程序计数器所指的新位置,运行新的任务。而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。

内容摘自:进程切换原理 - 林锅 - 博客园

内核态和用户态

Linux按照特权等级,把进程的运行空间分为内核空间和用户空间,也就是内核态和用户态。一些特殊的库函数调用调用,如open()打开文件等,需要切换到内核空间运行,用户空间是没有权限调用这些的。也就是说,进程既可以在用户空间运行,又可以在内核空间运行。在用户空间运行即为用户态,而陷入内核空间的时候,即为内核态。这种从用户态切换到内核态时,必须经过系统调用来完成。简单概括来说:

内核态:内核态是操作系统拥有最高特权级别的状态。在内核态下,操作系统可以直接访问计算机的所有硬件资源,包括内存、CPU和设备。它可以执行特权指令,直接操作硬件,修改系统配置,并且响应各种中断。

用户态:是应用程序和普通任务运行的状态。在用户态下,程序只能访问受限的资源和指令集。它不能直接操作硬件设备,也无法执行特权指令。即用户态下的程序运行在受限的环境中,不能对系统的核心部分进行直接操作。

所以其实我们的进程在运行时,有时是需要进行内核态和用户态之间的转换的。大致过程如下:

系统调用需要上下文切换。切换时,先保存CPU寄存器里原来用户态的指令位置。接着,为了执行内核态代码,CPU寄存器需要更新为内核态执行的新位置。最后才是跳转到内核态运行内核任务。系统调用结束后,CPU寄存器需要恢复原来保存的用户态,然后再切换到用户空间,继续运行进程。所以一次系统调用的过程,其实发生了两次CPU上下文切换。需要注意的是,系统调用过程中,并不会对虚拟内存等进程用户态的资源产生影响,也不会进行切换进程,这跟我们通常说的进程上下文切换还是有所区别的。所以,内核态和用户态之间的切换通常称为特权模式切换,而不是上下文切换。但实际上,系统调用过程中,CPU的上下文切换还是无法避免的。

—内容出处:进程切换原理 - 林锅 - 博客园

进程上下文切换

通过前面的内容我们知道,进程是由操作系统内核来管理和调度的,所以进程的切换只能发生在内核态。也就是说,进程的上下文不仅包括虚拟内存、栈、全局变量等用户空间的资源,还包括内核堆栈、寄存器等内核空间的状态。因此,进程的上下文切换就比系统调用时多了一步——在保存当前进程的内核状态和CPU寄存器之前,需要先把该进程的虚拟内存、栈等保存下来,而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈。这就是对进程上下文概念的简单阐述,如果想要了解更多关于进程上下文概念的内容,可以参考这篇博客:操作系统:进程上下文。如下是部分内容摘要:

        当一个进程在执行时 ,CPU 的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的上下文,以便在再次执行该进程时,能够必得到切换时的状态执行下去。在 LINUX 中,当前进程上下文均保存在进程的任务数据结构中。在发生中断时 , 内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中断服务结束时能恢复被中断进程的执行。

        内核空间和用户空间是操作系统理论的基础之一,即内核功能模块运行在内核空间,而应用程序运行在用户空间。现代的 CPU 都具有不同的操作模式,代表不同的级别,不同的级别具有不同的功能,在较低的级别中将禁止某些操作。Linux 系统设计时利用了这种硬件特性,使用了两个级别,最高级别和最低级别,内核运行在最高级别(内核态),这个级别可以进行所有操作,而应用程序运行在较低级别(用户态),在这个级别,处理器控制着对硬件的直接访问以及对内存的非授权访问。内核态和用户态有自己的内存映射,即自己的地址空间。

        正是有了不同运行状态的划分,才有了上下文的概念。用户空间的应用程序,如果想要请求系统服务,比如操作一个物理设备,或者映射一段设备空间的地址到用户空间,就必须通过系统调用来(操作系统提供给用户空间的接口函数)实现。

其中,为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片(一般是毫秒级别的,所以一般人无法感知),这些时间片被轮流分配给各个进程。也就是说,一个进程单次只会执行一个时间片的时间。大多数情况下这一个时间片的时间,并不足以让一个进程执行完毕。那么当一个时间片耗尽时,如果当前进程还没有执行完毕,就要把当前进程从CPU的运行队列中"剥离"下来,并将进程上下文的内容保存到进程控制块(PCB)中,以便下次加载时能够从当前停止的位置继续运行。紧接着将这个被剥离下来的进程PCB根据进程的调度算法由操作系统添加到CPU资源的等待队列中,然后再将新的进程加入到CPU的运行队列,切换进程上下文,完成进程切换。

除了时间片中断会使操作系统切换进程上下文外,还会有如下这些情况:

  1. 阻塞式系统调用、虚拟地址异常,导致被中断进程进入等待态。
  2. 时间片中断、I/O中断后发现更高优先级进程,导致被中断进程进入就绪态。
  3. 终止用系统调用、不能继续执行的异常,导致被中断进程进入终止态。

进程优先级与进程调度

进程优先级

一般情况下,操作系统中的进程数量非常的多,而CPU资源有限的,所以为了能够高效完成任务,更合理竞争相关资源,便引入了进程优先级。
其中,进程分为实时进程与非实时进程,实时进程通常是用户定义的一般进程,非实时进程一般是操作系统的内核进程。实时进程具有一定程度上的紧迫性,要求对外部事件做出非常快的响应。而普通进程则没有这种限制。所以,操作系统对进程的调度会区分对待这两种进程。通常实时进程要比普通进程优先运行。
不同的操作系统对优先级的表述是不同的,但基本上都大同小异。例如:

在Linux下,进程优先级在进程信息中是用PRI来表示的(priority的简写),这个PRI又与nice值(也就是NI)息息相关,PRI的相关内容概述如下:

  1. 与PID类似,PRI在底层的实现本质也是PCB中的一个整型字段(变量)。其中,PRI 越低,表示进程的优先级越高。
  2. 在Linux下,进程的优先级一共有140个档次,常被认为的0-139这140个档次,一般实时进程是0-99这个部分的,非实时进程是100-139后面这几个档次的。
  3. 非实时进程的优先级的值与nice值(也就是NI)息息相关,进程优先级=old+nice,这里的old指的是每一个非实时进程初始的默认值(不考虑映射的情况下是120),而不是上一次的进程优先级的值。
  4. 通过ps指令查看的一般普通进程的PRI为80(ps指令带杠的情况),而不是120,这是因为用ps指令查看显示的PRI值的范围是 -40~99,其中 60~99是普通进程,-40~59是实时进程。而由于一些版本原因,ps不带杠的情况下默认值是20。
  5. 而通过top指令查看的PR值范围是0~39,表示的是非实时进程的范围,这是因为top指令显示的PR值不监测实时进程。
  6. nice值只会影响非实时进程,也就是一般的进程,无法对系统内核的进程产生影响。其中,nice的范围为 -20~19,与非实时进程的40个范围相呼应。
  7. 可以通过在top指令(top→按r→输入pid→输入修改的nice值),或者用renice指令来修改一共非实时进程的nice值。其中,普通用户只能将nice值设为正值,只有用sudo提权之后或者root用户才可以把nice值设为负值。

补充 - top指令和renice指令的用法

top指令:

top命令经常用来监控linux的系统状况,是常用的性能分析工具,能够实时显示系统中各个进程的资源占用情况。

使用格式

        top [-d number] | top [-bnp]

常用参数

参数含义
-d numbernumber代表秒数,表示top命令显示的页面更新一次的间隔 (default=5s)
-b以批次的方式执行top
-n与-b配合使用,表示需要进行几次top命令的输出结果
-p指定特定的pid进程号进行观察

top命令显示的页面还可以输入以下按键执行相应的功能(注意大小写区分)

参数含义
显示在top当中可以输入的命令
P以CPU的使用资源排序显示
M以内存的使用资源排序显示
N以pid排序显示
T由进程使用的时间累计排序显示
k给某一个pid一个信号,可以用来杀死进程(9)
r给某个pid重新定制一个nice值(即优先级)
q退出top(用ctrl+c也可以退出top)

内容参考:linux top命令详解(看这一篇就够了)_Steven.1的博客-CSDN博客

renice指令:

top指令其实更偏向于进程的监视,renice指令比较适合修改非实时进程的优先级。

常用参数

-g 指定进程组id

-p 改变该程序的优先权等级,此参数为预设值

-u 指定开启进程的用户名

用法示例

   将PID为1101进程的PRI设置为80+12=32(省略了加号'+')

renice 12 1101

   将PID为987、32的进程,与进程拥有者为daem及root的优先序号码加1(没有省略加号'+')

renice +1 987 -u daem root -p 32

最后,如果感觉意犹未尽,想要对进程优先级了解的更多,可以尝试看一下这篇博客:技术|深入 Linux 的进程优先级https://linux/article-7325-1.html

进程调度算法

进程调度算法也称CPU调度算法(毕竟进程是由CPU调度的),当CPU空闲时,操作系统就选择内存中的某个就绪状态的进程,并给其分配CPU。具体来说,通常有如下几种情况:

  1. 进程从运行状态转到等待状态。例如进程运行过程中,在某一资源的等待队列处阻塞住。
  2. 进程从运行状态转到就绪状态。例如当前时间片到了,使正在运行的进程中断,切换上下文,使进程重新进入就绪队列进行排队。
  3. 进程从等待状态转到就绪状态。假设有一个进程是处于等待状态的,但是它的优先级比较高,如果该进程等待的事件发生了,它就会转到就绪状态,一旦它转到就绪状态,如果我们的调度算法是以优先级来进行调度的,那么它就有可能立马抢占正在运行的进程,所以这个时候就会发生CPU调度。
  4. 进程从运行状态转到终止状态。进程正常终止的情况。

那么为什么第3种情况也会发生CPU调度呢?
第2种状态是因为时间片到的情况,因为时间片到了就会发生中断,于是就会抢占正在运行的进程,从而占用CPU。

内容补充 - 抢占式和非抢占式:

非抢占式的意思是,当进程正在运行时,它就会一直运行,直到该进程完成或发生某个事件而被阻塞时,才会把CPU让给其他进程。而抢占式,顾名思义,就是进程正在运行时可以被打断,使其把CPU让给其他进程。

其中,发生在 1 和 4 两种情况下的调度称为"非抢占式调度",2 和 3 两种情况下发生的调度称为"抢占式调度"。而进程抢占的原则一般有如下三种:

  1. 优先权原则:允许优先级高的进程抢优先级低进程的CPU资源。
  2. 短进程优先:新到的短时间进程,可以抢占当前长进程的CPU资源。
  3. 时间片原则:按照时间片来分配时间来,时间结束就要停止该进程的执行,重新等待时间片的分配。

注意,调度算法影响的是等待时间(进程在就绪队列中等待调度的时间总和),并不能影响进程真在使用 CPU 的时间和 I/O 时间。


接下来,就让我们来认识一些常见的调度算法:

1、先来先服务调度算法(FCFS , First Come First Severd

先来先服务调度算法是一种最简单的调度算法,也称为先进先出或严格排队方案。当每个进程就绪后,它加入就绪队列。当前正运行的进程停止执行,选择在就绪队列中存在时间最长的进程运行。该算法既可以用于作业调度,也可以用于进程调度。先来先去服务比较适合于长作业(进程),而不利于短作业(进程)。
FCFS调度算法属于非抢占式的算法。从表面上看,它对所有作业都是公平的,但若一个长作业先到达系统,就会使后面许多短作业等待很长时间,造成短进程饥饿(进程饥饿是指进程长时间得不到CPU资源),因此它不能作为分时系统和实时系统的主要调度策略。但它常被结合在其他调度策略中使用。例如,在使用优先级作为调度策略的系统中,往往对多个具有相同优先级的进程按FCFS原则处理。

2、短作业(进程)优先调度算法(SJF , Shortest Job First | SPF  , Shortest Process First

短作业(进程)优先调度算法是指对短作业(进程)优先调度的算法。短作业优先(SJF)调度算法是从后备队列中选择一个或若干个估计运行时间最短的作业,将它们调入内存运行。而短进程优先(SPF)调度算法,则是从就绪队列中选择一个估计运行时间最短的进程,将处理机分配给它,使之立即执行,直到完成或发生某事件而阻塞时,才释放处理机。
SJF(SPF)调度算法属于非抢占式的算法,他的原则是下一次选择预计处理时间最短的进程,因此短进程将会越过长作业跳至队列头。该算法既可用于作业调度,也可用于进程调度。但是他对长作业不利,不能保证紧迫性作业(进程)被及时处理,而且作业的长短只是被估算出来的。

3、最短剩余时间优先调度算法(SRTN , Shortest Remaining Time Next

最短剩余时间是针对最短进程优先增加了抢占机制的版本。在这种情况下,进程调度总是选择预期剩余时间最短的进程。当一个进程加入到就绪队列时,如果比当前运行的进程具有更短的剩余时间,那么只要新进程就绪,调度程序就能抢占当前正在运行的进程。像最短进程优先一样,调度程序正在执行选择函数是必须有关于处理时间的估计,并且存在长进程饥饿的危险。

4、高响应比优先调度算法(HRRN , Highest Response Ratio Next

根据比率:R=(w+s) / s (R为响应比,w为等待处理的时间,s为预计的服务时间)

调度规则为:当前进程完成或被阻塞时,选择R值最大的就绪进程。

高响应比优先调度算法主要用于作业调度,该算法是对FCFS调度算法和SJF调度算法的一种综合平衡,同时考虑每个作业的等待时间和估计的运行时间。具体解释如下:

  • 如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行。
  • 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会。

5、时间片轮转调度算法(RR , Round Robin

时间片轮转调度算法主要适用于分时系统。在这种算法中,系统将所有就绪进程按到达时间的先后次序排成一个队列,进程调度程序总是选择就绪队列中第一个进程执行,但仅能运行一个时间片,例如30ms这种。在使用完一个时间片后,即使进程并未完成其运行,它也必须释放出(被剥夺)CPU资源给下一个就绪的进程,接着返回到就绪队列的末尾重新排队,等候再次运行。

在时间片轮转调度算法中,时间片的大小对系统性能的影响很大。如果时间片足够大,以至于所有进程都能在一个时间片内执行完毕,则时间片轮转调度算法就退化为先来先服务调度算法。如果时间片很小,那么处理机将在进程间过于频繁切换,使处理机的开销增大,而真正用于运行用户进程的时间将减少。因此时间片的大小应选择适当。而时间片设为20~50ms通常是一个比较合理的值。

6、最高优先级调度算法(HPF , Highest Priority First

优先级调度算法又称优先权调度算法,该算法既可以用于作业调度,也可以用于进程调度。其中,优先级用于描述作业运行的紧迫程度。

在作业调度中,优先级调度算法每次从后备作业队列中选择优先级最髙的一个或几个作业,将它们调入内存,分配必要的资源,创建进程并放入就绪队列。在进程调度中,优先级调度算法每次从就绪队列中选择优先级最高的进程,将CPU分配给它,使之投入运行。根据新的更高优先级进程能否抢占正在执行的进程,可将该调度算法分为:

  • 非抢占式优先级调度算法:当某一个进程正在CPU上运行时,即使有某个更为重要或紧迫的进程进入就绪队列,仍然让正在运行的进程继续运行,直到由于其自身的原因而主动让出处理机时(任务完成或等待事件),才把CPU分配给更为重要或紧迫的进程。

  • 抢占式优先级调度算法:当一个进程在CPU上运行时,若有某个更为重要或紧迫的进程进入就绪队列,则立即暂停正在运行的进程,将处理机分配给更重要或紧迫的进程。

而根据进程创建后其优先级是否可以改变,可以将进程优先级分为以下两种:

  • 静态优先级:优先级是在创建进程时确定的,且在进程的整个运行期间保持不变。确定静态优先级的主要依据有进程类型、进程对资源的要求、用户要求。

  • 动态优先级:在进程运行过程中,根据进程情况的变化动态调整优先级。动态调整优先级的主要依据为进程占有CPU时间的长短、就绪进程等待CPU时间的长短。

7、多级反馈队列调度算法(MLFQ , Multi-level Feedback Queue

多级反馈队列算法,不必事先知道各种进程所需要执行的时间,他是当前被公认的一种较好的进程调度算法。多级反馈队列调度算法的实现思想如下:

  1. 设置多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短。
  2. 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成。
  3. 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行。

可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。


内容参考:

  • 大厂面试爱问的「调度算法」,20 张图一举拿下 - 小林coding - 博客园 (cnblogs)
  • 操作系统——进程调度的几种算法_什么是短进程调度算法-CSDN博客

Linux具体的进程调度

前面我们说过,Linux的进程分普通进程和实时进程,实时进程的优先级(0~99)比普通进程的优先级(100~139)要高,当系统中有实时进程运行时,普通进程几乎是无法分到时间片的。所以,实时进程和普通进程的调度是两种不同的策略。因为实时进程的紧迫性,所以实时进程一般采用的是先来先服务(FCFS)和时间片轮转(RR)调度算法。而普通进程则采用一些相对公平的算法,例如在Linux2.6.23之后引入的CFS算法,其中CFS涉及红黑树等门槛较高的内容,所以就不展开叙述,感兴趣的可以参考:一文搞懂linux cfs调度器 - 知乎 (zhihu)https://zhuanlan.zhihu/p/556295381#:~:text=%E4%B8%80%E6%96%87%E6%90%9E%E6%87%82linux%20cfs%E8%B0%83%E5%BA%A6%E5%99%A8%201%201%EF%BC%8C%E4%BB%8B%E7%BB%8D%20CFS%EF%BC%88Completely%20Fair%20Scheduler%EF%BC%8C%E5%AE%8C%E5%85%A8%E5%85%AC%E5%B9%B3%E8%B0%83%E5%BA%A6%E5%99%A8%29%E7%94%A8%E4%BA%8ELinux%E7%B3%BB%E7%BB%9F%E4%B8%AD%E6%99%AE%E9%80%9A%E8%BF%9B%E7%A8%8B%E7%9A%84%E8%B0%83%E5%BA%A6%E3%80%82%20%E5%AE%83%E7%BB%99cfs_rq%EF%BC%88cfs%E7%9A%84run,%E8%BF%90%E8%A1%8C%E9%98%9F%E5%88%97%E3%80%81cfs%E8%BF%90%E8%A1%8C%E9%98%9F%E5%88%97%EF%BC%8C%E4%BB%BB%E5%8A%A1task%E3%80%81%E4%BB%BB%E5%8A%A1%E7%BB%84%E3%80%81%E8%B0%83%E5%BA%A6%E5%AE%9E%E4%BD%93%E7%BB%93%E6%9E%84%E4%BD%93%E7%AE%80%E4%BB%8B%EF%BC%9A%20...%204%204%EF%BC%8C%E6%B5%81%E7%A8%8B%E5%88%86%E6%9E%90%20%E6%95%B4%E4%B8%AACFS%E7%9A%84%E6%A0%B8%E5%BF%83%E5%B0%B1%E6%98%AF%E5%9F%BA%E4%BA%8Ecfs%E8%B0%83%E5%BA%A6%E7%B1%BB%E5%AE%9E%E7%8E%B0%E7%9A%84%E5%90%84%E4%B8%AA%E5%87%BD%E6%95%B0%EF%BC%8C%E6%8E%A5%E4%B8%8B%E6%9D%A5%E7%9A%84%E6%B5%81%E7%A8%8B%E5%88%86%E6%9E%90%E4%B9%9F%E5%9F%BA%E4%BA%8E%E8%BF%99%E4%BA%9B%E6%A0%B8%E5%BF%83%E5%87%BD%E6%95%B0%E3%80%82%204.1%20vruntime%E7%9A%84%E8%AE%A1%E7%AE%97%20

如果觉得上面这篇比较晦涩难懂,可以尝试看一下下面这篇博客的相关部分

Linux进程(三)--进程切换&命令行参数-CSDN博客https://blog.csdn/m0_64707620/article/details/133822294

虚拟地址空间

虚拟内存分段(分区)

首先,让我们先来认识虚拟内存分段的概念:

分段是将内存划分成各个段落(Segment),每个段落的长度可以不同,且虚拟地址空间中未使用的空间不会映射到物理内存中,所以操作系统不会为这段空间分配物理内存。这样的话,内核为刚创建的进程分配的物理内存可以很小,随着进程运行不断使用内存,内核再为进程按需分配物理内存。也就是说,尽管地址空间的范围和物理内存大小一样,但不会将全部空间映射到物理内存。

—— 内容摘自:操作系统修炼秘籍(9):虚拟内存分段 | 骏马金龙

所以,其实进程在运行时看到的内存是被划分为各种区域的。而这些所谓的内存分段,其实就是一种操作系统为进程虚拟化出来的一种逻辑地址,并不是真正意义上的物理内存。就相当于给进程带上了一种"有色眼镜",使得进程在看内存的对应区域时就有对应的限制(所以相应的,源代码在编译时,也要按照如上规则去存放数据)。而且,每个进程都不知道有其它进程的存在,都认为自己独享整个内存,但实际上操作系统肯定不会真的给他们分配这么多内存。

在学习C/C++时,为了能够更好地理解动态内存分区,一般会笼统地了解内存区域划分相关的知识,那时我们简单地将内存划分为堆区、栈区、静态全局区、文字常量区以及代码区这5块部分。但实际上进程的内存区域划分可以细分为如下几部分:(碍于本人才疏学浅,下图可能不够严谨,如有疏漏还请指正)

以32位系统为例,32位系统的内存最大限度为4G,操作系统将其划分为用户空间和内核空间两部分,用户空间占3G,内核空间占1G(注意,64位的内存区域划分比例并不也是三比一)。这里我们暂且不过多关注内核空间的内容。而对于用户空间,堆区和栈区是动态增长的,其它区域都是固定的。而且栈区的空间相对堆区要小很多,一般只有几兆,而堆区的空间则相对较大,具体与内存的大小有关。而保留区,它并不是一个单一的内存区域,而是对地址空间中受到操作系统保护而禁止用户进程访问的地址区域的总称。大多数操作系统中,极小的地址通常都是不允许访问的,例如NULL。至于其它各段,可以参考下表内容:

那么为什么要对虚拟内存进程分段呢?原因很简单,因为可执行程序中有着各种各样的数据,有只读的、可读写的等,正是因为数据的多样性,使得我们不能用一个规则对所有的数据进行管理,所以要对虚拟内存进行分段,不同的段用来存放不同的数据,以便对不同数据的针对性管理。

其实,可重定位目标文件的结构是要比上述内容复杂的,想要详细了解可重定位目标文件的结构、可执行文件装载与映射等内容,可以参考下面这篇博客:Linux可执行文件与进程的虚拟地址空间-腾讯云开发者社区-腾讯云 (tencent)https://cloud.tencent/developer/article/1624718

分页与内存映射

但实际上虚拟地址空间并不是简单的给进程规定了具体的规则这么简单,具体的实现还与页表之间是密不可分的。在认识页表之前我们需要了解一下页和页框等相关内容。

:页又称页面,是把进程的虚拟内存(逻辑地址),划分为一个个固定大小的页,并为每一个页加以编号,页中的每个字节用偏移量来控制。虚拟内存中的页是计算机内存管理的基本单位,大小一般在512B ~ 8KB之间(Linux下为4KB)。

页框:页框与页类似,页是虚拟内存划分时的一个基本单位,而页框则是实际的物理内存划分时的一个基本单位。而且为了保证映射的一致性,和页的大小是要保持一致的。

也就是说,页框和页表其实就是同一个"规则"在两种内存的两个不同的称谓罢了。

图片出处: 【小神仙讲 虚拟内存机制】哔哩哔哩

那么为了能够将虚拟内存的中页的内容映射到实际的物理内存上,便有了一个对应的映射表,名为页表,页表中保存了虚拟地址空间与实际内存地址的映射关系。

图片出处: 操作系统原理题源探秘(5)内存管理 - 知乎

但是,在只用一个页表的情况下会占用太多的内存空间。例如在32位系统下,一般一个页为4kb,一对映射关系需要一个CPU位宽(4字节), 那么4GB的虚拟内存就需要一个大小为4M的页表来记录每个页之间的映射关系。也许很多人会觉得4M好像也不大,但是这是32位系统下一个进程就需要一个4M的页表。而且如果是在64位系统的情况下,那么一个页表就需要256M的内存。这样一看就能明显感觉到只有一个页表造成的内存浪费问题了。

那么对应的解决方案是,采一种相对较复杂的多级页表的方式。因为虚拟内存中的堆和栈是动态单向增长的,所以大多数没有使用的虚拟内存是一整块大的区域,这样就可以采用一种"页表的页表"分级的方式,将内存中的页再按一定的规则再分块,如果块中有内容就指向下一级的页表,如果没有内容就什么也不指,这样就能够有效地解决大多数连续的空白内存问题。例如,下方是一个二级页表的图示:

图片来源: 【精选】操作系统---(32)多级页表及相关计算_多级页表计算-CSDN博客

可以发现,这是一种典型的B树结构。但这种方法在减少了内存消耗的同时,必定会消耗的时间有所增加。不过随着科技的发展,现代CPU的性能已经使得这种影响很小了。

内容补充

页表相关

页面共享

在多程序系统中,常常有多个程序需要共享同一段代码或数据的情况(典型的就是动态库)。在分页管理的存储器中,这个事情很好办:让多个程序共享同一个页面即可!具体的方法是:使这些相关程序的虚拟空间的页面在页表中指向内存中的同一个页框。
这样,当程序运行并访问这些相关页面时,就都是对同一个页框中的页面进行访问,而该页框中的页就被这些程序所共享。下图是3个程序共享一个页面的例子:


寄存器中存有页表的地址

CPU中的 cr3 寄存器会保存进程页表的地址(物理地址),也就是说,CPU能够直接找到页表,进行地址的映射。


页表不止有内存地址的映射

页表中不仅有页到页框之间的映射关系的内容,还有其它的一些内容,比如对应虚拟地址的访问权限的标记字段,不同的虚拟内存分段对应着不同的权限标记字段,自然也就有不同的权限。它能在进程访问虚拟内存的时候就对一些危险的操作进行拦截,提高了进程的安全性。

再比如,页表还有一个字段,用于表明映射的这个物理空间是否分配内存以及是否有内容。如果这个字段为0,就表示没有内容,说明这个进程是挂起的。这使得操作系统可以动态的执行进程,让进程边加载边执行。

内存相关

VMA和MMU

用户空间的内存分配采用了一种新的方法实现对进程动态内存的延迟分配,当用户态进程动态请求内存时,并没有真正分配物理页框,而是仅仅获得一段新的虚拟地址空间(ULK中称为线性区)的使用权,通过VMA(vm_area_struct)来管理这一段新的虚拟地址空间。

MMU(Memory Management Unit,内存管理单元)是一种硬件模块 ,用于在 CPU和内存之间实现虚拟内存管理 。 其主要功能是将虚拟地址转换为物理地址 ,同时提供访问权限的控制和缓存管理等功能。 放在整个大系统多核架构里面,每个处理器内置了MMU模块


内存交换 - swap分区

Linux内核为了提高读写效率与速度,会将文件在内存中进行缓存,这部分内存就是Cache Memory(缓存内存)。即使程序运行结束后,Cache Memory也不会自动释放。这就会导致Linux系统中程序频繁读写文件后,可用物理内存会变少。当系统的物理内存不够用的时候,就需要将物理内存中的一部分空间释放出来,以供当前运行的程序使用。那些被释放的空间可能来自一些很长时间没有什么操作的程序,这些被释放的空间被临时保存到Swap分区中,等到那些程序要运行时,再从Swap分区中恢复保存的数据到内存中。


写时拷贝

当使用fork等系统调用创建子进程时,子进程不论有无自己的VMA,它的VMA都有对于物理页的映射,但它们共同映射的这些物理页属性为只读,即Linux并未给子进程真正分配物理页,当父子进程任何一方要写相应物理页时,就会导致缺页中断,发生写时拷贝。此时操作系统会在另一个物理地址为子进程重新开辟一块空间,并在子进程的页表中添加对应的映射关系。操作系统会在另一个物理地址为子进程重新开辟一块空间,此时子进程的 页表 对应的物理地址也对应改变。所以,虽然父子进程的虚拟地址一样,但映射的物理地址是不同的。


Linux下的虚拟内存分段

Linux下,进程的虚拟内存分段的本质就是一个 mm_struct 结构体,这个结构体用来存放虚拟地址空间对应的内存区域划分中不同区域的start和end。其部分内容如下所示:

struct mm_struct
{
	long code_start;
	long code_end;
	long data_start;
	long data_end;
	long heap_start;
	long heap_end;
	long stack_start;
	long stack_end;
	......
}

可以看到,虚拟内存分段在实现上其实就是用整型字节划分各个分段的地址范围,并没有多想象中的富丽堂皇。

task_struct(Linux下的PCB结构)中就有一个 "mm_struct* mm"。而且,Linux下的每个进程在被创建时,不仅会创建task_struct,还会创建mm_struct。也就是说,内核数据结构并不只有PCB,还包括mm_struct等一系列的结构。


进程相关

可执行程序在编译阶段就已经有虚拟地址了

可执行程序在编译阶段其实就是已经编址好了的,可执行程序在链接阶段会按照ELF链接格式将代码按照不同的段划分好,加载的时候会按照这些段加载到虚拟地址空间对应的不同区域。执行的时候,进程只需要找到程序执行接口找到第一条执行语句对应的虚拟空间映射的物理内存拿到代码执行即可,当函数调用的时候,调用的位置存储的函数的虚拟地址,通过调用位置的虚拟地址找到执行语句,通过执行语句拿到要调用函数的虚拟地址,跳转到函数的位置去执行函数即可。


linux内核空间被所有进程共享

内核空间对于用户层,是一个虚拟的内存空间,并不是真实地址的。用户层程序运行在保护模式的“虚拟”的环境,所有虚拟和真实的转换,都是内核层来操作的。内核层共享,只是为了提供内核应该提供的访问接口。这样应用程序才能通过这些空间来和内核交互,以及调用其他功能和访问硬件。如果完全隔离,那么程序就不能有任何外部的函数调用,所有程序都要自备,而且所有硬件也都无法操作了。


进程的独立性

对每一个进程来说,在虚拟内存的环境中,进程不仅认为自己独享整个内存,完全不知道其它进程的存在,而且还让每一个进程以统一的视角来看待内存与地址空间。每个进程都认为自己所看到的虚拟内存就是真正的内存,并不知道有页表的存在(页表是由操作系统维护的),当进程访问了某一个虚拟地址时,通过MMU就可以映射到对应的物理内存。也就是说,进程就像"楚门"(一部电影的角色)一样,他能看到的,都是操作系统为它安排明白的,并不知道外面世界的样子。而且,每个进程都有自己独立的PCB和页表等内核数据结构,所以每个进程之间互不干涉,相互独立。

缺页中断

(内容参考:简述一下操作系统中的缺页中断__牛客网)

  • 中断的概念

中断是指计算机在执行程序的过程中,当出现异常情况或特殊请求时,计算机停止现行程序的运行,转向对这些异常情况或特殊请求的处理,处理结束后再返回现行程序的间断处,继续执行原程序。

  • 缺页中断的概念

缺页中断就是要访问的页不在主存,需要操作系统将其调入主存后再进行访问。当访问已映射在虚拟地址空间中,但是并未被加载在物理内存中的一个分页时,由中央处理器的内存管理单元所发出的中断。通常情况下,用于处理此中断的程序是操作系统的一部分。如果操作系统判断此次访问是有效的,那么操作系统会尝试将相关的分页从硬盘上的虚拟内存文件中调入内存。而如果访问是不被允许的,那么操作系统通常会结束相关的进程。

  • 缺页中断的分类
  1. 软性页缺失:指页缺失发生时,相关的页已经被加载进内存,但是没有向 MMU 注册的情况。操作系统只需要在 MMU 中注册相关页对应的物理地址即可。
  2. 硬性页缺失:硬性页缺失是指相关的页在页缺失发生时未被加载进内存的情况。
  3. 无效页缺失:当程序访问的虚拟地址是不存在于虚拟地址空间内的时候,则发生无效页缺失。
  • 缺页中断发生时的事件顺序
  1. 硬件陷入内核,在堆栈中保存程序计数器,将当前指令的各种状态信息保存在特殊的 CPU 寄存器中;
  2. 保存通用寄存器和其他易失的信息,以免被操作系统破坏;
  3. 当操作系统发现一个缺页中断时,尝试发现需要哪个虚拟页面。通常一个硬件寄存器包含了这一信息,如果没有的话,操作系统必须检索程序计数器,取出这条指令,用软件分析这条指令,看看它在缺页中断时正在做什么;
  4. 一旦知道了发生缺页中断的虚拟地址,操作系统检查这个地址是否有效,并检查存取与保护是否一致。如果不一致,向进程发出一个信号或杀掉该进程。如果地址有效且没有保护错误发生,系统则检查是否有空闲页框。如果没有空闲页框,执行页面置换算法寻找一个页面来淘汰;
  5. 如果选择的页框“脏”了,安排该页写回磁盘,并发生一次上下文切换,挂起产生缺页中断的进程,让其他进程运行直至磁盘传输结束。无论如何,该页框被标记为忙,以免因为其他原因而被其他进程占用;
  6. 一旦页框“干净”后,操作系统查找所需页面在磁盘上的地址,通过磁盘操作将其装入。该页面被装入后,产生缺页中断的进程仍然被挂起,并且如果有其他可运行的用户进程,则选择另一个用户进程运行;
  7. 当磁盘中断发生时,表明该页已经被装入,页表已经更新可以反映它的位置,页框也被标记为正常状态;
  8. 恢复发生缺页中断指令以前的状态,程序计数器重新指向这条指令;
  9. 调度引发缺页中断的进程,操作系统返回调用它的汇编语言程序;
  10. 该程序恢复寄存器和其他状态信息,返回到用户空间继续执行。

内存碎片问题

内容摘自:虚拟地址空间:用户空间和内核空间


内存碎片的产生

在Linux中,通过分段和分页的机制,将物理内存划分为4K大小的内存页,并且将页作为物理内存分配与回收的基本单位。通过分页机制可以灵活的对内存进行管理。

  1. 如果用户申请小块内存,可以直接分配一页给用户,就可以必秒因为频繁的申请,释放小块内存而发起的系统调用带来的消耗。
  2. 如果用户申请了大块内存,可以将多个页框组合成一大块内存后再进行分配。

但是这种直接的内存分配存在着大量的问题,非常容易导致内存碎片的问题.下面就分别介绍两种内存碎片:内部碎片和外部碎片.

外部碎片:

当我们需要分配大块内存的时,操作系统会将连续的页框组合起来形成大块内存,再将其分配给用户.但是频繁的申请和释放内存页,就会带来内存外碎片的问题.

 当需要分配大块内存的时候,要用好几页组合起来才够,而系统分配物理内存页的时候会尽量分配连续的内存页面,频繁的分配与回收物理页导致大量的小块内存夹杂在已分配页面中间,形成内存外碎片.

内部碎片:由于页是物理内存分配的基本单位,因此即使我们需要的内存很小,Linux也会至少给我们分配4K的内存页.

倘若我们需求的只有几个字节,那么该内存中有大量的空间未被使用,造成了内存浪费的问题.而我们频繁进行小块内存的申请,这种浪费现象就会愈发严重,这也就是内存内碎片的问题.

伙伴系统(buddy system)
要想解决内存外碎片的问题,无非就两种方法:

  1. 内存外碎片问题的本质就是空间不连续,所以可以将非连续的空闲页框映射到连续的虚拟地址空间
  2. 记录现存的空闲连续页框块的情况,尽量避免为了满足小块内存的请求而分割大的空闲块。

Linux选择了第二种方法来解决这个问题,即引入伙伴系统算法,来解决内存外碎片的问题。
伙伴系统就是把相同大小的连续空闲页框块用链表串起来,这样页框之间看起来就像是手拉手的伙伴,这也是其名字的由来.

伙伴系统将所有的空闲页框分组为11块链表,每个块链表分别包含大小为1、2、4、8

16、32、64、128、256、512和1024个连续页框的页框块,即2的0~10次方,最大可以申请1024个连续页框,对应4MB(1024*4K)大小的连续内存.每个页框块的第一个页框的物理地址应该是该块大小的整数倍.


因为任何正整数都可以由 2^n 的和组成,所以我们总能通过拆分与合并,来找到合适大小的内存块分配出去,减少了外部碎片产生 。

slab分配器
伙伴系统很好的解决了内存外碎片的问题,但是它还是以页作为内存分配和释放的基本单位.而我们在实际的应用中则是以字节为单位.例如我们申请2个字节的空间,但是其还是会向我们分配一页,也就是4096字节的内存,因此还是会存在内存碎片的问题.

为了解决这个问题,slab分配器就应运而生了。其以字节为基本单位,专门用于对小块内存进行分配。slab分配器并未脱离伙伴系统,而是对伙伴系统的补充,它将伙伴系统分配的大内存进一步细化为小内存分配。

对于内核对象,生命周期通常是这样的:分配内存->初始化->释放内存。而内核中如文件描述符、pcb等小对象又非常多,如果按照伙伴系统按页分配和释放内存,不仅存在大量的空间浪费,还会因为频繁对小对象进行分配-初始化-释放这些操作而导致性能的消耗。

所以为了解决这个问题,对于内核中这些需要重复使用的小型数据对象,slab通过一个缓存池来缓存这些常用的已初始化的对象。

当我们需要申请这些小对象时,就会直接从缓存池中的slab列表中分配一个出去。而当我们需要释放时,我们不会将其返回给伙伴系统进行释放,而是将其重新保存在缓存池的slab列表中。通过这种方法,不仅避免了内存内碎片的问题,还大大的提高了内存分配的性能。

下面就由大到小,来画出底层的数据结构

kmem_cache是一个cache_chain的链表,描述了一个高速缓存,这个缓存可以看做是同类型对象的一种储备,每个高速缓存包含了一个slab的列表,这通常是一段连续的内存块,并包含3种类型的slabs链表:

  1. slabs_full(完全分配的slab)
  2. slabs_partial(部分分配的slab)
  3. slabs_empty(空slab,或者没有对象被分配)。

slab是slab分配器的最小单位,在具体实现上一个slab由一个或者多个连续的物理页组成(通常只有一页)。单个slab可以在slab链表中进行移动,例如一个未满的slab节点,其原本在slabs_partial链表中,如果它由于分配对象而变慢,就需要从原先的slabs_partial中删除,插入到完全分配的链表slabs_full中。

本文标签: 进程操作Linux