
请君浏览
-
- 前言
- [1. 冯·诺依曼体系结构](#1. 冯·诺依曼体系结构)
- [2. 操作系统(Operating System)](#2. 操作系统(Operating System))
-
- [2.1 概念](#2.1 概念)
- [2.2 设计OS的目的](#2.2 设计OS的目的)
- [2.3 如何理解"管理"](#2.3 如何理解“管理”)
- [2.4 系统调用和库函数概念](#2.4 系统调用和库函数概念)
- [3. 进程](#3. 进程)
-
- [3.1 基本概念](#3.1 基本概念)
-
- [3.1.1 查看进程](#3.1.1 查看进程)
- [3.1.2 创建进程](#3.1.2 创建进程)
- [3.2 进程状态](#3.2 进程状态)
-
- [3.2.1 简单介绍](#3.2.1 简单介绍)
- [3.2.2 运行&&阻塞&&挂起](#3.2.2 运行&&阻塞&&挂起)
- [3.2.3 理解内核链表](#3.2.3 理解内核链表)
- [3.2.4 Linux的进程状态](#3.2.4 Linux的进程状态)
- [3.2.5 僵⼫进程 (zombie)](#3.2.5 僵⼫进程 (zombie))
- [3.2.6 孤儿进程](#3.2.6 孤儿进程)
- [3.3 进程优先级](#3.3 进程优先级)
-
- [3.3.1 简单介绍](#3.3.1 简单介绍)
- [3.3.2 查看优先级](#3.3.2 查看优先级)
- [3.3.3 修改优先级](#3.3.3 修改优先级)
- [3.3.4 优先级的极值](#3.3.4 优先级的极值)
- [3.4 进程切换](#3.4 进程切换)
- [3.5 Linux2.6内核进程O(1)调度算法](#3.5 Linux2.6内核进程O(1)调度算法)
- 尾声
前言
本专题将介绍关于Linux操作系统的种种,前几章我们介绍了Linux下常用的一些基本开发工具,本章将讲解Linux中系统的概念------进程。(本章节默认使用的环境是centos 7.8)
1. 冯·诺依曼体系结构
冯·诺依曼体系结构一种计算机设计架构模型,至今是绝大多数计算机系统的基础架构,例如我们现在的各类电脑、笔记本、服务器等大部分都遵守冯诺依曼体系。

截⾄⽬前,我们所认识的计算机,都是由⼀个个的硬件组件组成:
- 输入设备:例如键盘、鼠标、摄像头、磁盘等,从外界接收数据和指令。
- 中央处理器(CPU):包括运算器和控制器,负责执行指令和处理数据。
- 存储器:用来存储程序指令和数据,通常是随机存取存储器(RAM)。
- 输出设备:例如显示器,打印机等,将计算结果输出给用户或其他系统。
这里的存储器指的是内存 ,而我们平常口头上所说的电脑和手机的内存指的是磁盘(硬盘驱动器(HDD)、固态硬盘(SSD)、光盘、U盘等持久性存储设备),我们称之为外存。而我们的程序想要运行,第一步就是要先加载到内存中,也就是上图的存储器中。那么我们的程序在加载到内存之前则以文件的形式存储在磁盘中。
那么为什么我们的软件想要运行需要加载到内存上呢?这是由冯诺依曼体系结构决定的:它规定数据的流动只能以上图中红色的箭头进行流动,我们知道软件的运行其实就是CPU去访问我们的数据,执行我们的代码,也就是说CPU只能从内存中获取对应的代码和数据。
那么我们就能知道其实数据的流动就是从一个设备"拷贝"到另一个设备,所以
- 体系结构的效率就是由设备的"拷贝"效率决定。
也就是说在数据层面,我们的CPU只和内存打交道,外设(输入设备和输出设备)只和内存打交道。
那么为什么不让CPU直接和外设打交道呢?反而要借助内存来当作中间值呢?
-
最主要的原因是要提高效率:
CPU的处理速度通常远高于外设。因此,如果CPU直接与外设进行数据交换,可能会造成CPU空闲等待,从而浪费处理能力。通过存储器作为缓冲区,CPU可以在读取或写入外设数据时继续执行其他任务,提高了整体效率。
-
同时也是为了简化设计:
通过将数据和指令存储在统一的存储器中,可以避免复杂的硬件结构,使得计算机的设计和实现变得更加简单和一致。
那么可能又有人会问为什么不把磁盘的读取速度增加呢?这里简单的解释一下:使磁盘的速度增加同时还伴随的是其成本的增加:
可以说当代的计算机是性价比的产物。与之相关的则是芯片技术和摩尔定律:
- 摩尔定律:集成电路上可容纳的晶体管数目大约每两年会翻一番。
- 芯片技术:更小的晶体管能在更高的频率下工作。
两者结合也代表着计算机的发展。因此由于计算机硬件的快速迭代以及高速磁盘的成本造就了我们当代的计算机结构。
数据流动
对冯诺依曼体系结构的理解,不能停留在概念上,要深⼊到对软件数据流理解上,下面让我们用一个例子来深入了解一下:从你打开QQ,开始给朋友发送消息,到他得到消息之后的数据流动过程。如果是在qq上发送⽂件呢?
这个过程涉及到网络,不过我们先不关心,只是看数据的流动:我们和朋友可以看作是两台冯诺依曼体系结构,我们从键盘上输入消息,然后消息流入到内存,QQ这个软件从内存上获取消息,经过加工将其放回内存,然后消息流入到输出设备,这里我们的消息其实是"拷贝"到了网卡上,然后通过网络流入到朋友的设备的网卡上,这里的网卡所代表的是输入设备,然后在经过相同的操作,消息流入到朋友的输出设备上,也就是显示器上,发送文件也是一样的道理,只是输入设备由键盘变为了磁盘,输出设备由显示器变为了磁盘。干讲其实是不太好理解的,下面我们通过一张图片来深入理解:

2. 操作系统(Operating System)
2.1 概念
我们的Linux就是一款操作系统,那么操作系统是什么呢?
任何计算机系统都包含⼀个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
- 内核(进程管理,内存管理,文件管理,驱动管理)
- 其他程序(例如函数库,shell程序等)

操作系统是管理 计算机硬件和软件资源的系统软件,是介于用户和硬件之间的管理程序。它的主要任务是协调和控制计算机的各种资源(如CPU、内存、磁盘、输入输出设备等),为用户和应用程序提供一个方便、高效和安全的运行环境。
目前我们只需要知道操作系统是一款对进行软硬件进行管理的软件即可。
2.2 设计OS的目的
前面我们说了OS是用来管理软硬件的软件,那么我们设计它的目的其实就是:
-
对上,为应用程序提供一个良好的执行环境(目的)
-
对下,与硬件交互,管理所有的软硬件资源(手段)
在整个计算机软硬件架构中,操作系统的定位是:⼀款纯正的"搞管理"的软件。
- 由上图我们可以知道计算机的软硬件体系结构是层状结构。
- 如果要访问操作系统,就必须使用系统调用------其实就是函数,只不过是系统提供的函数。
- 如果我们的程序访问了硬件,那么它必须贯穿整个软硬件体系结构!
- 我们使用的各类库很多都在底层封装了系统调用。
2.3 如何理解"管理"
我们该如何理解操作系统所谓的管理呢?
我们以学校为例,在学校中有三个身份,分别是学生,辅导员,校长。在这里边学生是被管理者,校长是管理者,而导员是中间层。
我们做一件事情,总是先进行商议,也就是决策之后才会去执行。所以我们的校长也就是管理者对应着决策权,而导员也就是中间层对应着执行权。比如一个学生犯下了事情,那么通常是由导员上报给校长,校长经过决策后如果决定开除该学生,那么将会是导员去通知该学生并帮其完成各种手续。
同时,导员也会收集每个同学的信息,进行汇总,然后交给校长,校长通过对这些数据的管理来管理学生。
因此,我们可以看出,当管理者要管理被管理者时它们并不需要见面,因为管理者可以通过管理被管理者的"数据"来对被管理者进行管理,那么如何得到这些数据呢?由中间层进行获取。
我们的操作系统其实就是这个中间层。
还是上面的例子,当校长要对学生的数据进行管理时,应该如何管理才能简单又方便呢?其实很简单,我们可以定义一个结构体,里面存放各类信息,我们可以通过不同的需求,将这些结构体以不同的形式链接起来,例如我们将其以链表的形式连接起来,那么校长管理学生的工作就可以转换为对链表的增删查改:

在这个过程中,校长管理学生的工作其实就是建模的过程,它的过程就是先组织,在描述。
- 描述起来,⽤struct结构体
- 组织起来,⽤链表或其他⾼效的数据结构
凭借这六个字,我们便可以对任何"管理"场景进行建模!
现在我们还没有学习进程,那么操作系统是怎么进行进程管理的呢?很简单,先把进程描述起来,再把进程组织起来!
2.4 系统调用和库函数概念
操作系统要向上提供对应的服务,但是操作系统不相信任何用户(人),因此便有了系统调用,系统调用就是函数,用户在系统调用中通过输出参数给操作系统,然后操作系统给用户返回值,操作系统和用户之间进行某种数据交互,这就叫做系统调用。
系统调⽤在使⽤上,功能⽐较基础,对⽤⼾的要求相对也⽐较⾼,所以,有⼼的开发者可以对部分系统调⽤进⾏适度封装,从⽽形成库,有了库,就很有利于更上层⽤⼾或者开发者进⾏⼆次开发。
也就是说库函数和系统调用其实是上下层的关系。
3. 进程
3.1 基本概念
内核观点:担当分配系统资源(CPU时间,内存)的实体。
我们运行的每一个程序都是一个进程,在你的计算机上,打开一个文本编辑器、浏览器或音乐播放器。每一个都是一个独立的进程,正在执行它们各自的任务。

有了前面的基础,我们知道操作系统管理进程也是先对进程进行描述,再组织。那么操作系统是如何去描述进程的呢?
进程信息被放在⼀个叫做**进程控制块(PCB,process control block)**的数据结构中,可以理解为进程属性的集合。 PCB是一个统称,在Linux下,PCB叫做task_struct
。
task_struct
是Linux内核的⼀种数据结构,它包含着进程的信息,是一个在Linux中描述进程的结构体。
上面我们说每一个运行的程序都是一个进程,其实进程并不单单只有运行的程序,在Linux中:
进程 = 内核数据结构对象(task_struct) + 程序的代码和数据
那么下面让我们来认识一下task_struct
:
task_struct
中存放的都是进程的各种属性,例如:
- 标识符:描述本进程的唯⼀标⽰符,⽤来区别其他进程
- 状态: 任务状态,退出代码,退出信号等
- 优先级: 相对于其他进程的优先级
- 程序计数器: 程序中即将被执⾏的下⼀条指令的地址
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下⽂数据: 进程执⾏时处理器的寄存器中的数据
- I∕O状态信息: 包括显⽰的I/O请求,分配给进程的I∕O设备和被进程使⽤的⽂件列表
- 等等...
进程的所有属性我们都可以直接或间接在相应的task_struct
中获取。
在Linux的内核源码中我们可以找到task_struct
的定义:

并且每一个task_struct
都是由双向链表来组织起来的,在内核源代码里我们也可以看到:

所有运⾏在系统⾥的进程都以task_struct
链表的形式存在内核⾥。
我们编写一个程序,在没有运行前都是存放在磁盘中的二进制文件,由冯诺依曼体系结构我们知道,我们如果想要执行这个程序,那么我们的可执行程序就会被加载到内存上,此时在加载内存上的是我们可执行程序的代码和数据,我们知道操作系统也是一个软件,所以在我们启动电脑加载等待的那一段时间里,操作系统就被加载到了内存。操作系统必然要对多个被加载到内存的程序进行管理,但是对于加载到内存中的代码和数据操作系统并不认识,也没有办法去对它们进行调度等操作,因此,就有了PCB,也就是,操作系统会给每一个加载到内存的代码和数据分配一个task_struct
,它里面存储了各类属性,使得操作系统可以对相应的代码和数据进行正确的操作,而对于一个task_struct
结构体和它对应的代码和数据,我们称之为进程。而进程是以链表的形式链接在一起的,所以操作系统对进程的管理,就变成了对链表的增删查改。

操作系统管理进程的方式也再一次体系了先描述,后组织的思想。
3.1.1 查看进程
我们历史上执行的所有的指令、工具、自己编写的程序等运行起来都是进程!每一个进程都有属于自己的pid,相当于进程的名字,不会有相同的pid同时存在,pid作为进程的一种属性,它存在于进程的task_struct
中,那我们能不能去查看某一个进程的pid呢?答案是可以的:
在Linux中提供了一个名为
getpid()
的系统调用,它可以帮助我们把进程的pid从task_struct
中拷贝出来,它的返回值pid_t
是系统提供的一种数据类型,由于Linux的内核是用C语言写的,所以这里的pid_t
其实就是int
整数,也就是我们当前进程的pid。下面让我们运行一个程序,看看它成为进程后的pid:

该程序是一个死循环,这样方便我们一会去对该进程的pid进行验证。代码也很简单,每过一秒打印一次该进程的pid,运行该程序:

从运行结果我们可以看到该进程的pid是7095,在Linux中⼤多数进程信息可以使⽤top和ps这些⽤⼾级⼯具来获取 :
-
ps axj
:对于ps的选项这里我们不过多解释,大家只需要知道我们可以通过该指令来查看进程的信息,不过该命令只能查看我们执行该命令时的进程:
我们可以看到进程之多,很难去找到目标进程,因此我们可以通过行文本过滤器grep来快速找到我们的目标进程:
我们先来分析一下我们输入的命令:
bashps axj | head -1 && ps axj | grep proc
&&
是用于连接我们要执行的两条命令,ps axj | head -1
是显示ps axj
的第一行,也就是进程的属性名,ps axj | grep proc
是显示我们运行的进程,因为我们的程序名是proc
。同时我们也可以用分号;
来代替&&
。可以看到我们的pid与我们查到的pid是相同的:
那么上图中第二个进程是什么呢?其实在上面的命令中的最后我们用grep来帮我们过滤信息,我们知道其实我们的每一个命令执行时都是一个进程,所以在我们用
ps axj
查看进程时grep也在运行,所以这第二个进程就是grep。 -
top
:使用top
命令时,可以查看当前系统中正在运行的进程及其资源使用情况,它是实时变化的,需要我们通过q来退出。同时使用top
命令后我们可以改变进程的优先级以及杀死一个进程,这些我们到后面再说,下面先让我们来看一看top
的效果:
那么像我们上面写的死循环程序我们在运行时该怎么退出呢?也就是说我们该如何结束或者说杀死正在运行的进程呢?目前有两种简单的办法:
ctrl+c
:之前我们在使用某些命令卡住时我们都会使用ctrl+c
来退出,其实ctrl+c
的作用就是杀死当前进程:kill -9 [pid]
:此外,我们还可以通过命令kill -9 [pid]
来杀死某一个进程,这里涉及到信号的概念,我们不过多解释,只需要知道该命令即可:
在Linux当中我们也可以ls命令查看目录结构去查看进程,proc也就是process(进程)的简写,也就是说我们可以通过文件的方式去查看进程。操作系统不仅仅可以把磁盘上的文件让我们用ls这样的命令让我们查到,它把内存的相关数据也以文件的方式呈现出来,让我们可以动态看到内存相关的数据,/porc
目录就是内存级别的文件系统,例如下图中以数字为名称的一个个目录就是一个个进程,这些数字就是每一个进程的pid:

这也符合Linux上一切皆文件的概念
例如我们在启动一个进程,我们可以在该目录中查到对应pid的目录:

而当我们杀掉该进程后,再在/proc目录中寻找就找不到对应的目录:

也就是说当我们的进程退出后系统便会自动清除其所对的目录,而一个新进程出现时,系统也会自动创建相应的目录。
这里我们补充两个小知识点:
这些进程目录中存放着进程的各种信息,其中我们需要注意两个:
cwd、exe
,虽然它们涉及到了软硬链接,不过我们先不考虑,只看它们的作用:这里我们不卖关子,直接介绍:
cwd
:它的值是我们程序所处的目录,它的作用是当我们在进行文件操作时,如果要打开一些文件时我们没有输入绝对路径,而是输入的相对路径,那么它的相对路径就是相对于cwd
的,例如我们要以只写的方式打开文件a.log
:
cfopen("a.log", "w");
可以看到这里我们给的是相对路径,也就是cwd所代表的路径下,如果该路径下没有文件
a.log
,那么就会在该路径下创建该文件。也就是进程会记录下来自己的当前文件。
exe
:进程对应的可执行文件。
Linux中的所有进程都是由其父进程创建的,一个父进程可以创建多个子进程,所以Linux中所有进程的结构也是多叉树结构。上面我们可以看到getppid()
也是一个系统调用,它的作用是得到父进程的pid,

运行该程序,进行观察:

我们可以发现在我们不断启动杀死进程,每一次执行该程序的进程的pid都不同,这是因为每一次我们启动进程的时候都是向系统里重新加载,所以每一次都不同。但是我们可以发现每一次执行我们的父进程是不变的,那么这个父进程是谁呢?我们可以去查一查:

可以看到我们查出来的进程是bash,那么bash是什么呢?前面我们讲过,bash就是我们当前使用的命令行解释器,所以命令行解释器的本质也是一个进程。
os会给每一个登录用户分配一个bash(前面带-表示远程登录)
当前我们有两个登录,所以应该有两个bash,下面让我们来验证一下:

所以命令行解释器就是一个进程,它先打印对应的字符串,然后等待我们输入命令,等我们输入完成后再去进行相应的操作,跟我们在C语言使用scanf
是一样的。所以我们在命令行上执行的所有命令的进程的父进程都是bash
。
3.1.2 创建进程
那么bash是如何创建进程的呢?也就是说我们该如何去创建一个子进程呢?我们使用代码创建子进程的方式也是使用一个系统调用------fork
:

这里我们需要先知道fork
有两个返回值:

可以看到当我们使用fork
成功创建子进程后,它会给父进程返回子进程的pid,给子进程返回0,那么一个函数为什么会有两个返回值呢?这是因为当我们的父进程创建子进程时,操作系统会给子进程分配一个task_struct
,但是由于没有程序新的加载,所以子进程没有自己的代码和数据,因此它和父进程使用同一份代码和数据。
因此我们可以让父子执行不同的逻辑:


相信到这里大家有很多问题:
- 为什么fork给父子返回不同的返回值?
- 为什么一个函数会返回两次?
- 为什么一个变量既等于0,又大于0,导致if else语句同时成立?
下面让我们一个个的来解决这些问题,要解决这些问题,我们需要先简单了解一下fork()
:
在调用 fork()
时,操作系统会创建一个新的进程(子进程),并将父进程(当前进程)的执行上下文(包括寄存器、内存和程序计数器等)复制到新的子进程中。这意味着在子进程中,最重要的一个点是:
fork()
调用成功后,子进程会从fork()
调用的下一行代码开始执行,这时,父进程和子进程的pid
(fork()
的返回值)的值是不同的。
-
父进程 调用
fork()
:如果
fork()
成功,父进程的pid
变量会被设置为新子进程的进程ID(一个正整数)。 -
子进程 从
fork()
调用的下一行开始执行:在这个情况下,在子进程中,
pid
的值会是0
。因此,可以通过检查pid
的值来区分父进程和子进程。
通过这种设计,父进程可以知道它的子进程的PID,而子进程可以知道自己是新创建的进程。从而使它们可以进行独立的操作,同时也确保父进程可以追踪和管理它所创建的子进程。这也就是fork()
会给父子进行返回不同的返回值的原因,
因为子进程是被我们创建的,所以它没有独立的代码和数据,所以子进程会默认复制父进程的代码和数据。由于进程是通过PCB中的指针去指向内存中对应的代码和数据,所以这里我们可以看成是子进程进行了浅拷贝,指向和父进程相同的代码和数据,只是当我们的子进程或者父进程如果想要修改其中的某项值时,会发生写时拷贝,操作系统会在内存中重新开辟一块空间,把修改的数据拷贝一份,让目标进程去修改这个拷贝。这里边具体的细节我们后面再进行讲解。下面让我们用一张图来简单的理解一下:

所以我们可以得出一个结论:进程具有独立性。
因此一个变量既可以等于0,也可以大于0,其实这只是我们肉眼看到的好像是一个变量既可以等于0,也可以大于0,实际上这是父子进程两个进程在并发执行,虽然父子进程共享同一份代码,但是它们各自运行自己的,不会互相影响。
举个例子,就像两个人写同一篇作文时,虽然两个人写的是同一个题材,但他们有各自的思路与想法,因此写出来的作文虽然题材相同,但是内容不同,这里作文题材就是我们的代码,两个人就是父子两个进程。

那么一个函数为什么会返回两次呢?其实也很简单,在我们调用fork()函数时,在它执行完之前我们的子进程就已经被创建出来了,也就是说在fork()函数return之前子进程就已经存在了,所以父子进程各有一个返回值。

以上就是我们对进程的一个初步的认识,接下来让我们再来深入的了解进程。
3.2 进程状态
3.2.1 简单介绍
一个CPU在同一时刻只能执行一个进程,但是在内存中却有着很多进程存在,这时有的进程正在CPU中被执行,而优点CPU还在内存中等待,所以进程状态指的是操作系统中某个进程在某一时刻所处的运行状态。不同的状态反映了进程当前的活动情况以及它与CPU和资源的关系。下图是操作系统中通用的表示进程状态的图,我们了解一下即可,接下来将结合Linux中具体的进程状态进行讲解,可以与该图进行一下对比:

3.2.2 运行&&阻塞&&挂起
-
运行:每一个CPU都会维护一个运行队列(runqueue),它里面存放的是进程的PCB(task_struct)。在当代大部分的计算机中,正在运行或者已经在运行队列中等待运行的进程都处于运行状态。
-
阻塞:当我们在使用
scanf
或者cin
时,其实不是等待用户输入,而是在等待键盘硬件就绪,也就是等待键盘上有按键被按下了,当我们没有按下键盘时,称为键盘文件不就绪,这时scanf这个进程就没有办法读到数据,它就需要等待。所以阻塞状态就是等待某种设备或资源就绪。OS要管理系统中的各种硬件资源也是通过先描述,在组织的方法进行管理,所以对于这些硬件也都有一个结构体对其进行描述,这些结构也是以链表的形式在操作系统中,所以操作系统对硬件的管理就变成了对该链表的管理,与OS对进程的管理是一样的。
因此在操作系统中不仅有运行队列,还有设备队列。
在我们每一个设备的结构体中都有一个类型为
struct tast struct*
的等待队列,当我们CPU在执行某一个进程时,如果要执行scanf从键盘读数据,但是OS查询键盘发现其处于不活跃状态,也就是没有按键被按下,那么该进程的PCB就会被从CPU中拿出来,将其链接到键盘对应的结构体中的等待队列中,此时该进程就处于阻塞状态。一旦键盘上有按键被按下,操作系统便会从查看对应的等待队列,将处于队首的PCB重新链入运行队列中。
-
挂起:我们的内存的大小是有限的,当我们的内存空间严重不足时,有一些处于阻塞状态或者其他一些暂时不会被执行的进程的代码和数据便会被唤出到磁盘中特定的空间(swap交换分区),这些进程的状态就处于挂起状态。当这些进程需要执行时OS便会从磁盘中将其对应的代码和数据重新唤入到内存中。
进程状态的变化的表现形式之一就是要在不同的队列中流动,本质都是数据结构的增删查改。
3.2.3 理解内核链表
在Linux中的双向链表与我们之前定义的双向链表不同,我们定义的双向链表的每一个节点中包含了数据以及前后指针,而在Linux内核中的双向链表的结点中只有前后指针:

所以在我们的task_struct
结构体中就会存放该链表的指针,只不过这时我们链表的结点中的next
不再指向下一个task_struct
的开头,而是下一个task_struct
中的next
:

所以我们就可以通过这个链表去遍历每一个task_struct
,但是我们通过链表去遍历我们只能得到该链表中task_struct
的链表节点的地址,但是我们如果要遍历task_struct
肯定是要得到它的各种属性,那么我们该如何通过链表的节点去找到整个task_struct
的数据呢?其实很简单,我们知道,我们结构体的地址其实就是它第一个成员的地址,并且成员地址是依次变大的,所以我们就可以通过
c
next - &((struct task_struct*)0->links)
得到我们task_struct
中起始地址到links
成员的偏移量(C语言中有专门的宏offset
去求偏移量),所以只要我们知道当前task_struct
的links(也就是next)的地址减去偏移量,就可以得到task_struct
的起始地址了,这样我们就可以随意的访问task_struct
中的每一个成员了。
所以我们可以使一个task_struct
既属于运行队列,也属于全局链表。可以存在于多种数据结构中

其实在内核数据结构中很多都是网状的!
3.2.4 Linux的进程状态
⼀个进程可以有⼏个状态(在Linux内核⾥,进程有时候也叫做任务)。进程状态就是task_struct
内的一个整数:

在Linux内核软代码里这样定义进程状态:
c
/*
*The task state array is a strange "bitmap" of
*reasons to sleep. Thus "running" is zero, and
*you can test for combinations of others with
*simple bit tests.
*/
static const char *const task_state_array[] = {
"R (running)", /*0 */
"S (sleeping)", /*1 */
"D (disk sleep)", /*2 */
"T (stopped)", /*4 */
"t (tracing stop)", /*8 */
"X (dead)", /*16 */
"Z (zombie)", /*32 */
}
我们也可以使用
ps axj
命令去查看进程的状态,这里我们简单介绍一些ps的选项:
- a:显⽰⼀个终端所有的进程,包括其他⽤⼾的进程。
- x:显⽰没有控制终端的进程,例如后台运⾏的守护进程。
- j:显⽰进程归属的进程组ID、会话ID、⽗进程ID,以及与作业控制相关的信息
- u:以⽤⼾为中⼼的格式显⽰进程信息,提供进程的详细信息,如⽤⼾、CPU和内存使⽤情况等
我们先来认识一下这些状态:
-
R 运⾏状态(running): 并不意味着进程⼀定在运⾏中,它表明进程要么是在运⾏中要么在运⾏队列⾥。
我们执行一个程序
-
S 睡眠状态(sleeping): 进程在等待某个事件(如IO操作完成),可以被信号中断。也就是阻塞状态。也叫做可中断睡眠(interruptible sleep))。
-
D 磁盘休眠状态(Disk sleep):进程等待IO等不可中断资源,不能被信号打断(通常用于等待硬件)。也叫不可中断睡眠状态(uninterruptible sleep)。如果我们的进程要往磁盘中写入数据时,那么该进程就会处于D状态,这是为了避免在写入时突然被中断导致的数据丢失。
-
T 停⽌状态(stopped): 进程被暂停,通常是因为接收到停止信号(如
SIGSTOP
、SIGTSTP
)。T状态是由系统决定的。t (tracing stop)经常在我们调试程序时打断点使程序执行一部分代码后停在指定的代码时,该进程就处于t停止状态,是由我们用户自己决定的。 -
X 死亡状态(dead):这个状态只是⼀个返回状态,我们不会在任务列表⾥看到这个状态。
-
Z 僵尸状态(zombie):下面详细介绍。
其实我们使用的kill
命令就是给指定的进程相应的信号,同时我们的ctrl+c
和ctrl+z
这些快捷键其实也是信号。关于信号的具体内容我们后面会进行详细讲解。

3.2.5 僵⼫进程 (zombie)
僵尸状态(Zombies)是⼀个⽐较特殊的状态。它是当子进程退出并且⽗进程没有读取到⼦进程退出的返回代码时就会产⽣(至于父进程如何得到子进程退出时的返回代码后面会将)。
存在该状态的原因:
- 对于父进程来说,它不会无缘无故的创建一个子进程,当子进程被父进程创建后,那么它肯定要完成某种事情,前面我们说过进程之间具有独立性,那么父进程该怎么知道子进程是否完成了相应的任务呢?所以便有了僵尸进程,当子进程完成退出后,便会处于僵尸状态,等待父进程去读取。如果父进程不进行读取,那么子进程就会一直处于Z状态。
进程的状态以及父进程需要读取子进程的信息都属于子进程的各类属性,所以它们都保存在task_struct
中, 也就是说如果进程一直处于Z状态,那么我们就要一直维护 task_struct
。
我们可以通过下面的代码进行验证:
c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
int id = fork();
if(id == 0)
{
printf("我是子进程,我的id=%d, 我的父进程id=%d\n", getpid(), getppid());
}
else
{
while(1)
{
;
}
}
return 0;
}
我们让子进程打印一行字符串后就结束,而父进程一直运行,让我们来看看子进程执行结束后的状态:

可以看到当我们的子进程结束后没有被父进程回收时,子进程就会处于僵尸状态。
那么当一个父进程创建了很多子进程,但就是不回收,就会造成内存资源的浪费,因为数据结构对象本身就要占用内存。当父进程一直不回收子进程,那么就会产生内存泄漏的问题。
当我们进程退出之后,那么在进程执行时在堆上动态开辟的空间如果没有释放,那么系统会自动回收,所以当进程退出后就不会存在内存泄漏的问题。那么什么样的进程才存在内存泄漏的问题呢?其实很简单,就是那些不容易退出的进程,也就是常驻内存的进程是最怕内存泄漏问题的。操作系统就是一个常驻内存的进程,我们的操作系统也是一个软件,所以它也是一个进程,并且OS陪伴着我们从开机一直到关机。
因此当子进程进入僵尸状态后,父进程一定要回收子进程(具体做法后面讲)。
3.2.6 孤儿进程
上面是子进程先退出会有僵尸进程,那么如果是父进程先退出呢?
在操作系统中,某个进程的父进程已经结束,而该进程仍然在运行的状态,那么该进程就叫做孤儿进程。此时孤儿进程会被操作系统的init
进程(PID为1)收养。init
会定期检查这些孤儿进程并将其变为其子进程,init
进程成为它们的父进程。
为什么要有进程去领养孤儿进程呢?这是因为如果一个进程处于孤儿进程后,那么当该进程结束后便会处于僵尸状态,而且没有父进程能进行回收,因为它的父进程已经结束,会造成资源泄漏甚至内存泄漏。为了避免这种情况,操作系统便会给孤儿进程重新找一个父进程,确保孤儿进程能够被有效管理和清理。
下面让我们通过一段代码演示一下:
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 1;
}
else if(id == 0){
//child
while(1)
{
;
}
}
else{
//parent
while(1)
{
;
}
}
return 0;
}
让我们来看看结果,这是程序刚开始执行时,父子进程都在运行:

此时我们杀掉父进程:

可以看到此时父进程已经结束,子进程的父进程pid变为1,也就是说子进程被init
进程领养,此时虽然子进程显示的状态还是运行状态,但是我们可以看到在终端上并没有显示,这是因为此时子进程在后台运行。
一个进程变成孤儿进程后会变成后台进程,也就是在后台运行。在Linux中,如果你想要一个进程在后台运行,那么只需要在执行该命令时在后面加上&
即可,后台进程运行的表现形式是在进程状态后没有+
号,前台进程的状态后有+
号。

3.3 进程优先级
3.3.1 简单介绍
进程优先级是进程得到CPU资源的先后顺序,就是指进程的优先权(priority) 。进程优先级越高,操作系统越可能在调度中选择它,从而使其优先获得CPU的执行机会。
那么为什么进程要有优先级呢?这是因为在单核处理器中(也就是只有一个CPU),同一时刻只能执行一个进程,因此我们需要通过优先级来确认哪个进程被先执行。
- 通过将高优先级的进程首先调度,使得CPU可以优先执行那些更重要或更需要快速响应的任务,从而提高系统的整体效率。
- 通过适当的优先级调度算法,操作系统能够在多个进程之间公平分配资源,避免某些低优先级进程的自我阻塞。
这里我们要区分一下优先级和权限:
- 优先级是某些进程一定能够能到资源,只是谁先谁后的问题
- 权限则是是否能得到资源
进程的优先级也是进程的属性,存放在task_struct
中,是一个整数。这个整数的值越低,表示优先级越高,反之优先级越低。基于时间片的分时操作系统,考虑到公平性,优先级的变化幅度不能太大。
3.3.2 查看优先级
在Linux中我们可以通过命令ps -al
来查看我们进程的优先级:

这里的PRI就是我们进程的优先级,该值越小进程的优先级越高。NI是nice值,表示进程优先级的修正数值。
进程真实的优先级(也就是上图中显示的PRI)= PRI的默认值(80)+NI
因此在Linux中,我们通过修改NI的值来改变进程的优先级。我们可以通过top工具来更改NI值:

进⼊top后按"r"‒>输⼊进程PID‒>输⼊nice值
更改后再让我们来查看一下进程的优先级:

可以发现我们将NI值改为10后,进程的优先级变为80(PRI的默认值)+10(NI)
,那么我们再将改进程的NI值改为5看一看结果:
可以看到,当我们修改NI值为5后,PRI的值变为85,这是因为进程的优先级只等于PRI(默认)+ NI
,跟之前的优先级无关。
3.3.3 修改优先级
我们除了可以通过top命令来修改进程的优先级,还可以通过nice
和renice
两个命令来进行:
-
nice
命令用于以特定的优先级启动一个新的进程(通过改变NI值)。默认情况下,会将NI值变为10,使优先级增加从而让其他重要进程获得更多的CPU时间。语法结构:
bashnice -n num <command>
演示:
-
renice
命令用于动态调整已经运行中的进程的优先级。这允许用户在进程执行时根据需要改变其优先级。语法结构:
bashrenice num -p pid
演示:
可以看到,这些命令都是通过修改NI的值来改变进程的优先级。所以,调整进程优先级,在Linux下,就是调整进程nice(NI)值
除此之外,我们还可以在代码中使用一些系统调用修改优先级:

了解一下即可。
3.3.4 优先级的极值
Linux进程的优先级是存在极值的,范围是[60,99]
,一共40个优先级。所以在我们更改NI值时NI值也存在极值,它的范围是[-20,19]
,当我们修改NI时如果输入的值超过了这个范围,那么NI便会被更改为离范围内最近的值:
例如我们把NI值改为100:

把NI值改为-100:

在Linux中之所以要为优先级设定范围,是为了防止优先级设定的不合理,导致优先级很低的进程长时间得不到CPU资源,进而导致进程饥饿。
竞争vs独立 && 并行vs并发
竞争性:系统进程数⽬众多,⽽CPU资源只有少量,甚⾄1个,所以进程之间是具有竞争属性的。为了⾼效完成任务,更合理竞争相关资源,便具有了优先级
独⽴性: 多进程运⾏,需要独享各种资源,多进程运⾏期间互不⼲扰
并⾏: 多个进程在多个CPU下分别,同时进⾏运⾏,这称之为并⾏
并发: 多个进程在⼀个CPU下采⽤进程切换的⽅式,在⼀段时间之内,让多个进程都得以推进,称之为并发。并发涉及到很短的时间片切换,这些时间片的单位来到了微秒级甚至纳秒级,使得多个进程看起来在同时运行。
3.4 进程切换
我们先来了解一下时间片的概念:
- 时间⽚:当代计算机都是分时操作系统,每个进程都有它合适的时间⽚(其实就是⼀个计数器)。时间⽚到达,进程就被操作系统从CPU中剥离下来。
时间片是为进程分配的最大执行时间长度,通常以毫秒为单位。当一个进程使用完其时间片后,系统会强制进行进程切换,将CPU控制权转移到下一个准备好的进程。在这种策略中,CPU为每个进程分配一个固定长度的时间段,以确保多个进程能够公平地共享CPU资源。
所以当一个进程占据CPU,并不会一次性就把自己的代码执行完(不考虑极端情况),当它的时间片到了之后,它就要为其他进程让地方了。那么当这个进程下一次占有CPU后如何恢复自己之前的数据呢?这与寄存器有关。
寄存器就是CPU内部的临时空间。在CPU中存在着非常多的寄存器,用于存储CPU在执行指令时所需的临时数据和指令。所以当我们的进程在需要切换时,进程会保存当前进程的上下文数据,也就是CPU内寄存器中的各种数据,包括当前代码运行到的位置以及各种临时数据,这些都被保存到进程的task_struct中的TSS结构体中(感兴趣的可以查看Linux源码),当进程再次占用CPU时,就会通过该结构体中的数据覆盖寄存器的内容。

所以进程切换最核心的就是保存和恢复当前进程的硬件上下文的数据,也就是CPU中寄存器的内容。
3.5 Linux2.6内核进程O(1)调度算法
在Linux 2.6内核中,O(1)调度算法是为了提高进程调度的效率而设计的。该调度算法的主要特点是能够在常数时间内(O(1))做出调度决策,无论系统中有多少进程。这种设计使得Linux内核在多任务环境下能够有效地管理并调度大量的进程。
一个CPU有一个运行队列(runqueue)。

上图是Linux2.6内核中进程运行队列的数据结构,
下面我们先来看这里面的queue[140]
,它其实是是一个指针数组,类型是struct task_struct*
。其实在Linux中一共是有140个优先级,只是前100个,也就是[0,99]是实时优先级,只是我们并不考虑这一部分,因为它涉及到的是实时操作系统,而我们所处的互联网领域上使用的都是分时操作系统。
实时操作系统:
实时操作系统广泛应用于需要高度可靠性和及时响应的领域,如工业自动化、航空航天、医疗设备、汽车电子、通信系统、消费电子等。
实时操作系统是针对需要即时或准时反应的系统设计的,具备严格的时间约束,以确保关键任务的及时完成。通过专门的功能和优化,RTOS能够有效地管理资源,以满足各类实时应用的需求。
所以我们只关心[100,139]这四十个优先级,与我们前面说的优先级范围是一致的。所以我们便可以用我们的优先级映射到queue[140]
的下标中。在这个指针数组中每一个指针的类型都是task_struct*
,所以我们就可以把优先级相同的进程链入到相应下标的队列中。这样我们在调度进程时便可以顺着queue[140]
去寻找依次调度进程,在局部上也就是优先级相同的进程我们采用FIFO(先进先出)进行排队调度。
它的本质是一个开散列的哈希算法。
这样进行调度还需要遍历queue[140]
,效率还是不高,那么调度器该如何快速地挑选一个进程呢?这就要靠我们的另一个成员bitmap[5]
了,也就是位图。它的类型是unsigned int
,就是无符号整数,一个无符号整数对应32个比特位,那么5个就对应了160个比特位,它刚好对应了我们queue[140]
中的140个下标一一对应,多出来的不管,这是无法避免的损耗。所以每一个比特位的内容就代表了queue[140]
中对应的队列是否为空,0表示为空,1表示不为空。因此,我们便可以使遍历queue[140]的操作转换为查找位图的操作,大大提高了效率。
而nr_active
表示的是整个队列当中一共有多少个进程,所以在我们调度进程时先查nr_active
,它大于0再查位图,找到第一个不为0的比特位对应的下标,然后在queue[140]
中取对应的队列的队首。
我们观察上图中可以发现有两个就绪队列 :也就是*active
和*expired
指向的两块内容。这两个队列的成员是一样的,这是为什么呢?举一个简单的例子:现在我们有一个优先级为60的进程和一个优先级为99的进程,并且优先级为60的进程是一个死循环。当60进程被调度,时间片结束后,因为还要在被调度,所以又把它链回到运行队列里,那么下一次调度器再次调度时由于它的优先级高,所以还回调度这个60进程,这样我们的90进程显然就一直不会被调度,就会导致进程饥饿,这显然不是我们想看到的。
所以在运行队列中存在两个就绪队列,一个叫做active
,一个叫做expired
,当一个进程从cpu中被切换出来后,我们把它链入到expired
队列中,然后直到active
队列中的进程都被调度过后,也就是active
队列中的进程为空了,此时我们交换active
和expired
两个指针的内容,使expired
成为新的active
。

一个新的进程被链入到运行队列中时在没有其他因素干扰下是将其链入到其中的expired
中。
简单总结一下:
- 过期队列和活动队列结构⼀模⼀样。
- 过期队列上放置的进程,都是时间⽚耗尽的进程。
active
指针永远指向活动队列。expired
指针永远指向过期队列。- 活动队列上的进程会越来越少,过期队列上的进程会越来越多,当活动队列中没有进程时,交换
active
指针和expired
指针的内容,就相当于有具有了⼀批新的活动进程!

在系统当中查找⼀个最合适调度的进程的时间复杂度是⼀个常数,不随着进程增多⽽导致时间成本增加,我们称之为进程调度O(1)算法!
尾声
本章讲解就到此结束了,若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!