对于进程控制的第一个学习部分那就是使用fork去创建子进程这一部分,请去复习fork那一节的笔记。
这里我们主要学习一个在使用fork创建子进程的时候,是如何进行写时拷贝的,在之前的那一节fork的学习中我们学习到的是使用fork创建一个子进程,这里我们要学习的是使用代码去创建多个进程。
使用fork创建子进程
这里当我们使用fork创建子进程之后,对于父进程会返回子进程的pid,对于子进程会返回0。那么我们在代码中使用一个pid_t的变量(a)来储存fork的返回值。那么再将fork返回的值赋值给a的时候,就发生了写入,此时os发现这个变量父进程的页表和子进程的页表中都有一个相同的虚拟地址指向他,所以这里os就会在物理内存上重新开辟一片空间,并将第一个要进行写入的进程的页表的映射关系修改。由此也就导致了在父子进程中同一个虚拟地址,确存在了两个不同的值,因为在页表中已经修改了虚拟地址的映射。
这也是为什么在父进程中a这个值为大于0,而在子进程中a这个值为0.
所以这里当进程调用fork后,内核做了什么
经过fork的学习之后,我们知道在使用fork之后我们的父子进程是数据代码共享的。而如果我们在后面的代码中不使用if和else进行分流的话,我们的父进程和子进程是会执行出一样的结果的,但是因为fork之后,父进程if成功了,而子进程if失败了,所以就会打印出不同的结果。这里运行思路的总结就是,父进程和子进程对自生的pid的判断来分出谁是父进程谁是子进程的。
写时拷贝的过程
下面我们来更加理解一下写时拷贝:请看下面的两张图
这里的虚存也就是进程地址空间, 这里进行了简化。
上图的过程简述一下就是子进程在创建的时候,以父进程为模板创建进程和页表,所以在修改内容之前,父子进程的代码和数据是共享的。而在修改之后因为我们要修改的这个值在父进程和子进程中都有,所以这里就产生了写时拷贝将要被修改的值拷贝一份新的在物理内存中,并将这个物理地址交给要修改这个值的页表中。
下面我们思考一个问题:
当我们的子进程需要写时拷贝的时候,我们的子进程正在进行写入啊,os时在什么时候完成写时拷贝的工作的呢?
那么在父进程创建子进程的时候就会进行第一步:
即父进程会将页表中自己的所有的权限都设置为只读。然后才会创建子进程,所以此时对于父进程以及子进程即便是在数据区的全局变量,也依旧只存在只读权限。以上的工作我们用户是不知道的,而我们用户不知道就会对某一个信息进行写入,一旦写入,因为此时页表中的所有的数据都是只读的,当用户写入的时候,页表转化会因为权限的问题而出错,一旦出错os就会来处理这个错误。如果os发现这个数据是处于可以写入的地方,那么os就会这这个时候,进行写时拷贝的操作。将父进程和子进程共享的这个将要被写入的数据重新拷贝一份,交给要改变这个值的那个进程。但是如果此数据本来就是处于非写入区域的,os就会阻止这个进程的写入。
完整的逻辑图:
以上的这个操作就是写时拷贝。
我们这里还可以给出一个结论,在进程地址空间中进行划分不同区域的时候,不同的区域之间是绝对不可能出现区域之间重叠的现象的,因为重叠了不好做权限处理。os肯定是知道用户对于某一数据是只读的还是只写的。
所以如果现在子进程想要修改代码段的数据,os会直接报错,因为即便此时的数据和代码,父子进程是共享的,但是os也不会允许一个进程给没有写权限的区域内写入数据。
当然在后面的学习中,我们会学习到系统调用做到对代码区的数据进行写时拷贝。
通过以上的操作就能够实现一个父子进程惰性分离,也就是父子进程的数据在能够共享的时候,就还是共享,当需要分开的时候在分开。因为当父子进程的数据分离的时候,一定意味着要进行开辟空间,所以越迟进行分离,那么在没有分开之前在os中就会更多的内存可以被使用。所以os在做内存管理,或者是很多内存方面的申请时都会采用惰性的方式去进行。
正如之前说的正因为有了进程地址空间和页表的存在,对于一个很大的程序,我并不一定要将整个代码都放到内存中执行,而是你需要哪一部分,我就去运行哪一部分的代码。而当你访问的代码不在内存中的时候,os触发缺页中断,再从磁盘中读取数据到内存中,重新建立映射等等。
这里我们再进行一个思考,当我们需要对某一个数据进行写时拷贝了,为何要先将父进程的那一段数据先拷贝一份到新空间再建立映射呢?为什么不能直接开辟空间,然后不拷贝直接将值放到新地址处呢?
原因非常简单,因为我们拷贝的这一个数据块中并不是所有的数据都需要改变的。并且还有一个点就是
覆盖和修改是不一样的。例如这里存在一个数组,我只想对这个数组中的内容做局部性的修改,并不是要修改全部的数据,这也是修改,那么此时如果是只开辟空间而不进行拷贝的话,是不正确的。
以上就是写时拷贝的一个细节
下面是两个结论
1.首先创建子进程是希望子进程复制父进程,但是我希望子进程和父进程做不一样的事情(使用if else分流)。此时我们就能做到我们的一份代码就能以多进程的方式进行并行,或者并发运行。这就是fork创建子进程的第1种用法。
2.我们希望子进程帮我们去执行一个任务,比如ls命令就是bash的一个子进程就帮助我们用户,打印了当前目录下的全部文件和文件夹,此时我们为什么创建子进程呢?这里我们创建子进程是希望子进程帮助我们执行一个新的程序。
下面我们来写一段创建多进程的代码:
下面是代码运行的分析图
这里的每一个自己子进程都会进入到work函数中并且在完成work函数之后,被exit函数直接终止掉这个子进程。
而在每一个子进程都被执行完后
我们这里选择让父进程多休息一下。让我们的子进程全部进入僵尸状态。
从运行结果也可以看出来,当我们的父进程进入休眠的时候所有创建的子进程都进入到了僵尸状态。
下面我们修改一下上面的代码让他变得更好看一些
那么这么写在未来的时候,你就能够创建不同数量的进程去执行各种不同的任务了。
下面我们来介绍终止进程的学习。
进程终止的学习(1)
main函数的返回值(退出码)
我们首先知道我们的main函数最后一般都是选择返回0的,那么为什么最后一定是返回0呢?这个0是给谁返回的呢?有什么意义呢?
下面我们就来了解一下main函数的返回值,首先我们要知道main函数也是一个函数也就意味着谁调用main函数,那么这个返回值最后就会返回给谁。我们之前学习c/c++的时候我们知道main是被一个__startart的函数调用的,但是我们不管这个函数关键在于,main函数将这个返回值交给了__start这个函数,但是这个函数还是要将这个返回值继续向上交付的.为什么呢?
今天我们知道,这个main函数在运行的时候,本质就是一个进程在运行。
下面我们修改一下下面这个代码的返回值
保存推出后编译运行一下。这个代码一编译起来,就形成了进程,而我们知道我们在命令行上运行的代码本质都是bash的子进程。而我们现在运行起来的进程,最后会将自己对应的main函数返回值,交给父进程bash,而在bash中我们可以使用
echo $?
的命令来获取最近的一次返回值
这里的这个10就是main函数的退出码,这是我们现在看到的现象。
那么为了分析清楚这个情况,我们就来理解一下什么是进程退出。
这里我们提出一个现象,我们在现实生活中,做一件事情时,无非只有3种情况,第一种,事情做完了但是结果不太好,第二种,事情做完了结果非常好,第三种事情因为某些原因没有做完。
所以当一个进程退出的时候,也就只存在三种情况,第一种代码跑完了结果很正确,第二种代码跑完了,结果不正确,第三种,代码因为某种错误没有跑完。
就以冒泡为例子,第一种就是冒泡跑完后,排序成功了,第二种冒泡跑完了,但是排序没有成功。第三种冒泡因为某种错误没有跑完。
目前我们只考虑前两种情况。
而当代码跑完之后,我们得知道这个代码跑完的结果是否是正确的,这个我们是指谁,也即当我们写完一个代码之后,我们需要让谁知道这个代码的结果是否正确呢?在我们写的一些算法题中(例如翻转链表)这里是我们用户自己去看。但是如果我们是在多进程中,我们创建子进程的目的是让子进程帮我办事(例如让子进程完成文件读取),那么此时的我们应该知道子进程帮我们把事情做的怎么样?也许有人会认为,所有的运行结果都会打印出来的,但是万一这条指令没有任何的输出呢?例如网络发送信息,本地是不会打印你发送的信息的。这里需要知道的就是不是所有的输出结果都需要打印的,所以在多进程的环境中,我们指的就是父进程,因为父进程需要关心子进程把事情办的怎么样了。所以我们子进程需要一种方式让父进程知道我们把事情办的怎么样了。现在我们只关心两种:一种跑完结果正确,一种跑完结果不正确,那么父进程怎么知道子进程是哪一种情况呢?
所以这里就存在了main函数的返回值,
所以
这里父进程就可以通过子进程的返回值来得到子进程把事情办的怎么样了的信息。假设这里存在一个进程的子进程返回了非0的数字,代表这个程序运行结果出错了,此时我们最关心的就是哪里出错误了,出的错误是什么。
例如当一个子进程的返回码为1,可以代表这个子进程打开文件出错了,2代表子继承创建空间失败了等等,而返回0代表运行结果城正确,此时我们就可以使用不同的退出码来代表这个进程出错的原因了。
所以我们在写代码的时候,对于某些一旦出错了就无法往下运行的代码处判断一下,如果出错使用不同的非0数字退出,让父进程知道这个子进程是因为哪里的错误而退出的。但是现在的退出码只是一个数字,而各个数字代表的不同的退出原因是需要我们人为的去定义的。我们可以自己去定义,所以我们人可以使用特定的命令将退出码转化为特定的退出字符,便于我们人去查看错误。
其中我们的系统已经提供了一批接口,来把不同的退出码转化为不同的退出原因,方便我们去查这个接口如下:
当然如果你不想使用系统自带的错误原因,你也可以自制,怎么自制下面会说明。
这里因为我不知道系统自带的错误码有多少,所以我就使用200测试一下
可以看到一共有135个退出原因.
下面我们在回到最初的那个现象:
这里main函数的退出码需要被它的父进程知道,原因在上面说了
这里的?是一个环境变量里面保存的
所以这里我们能够得出一个结论,我们的父(bash)是通过子进程main函数的退出码,来了解这个子进程把事情完成的怎么样了的。而此时我们上面的代码的退出码为10代表出错了,但是我们并不知道出错的原因是什么?
此时如果我们再次echo $?为何这里的值变成0了呢?
因为echo也是一个命令,而命令的底层就是一个程序,就一定会存在退出码,所以这里的退出码是echo执行后的退出码。因为?保存的最近一次子进程执行完毕后的退出码。
所以下面我们呢就将上面的知识结合一下
我们知道ls是一个指令,而当我们使用ls 打开一个不存在的文件的时候,这个ls程序就报错了,报错了,我们此时最关心的应该就是错误的原因是什么?
而此时打印出的错误原因是后面的那个No 那一串字符。
此时我们去查看这个?的值
发现是2,所以此时我们的bash也是知道这个ls程序错误的原因的,那么为什么是2呢?
因为2在系统默认的错误描述字符串中对应的就是这句话
我们还可以看到3号对应的是No such progress 没有对应的进程,那么下面我们使用kill来试一下这个命令。
但是此时我们却发现退出码是1。
此时我们发现一些命令是不符合系统内部自带的错误字符对应码的。
那是因为我们不使用系统自带的错误字符对应码,我们也可以选择自定义。
如何定义:
此时我们自己的进程在退出的时候,我就定了属于我自己进程的三个退出码。退出码所对应的的描述,我们只需要将下标放到字符数组中打印一下即可。
所以这里存在了一个最终的结论
下面我们来思考下面的问题:
那么这两者有什么联系呢?(c的错误码和退出码)
我们知道的一个点就是当我们使用我们库函数或者是系统就那个调用的接口(c语言中)出错的时候,我们的erron变量会被自动的设置,而我们在一个代码中可能调用多次库函数,而每一次调用errno变量是都有可能出现错误的,而我们的errno会记录最后一次调用库函数的错误。
下面我们使用下面的代码验证一下:
这个代码的功能是以读的方式打开当前目录下的log.txt文件,但是因为当前目录下是不存在log.txt文件的,所以这里一定是会失败的,我们可以观察一下在调用fopen前errno的值,和在调用fopen函数后errno的值。
那么这个2对应的错误码描述是什么呢?
这里可以使用strerror函数来获得错误码描述。
下面是代码:
可以看到错误码的描述就是当前目录不存在当前文件。
那么这个错误码和退出码之间的关系是什么呢?
图中的意思就是错误码和退出码虽然表现的事物不一样,但是具有一样的表征,因为错误码和退出码,都是为了让父进程/用户知道我们的进程/函数出错误了,而这个错误到底是什么(将错误码/退出码通过某种方法变成错误码/退出码描述)。
所以在未来写代码的时候,我们可以让退出码和错误码保持一致
这就是我们所规定的使用系统自带的退出码描述来解决问题的方法。如果你不愿意这么写,那么你把错误码的描述一打,然后可以返回一套自己的退出码。
此时我们的父进程和用户就知道我们的子进程/函数是哪里出现错误了。
那么现在我们用户就能够通过退出码来知道我们的进程是否得出正确的结果了。
那么这里我们思考一个问题,系统调用是os提供的一个接口,而errno是c语言中的一个全局变量,那么为什么系统调用失败以后,能够修改errno的值呢?
这个答案我们现在可以理解成因为Linux内核也是使用c语言写的。所以os提供的系统接口也是c式的接口所以这里我们的系统调用就能够修改errno的值。
所以系统调用失败了,会自动的设置errno的值。
下面我们就可以对一种场景来使用这个错误码和退出码,假设现在在代码中,你需要打开某个文件,但是打开文件失败了(如果打开文件失败,代码也会自己直接返回退出码),那么现在这个函数就会返回一个错误码,如果你不想使用系统自带的退出码信息,那么这个时候,你可以就以0代表成功,1代表失败,然后对于详细的错误,在出错的时候,将这个详细的错误码信息打印出来。下图就是成功就是0失败就是1,对于失败的具体原因我已经打印出来了(打印错误码信息)。
你也可以使用系统的退出码信息。即让退出码等于错误码,然后返回。
那么上面我们考虑的都是代码运行完毕,但是结果可能不正确的情况,下面如果我们的代码出现异常了呢?
此时我们的代码没有跑完,此时的退出码也就没有意义了。
因为只要你的代码没有跑完你的结果自然是没有意义的。
那么什么情况下代码异常了呢?
什么是异常
这里我们引入一下:
首先我们写一段会出现异常的代码:
这里我们的代码除0错误会异常。
此时编译运行一下
会发现最后系统给我们报出了一个浮点数异常。
还有一种
这里不能写入是因为0号区域是属于代码段的,是不能写入的。
这里的错误就是段错误。
所以这里的程序崩溃了,本质就是进程异常了,而一旦进程异常了os是绝对不会继续让这个进程运行的。此时的这个进程只能被os杀掉。这也是为什么我们的异常代码无法运行的。
那么这里os是如何杀掉进程的呢?其实当某一个进程异常之后,我们的os是通过信号的方式来杀掉我们的进程的。
下面是os中所有的信号
那么我们的第一个错误的时候,os是传递了哪一个信号把我们的进程杀掉的呢?
这里浮点数异常对应的就是8号信号。
所有当我们的进程出现异常的时候,os会检测到进程的异常信息,然后os会把这个异常信息转化成为异常的信号然后把异常的进程杀掉。这个结论的底层实现我们后面会讲。
同理段错误的错误信号为11号信号,如何证明当我的进程出现异常了,os会把异常信息转化为异常信号然后杀死异常进程呢?
这里我们可以通过给一个正常的进程发送8号或者是11号信号的方式证明,看这个正常的进程是否会复现这个报错信息。
由此我们的结论就是:
所以对于一个进程在运行时的第一步就是这个进程是否出现异常,如何判断呢?就是查看这个进程有没有收到信号,如果这个代码正常的运行了,那么我们只需要关注最后的退出码就可以判断这个进程的结果是否正确了。
总结就是我们的父进程只有关心2个数字和一个信号就能够判断子进程是否完成了父进程所分配的任务。
信号是没有0号信号的,因为对于一个进程而言1,没有收到信号代表这个进程是正常的,收到了信号代表这个进程异常了,具体的异常就看你是几号信号。
希望这篇博客能对你有所帮助,写的不好请谅解,如果发现了错误欢迎指正。