引言:
北京时间:2023/3/19/15:16,刚刚睡醒,我发现我真的能睡,早上将反向迭代器剩下的一些知识学完,发现,昨天那篇博客发的有些匆忙了,最后有关反向迭代器的知识都没有把精华部分给分析完整,充分意识到了迭代器的神奇以及泛型编程(模板)在使用上的无敌,模板是真的好用,所以暂时我们先把C++搁置一下,今天我们就来把欠蛋哥的钱还一还,如果再不还,就快要还不起了,今天我们就来学习一下有关进程控制的知识
复习虚拟地址空间
进程是一个很伟大的概念,从进程出发,从而衍生出了很多关于进程的知识,如虚拟地址空间,虚拟地址空间就是我们的操作系统(OS)给每一个进程画的一个大饼,让每一个进程都可以认为是它在独立的占用和使用整个内存空间,从而方便操作系统进行进程的管理(通过先描述,再组织),从而可以更好的提高操作系统的运行效率,具体管理方式请看上篇博客(mm_struct);并且虚拟地址空间是一个具有很多功能区域的空间,怎么理解虚拟地址空间划分成了很多的区域呢?其实本质理解,虚拟地址空间就是一个线性地址空间,我们通过将该线性地址划分为一个一个的区域(start和end)来控制每一个区域的起始和结束,这样就可以很好的理解,为什么虚拟地址空间有很多的不同区域,并且每个区域有不同的作用(存放的数据信息不同);并且在上篇博客中,我们还了解了有关页表和物理内存的相关知识,浅浅的了解了虚拟地址空间和物理空间之间的关系和之间数据的转换等知识,所以此时我们如果想要更深的了解有关虚拟地址空间、物理内存是如何通过页表进行转换的话,我们需要等我们学习了线程相关的知识,到时候,我们就可以深入理解页表多进程和多线程等知识,此时这里,我们就简单抽象一个操作系统和虚拟地址、物理内存之间的关系;
例:在学校中,每一个学生有一个学号和一个宿舍房间号,此时辅导员叫班长去通知某个学号为10的学生去找辅导员(此时的学号就类似于是虚拟地址),此时班长就通过这个虚拟地址在一个班级名单(mm_struct)中去寻找对应的学号,找到之后,查找到了该生的相关信息(具体是那个宿舍房间号),此时班长就通过这个宿舍房间号准确的找到了该学生(在内存中找到了相应的代码和数据),然后通知他,让他去找辅导员,并且当该学生找到辅导员之后(操作系统找到了相应的代码和数据),该学生只会对辅导员说,他是学号为10的学生(而不是某个宿舍房间号),表明操作系统和代码数据之间的交流是通过虚拟地址空间而不是物理内存,并且上述的学号就类似于虚拟地址,而宿舍房间号就类似于物理内存地址;所以上述就是我们抽象出来的操作系统和虚拟地址、物理内存之间的关系,此时有关虚拟地址空间的知识,我们就复习的差不多了,接下来,我们就正式的进入到新知识的学习之中。
进程创建,fork
搞定了上述虚拟地址空间有关的知识,此时我们了解到了,虚拟地址空间概念的提出,主要还是为了服务我们的进程,所以进程才是老大,所以接下来我们就来深入学习一下进程创建等知识,并且在我们目前的理解之中,进程就是等于内核数据结构(pcb)加该进程对应的代码和数据
写时拷贝问题
相信我们以前都见过fork函数,fork函数是linux中一个很重要的函数,它的作用就是用来创建一个新进程,并且运行fork函数,本质上就是在运行一个进程,在执行了该进程之后,fork函数就会执行其对应的代码和数据,本质上是在进行系统调用,此时进行了系统调用之后,内核就会按照fork函数中的代码特性,做如下的工作:
- 分配新的内存块和内核数据结构给子进程(虚拟地址)
- 将父进程部分数据结构内容(pcb)拷贝给子进程
- 添加子进程到系统进程列表当中(pcb队列)
- fork返回,开始调度器调度
但是此时注意,当fork函数创建好子进程之后,本质上子进程具有父进程相应的部分代码和数据,并且父进程和子进程指向的代码和数据是在同一块物理内存之上的,只有当子进程或者父进程想要修改它的代码和数据之时,操作系统才会在物理内存上重新开辟一块空间,用于存储子进程或者父进程修改之后的数据(目的:遵守进程具有独立性原则);并且注意,此时父进程和子进程在执行完相应的代码之后,都有一个返回值,父进程返回的是子进程的pid,而子进程返回的是0,和fork创建子进程之后,两个进程分流,谁先执行是由调度器决定;如下图:就是父进程、子进程和物理内存中的代码数据之间的关系(指向的是同一块空间),更深层次的知识,等我们学习了页表的工作原理之后,再展开讲解,此时我们只要明白在不修改之前,它们的代码和数据是物理内存中的同一块空间就行;
并且此时如果更改了父进程或者子进程其中的代码和数据之后,操作系统就会在内存上开辟一块新的空间供给其存放修改之后的数据,如下图:但是注意,此时只读页表是不会改变(具体详情见页表详解链接)页表相关知识
所以上述的,操作系统检测到父进程或者子进程有修改数据的行为,然后开辟新空间,并且将我们需要修改的数据拷贝到新空间之中的这个过程,我们就称之为写时拷贝
,当我们完成了写实拷贝之后,此时页表会自动的修改相应的和物理内存中代码和数据的映射关系,进而实现进程独立性,此时就又涉及到了页表的相关知识(页表中权限问题),如上所示的只读,只写等!这里不多做讲解,以后肯定是会学习到的,我们只要了解,页表映射关系的修改是有据可循的,不是随意的,感兴趣的同学可以去看上述的那个链接,这里更重要的点是,如何理解为什么要有写时拷贝?
首先我们要明白,操作系统是不允许有任何形式的资源浪费的,所以此时我们就可以明白,只有当父进程或者子进程具有修改数据的意愿之后,我们才进行写时拷贝,而不是把空间提前开好,供给给子进程使用,如果是这样,就会导致物理内存上的两个空间存储了同样的数据(造成浪费),所以写时拷贝概念就是为了解决父进程和子进程不必开辟两块空间,但是又可以修改数据的问题,你修改,我就开辟新空间,你不修改,我就不开辟新空间,按需开辟空间、申请空间。
总结: 创建进程需要占用资源(CPU资源、内存资源),原因:因为进程等于内核数据结构加该进程的代码和数据,所以当创建进程,系统多了一个进程之后,操作系统就必须去管理这个进程,当操作系统管理这个进程时,此时管理的方法就和我们日常生活中的管理方法类似(先描述,再组织),所以操作系统就需要为该进程创建对应的内核数据结构对象,并且将该内核数据结构对象给链接到相应的等待队列之中,并且还需要向内存申请空间去存储该进程对应的代码和数据。
注意:我们的fork函数也是存在子进程创建失败的情况的,例如,系统中的进程太多,资源不够用时,实际的进程数超过了限制,所以操作系统是要约束用户创建进程的数量的(防止进程太多把操作系统都搞崩掉)
进程终止问题
首先,进程肯定都是有终止的时候的,并且通过常识可以发现,进程终止是可以分为不同的情况的,一个是进程正常执行完,一个是进程崩溃,并且进程如果正常执行完,此时也可以细分成两类,一个是结果正确,一个是结果不正确,综上,我们在平时写代码、创建进程或者调用某些指令(调用进程)的时候,我们都并不怎么关心一个进程是否正常运行成功,我们关心的往往是该进程为什么结果不正确或者是该进程为什么崩溃,所以,此时我们就一起来探讨一下有关进程终止会出现那些崩溃和结果不正确的情况,如下:
为了了解一下进程的终止情况,此时就会涉及一个叫进程退出码的概念,所以接下来,我们就一起来看看什么是进程退出码;首先在了解进程的退出码这个概念之前,我们要明白,可以通过$?
这个符号来获得上一指令的返回值,也就是进程退出码,所以现在问题就变得很简单,想要获得进程退出码,使用$?
就行,并且平时我们在写代码的时候,我们经常都要使用main函数,并且使用main函数,我们总是会和return关联使用,从而获得该main函数的进程退出码,如下图代码所示:
所以此时得出结论:每个进程都有对应的退出码,并且可以使用$?
来获取退出码,但是$?
只会保留最近一次进程的退出码;
但,我们此时可以发现,当我们获得了退出码之后(0表示成功),剩下的1 2 3 ……等退出码表示的是什么意思呢?所以当我们获取了一个进程退出码之后,我们并不认识,必须要把退出码和相应的描述挂钩,这样我们才可以理解,该退出码表示的意思,进而知道,该进程的退成错误,或者是崩溃原因,如下图就是相应的退出码所对应的错误描述:(通过strerror获取)
上述代码表示的就是获取0到200进程退出码的对应信息,如下图(在Linux操作系统下一共有134个错误码信息,省略了部分)就是Linux操作系统下了前122个进程退出码信息,例如:此时0退出码表示的就是成功,其余的进程退出码都是相应的错误码,表示着该进程退出的各种原因(注意:我们在Linux中使用的各种指令本质上就是各种的函数接口,本质上就是各种的进程,所以执行一个指令,也就是创建一个进程,并且执行该进程,同理,你也可以获得该进程的进程退出码,成功返回0,非成功返回非1 2 3……);
总:所以总的来说,一个进程在终止的时候,就有两种可能,一种是异常,一种是执行,异常时,它就会返回相应的异常信号(这点,我们在下述的进程等待中细讲),执行时,分两种,一种是成功,一种是失败,成功返回0,失败返回非0(告诉我们进程为什么执行失败),并且当执行完成之后,进程就退出了,操作系统中就少一个进程,此时操作系统就要释放该进程对应的内核数据结构和独立存储在内存中的代码和数据
常见的进程退出方式
常见的进程退出,main函数return和exit函数code(exit的进程退出码)(main return 0、exit(-1)
),但是注意这两个进程退出方式是有很大的差别的,例:普通的函数return仅仅表示的是该函数返回,只有main函数return表示的才是进程退出,且exit(code),可以在代码中的任何位置让该函数表示进程退出而main函数return只能在所有的代码执行完之后才可以退出;了解了这些知识之后,此时我们再来了解一个新的进程退出方式的函数 _exit
,这个函数使用上和exit相似,但是功能上差距还是很大的,例:
使用exit函数,退出时可以获取我们想要打印的内容,使用_exit函数,退出时并不可以获得我们想要的内容,这是为什么呢?原因就涉及到了_exit和exit在功能上的差别和输入输出缓冲区的概念问题了,如下图:
所以总的来说,exit在结束进程的时候,不仅会执行我们定义的清理函数,而且还会将数据从缓冲区中刷新出来,而_exit函数却什么工作都不做,直接就将进程终止,所以使用_exit就看不到我们想要打印的内容,数据任然保存在缓冲区(buff数组)中;并且此时通过上述的现象,此时我们就要明白,我们用户是没有终止进程的权利的,只有操作系统具有终止进程的权利和能力,所以我们在使用exit函数时,本质就是在调用系统接口,让操作系统去完成我们想让它完成的事情(终止进程),所以可以发现,操作系统是exit的顶头上司,但是exit却可以冲刷缓冲区,所以也可以知道缓冲区肯定不在操作系统内部,所以此时引入一个新的概念,缓冲区是在我们的C库中,方便我们接下来了解缓冲区的概念;
所以总的来说exit的工作原理如下:
- 执行用户通过 atexit或on_exit定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入
- 调用_exit(操作系统提供的接口)
总结:进程终止都是通过系统调用来完成的,无论什么方式
什么是进程等待
为什么需要进程等待
想要了解为什么需要进程等待这个概念,此时就涉及到我们之前学习的进程状态问题,当一个进程需要可以让别人去核实该进程退出的原因和结果时,此时这个进程就需要维持僵尸状态,所以僵尸进程的本质就是为了可以让父进程或者操作系统读取到退出结果或者相关信号,但是又由于,僵尸进程已经处于死亡状态,该进程占用的资源不能被操作系统清理,所以此时就有可能导致内存泄露问题,所以为了解决这个问题,此时就需要进行进程等待,因为只有进行了进程等待,目的:获取僵尸进程的退出结果和信息,回收僵尸进程,释放僵尸进程所占的资源;从而将僵尸进程给终止;
进程等待是什么
搞定了上述为什么需要有进程等待问题,此时我们具体的来看一看什么是进程等待,上述我们说了进程等待的目的是为了获取僵尸进程的退出结果或者相关退出信号,所以此时我们可以知道进程等待就是通过系统调用的方式,去获取子进程退出码或者退出信号的一个方式
如何进行进程等待
从上述的进程等待是什么,此时我们又可以了解到,进程等待本质就是去调用系统调用获取进程的退出码或者退出信号,所以此时我们可以知道,进程等待的方式就是去调用系统调用,调用系统调用此时就涉及到了相关的接口函数(操作系统向外提供给用户使用的),我们只有使用该函数,才可以完成系统调用,才可以实现进程等待,所以有关进程等待的接口就是:wait/waitpid
,其中wait接口的头文件 #include<sys/types.h>、#include<sys/wait.h>
,基本使用方式:pid_t wait(int*status);
如下图:就是一个纯正的进程等待的示例
循环查看Linux系统中的mytest进程指令:while :; do ps ajx | head -1 && ps ajx | grep mytest | grep -v grep;sleep 1;echo "--------------";done
这样我们就可以一直检测mytest中的代码的执行情况了,如上图中所示,mytest可执行文件中有两个进程在执行,后来一个变成僵尸状态,另一个进程回收僵尸进程等过程,我们通过sleep和进程运行情况,都可以很好的观察出来;
waitpid接口,头文件同上述wait接口一样,基本使用形式:pid_ t waitpid(pid_t pid, int *status, int options);
此时该接口拥有三个参数,pid,status,options
;
第一个参数pid表示的是等待进程的pid,若pid = -1,那么和wait函数等效,等待的只能是该进程的子进程,若pid > 0,那么就不需要等待该进程的子进程,而是等待任意一个与该pid相等的子进程;
第二个参数status,是一个整形指针类型的参数,并且此时身为指针参数,其可能是一个输出型参数(本质上确实是一个输出型参数),输出型参数指的就是这个参数在函数参数中是未知的,并且这个值是可以修改的,最后可以返回给调用函数,直接改变调用函数中的某个变量的值(等价于,我们可以通过这个输出型参数指针,把这个值拿到该函数外部中被调用函数使用(main函数)),所以本质上waitpid
中的这个status输出型参数,就是用来把该进程的退出码和退出信号返回给调用函数(调用进程(bash/shell)(操作系统)),从而让父进程或者操作系统知道你的退出码和退出信号;
第三个参数options,该参数用于若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待;若正常结束,则返回该子进程的pid;
深入理解退出码和信号
承接上述waitpid
的第二个参数,status,从该参数我们了解到,设置这个参数的目的是为了可以让外界获取该进程的退出码和退出信号,所以我们接着这个点,来谈谈什么是退出信号,如下图:就是Linux操作系统下的退出信号指令:kill -l
发现在Linux操作系统下,一共有64个退出信号(没有0信号),明白了这点之后,我们就可以发现,不仅是我们上述所说的退出码,就连我们的退出信号也都是通过编码的形式来实现的,但是此时就会有一个问题:就是我们的 status
输出型参数,不过只是一个int*
类型的指针,它是怎么同时返回退出码和退出型号的呢?不是说退出码和退出型号是两个完全不同的编码吗?怎么可以同时使用一个参数返回呢?所以此时想要弄懂这个问题,就需要引入一个新的概念,位图结构,引入这个概念之后,我们就能了解到,我们不能把int* status
纯纯的当作一个完整的整数理解,而是要把它看做是一个位图结构
理解位图结构:
上述的 int* status
,根据位图结构来理解,此时我们可以把status
理解成是一个具有32个比特位的位图结构(指针类型只是针对于输出型参数),所以此时就可以将32个比特位通过位图的方式分开理解,不同区域的比特位具有不同的含义,如下图所示:
如上图,按照位图结构,此时我们就可以从一个整形数据中获得两个不同的返回值了,并且此时涉及到一个core dump标志(这里我们不多做理解),并且如下图:
我们可以发现,我们获得的是一个27392的值,并不是我们想象中的107,所以充分表明,这个status表示的不是一个单独的整形数值,而是表示一个位图结构,通过位运算获取数值
并且我们要明白,如果退出信号为0,表示的就是没有收到信号,也就是表示该进程正常执行(不存在异常),此时只要注意它的退出码就行,退出码为0,进程成功执行,非0则表示失败,但是如果,此时退出信号不为0,那么就会导致该进程是异常的,所以此时退出码无论为0还是不为0就没什么太大的影响了,因为,此时的进程已经异常了,退出码表示成功还是不成功该进程都是有问题的,所以我们已经不关心了,只需要关系,返回的退出信号就行。
该博客摆烂,要去上课了,不然还可以写,但是不怕,下篇博客承接,撤退!