一、进程创建
1.1 认识fork
fork函数是系统调用接口,用来创建子进程的
根据进程的pid,可以看出父进程fork后分为父进程和子进程,分开后的父进程就是原来的自己,然后多了一个子进程。父进程的父进程是bash
那么fork之后多创建一个子进程有啥用呢?既然多了一个进程,肯定是要做事情的,父进程一个不够,让子进程去做,同时父进程去做另一件事。怎样让它们同时各自做不同的事情?fork是有返回值的,让返回值对接下来的代码进行分流,给父进程和子进程不同的任务。
现象:
给父进程的id是子进程的pid,给子进程的id是0,为什么?后面再谈论
父子进程各自做不同的事情,验证:两个while循环:
两个进程可以同时进行,为什么?分析原理:进程是等于内核数据结构(PCB)+ 可执行程序(代码和数据),在fork之前,父进程有自己的PCB和代码数据,fork之后,该函数会多创建一个子进程,这个子进程也同时会有PCB,它是以父进程为模板创建的。那代码和数据呢?首先要清楚的一点是:代码和数据都是父进程提供的。也就是说子进程运行的代码是父进程给的,父子进程共享父进程的代码和数据。那它们两个执行不同的事情是怎样的呢?根据返回值id,id为0是子进程的执行流,id大于0,是父进程的执行流,父进程的代码被分开同时执行。还有一点,CPU只要遇到运行起来的进程,不管是谁,都是可以同时进行的(同时打开浏览器、音乐、游戏等)。总结一下就是:fork之后,代码共享父进程的
fork返回值的问题:
一、给父进程的id为什么是子进程的pid,给子进程的id为什么是0?
父进程与子进程是一对多的关系,即一般一个父进程有n个子进程,父进程要找到某个子进程对它进行控制等操作的话,必须要有该子进程的pi信息,否则怎么找到要找的子进程;而父进程只有一个,子进程找父进程是唯一的,所以返回0即可。
二、两个进程能同时执行,说明id从fork函数那里返回的有两个值,以前我们的认识是一个函数一个返回值,为什么fork有两个返回值呢?
fork函数的作用是多创建一个子进程(也可以创建多个),在其函数内部的代码中就已经实现好了,也就是说,父进程和子进程在fork函数内部就已经分流了,各自执行不同的任务,所以也就可以返回两个返回值,父进程返回父进程的id,子进程返回子进程的id
三、就算有两个返回值,可是只有一个变量id,为什么一个id既可以等于0,又可以大于0?
与进程地址空间有关,进程之间是具有独立性的,互不影响对方。
1.2 写时拷贝
为什么父子进程代码共享?
本质是通过页表指向物理内存同一块区域。磁盘中的可执行程序加载到内存,物理内存开辟空间放代码和数据。父进程在地址空间申请的虚拟地址,代码区的的虚拟地址通过页表的映射关系找到物理内存的代码,数据区同理。fork之后,子进程是被按照以父进程位模板创建的,所以地址空间和页表是以拷贝发方式给子进程,父子进程的虚拟地址是相同的,页表也是,在没有对一方进行写入的情况下,默认子进程的地址空间的虚拟地址通过页表指向的是前面父进程在物理内存中开辟的代码和数据,所以父进程和子进程共享代码。
如果对子进程重新写入,会发生写时拷贝,父子进程各自的一份数据
为什么要有写时拷贝?
1.为什么子进程被创建,父进程不直接把数据给子进程?主要有两个原因:资源问题和成本问题。假如一个子进程创建出来后,用户并没有使用它,或者是没有完全使用,那么如果子进程被创建要单独给它数据的话,不就是资源浪费了吗,所以默认先让子进程与父进程的资源共享,当需要用子进程时,再按需申请资源。如果每次创建的子进程都要有自己的数据,创建成本增加,更何况有时候该子进程没有被使用。2.写时主要是修改,申请的空间拷贝父进程的数据,对数据重新进行写入,有了写时拷贝可以增加程序运行确定性,除了申请空间的位置不一样,其他的是一样的,这样可以提高使用性。
如何做到写时拷贝?
该过程与页表有很大的关系。首先,页表不仅有前面说的只放虚拟地址和物理地址,还有很多选项,其中有一个选项是权限,r、w、rw。看以下代码,我们输入一个字符串,然后对它的首字符修改,结果运行出错,不能修改。原因:从语言上的角度是,该字符串在常量区,具有常属性,不能被修改。在操作系统的角度是,该字符串常量其实是虚拟地址,对字符串的首字符修改,也是虚拟地址,修改就是写入,这个过程必离不开虚拟地址到物理地址的转换——通过页表,但是页表上表示该虚拟地址的权限是只读,也就是不能写入,所以操作系统就不让用户修改。
为什么数据段是只读?
fork后子进程被创建,数据段是只读,那么转换就会出问题,这个就是缺页中断,于是操作系统就会采取措施,解决这个问题。只要有一方有写入,操作系统就会把页表中权限选项改为读写,此时就可以进行写入了。也就说有转换的问题,即缺页中断,因为是只读的,操作系统知道这个情况,就会发生写时拷贝,重新开一块空间给子进程,同时将权限变为读写。(告诉操作系统有进程要写入了,让操作系统发生写时拷贝;怎么告诉操作系统,因为某一方有写入,但权限是只读,页表转换出现问题,即缺页中断,此时这个消息会给操作系统;操作系统的做法:写时拷贝,在物理内存新开空间连同父进程的数据给子进程,子进程可以重新写入数据,同时页表的权限改为读写,修改权限是在子进程重新写入数据之前已经做好工作了)
fork失败原因: 一、系统中有太多进程;二:实际用户的进程数超过了限制
二、进程终止
2.1 进程退出
main函数的返回值,叫做进程的退出码。一般情况下0表示进程执行成功,非0表示失败。非0表示失败的错误码比如:1、2、3、4、5等,不同的数字表示不同的错误原因。可以使用echo $?查看退出码:后面一次的退出码是0表示该指令执行成功,它的默认退出码是0
错误码要转化为错误描述。 有两种:一是语言和系统自带的方法,转化为错误码
二是自定义:
main函数return,表示进程退出;其他函数退出,表示该函数调用完毕
2.2 函数退出
errno查看函数的执行情况——成功、失败、错误原因。只读不会在当前目录创建新的文件
所以进程退出的情况有三种:进程代码执行完,结果是正确的、不正确的和未执行完,进程出异常的。进程出异常的本质是收到了异常信号,每个信号都有自己的编号,不同的信号编号表示异常的原因,下面见见信号:
其实评估一个进程的最终情况,只要看两个数字,一个是信号编号,另一个是退出码,对应前面的三种情况:结果正不正确看退出码,有没有异常看信号。只要信号异常,不管结果正不正确都不能正常执行。
2.3 exit
查看接口信息:
可以使进程终止,参数是退出码;在任何地方调用,都可以终止进程
除了exit库函数,还有一个叫做_exit的系统调用接口,它的功能和前者基本类似,不同的是exit会支持刷新缓冲区,_exit不会。通过代码看现象:
exit其实是_exit封装的库函数
三、进程等待
为什么要有进程等待?
- 子进程退出,如果父进程不管,子进程变僵尸,导致内存泄漏问题
- 回收子进程资源,获取退出信息
wait函数有两个,两个差不多是一样的,只是参数数量不同。
下面看看wait函数父进程回收子进程资源:wait的参数先设置为NULL
fork之后,谁先运行不确定,由调度器决定。但是最后一个退出,一定是父进程,因为它要回收子进程的资源。
获取子进程退出信息,同时解决僵尸:使用waitpid,参数中的pid解释如下,这里我们的代码直接用id来表示,第二个参数是输出型参数,获取退出信息。
获取status:
回收的子进程就是fork后父进程创建的子进程,rid表示的就是子进程的pid;但是status退出码为什么是256?因为status是局部使用的,有自己的格式,根据exit(1),所以退出码是1,格式如下。
1️⃣通过位运算来获取子进程status:
2️⃣通过宏获取子进程status:
可以通过变量获取子进程的退出信息吗?
不能,因为进程具有独立性,父子进程改了对方的数据之后,无法看到对方的数据。前面说过,退出信息主要是两个:进程退出码和信号编号。这两个是数据是放在进程的PCB对象中,PCB对象由操作系统管理,也就是说,要得到数据必须通过访问操作系统,访问操作系统必须通过系统调用接口来实现。
阻塞等待与非阻塞等待
调用wait和waitpid的三种情况:
- 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获取退出信息
- 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞
- 如果不存在该子进程,则立即出错返回
当子进程还在运行时(子进程还没结束,结束后再由父进程回收),父进程在做什么?阻塞等待。与前面的进程状态中的阻塞相似,父进程在等待子进程结束,它什么也没做,只是单纯的等,此时父进程把自己的状态设置为S睡眠状态。
非阻塞等待
子进程正在运行,父进程通过系统调用(waitpid)访问子进程,子进程没有结束,返回值为0,告诉父进程子进程还在运行中,然后再访问,以此循环,多次访问。直到某一个访问子进程运行完了,父进程获取子进程的退出信息。注意,阻塞是一直正在访问,即一次访问,父进程一直在等待,没有做其他事情。但是非阻塞等待,父进程访问次数有多次,只要每次访问完子进程还没结束,父进程可以去做别的事情,不会干等。
四、程序替换
程序替换原理(什么是程序替换)
一般来说,我们自己写的程序,执行这个程序执行的是自己的代码。但是如果我们创建的程序想执行其他程序的代码行不行?其实是可以的,通过程序替换可以执行别的程序的代码。过程:磁盘中有多个可执行程序,比如程序A,是我们自己写的;程序B,是系统自带的(例如指令ls,指令运行起来也是进程)。程序A的代码和数据对应放在物理内存中的代码段和数据段,如果程序A要执行程序B的代码和数据,核心工作是将程序B的代码和数据在物理内存中覆盖程序A的,然后相应的虚拟内存的区域大小也会调整,页表重新建立映射关系,CPU调度的时候就可以做到执行程序A,但代码是程序B的,也就执行程序B了。注意:替换过程中没有创建新的进程
怎么替换? 通过系统调用接口。在物理内存中覆盖数据和代码是操作系统做的,要知道操作系统的工作后数据必须要通过系统调用接口——exec*
先来第一个execl,它的第一个参数是程序的路径,第二个是参数是说明程序如何执行,第三个参数是可变参数。第二个参数是程序名称,后面的可变参数是选项,所以命令行怎么写,参数怎么传。注意,不管参数有几个,参数最后一个是NULL,不是"NULL"。
细节1:只要替换成功,那么exec* 后续的代码就不会执行。细节2:exec* 只有失败返回值,没有成功返回值。细节3:替换完成,不会创建新的进程。细节4:可以按非标准传参,但是尽量用标准传参。
创建一个进程,先PCB、页表、地址空间还是先把程序加载到内存?
先前者,因为就算先把程序加载到内存,PCB对象的属性和页表的映射关系都没有准备好。如果是是前者先,即使没有把程序加载到内存,当用户有请求的时候,操作系统通过缺页中断在内存中把程序加载进来,然后页表再重新建立映射关系,进程就拿到了代码和数据。
程序加载是什么?为什么?怎么做的?
程序加载是指把程序加载到内存中去。因为是冯诺依曼体系规定的。程序替换。
多进程版本
通过代码观察现象:多创建一个子进程,父进程等待,子进程执行中程序替换
父进程可以拿到执行结果,并没有像子进程那样被其他程序替换掉。子进程被创建时它的页表指向的代码和数据和父进程是一样的(默认情况下),但是要进行程序替换,此时内存中发生写时拷贝,不仅数据拷贝了,代码也拷贝了,父进程还是原来的代码和数据不影响,子进程的代码和数据被其他程序替换了。所以总结下:进程具有独立性,替换时发生写时拷贝。
关于xshell如何运行起来的
bash进程创建子进程,bash等待子进程,我们输入的指令替换子进程,bash正常运行不影响,子进程运行的是我们的输入的指令,然后得到结果(替换原来的子进程)
各种exe接口
第一个是最简单的,在上面已经展示了。接下来主要再学习4种:以带l、v、p、e的进行区分
- e:与环境变量有关
- p:不需要路径,只要给程序名字就行,系统替换的时候,会自动去环境变量种查找
- l:列表的形式,主要是在参数列表中,有多个参数的
- v:数组,参数直接传数组名就行了
一个程序调用其他不同语言的程序
修改makefile,创建一个c++文件,使用execl函数调用c++文件。无论什么语言,只要能在linux下运行,都可以去程序替换
环境变量的传递:
我们并没有传环境变量,但是子进程默认就拿到了,为什么?因为默认可以通过地址空间继承的方式,让所有的子进程拿到环境变量。进程程序替换,不会替换环境变量数据。
所以子进程拿到环境变量的方式有以下3种:
一、以继承的方式,直接全部拿到(上面的代码有展示)
二、新增环境变量——putenv。bash本地看不到,但是可以被子进程继承拿到,显示的时候就能看到了。
三、设置全新的环境变量——execle(覆盖)
也可以把环境变量表以参数的形式传递过去