【Linux】进程
CSDN 2024-06-12 12:07:03 阅读 55
一、冯·诺依曼体系结构
进程是由操作系统分配和调度的,而在谈操作系统之前,我们需要了解计算机是如何组成的
现代计算机发展所遵循的基本结构形式是冯·诺依曼体系结构,采用二进制逻辑,由五部分(运算器、控制器、存储器、输入设备、输出设备)组成
其中,输入设备是向计算机输入数据和信息的设备,例如鼠标、键盘、摄像头、磁盘等
输出设备是计算机硬件系统的终端设备,用于接收计算机的输出内容,例如显示器、网卡、音响等
有的设备也可以既是输入设备又是输出设备,例如磁盘、网卡
运算器和控制器集成在CPU中,一个用于对数据进行算术运算或逻辑运算,一个用于对计算流程进行一定的控制
存储器指的是计算机中的内存,其在冯·诺依曼体系结构中处于核心地位,是外存与CPU进行沟通的桥梁,提供了硬件级别的缓存空间
这五大部分都是独立的个体,各个单元之间需要用系统总线和IO总线连接起来才能共同工作
为什么内存如此之重要?首先我们需要理解数据是如何在硬件之间流动的
数据流分为两种:输入流和输出流
输入流指数据从外存中输入到系统中进行计算,输出流则是数据从系统中输出到外部
而数据流的效率是遵循木桶效应的,我们通过下面的金字塔图可以了解到不同存储设备的区别,从快到慢分别是寄存器、三级缓存、内存、固态硬盘和机械硬盘
如果我们让数据流直接在外存与CPU之间流动,因为外存的速度很慢,所以即使CPU的速度再快,整体的速度也会受限于外存。
那为什么我们不用更快的设备替代外存呢?我们当然可以直接用一大堆寄存器来作为计算机的存储设备,但是这样子成本太高,所以我们需要寻找一个折中的方法
因此我们在外存和CPU之间增加了内存,它的价格相比寄存器便宜许多,速度相比硬盘又高了许多,能够提高数据流的效率。
二、操作系统
2.1 概念
操作系统(Operating System)是计算机内置的一款进行软硬件资源管理的软件,通过进行进程管理、内存管理、文件管理、驱动管理等来为用户/应用程序提供一个良好的执行环境
2.2 结构
操作系统的组织结构大体分为三个部分:用户部分、系统软件部分和硬件部分。操作系统就处于系统软件部分的中间层,它对上为用户提供系统操作接口(system call),对下通过管理驱动程序来调度底层的硬件。
我们是不能直接对操作系统进行操作的,只能使用操作系统提供的系统调用接口,这也是操作系统的自我保护机制。就像银行只会为来取钱的客户开一个窗口,而不会直接把金库打开让客户取钱一样。
操作系统对外表现为一个整体,只会暴露出部分接口供开发者使用,而开发者们可以对这些系统调用接口进行封装,就成为了编程语言中的各种函数。例如printf这种库函数就是对系统调用接口进行封装而来的。
对内,操作系统需要对底层硬件进行统筹管理,具体是如何做到的呢?
我们可以把操作系统看成校长,底层硬件看成学生,校长不需要直接与学生进行接触,只需要得到所有学生的一切基本信息,通过管理学生的信息就可以做出决策,然后让辅导员(驱动程序)执行决策即可。
如何管理学生的信息呢?例如我们可以用链表来维护一个学生信息管理系统,将学生的基本信息存储到节点中的结构体,通过对链表的增删查改就可以完成对学生的管理工作,这一过程可以简化为:描述学生的基本信息并进行组织
所以管理的本质,实际上就是先描述,再组织的过程。操作系统可以通过驱动程序拿到底层硬件的各项数据,并将这些数据用一个结构体描述出来,然后对各个硬件的数据进行组织。
而在组织的过程中,我们需要将描述出的信息结构体用各种数据结构来组织,所以操作系统中注定存在大量的数据结构。
通过上面的学习我们可以得出,操作系统在管理进程的时候,也是遵循先描述再组织的!
三、进程
3.1 概念
进程是程序的实体。程序要运行,首先得加载到内存中,而正在运行的程序就叫做一个进程。
当我们在Windows下打开任务管理器时,其中显示的就是一行行的进程
可以看到,操作系统中会同时运行很多个进程 。
前面说过,操作系统在进行管理进程的时候也是先描述再组织的,具体是如何实现的呢?
3.2 进程管理
我们在生活当中是如何认识一个事物或对象的?实际上都是通过该事物的属性认识的。当属性够多,我们就可以把这堆属性的集合当作目标对象。
所以当我们在创建进程并管理进程时,首先需要掌握进程的所有属性,因此出现了描述进程的结构体对象——PCB(Process Control Block),它就是进程属性的集合。有了PCB,我们就成功的描述了进程,接下来就是对进程的组织了。
在Linux下的PCB叫做task_struct,它的内部包含了进程的所有信息,包括进程的编号、状态、优先级、程序计数器、上下文数据等等
所以一个完整的进程需要包含描述自身属性的内核PCB结构体+自己的代码和数据,我们可以通过双链表把所有进程的PCB全部链接起来,然后就可以将对进程的管理转化为对这个链表进行增删查改了。
3.3 查看进程
要在Linux下查看正在运行的进程信息,我们可以用top或者ps命令
其中,PID是自己的进程标识符,每个进程都有一个唯一的标识符用于区分。
PR是优先级,NI是nice值,S是状态,这些我们在后面都会提到。
我们可以试着写一个无限循环的程序,然后对其进行观察
可以看到,这个进程就是我们当前正在运行的程序,它的PID是23094,PPID是父进程的标识符
我们知道Linux下一切皆文件,进程的信息都存放在名为/proc的系统文件夹下、以自己PID为名的文件夹中,我们可以用ls命令查看其内容
3.4 获取进程标识符
接下来我们将会认识到两个系统调用接口getpid()和getppid(),它们的功能分别是获取自身的进程标识符和父进程的标识符
这两个接口在使用时需要带上头文件<unistd.h>和<sys/types.h>
#include <stdio.h>#include <unistd.h>#include <sys/types.h> int main(){ while(1) { printf("进程运行中\n"); printf("pid: %d\n", getpid()); printf("ppid: %d\n", getppid()); sleep(1); } return 0;}
运行该程序,并用ps命令观察,结果展现如下:
可以看到PID和PPID是一致的
3.5 fork方法创建子进程
现在我们已经对进程有了基本的了解,包括什么是进程、进程是如何被管理的、进程包含哪些信息、如何获取进程的标识符等,接下来我们学习如何创建子进程
Linux系统中有一个系统调用接口叫做fork,我们可以在man手册中来初步了解它
从中我们可以得到以下信息:
fork是一个用于创建子进程的接口,包含在头文件<unistd.h>中fork会返回一个类型为pid_t的值,我们猜测它会返回一个PID创建出的子进程不会继承父进程的一些属性,是独立于父进程的
实际上,在创建出子进程后,fork函数会返回两次,在子进程中fork的返回值为0,在父进程中fork的返回值为子进程的PID。如果fork创建进程失败,则会返回-1
如何验证?我们可以写一段代码看看:
#include <stdio.h>#include <unistd.h>#include <sys/types.h>int main(){ printf("程序开始运行, pid: %d, ppid: %d\n", getpid(), getppid()); pid_t id = fork(); if(id == 0) { //子进程 while(1) { printf("我是子进程, pid: %d, ppid: %d\n", getpid(), getppid()); sleep(1); } } else if(id > 0) { //父进程 while(1) { printf("我是父进程, pid: %d, ppid: %d\n", getpid(), getppid()); sleep(1); } } return 0;}
运行程序,现象如下:
可以看到此时有两个名为test的进程,通过对比PID和PPID可以看到子进程已经成功创建并开始运行了
现在我们验证了,fork函数的确会返回两次,所以才能够分别进入两个分支并同时进行无限循环
而且,子进程可以成功进入循环,说明它是与父进程共享代码和数据的!一般而言子进程创建出来后就会与父进程共享后面的代码和数据,包括return语句,所以会返回两次。返回不同的值,也是为了区分父子进程,让不同的执行流来执行不同的代码块。
但是进程是有独立性的。子进程和父进程共享代码可以理解,因为程序运行中代码是不能被修改的,但是数据是可以被修改的,如果父子进程共享数据的话,那么一个进程修改数据之后不会影响到另一个进程吗?
这里牵扯到另一个概念,即子进程的写时拷贝。父子进程最初共享同一份数据,如果此时子进程要修改父进程的数据,那么就会发生写时拷贝,即给子进程分配一块新的空间并拷贝原数据,然后进行修改。相比一开始就把全部数据拷贝一份,这种方法能够很好的减少空间浪费。
所以这也解释了为什么变量id能够同时有两个不同的值。
但是如果你有兴趣的话可以在父子进程中分别打印一下变量id的地址,会发现两个进程中id的地址是相同的,但是竟然有不同的值。这里牵扯到进程地址空间的相关知识,暂时不在本文中细讲。
总结:在fork函数中创建子进程时先创建子进程的PCB,再填充PCB对应的内容,然后让父子进程共享同一份代码和数据,最后对父子进程返回不同的值。子进程在修改数据时会发生写时拷贝。
四、进程状态
进程状态可以反应进程在执行过程中的变化,在操作系统中进程状态可以分为五个基本状态,即新建状态、运行状态、就绪状态、阻塞状态、终止状态
其中最主要的是运行态、就绪态和阻塞态。当一个进程在CPU中被执行时处于运行态,但是操作系统中有很多个进程需要运行,不能让一个进程占用CPU过长时间,于是需要按照时间片来排队执行。当一个进程的运行时间片到了,就需要退出运行态,重新等待CPU的调度,变成就绪态。如果一个进程在执行期间需要等待某个事件,例如进程要访问外设,但是外设没有输入时,进程就要进入外设的等待队列,此时就会进入阻塞态,等待事件的完成。当外设输入后,进程重新回到CPU的运行队列。
实际上还有挂起状态,当操作系统内部的内存资源严重不足时,操作系统就会将一些进程的代码和数据换出到磁盘中,此时进程就处于挂起状态。当需要运行时再将代码和数据从磁盘换入到内存中
4.1 Linux中的进程状态
操作系统大致遵循以上的模型,不过Linux中的进程状态与上面说的有微小的差别,我们可以看看Linux的内核源代码中对进程状态的描述
/** 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 */};
可以看到在Linux中,运行状态细分为了七种:
R(running):运行状态,进程在被CPU调度或处于运行队列中S(sleeping):睡眠状态,进程在等待时间完成,也叫做可中断睡眠状态D(disk sleep):磁盘休眠状态,也叫不可中断睡眠状态,该状态下的进程会等待IO的结束T(stopped):停止状态,可以通过发送信号让进程停止t(tracing stop):追踪状态,程序在调试时遇到断点停下的状态X(dead):死亡状态,进程死亡后被回收的状态Z(zombie):僵尸状态,进程死亡后未被回收时保持的状态
接下来我们一个个观察这些状态
(1)R状态
我们运行一个无限循环的程序,循环内部不作任何语句,就可以观察到该状态了
#include <stdio.h> #include <unistd.h> #include <sys/types.h> int main() { while(1) { ; } return 0;}
运行程序,结果如下:
可以看到此时进程的状态就是R+,这个+代表进程此时在前台运行,如果我们想让进程在后台运行的话,只需要在运行程序时在命令的后面加上 & 即可,此时就可以在运行程序的同时输入其他命令了
此时进程就正在后台运行中,如果我们要杀死该进程,只需要输入kill -9 +进程PID即可
有些人可能会好奇下面的grep --color=auto test这个进程是什么,实际上这个就是我们输入的命令,因为Linux中的命令也是程序,在输入命令后bash就会创建一个命令子进程
(2)S状态
要观察S状态,我们可以在程序中写一个scanf函数,但是运行程序后不进行输入。
可以看到此时进程状态就是S+,进程正在等待外设的输入。
(3)D状态
在操作系统的内存处于危急存亡的时刻,将进程挂起也不管用了,这时候操作系统就会开始杀进程
但是假如一个进程正在向磁盘中写入一些十分重要的数据,在等待磁盘写入完毕的期间会处于阻塞态,如果此时被操作系统杀掉了,就会导致数据丢失进而产生严重的后果。因此针对这种情况设计了深度睡眠状态即D状态,位于该状态的进程无法被操作系统杀掉。
实际当中我们很难观察到D状态的出现,所以这里不作演示
(4)T状态
前面提到我们可以通过发送信号让程序终止,具体信号可以用kill -l查看
可以看到18号信号SIGCONT是继续进程,19号信号SIGSTOP是暂停进程。
获取到进程的PID后,我们就可以输入kill -19 +进程PID来暂停一个进程了,例如:
可以看到,此时进程处于暂停状态,输入kill -18 +进程PID即可恢复运行,但是会变为在后台运行,所以我们需要用kill -9来杀死该进程
(5)t状态
我们写一个程序并用gdb调试一下,就可以观察到t状态了
可以看到,我们在程序中打了一个断点并开始运行程序,当运行到断点处时程序暂停,此时观察进程状态就会发现该进程正处于t状态
(6)X状态
进程死亡后的状态,这个状态持续的事件十分短暂,我们几乎无法观察到
(7)Z状态
实际上,进程死亡后并不是立即就变为X状态了,而是需要先经过Z状态即僵尸状态。
就像一个人突然在大街上挂了,并不是马上就埋了,而是先要让110、120和法医等进行现场的维护和死亡原因的判断等工作,家属料理后事,然后才入土。
进程也是这个理,当一个进程死亡后,需要维护自己的相关资源等待父进程回收并提交进程的死亡信息。如果父进程迟迟不读取子进程的状态,子进程就会一直保持Z状态不能被释放。
我们可以通过运行一段代码来观察Z状态:
#include <stdio.h>#include <unistd.h>#include <sys/types.h>int main(){ pid_t id = fork(); if(id == 0) { int cnt = 5; while(cnt) { printf("子进程运行中...\n"); sleep(1); cnt--; } printf("子进程结束!\n"); } else if(id > 0) { while(1) { printf("父进程运行中...\n"); sleep(1); } } return 0;}
这段代码中,子进程循环5次后就会结束,而父进程一直保持运行,所以在预想中子进程结束后会一直保持Z状态。我们运行代码,结果如下:
可以看到结果符合我们的预期,子进程结束后父进程还在运行,所以子进程一直保持Z状态
因为进程的维护需要占用内存资源,如果存在过多的僵尸进程而迟迟不回收,就会造成内存泄漏
4.2 孤儿进程
前面提到,子进程先结束而父进程迟迟不结束,子进程会保持僵尸状态
如果父进程提前结束,而子进程还在运行,那么子进程就变成了孤儿进程。当子进程结束之后该由谁来回收呢?
我们可以将上面的代码修改一下,让父进程先结束而子进程一直运行,然后进行观察:
可以看到,父进程退出后子进程的PPID变为了1,即我们的操作系统。
所以当父进程比子进程先结束时,子进程就会被操作系统领养,最后由操作系统负责回收
五、进程优先级
在学习进程优先级之前,我们需要了解几个概念:
并行:多个进程在多个CPU下分别同时运行并发:多个进程在一个CPU下采用进程切换的方式,在一段时间内让多个进程都得以推进
很明显,我们大部分人的计算机中都是只有一个CPU的,进程在我们的计算机内采用并发运行
既然CPU只有一个,而进程有很多个,那么进程之间必然存在竞争关系,如果一个进程长时间得不到CPU资源,该进程的代码就无法得到推进,造成进程饥饿。
CPU需要保证进程之间良性竞争,所以就需要确定进程的优先级
输入ps -l命令,输出的内容如下:
其中的PRI就是进程优先级,代表程序被CPU执行的先后顺序,PRI值越小则进程优先级越高,默认进程的PRI值为80
而NI是nice值,用于调整进程的优先级,进程的实际PRI值等于默认PRI值+nice值
我们可以通过调整nice值来调整进程的优先级,其取值范围为-20~19,不能超过这个范围。所以进程优先级的范围就是60~99
但是CPU具体是如何针对优先级来调度进程的呢?
在CPU的运行队列中可以维护一个task_struct*指针数组(活跃队列),其中前100个下标属于操作系统,100-139的下标代表40个优先级
每个task_struct*指针都指向一个当前优先级的进程队列,所有的R状态进程就根据优先级的不同打散到代表不同优先级的运行队列中,所以CPU按照下标的顺序就可以按优先级顺序来运行进程,类似于哈希桶的结构。
判断活跃队列是否运行完毕,如果每次都遍历一遍的话效率就有点低了。这里我们使用位图(即图中的bitmap)来进行判断,5个int类型元素总共160个bit位,每个bit位映射1个优先级,通过某一bit位的值就可以判断该优先级的队列是否为空了。这就是Linux的O(1)调度器的核心算法
但是当进程的时间片到了后需要重新排队,并且同时也可能会有别的进程入队,如果活跃队列既要进行进程的调度,又要维护后加入的进程,就会浪费CPU资源。这时CPU会把这些进程插入到另一个task_struct*指针数组(过期队列)中,当活跃队列为空时,只需要交换一下指向两个队列的指针,就可以继续运行新一批的进程了。
完.
上一篇: Ubuntu20.04双系统安装详解(内容详细,一文通关!)
下一篇: 树莓派最新的安装,设置与教程,助你一次性设置完毕树莓派。(以macOS为例,包含windows和linux)史上最详细!一次性完成所有设置!
本文标签
声明
本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。