Linux-进程控制

Posted Booksort

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux-进程控制相关的知识,希望对你有一定的参考价值。

作为操作系统对于其中的进程控制,对于一个多进程少CPU的情况,操作系统要有一个组织调度进程的算法来合理分配资源。

进程调度队列

NICE与PRI

首先回顾一下NICE与PRI,这是表明进程的优先级的大小,越小,就代表进程优先级就越高。
NICE的范围是[-20,19],而PRI则默认是80.我们一般也只能通过控制NICE的值来间接影响PRI,从而改变进程的优先级。而NICE的区间也说明PRI的范围是[60,99]。也就是意味着,一个进程的优先级也就是在这个范围,且PRI越小,优先级越高。

运行队列-runqueue

一个CPU具有一个活动队列,我们初学这些东西,我们不考虑多个CPU的情况。

优先级

队列中会有一个专门的数组来组织进程,根据优先级。在活动队列中,一共140个优先级,其中

  1. 0~99:实时优先级(我们不考虑)
  2. 100~139:普通优先级(与我们的PRI对应)
    这里


像这样的数组,按优先级来排列,优先级一样的进程在同一个”元素“下,CPU就按照这个顺序区依次遍历优先级数组的每一个元素,从0开始向139遍历。

但是,如果仅仅是这样的话,时间复杂度还是不小。如果有的优先级下的元素优先级队列是空的,那CPU区遍历这个岂不是浪费资源,所以提供了位图这个概念。将0~139全部位图来表示

bitmap[5]:一共140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用5*32个比特位表示队列是否为空,这样,便可以大大提高查找效率!

过期队列和活跃队列


在CPU的运行队列中,有两个一摸一样的队列,其分别是活跃队列与过期队列,但是这并不是绝对的,该位置这个时刻是过期队列,过一段时间可能就是活动队列。

  • 活动队列:保存还未被CPU调度过的进程
  • 过期队列:储存已被CPU调度后的进程

当一个周期调度后,又要才想起开始i调度,过期队列就会变成活动队列,而原先的活动队列也就会变成保存已被调度后的进程的过期队列。

当要操作系统要增加新的进程时,会控制,新的进程直接按照优先级放入过期队列中对应的位置,当这个活跃队列中的进程调度完后,会跑去调度原先的过期队列,正好符合逻辑。

active指针与expired指针

  • active指针:永远指向活跃队列
  • expired指针:永远指向过期队列

当活跃队列调度完后,仅需将active指针与expired指针交换一下指向位置,即可更换活跃队列与过期队列的身份。

进程创建

fork函数深入认知

Linux-进程概念中,已经介绍过fork函数,该函数在程序运行后,可以通过系统调用创建一个子进程。子进程共享父进程的代码和数据。然后我们可以通过if/else来分流,让两个进程执行不同的操作。
同时,还认知到这个函数有“两个返回值”,

如果是子进程就返回0,如果是父进程就返回子进程的pid

为什么子进程返回0,父进程返回子进程pid?

因为,父进程不需要标识,而子进程需要标识。一个父进程可以有多个子进程,而一个子进程却只能有一个父进程。
父进程为了区分子进程需要返回子进程的pid来进行区别。
父进程会给子进程任务,父进程会管理子进程,而子进程只能给父进程返回自己的处理状况。

为什么有有两个返回值?

根据创建进程的流程,fork()函数要创建一共子进程,要先将父进程的task_struct、mm_struct、页表、其它信息拷贝过来,

(1)创建子进程相应的数据结构,同时在创建子进程相应的数据结构时,会相应的修改一些信息,比如:pid与ppid的信息。
(2)操作系统也要管理子进程,而管理的概念是:先描述,再组织

上一步创建子进程相应的数据结构就是在描述子进程,这一步就是要组织进程

现在就是要将子进程的PCB(task_struct)链接进入系统的调度列表中,也就是加入CPU的runqueue中。

做完这两步,子进程已经创建好了,且也能被操作系统管理。也就是说,这个子进程在做完这两步后,就能像父进程一样被操作系统调度。

也就是说,子进程在函数 return 之前就已经创建好了。

之后,作为一个fork()函数,要返回一个值。假设fork()中的return,父进程先调度,在原先为父进程创建的进程地址空间中,父进程返回其子进程的pid,于是此时,该进程地址空间中该返回值的虚拟地址上的值就确定下来了。然后子进程由于创建好了,由于共享父进程的代码。

当子进程被调度时,执行该函数的返回值,由于没有自己的子进程,就要返回0。而这个返回值在进程地址空间的值不一样,就会发生写时拷贝,操作系统就会为子进程的该返回值在别的地方(物理内存)单独开辟一个空间,再被页表处理成原先的地址。

其实这个fork创建子进程后,父子进程谁先调度,取决于调度算法,我这里只是假设父进程先调度 return,如果子进程先调度 return 也是一样的,会发生写时拷贝。

所以就相当于是,这个return被执行了两次,在两个进程中分别被执行了一次。


像这个代码的输出结果


因为,子进程已经被调度了,所以这给不同的代码,其实是再不同的进程中被打印出来,这样交替打印,其实是CPU切换进程的结果,子进程被CPU执行一个时间片,就换成父进程被执行一个时间片。两个进程其实都被卡在了各自的while中。

这样,可以让程序在同一时间内执行不同的操作。

fork有可能会创建进程失败

由于资源毕竟是有限的,操作系统的管理能力也是存在一定的限制,如果系统中进程过多,超出了操作系统的限制,也会fork调用失败

写时拷贝

父子进程代码和数据是共享的,如果任意一个进程对数据发生写入(更改),就会针对该数据写时拷贝一份,虽然在虚拟内存中是一份(地址一样),但实际在物理内存中,操着系统已经在另外一个位置申请了空间,储存写入到那个数据,这一份专门给写入的那个进程,页表将的物理地址处理成一个。
也就是说,物理内存中有两份数据,而虚拟内存中只有一份。

当然,只是对于被修改的数据,对于未修改的数据,父子进程的页表实际是指向同一块物理内存。

好处

  1. 进程具有独立性,可以防止进程之间互相影响
  2. 可以减少资源消耗,按需分配。
  3. 延时分配,当某个进程被调度时,再分配空间。有利于资源调度。
  4. 让进程调度和内存管理解耦分离,需要时,再使用

对于代码而言,绝大部分不会发生写时拷贝,绝大部分是只读的。

进程终止

进程退出的情况

  • 代码运行完毕,结果正确
  • 代码运行完毕,结果不正确
  • 代码异常终止(进程崩溃)

我们都知道,当一个进程结束后,其父进程要知道,该进程的任务执行情况,是执行完了,还是执行异常导致进程提前结束,如果执行完了,要知道任务执行的怎样…
而这是就需要一个标志来表明进程的执行情况。

比如说一个C语言程序,进程执行指令的入口都是mian函数,出口基本上也是main函数。系统会直接或间接调用main函数,而程序执行完毕,进程结束,需要return 0或者说需要一个返回值。

语法的角度上来看,函数需要返回一个整型。
结构的角度上来看,这个返回值是直接或间接返回给了操作系统,是操作系统需要这个返回值

所以得出,系统为了知道进程的允许情况,需要靠这个main函数的返回值来了解,也叫-进程退出码

进程退出码


return就是返回给操作系统的进程退出码
该程序,允许起来后,被加载到内存中。

echo $?指令

$?可以查看最近一次的进程退出码
echo能将进程退出码打印在屏幕上

该进程被我强制终止后,进程退出码是130。



该程序自动会结束进程

其进程退出码是0

如果,我手动更改return 的返回值


就是我更改的返回值,也就是说明,我们可以通过进程退出码(main函数的返回值)来判断main函数的执行情况是否正确或者说符合预期

echo $?可以查看进程的执行情况,通过进程退出码
ls/cd...这些指令,前面也提到了,这些运行起来都是进程,这些也是有退出码的。

这是允许成功的

这是运行失败的。


对于一个进程而言,

退出码是0,就代表着运行结束,执行正确,
退出码是!0,就意味着,进程运行不成功,而影响进程运行失败的原因有很多种

所有的进程退出码

该程序可以打印出所有的进程退出码





最多到133,一共134个进程退出码。

系统和用户都可以通过进程退出码来确定,进程失败的原因。

进程退出码-数字给操作系统了解进程情况,
而进程退出码的描述-字符串给人了解进程退出的情况

常见进程退出方法

进程正常退出

  • 从main函数返回
  • 调用exit() - 在任意地方调用都会造成进程终止,同时会刷新相关数据,释放进程占用的资源
  • 调用_exit() - 在任意地方掉用回造成进程终止,但并不会刷新数据,清理资源

进程异常退出

比如说CTRL+C,进程在运行时的越界,除0,之类的错误

进程异常退出后,其退出码还有意义吗

异常推出后,进程退出码就没有任何意义了。

因为反应不了异常退出的问题在哪。反应不出任何问题。

进程终止,操作系统做了什么

则该进程就不需要被OS管理,则,该PCB就需要被从各种调度队列,进程列表中删除取出,然后根据PCB保存的各种信息,去找到并删除相关的数据结构,虚拟内存mm_struct,页表等待。还要释放掉申请的内存。

进程等待

进程等待的用处

之前的博客介绍过,一个进程结束,但是其申请的资源并不是被立即释放掉,该进程是需要其父进程来读取其进程的信息,而这个就需要等待父进程来读取子进程的信息,而这个等待的阶段该子进程就处于僵尸状态-Z。如果父进程一直不读取子进程,子进程就一直处于僵尸状态,可能造成内存泄漏的问题。

而父进程要读取子进程的信息,父进程也是会受到一定的影响,父进程就会通过进程等待的方式来读取子进程的信息回收子进程的资源

进程等待的方法

wait方法

我的man包少了,暂时不知道怎么补全,还查不到wait()

头文件:
#include<sys/types.h>
#include<sys/wait.h>

函数:pid_t wait(int* status)
	 pid_t ~ int
返回值:等待
		成功,返回被等待进程的pid,
		失败则返回-1
参数:子进程的退出状态,不用时可置NULL
(在外创建一个int变量,传址调用,可查看进程退出状态)

这个代码以运行,然后我监控器进程的状态。

右边是我写的监控脚本监控的进程状态,当子进程退出后,而父进程在休眠,没有读取子进程,则子进程就会一直处于僵尸状态-Z+,+代表是在前台。
父进程一直在休眠,子进程执行完后,exit(0)退出了,就处于僵尸状态。

为了证明wait确实是父进程读取子进程

运行后,

事实证明,wait确实是父进程读取子进程后后,子进程就从监视状态中退出了。

子进程运行,父进程在干嘛

通过这份代码来执行证明

运行现象

父进程执行了第一个printf后,就该执行wait,但是一直是子进程在执行,当子进程执行了10次后(子进程结束),父进程才会再开始执行下一步指令,读取成功的打印。
这里父进程在wait之前可没有sleep,休眠,前两份代码父进程没执行是因为在休眠,现在父进程完全是在等待子进程执行完毕。

所以,当子进程在退出前,父进程一直在等待,等待子进程退出后读取子进程的信息,与回收子进程的资源-阻塞等待

waitpid方法

头文件与wait一样

pid_ t waitpid(pid_t pid,//指定等待的子进程的pid
			   int *status,//子进程的退出结果
			   int options);
返回值:
	当正常返回的时候waitpid返回收集到的子进程的pid
 	如果设置了选项WNOHANG,而调用中waitpid发现没有
已退出的子进程可收集,则返回0;
	如果调用中出错,则返回-1,这时errno会被设置成相应
的值以指示错误所在
参数列表:
	pid
		pid=-1,则会等待任意一个进程
		pid>0,则等待那个指定的进程
	status
		进程的状态值
	option
		0:父进程-阻塞式等待
		WNOHANG:父进程检测到子进程没有退出,就返回0
,父进程不会一直等待读取子进程-非阻塞等待

int 32位的status的组成

也就是说status的次低8位8-15的比特位保存了进程退出码。
如果想得到进程退出码要进行一些位运算处理。

低16位是我们主要使用的

进程退出码 = (status>>8)&0xFF
终止信号   = status&0x7F

进程异常的原因,是因为进程运行时发生了某种错误(野指针,除0…),导致进程收到信号

  1. 如果是正常终止,则低8位就置0,就不用,
  2. 如果是信号终止,则次低8位就置0。

注:如果进程异常退出,则进程退出码是没有任何意义的

waitpidd在多个子进程中的运用


一个父进程创建10个子进程,并且依次等待,来看看进程的状态


随着子进程依次被父进程读取,监控列表中的处于僵尸状态的子进程越来越少。



而,父进程在子进程运行时,父进程仅仅是在阻塞式的等待,直到子进程退出处于僵尸进程,父进程去读取子进程的信息

阻塞与非阻塞

像上面的程序,当子进程还在运行时,父进程就是在一直等待子进程运行完毕,什么任务也不做,仅仅是在等待子进程执行完。就是阻塞等待

非阻塞等待,是指,子进程在运行时,父进程并不会只是在等子进程运行完毕,而是也有自己的任务要完成,当子进程执行完后,退出,父进程会停下,自己的任务去读取子进程的信息,也就是说,父进程在等子进程的同时,也会去执行自己的任务。

非阻塞接口的轮询检测方案

对于使用了WNOHANG进行非阻塞式等待。但是进程运行时,父进程检测,可能有3个结果

  1. 父进程等待成功,检测到子进程没有退出,返回0
  2. 父进程等待成功,检测到子进程退出,返回子进程pid
  3. 父进程等待失败

两个情况,父进程都是等待成功了,只不过和阻塞式等待不同,父进程并不会一直等待,等待成功就返回、函数调用结束,所以,这就会导致父进程提前结束,子进程变成孤儿进程,会被OS接管。


如果仅仅读取一次是无法满足要求的,所以说,父进程的非阻塞式等待要一直等待读取,直到读到子进程pid,子进程结束后。

还要有,等待失败的情况。毕竟是要检测的还可能出现检测等待失败的情况。

这个方法叫:非阻塞接口的轮询检测方案

进程替换

对于上面的代码,使用if...else 分流,让父子进程执行不同的代码。但是,这个实际上还是属于父进程的代码。因为父子进程代码共享,但是为了满足很多不同的需求。

可以通过进程替换,来让子进程完全不去执行父进程的任何代码。


这个意思就是说,进程替换,子进程会去执行比如:从磁盘加载新的进程,去执行新的代码,这也是前面说的”代码的写时拷贝

替换原理

用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数,以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。

execl函数

头文件:#include <unistd.h>

原型:int execl(const char *path,//路径 
		  const char *arg,//文件名
		  ...);//该文件的参数列表
返回值:
	调用成功,函数不返回
	调用失败,返回-1,失败原因在errno中,可通过
perror打印

举例:

这是运行后的结果

而我们都知道,像top/ls这些指令,都是程序,一旦运行就会被加载成进程,所以exec函数,调用程序,也是把这些程序从磁盘中加载进内存中变成进程。

进程替换,就是进程调用程序。

而execl函数就可以看成是Linux加载器的底层调用。

当execl后,就相当于,将例外一个程序的数据和代码加载到内存中,而此进程不再运行当前的数据和代码,而是去运行新的数据和代码。

提问:当进程加载新的程序到内存中,是否创建了新的子进程或者新的进程

没有,进程替换是没有创建新的进程
原先进程的在内核中的数据结构:PCB、页表,虚拟内存(mm_struct)…是没有发生任何变换的,所以是不会发生进程创建

而加载进来的程序其代码和数据仅仅是加载到内存中,对原先的代码和数据进行了替换,而页表会将其地址处理成和原先一样的地址。

进程的内核的数据结构是没有任何变化的。

提问:替换后,原先的代码还会不会执行

不会


理论上,excel后面还有代码可以执行。
实际上,

并没有执行后面的代码,也就是所一旦进行了进程替换,其后面的代码就不会执行了。
也就是说,一旦替换后,则后面的代码就消失了,CPU就再也不会执行了,OS也就看不到后面的代码了。
也就是,execl虽然作为一个函数,但是这毕竟是系统级别的函数,这个函数一旦调用,一旦加载程序成功,就会会再返回了。

就相当于替换后,重新加载了代码和数据到内存中,而页表重新指向新加载进来的代码和数据的地址,新的代码和数据完全替换了原先的带啊吗和数据,这也是为什么替换后原先的代码都不会执行了,因为都找不到了,系统和CPU已经看不到原先的代码了。

可以理解成,函数一旦执行成功,一经替换,就不会返回了。

如果进程替换失败,那后面代码还会执行吗?

会,进程替换失败,可执行程序的代码和数据加载失败,原先的代码和数据是不会受到影响

输出结果

像exec系列的函数,一旦调用成功,就不会再返回了,但是这样会影响进程后续代码的执行,所以,这个进程替换,最好还是配合子进程进行调用,才能起到效果。

输出结果

进程替换函数

#include <unistd.h>`

int execl(const char *path,
		  const char *arg,
		   ...);//传参列表
int execlp(const char *file, 
		   const char *arg,
		    ...);//传参列表
		    
int execle(const char *path,
		   const char *arg, 
		   ...,//传参列表
		   char *const envp[]);
		   
int execv(const char *path, 
		  char *const argv[]);
		  
int execvp(const char *file, 
		   char *const argv[]);
		   


这些可以说是系统加载器的底层调用了。
但是,这些系统函数不仅能调用Linux内置的指令程序,也能对我们自己写的程序进行替换,因为,我们自己写的程序和Linux中的那些指令本质上一样的,都是可执行程序(ELF),只不过路径不同。

所以,对于任何的可执行程序exec函数都是能进行替换的。

哪怕是C语言的进程替换python、shell、C++、Java…这些不同语言的可执行程序都是可以进行替换的。

比如,一个进程fork创建子进程后,子进程调用一个自己写的可执行程序

这就是那个可执行程序的源代码

事实证明,确实可以调用其它的可执行程序

execle&&execve

介绍这两个系统加载函数,先介绍一下,其中的e是什么意思

e是指自定义环境变量,也就是说,我们可以自定义一个环境变量,让我们自己维护,让这个可以达到和系统级别的可执行程序一样的使用环境变量中的路劲。

getenv()函数

char* getenv(char* name);
参数:
	环境变量的名字
返回值:
	该环境变量的内容,也就是其中保存的路径

输出结果




execve()函数与上面5个函数,只是传参的方式不一样,其本质作用基本都是一样的,在Linux的加载器中充当底层算法。
而上面5个函数的底层则是execve函数,上面5个函数实际上调用的都是这个execve函数

总结进程替换函数的命名规则

  • l-(list) : 表示参数采用列表
  • v-(vector) : 参数用数组
  • p-(path) : 有p自动搜索环境变量PATH
  • e-(env) : 表示自己维护环境变量

且这些函数只用调用失败会返回-1,

调用成功则不会返回

模拟实现简易shell

先了解一下我们使用的shell程序中进程的控制关系。
当我们登录了自己的账号,Linux就创建了一个bash进程,当我们输入某些指令时,bash进程作为主进程,并不会自己亲自去对这些命令进行解释,因为成本太高,风险太高。

而是会创建一个子进程去作为这些命令的解释器,
而bash进程会等待子进程的退出。
当子进程退出后,bash会继续读取新的命令(如果有的话),循环往复,继续执行相关操作。

所以说bash完成命令解释有两个主要的核心调用

  1. fork()创建子进程
  2. exec进行子进程替换


这些简单的代码可以模拟实现一个建议的shell,感觉还是有点小成就的。

感谢大佬观看!!!!!!

以上是关于Linux-进程控制的主要内容,如果未能解决你的问题,请参考以下文章

linux c 退出进程的代码

Linux - 进程控制 代码(C)

Linux下的进程控制块—task_struct

Linux 如何查看进程和控制进程

Linux系统:进程控制

Linux]——进程控制