目录
进程创建
写时拷贝
代码共享原理
写时拷贝的设计
写时拷贝原理
进程终止
信号编号
进程退出码
exit函数/_exit函数解析
进程等待
等待接口
status
父进程等待方式
阻塞等待
非阻塞等待
进程替换
进程替换接口
Shell运行规则
环境变量与进程替换
su-/su指令与进程替换
进程创建
在Linux中,通过fork函数从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
返回值:给子进程返回0,给父进程返回子进程的pid(这是为了让父进程找到子进程,而设计的。子进程通过ppid找父进程,父进程通过fork返回值找到子进程),出错则会返回-1。
调用fork函数后,操作系统内核做的工作:
1、分配新的内存块和内核数据结构给子进程
2、将父进程部分数据结构内容拷贝至子进程
3、添加子进程到系统进程列表当中
4、fork函数返回,由调度器调度
子进程会继承父进程PCB中的大部分属性,少量的pid、ppid等属性是自己的属性,代码也和父进程共享一份代码,需要修改时会发生写时拷贝。
fork之后,父子进程谁先执行完全由调度器决定。
写时拷贝
fork返回给父子进程不同的值本质是由写时拷贝来完成的。
程序被编译为二进制之后,所有的变量名、函数名都经过翻译变为物理地址,变量名、函数名只是为了方便我们用户去编程,提高代码可读性、可写性。
代码共享原理
父子进程的代码如何做到共享呢?
结论:父子进程各自的页表指向同一块区域(内存)中。
当父子进程不写入/修改数据时。
当父子进程任意一方进行写入时(这里以子进程写入为例)
写时拷贝的设计
为什么不在创建子进程时,直接将父进程的数据给子进程拷贝一份呢?
原因:因为如果在创建子进程的时候直接给子进程拷贝一份,如果子进程不执行写入/修改操作,或者执行一段时间之后再写入,那这部分空间操作系统就管理不了了,在子进程还存在的情况下,并且子进程写入的数据可能很少,这样就增加了fork的成本,操作系统效率也会变低,所以,操作系统通过写时拷贝按需分配内存。
为什么在写入的时候,申请出空间之后,还要将原数据给需要修改数据的进程拷贝一份呢?
原因:可能进程的修改要在原数据的基础之上修改,也可能只修改一部分,剩下的还用原来的数据。操作系统无法很清楚的判断出这些操作,所以,申请好空间之后,拷贝一份泛用性比较强。
写时拷贝原理
写实拷贝如何做到呢?
页表是用来帮助操作系统进行虚拟地址-物理地址相互转化的,同时也会做检测(缺页中断),其实,页表也有很多其他的属性,写时拷贝就是借助页表中的权限属性。
页表的虚拟-物理地址的转化工作也是受权限来控制的。
比如:用户对某个虚拟地址的空间进行写入时,页表会判断该虚拟地址是否有w(写)权限,如果有写权限,就由虚拟地址转换为物理地址,如果没有写权限,就不能发生地址转换。就像之前C语言中的char*p="hello world","hello world"是字符常量,字符常量不能修改,就不能执行*p="haha"这样的指令。本质上是因为"hello world"在内存中存储,指针p在页表中记录的是地址,这个地址的权限是只读权限,所以在进行虚拟-物理地址转换中,由于权限问题,导致转换失败。
说明一下:这里也可以加const,const是在程序编译的时候检测出问题,将运行的错误提前在编译的时候就暴露出来,是一种防御性编程。
fork之前
fork创建子进程之后,操作系统会故意将父子进程的数据地址权限修改为只读。
等到父子进程某一方发生写入时,需要将虚拟地址转换为物理地址,但是此时权限是只读,所以就会转换失败,操作系统接收到转换失败的信息后,识别是此情况之后,发生缺页中断,由操作系统向内存申请空间,构建新的映射关系将其填入页表中,将权限恢复成可读可写,继续执行写入工作即可。
总而言之。写时拷贝就是由操作系统介入处理完成的。
操作系统会将大部分数据都设置为只读属性,只要系统中触发写时拷贝,操作系统就会介入处理,按需触发写时拷贝。
fork常见用法是创建了子进程之后,通过返回值区分父子进程,利用if-else来使父子进程来执行不同的操作。
fork调用失败原因:
1、当前系统中有太多的进程,此时创建进程会被操作系统拦截。
2、当前用户创建了太多的进程(操作系统限制用户只能创建一定数量的进程)。
进程终止
进程退出时,一共有三种情况
1、代码没执行完,进程异常结束。
2、代码正常执行结束,结果不正确。
3、代码正常执行结束,结果正确。
任何进程最终执行的全部情况,由两个数字决定,分别是信号编号、进程退出码。
信号编号 | 进程退出码 | 意义 |
0 | 0 | 代码正常执行结束且结果正确 |
0 | 1 | 代码正常执行结束且结果不正确 |
1 | 0 | 代码执行出现异常(退出码无意义) |
1 | 1 | 代码执行出现异常(退出码无意义) |
信号编号
情况一中代码没有执行完成,进程就异常结束了。进程异常结束(比如,发生了野指针、数组越界、除0错误等)的本质是进程收到了信号,信号就是一个编号,每个编号对应一种错误信号,即我们可以通过信号编号来确定使程序异常的原因信息。
kill -l #查询当前系统的信号编号表
这些信号都是被定义好的宏常量,本质就是前面的数字。异常信号从1开始。
进程退出码
main函数的返回值,就是进程退出码。
进程退出码是用来反映进程在代码执行完成的情况下,结果是否正确的数据,0表示结果正确,非0表示结果不正确且具体的非0编号来表示失败的原因。
echo $? #记录最近一次进程退出码
不同的退出码,对应不同的退出信息,就形成了错误码表。
错误描述(错误码表)分为语言系统自带的和自定义的错误码表。
语言系统自带的错误码表
退出码可以设置退出码字符串,可以自定义。
main函数在退出时,表示进程退出,退出的值即为退出码
其他函数退出只表示函数调用完成。
函数(也叫子程序)退出之后,我们通过函数的返回值来获取函数执行的情况。我们通过函数返回值来获取函数执行结果。那函数执行的成功与失败我们该如何获取呢?
这时候,C标准库就为其中的函数提供了一个errno错误码,是一个全局变量,且只服务于标准库函数。自定义函数没有。
所以,库函数的返回值返回的是执行结果,errno变量返回的是函数出错的错误码。
exit函数/_exit函数解析
区别:
exit接口是用户调用接口、_exit函数是系统调用接口。
exit函数在退出时会刷新缓冲区,然后退出。_exit函数不刷新缓冲区就直接退出。
exit函数封装了_exit函数,两个函数底层都借助操作系统终止进程。
这里的缓冲区是语言库级别的缓冲区,不是操作系统中的缓冲区。
如果是操作系统中的缓冲区,_exit函数也会刷新缓冲区,所以此缓冲区是语言库级别的缓冲区。
进程在创建时,操作系统为其PCB属性申请内存空间,创建页表,将磁盘中的代码和数据拷贝至内存,构建页表映射关系,当进程退出时,释放PCB属性的资源,释放内存中的代码和数据,僵尸进程保留PCB至父进程等待回收资源。
进程等待
我们都知道,一个进程的终止都是从僵尸状态变为终止状态。
进程执行完成之后,首先变为僵尸状态,在这个状态下,该进程的代码和数据(可执行程序)、页表、PCB中大部分信息都会被操作系统释放掉,唯一保留下来的就是用来记录该进程执行情况的退出信息,这里的退出信息就是进程终止中的信号编号和进程退出码,通过获取这两个数字就可以获取该进程的执行情况。等该进程的父进程通过等待获取到该进程的退出信息之后,进程才会进入终止状态,PCB彻底被释放。进程也会彻底消失。
父进程通过等待获取该进程的退出信息。
父进程等待子进程是为了回收子进程的资源(释放资源)和获取子进程的退出信息。
如果进程的退出信息迟迟不被该进程的父进程等待获取,那么该进程的PCB就会一直存在,造成内存泄漏。
等待接口
wait在等待时,默认会进行阻塞等待,且等待任意一个子进程。
阻塞等待是指即使父进程执行完了,只要子进程没有执行完,父进程就不退出,一直等子进程退出获取退出信息。
阻塞等待返回值:>0:等待成功,具体的数字是被等待的子进程的pid
<0:等待失败
fork创建子进程之后,父子进程谁先执行是由调度器决定的。最终是父进程最后退出。
while :; do ps axj | head -1 && ps axj | grep mybin(可执行程序名称) | grep -v grep; sleep 1; done #每个1秒检测一次
#测试代码
#include<stdio.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
int main()
{pid_t id = fork();if(id==0){//child processint num=5;while(num!=0){printf("I am child process,pid:%d,ppid:%d\n",getpid(),getppid());sleep(1);num--;}exit(1);}//father processint status=0;pid_t rid=wait(&status);if(rid>0){printf("wait success\n");}else{printf("wait fail\n");}return 0;
}
status
下面了解一下status这个输出型参数。
我们知道,一个进程的退出信息由退出码和信号编号组成。信号编号表示该进程是否异常结束(0表示正常结束,非0表示异常结束,具体的数字表示具体的信号)、进程退出码表示该进程在正常执行完成之后的返回值(用于检验结果是否正确,0表示正确,非0表示错误且具体数字表示错误信息编号,之后可用strerror函数查看具体错误信息)。
status变量用来记录进程退出码和信号编号,status为int类型,4字节=32bit,Linux就是用这32个比特位的后16位采用二进制方式(将数值转化为二进制)记录进程退出码和信号编号的。
我们传入status变量的地址,父进程wait等待完成之后,status变量被赋予了值
获取信号编号 :status&&0x7F
获取进程退出码:(status>>8)&&0xFF
此外,Linux内核(C语言)还提供给我们两个宏来查看信号编号和退出码信息。
WIFEXITED(status)宏来判断程序是否正常退出,若程序正常退出,返回真,异常则为假。
WEXITSTATUS(status)宏返回进程退出码,此接口用于查看进程退出码。
WIFEXITED(status)、WEXITSTATUS(status)都是通过宏来获取系统调用。
为什么不用两个全局变量来拿到退出码和信号编号呢?
原因在于:定义的全局变量必然是属于父进程的,子进程退出时要修改代码和数据会发生写时拷贝,操作系统会为子进程单独开辟空间,所以子进程将自己的退出码和退出信息写入到了属于自己的数据段,子进程无法写入到父进程的数据中,因为进程具有独立性,父子进程无法直接互相修改对方的数据且双方都无法获取对方的数据,所以只能通过系统调用接口。
信号编号和退出码只属于操作系统内部进程PCB中的信息,用户无法访问。
读取子进程的退出信息,本质是读取内核数据。
父进程等待方式
父进程在等待子进程执行完的这段时间,有阻塞等待和非阻塞等待两种方式。
options参数:0表示阻塞等待,WNOHANG表示非阻塞等待。
阻塞等待
父进程在等待子进程时,发现子进程还在运行中,由于系统调用的原因,将自己的状态设置为非R状态(大多都是S状态),将自己链接到等待队列中进行等待子进程。
父进程在调用等待这个系统调用时,检测出子进程没有退出时,就一直等待子进程退出,在此期间,父进程只能等待无法执行别的指令,等子进程退出了,再返回。
非阻塞等待
父进程执行完等待时,检测到子进程还在运行,没有退出,就立马返回,不等子进程退出。
父进程在调用等待时,检测子进程状态,如果子进程没有退出就返回(返回0),则每隔一段时间再去检测(再去调用等待),等到某次调用时,子进程退出了,就不再检测了。在子进程运行的过程中,父进程返回再到下一次调用等待的过程中,这期间父进程是可以执行其他指令的,一直做这种轮询检测。
每单次检测称为非阻塞,这个过程称为基于非阻塞状态的轮询访问,轮询期间,父进程可以执行其他指令。
非阻塞返回值:>0:表示等待成功
=0:表示调用等待接口成功,但子进程还在运行没有退出
<0:表示出现了异常,接口调用失败。
父进程在进行非阻塞等待时,可以执行别的操作。
子进程退出信息由bash获取。
命令行参数在执行指令时,bash会创建子进程,让子进程去执行我们输入的命令,bash则会等待,等到子进程退出之后,bash调用等待接口释放子进程且拿到子进程的退出信息,并将子进程退出码覆盖写入到自己用来记录上一条指令的退出码字段中去,以此类推,所以,执行echo $?指令时,才可以拿到最近一次进程的退出信息。
进程替换
我们创建的进程,只能执行当前的代码,最多可以通过if-else分流让父子进程执行不同代码中的不同区域,但如果我们创建出子进程,想让子进程执行其他的代码和数据呢?---进程替换。
CPU正在执行当前进程时,当这个进程的代码和数据被操作系统调换之后,CPU不关心替换,继续执行。
进程替换就是将新代码和新数据替换旧代码和数据,重新构建页表映射关系,程序分配调整虚拟内存,这些工作由操作系统完成,操作系统通过系统调用,在替换的过程中,还用的是之前程序的壳子,只是放入了新的内容罢了,没有创建子进程。
旧进程的mm_struct、页表、对应的物理内存、新的代码和数据替换旧的代码和数据。重新在旧的属性中调整,整个过程中,没有创建新的进程,操作系统做的这一切,底层都采用系统调用。
进程替换接口
每个进程替换接口特有字母含义:
l :表示参数列表
p:表示系统会去PATH环境变量中查找,只需要传指令名,不需要带路径。
v:表示需要传命令行参数数组
e:表示可以传自定义的环境变量表
程序替换成功之后,程序替换函数之后的代码将不被执行,因为调用程序替换接口之后,内存中的代码和数据已经被替换了。
程序替换系列接口只有调用失败时才会返回-1,调用成功不返回(出错失败返回值,成功没有返回值)。
系统替换接口若失败,程序必定执行失败,失败就会执行之后的代码,所以直接退出即可。
一般程序替换接口之后接exit退出函数。
调用成功则不执行后续代码,调用失败则需要退出函数使其退出。
整个程序替换期间,不创建新的子进程。
创建新的进程时,首先创建PCB属性、页表等,再将程序加载至内存。
程序替换时,如果源程序有代码和数据,那么新程序就替换旧程序的代码和数据。如果原程序没有代码和数据,新程序就加载至内存。
程序替换的本质就是加载。写时拷贝的本质是申请空间。
加载就是将磁盘中的数据拷贝至内存,由操作系统来做,程序替换是系统调用接口。
Shell运行规则
1、bash创建子进程,先创建出PCB属性、页表,子进程会继承bash的代码和数据。
2、子进程发生程序替换,替换为我们输入的指令(指令也是程序),子进程发生写时拷贝,操作系统为子进程的mm_struct、页表等信息做调整。
3、bash等待子进程退出,释放子进程并获取退出信息。
在系统的角度看来,任何语言在运行之后都是进程,所以可用一种语言写的进程替换另一种语言写的进程。
环境变量与进程替换
子进程默认拿到的环境变量,默认可以通过进程地址空间继承父进程的方式,让所有子进程都拿到环境变量。
进程替换之后,不会替换环境变量中的数据,因为进程始终没变。
每个进程的命令行参数都是通过继承父进程的命令行参数表来的。要想修改或者添加,就会发生写时拷贝,操作系统为该处理进程申请空间一系列操作。
bash在启动时,会根据配置文件构建一张系统环境变量表,bash子进程会继承下来,所以,以后每个进程都会继承其父进程的系统环境变量表,所以子进程就算不传环境变量表也可以使用父进程的环境变量表访问环境变量。
如果当前进程要修改或者添加环境变量,就会发生写时拷贝,操作系统为其操作处理,将该进程新增的环境变量添加至该进程自己的环境变量表中(调用putenv(指定环境变量)接口)。所以进程替换带e参数是将指定的环境变量传给子进程。
程序替换的系统调用接口只有一个,其他程序替换接口都是对系统调用接口的封装。
su-/su指令与进程替换
在发生用户替换时,也是创建子进程,然后让子进程替换掉bash。
su -指令会切换至root的家目录,会有新的环境变量。
su指令之后,使用的还是之前bash的环境变量,工作目录不变。
最后,如有不足,清各位大佬指正!!!