🦄个人主页:修修修也
🎏所属专栏:Linux
⚙️操作环境:Xshell (操作系统:CentOS 7.9 64位)
目录
📌操作系统
🎏操作系统的概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统包括:
- 内核(进程管理,内存管理,文件管理,驱动管理)
- 其他程序(例如函数库,shell程序等等)
🎏设计操作系统的目的
- 与硬件交互,管理所有的软硬件资源
- 为用户程序(应用程序)提供一个良好(稳定, 高效, 安全)的执行环境
🎏操作系统对进程的管理
🕹️操作系统为什么要对进程进行管理?
进程是操作系统进行资源分配和调度的基本单元。操作系统通过管理进程来实现对资源的分配和调度。
🕹️操作系统如何对进程进行管理?
操作系统通过下面两个行为来完成对进程的管理:
- 描述进程
- 组织进程
描述进程主要是将进程的各种重要的属性描述出来, 比如将进程id, 父进程id, 进程状态, 进程优先级等属性合为一个结构体来描述一个进程。这就像我们将学生的各种属性, 如姓名, 性别, 学号, 班级, 年龄, 成绩等合为一个结构体来描述一个学生一样。
组织进程则是通过按照进程的各种属性来将进程组织运行起来。这也很好理解, 比如我们要组织一个班级进行野炊活动, 我们就按照各个同学的特长将同学们分组, 一组去拾柴, 一组去收拾场地, 一组去收集食材, 一组处理食材。这样组织同学们共同完成野炊这个任务, 就类似于操作系统组织进程完成用户的任务一样。
注意, 操作系统管理的只是进程的属性, 通过对进程属性结构的增删查改来管理进程, 而并非去管理每个进程本身有什么行为。
📌进程
🎏进程的概念
进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配的基本单位,是操作系统结构的基础。
在早期面向进程设计的计算机结构中,进程是程序的基本执行实体, 如正在执行的程序;
在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
🎏进程的描述——PCB(process control block)
- 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
- 课本上称之为PCB(process control block),Linux操作系统下的PCB是: task_struct
🕹️Linux下的PCB——task_struct
- 在Linux中描述进程的结构体叫做task_struct。
- task_struct是Linux内核的一种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
- task_struct非常大, 感兴趣的朋友可以看一下它的源码 : task_struct英文源码原文
🕹️task_struct内容分类
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
- 上下文数据: 进程执行时处理器的寄存器中的数据[休学例子,要加图CPU,寄存器]。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息
🎏进程管理
🕹️组织进程
Linux内核中, 最基本的组织进程task_struct的方式是采用双向链表组织。
但实际上Linux内核中组织进程的方式错综复杂, 可能是好几种数据结构互相嵌套在一起。
🕹️查看进程
如下代码,我们先在Linux中创建一个进程(程序):
#include<stdio.h> #include<unistd.h> int main() { while(1) { printf("进程正在运行中...\n"); sleep(1); } return 0; }
然后用gcc编译运行这个程序:
可以看到进程正在每隔一秒打印一次提示信息,然后我们在另一个终端使用ps命令查看一下这个进程信息:
ps ajx | head -1 && ps ajx | grep mypro //查看mypro相关的进程 //或 ps ajx | head -1 ; ps ajx | grep mypro //查看mypro相关的进程
还有一种查看进程的方式是通过查看/proc目录来查看进程:
ls /proc //查看当前系统的所有进程
然后我们可以通过查看具体的进程目录来查看进程的信息:
ls /proc/4206 -l
或者也可以通过top命令来查看进程:
🕹️通过系统调用获取进程标示符
- 进程id(PID)
- 父进程id(PPID)
可以通过getpid(),getppid()来获取进程id和父进程id,函数手册如下:
函数使用示例:
我们编写一个进程,然后调用getpid()和getppid()函数来获取它的进程id和父进程id,代码如下:
使用gcc编译运行,再在另一个窗口查询进程相关信息:
可以看到getpid()和getppid()获取的进程id和父进程id信息是正确的。
🎏创建进程
🕹️fork()函数
我们前面尝试了在指令级别运行一个进程,即运行我们写的程序; 现在我们来看一下在代码层面如何创建一个进程。
先来看一下Linux的fork()函数手册:
我们通过一段代码来观察一下fork()函数调用的现象,代码如下:
编译运行代码,结果如下:
可以看到, fork()函数后的代码竟然被打印了两遍! 这是因为我们在调用fork()函数之前程序只有一个执行流,而调用fork()函数之后程序就开始有两个执行流了,所以两个执行流都执行了打印操作,我们的屏幕上才会看到两个打印的结果。
🕹️fork()函数的两个返回值
我们可以看到fork()函数拥有两个返回值,一个是返回给父进程的子进程id,一个是返回给子进程的0。如果fork()函数创建子进程失败了,就会给父进程返回-1,并适当设置errno报错.
下面我们测试一下接收fork()函数的返回值,代码如下:
编译运行,结果如下:
可以看到, 子进程和父进程分别执行了其对应的不同的代码逻辑,并且我们可以明显发现,这个测试中fork()函数应该是优先执行父进程的,所以父进程的信息先被打印了出来。但实际上父子进程的执行先后是由调度器来决定的, 可能不同系统, 不同进程甚至不同时间测试结果都有可能不一样, 完全取决于调度器如何安排, 是不确定的。
至此,我们发现fork()函数确实是创建了一个新的进程, 只有两个进程一起运行, 这段代码才可能打印出两个if语句的分支循环结果,否则按照C语言单进程的执行逻辑, 程序一旦进入其中一个死循环就再也不可能出来了, 那么我们将只能看到其中一个死循环在打印。
🕹️进一步探究fork()函数
关于fork()函数,有几个问题需要解答一下:
1.为什么fork()函数要给子进程返回0,给父进程返回子进程pid?
我们给父子进程设置不同返回值的目的,就是为了让我们在调用fork()函数之后,可以根据不同的if判断来让父子进程执行不同的代码片段。因此返回不同的返回值,是为了区分不同的执行流,使其可以执行不同的代码块。需要注意的是,一般而言,fork()函数之后的代码是父子共享的!
而具体我们给父进程返回子进程的pid的原因则是为了方便父进程后续对子进程做管理, 因为父进程的子进程可能不止一个,因此需要分别记录下子进程的id方便其后续对子进程的精准管理。
2.fork()函数究竟做了什么?
fork()函数的主要工作大概有下面几个:
- 创建子进程PCB
- 填充PCB对应的内容
- 让子进程和父进程指向同样的代码
- 父子进程都有独立的task_struct,可以被CPU调度运行
- ......(fork()函数的主要工作完成之后,后面的代码就是父子共享的了!)
- return id ; (因为这里的fork()函数的return 语句已经在成功创建子进程后了,这意味着从那之后父子进程就会分别拥有一个return 语句,这样就可以做到父进程返回一个值,子进程返回一个值了)
3.一个函数是如何做到返回两次的?我们该如何理解这一现象?
如上问fork()函数的工作内容所示,因为一旦子进程创建好后后面的代码就是共享的了,所以return 语句自然也变成父子共享的,所以他们就会通过这个共享的return 语句来得到分别属于自己的返回值。这样也就做到了返回两次值的效果。
4.一个变量怎么会存有不同的内容?如何理解这一现象?
对父子进程而言,他们的进程代码是共用的,而它们的进程数据则采用写时拷贝的方式, 拷贝出的进程数据互不干扰, 可以分别使用。即实际上这个变量已经不是一个变量了,而是父进程的变量和写时拷贝出的子进程变量这两个变量,因此可以存不同的内容。
这样安排的主要原因是进程间相互具有独立性, 进程代码因为不会被进程更改,所以父子共享是没问题的,但进程数据是可能被进程更改的,如果父子进程间可以互相影响数据,那么就很容易导致出错的情况。
这就好比我们现实生活中父子可以在一栋房子里生活, 不会因为父亲住了房子就坏了,儿子不能住了,也不会因为儿子住了导致房子有什么变化导致父亲不能住了。但是父子间不能共用一个钱包,否则可能父亲刚想给家里买一台空调,就发现钱包的钱已经被孩子拿去买乐高了。这样会互相干扰,就会导致出事。
🎏进程状态
🕹️操作系统层面进程的状态
进程状态反映进程执行过程的变化。这些状态随着进程的执行和外界条件的变化而转换。在三态模型中,进程状态分为三个基本状态,即运行态,就绪态,阻塞态。在五态模型中,进程分为新建态、终止态,运行态,就绪态,阻塞态。
🌳运行状态
进程占有处理器正在运行或正处于运行队列中。
🌳阻塞状态
进程不具备运行条件,正在等待某个事件的完成。
🌳挂起状态
进程的挂起状态是指计算机系统中,一个进程因为某些原因而暂时不能继续执行,但仍然保持在进程表中,并且有可能在将来恢复执行的状态。
🕹️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 */ };
🌳R : 运行状态( running )
- 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
但是要注意,我们查看进程时,不能只根据表面来推测判断它处于什么状态,而应该更严谨的去分析进程的行为再做判定。
比如,我们创建一个程序,代码如下:
然后我们运行进程,查询该进程的状态:
可以发现明明左边进程一直在运行,但是右边查询进程属性的结果却显示进程在等待状态。这是因为我们在进程中调用了printf()函数,即调用了硬件设备显示器来向屏幕上打印内容,而当硬件显示器在打印内容时,进程就是处于等待硬件工作的状态的, 并且硬件显示器向屏幕打印的时间相对CPU运行时间来说很慢,两者是数量级的差别,硬件几乎占了99.99%的时间,所以我们在查询进程状态时,大部分时间查到的都是进程在等待打印的时候。
当我们将while循环中的打印语句删除,再运行,查询进程状态:
可以看到,进程当前处于R运行状态:
小tips:
带+的状态表示是在前台运行的进程,如果我们在运行进程指令后面加一个 & ,那么进程就会在后台运行,状态后面就不会有+号。(杀后台进程用kill -9 [pid]来完成)
🌳S : 睡眠状态( sleeping )
- 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断(浅度)睡眠(interruptible sleep))。浅度睡眠状态意味着该进程当前是可以相应操作系统的操作的,比如可以直接被操作系统杀死。
🌳D : 磁盘休眠状态( Disk sleep )
- 有时候也叫不可中断(深度)睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。并且该进程不会响应任何操作系统的请求, 即操作系统无法将其杀死或者是进行其他任何操作。这样做主要是为了防止操作系统将某些重要的正处于等待状态的进程误杀。
🌳T : 停止状态( stopped )
- 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
可以通过给进程发kill -18/19 [pid]来使进程恢复运行/暂停.
T状态和S状态的区别是:两者都可以是为了等待某种资源而暂停,但T状态更为自由一些,它也可以不是因为等某种硬件资源,而是单纯的就是不想进程再运行,所以就可以将进程暂停。
🌳Z : 僵死状态( zombie )
- 僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程(使用wait()系统调用,后面讲)没有读取到子进程退出的返回代码时就会产生僵死(尸)进程
- 僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
- 所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程就会进入Z状态
- 处于僵死状态的进程就被成为僵尸进程,其相关资源尤其是task_struct结构体不能被释放,这也就会导致僵尸进程会一直占用内存资源!
一个进程在退出之后并不是就要立即将自己的所有资源全部释放, 而是操作系统要将该进程的退出信息维持一段时间, 直到该退出进程的相关进程知道了该进程退出的相关信息和原因之后,才会释放该进程的相关信息和资源。从进程退出,到相关进程接收到退出信息之间的这一段状态,就成为进程的僵死状态。
我们通过一段代码演示一下僵死状态,我们用fork()创建一个子进程,然后让它休眠3秒之后直接退出,同时我们让父进程休眠30秒,这样在子进程退出后由于父进程处于休眠状态就没法立即回收子进程的信息,子进程就会进入僵死状态,代码如下:
然后我们编译运行程序,调出监控窗口查看进程状态, 监控命令如下:
while :; do ps axj | head -1 && ps axj | grep zombie;sleep 1; echo "----------------------------------------------";done
查看结果,可以看到子进程确实从第三秒后就变成了僵死状态,名字后面也被标上了<defunct>:
僵尸进程的危害:
- 无人回收时进程的退出状态必须被维持下去,因为他要告诉和它相关的进程(父进程),你交给我的任务,我完成的怎么样,又或是遇到了怎样的状况。父进程如果一直不读取子进程的退出信息,那子进程就会一直处于Z状态!
- 维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,如果Z状态一直不退出,PCB就一直都要维护
- 如果一个父进程创建了很多子进程,但是不回收,就会造成内存资源的浪费!因为数据结构对象本身就要占用内存,要在内存的某个位置进行开辟空间,这就会导致内存泄漏!
🌳孤儿进程
我们刚刚讨论的是子进程比父进程先死亡的情况,但还有一种可能的情况是父进程比子进程先死亡,这种子进程就被成为孤儿进程,该进程会被1号进程(即操作系统)领养,代码如下:
编译运行,调用监控查看结果:
所以父进程是1号进程(操作系统)的进程就被称为孤儿进程.操作系统领养孤儿进程的主要目的是为了后续回收孤儿进程的退出信息并将其释放,防止存在内存泄漏问题。
🌳X : 死亡状态( dead )
- 这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
🎏进程优先级
🕹️基本概念
- cpu资源分配的先后顺序,就是指进程的优先权(priority)。
- 优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能( 但前提是能够客观公平的设置,一般情况下还是遵守调度器的调度,不要擅自修改进程优先级 )。
- 还可以把进程运行到指定的CPU上, 把不重要的进程安排到某个CPU,可以大大改善系统整体性能。
🕹️PRI和NI的概念
我们在linux或者unix系统中,用ps –l命令会输出以下几个内容:
- UID : 代表执行者的身份
- PID : 代表这个进程的代号
- PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
- PRI :代表这个进程可被执行的优先级,其值越小越早被执行
- NI :代表这个进程的nice值
- PRI即进程的优先级,通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高
- NI就是nice值,其表示进程可被执行的优先级的修正数值
- PRI值越小越快被执行,加入nice值后,PRI变为:PRI(new) = PRI(old) + nice
- 当nice值为负值时,该程序优先级值将变小,即其优先级会变高,则其越快被执行
- 调整进程优先级,在Linux下,就是调整进程的nice值
- nice其取值范围是-20至19,一共有40个级别
- 需要强调的是,进程的nice值不是进程的优先级,他们不是一个概念,但是进程nice值会影响到进程的优先级变化。nice值是进程优先级修正的修正数据
🕹️查看/修改进程优先级命令
查看进程优先级的方式和查看进程信息的方式一样,可以使用ps命令或top命令来查看:
使用ps命令查看进程PRI值和NI值:
使用top命令查看已存在进程的nice值:
修改进程优先级的方式是使用top命令:
- 进入top后按“r”–>输入进程PID–>输入nice值
- 需要注意的是普通用户不能修改nice值,只有root才可以修改进程的nice值
进程的其他概念:
- 竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
- 独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰, 因此父子进程间PCB是独立的,代码可以共享, 但数据需要写时拷贝
- 并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行
- 并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发
结语
希望这篇关于 操作系统与进程 的博客能对大家有所帮助,欢迎大佬们留言或私信与我交流.
学海漫浩浩,我亦苦作舟!关注我,大家一起学习,一起进步!
相关文章推荐