【linux】linux进程控制(一)(fork进程创建,exit进程终止)
小编个人主页详情<—请点击
小编个人gitee代码仓库<—请点击
linux系列专栏<—请点击
倘若命中无此运,孤身亦可登昆仑,送给屏幕面前的读者朋友们和小编自己!
目录
-
- 前言
- 一、进程创建
-
- fork函数
-
- 概念讲解
- 使用for循环同时创建多个进程
- 写时拷贝
- 二、进程终止
-
- 进程退出场景
-
- 代码运行完毕,结果正确或不正确
- 代码异常终止
- 总结
- 进程退出方法
-
- 正常退出
-
- return和exit的区别
- exit和_exit的区别
- 异常退出
- 总结
前言
【linux】linux进程概念(五)——进程地址空间——书接上文 详情请点击<——
本文由小编为大家介绍——【linux】linux进程控制(一)(fork进程创建,exit进程终止)
一、进程创建
fork函数
概念讲解
fork函数是作用是从一个已存在的进程中创建一个新进程,新进程叫做子进程,原来的进程叫做父进程
- 使用fork函数的时候需要包头文件#include
- 对于fork函数给父进程返回子进程的pid,给父进程返回0
- 关于fork函数的大部分内容,小编在之前的文章中已经进行了详细的讲解了,详情请点击<——
- 调用fork函数之后,操作系统会做如下操作
- 分配新的内存和内核数据结构(task_struct(PCB)&& mm_struct(进程地址空间)&& 页表)给子进程
- 将父进程的内核数据结构的字段内容拷贝给子进程内核数据结构对应的字段上
- 将子进程添加到CPU的运行队列中
- fork函数返回,调度器开始调度
#include #include int main(){ printf(\"before,pid: %d, ppid %d\\n\", getpid(), getppid()); fork(); printf(\"after,pid: %d, ppid %d\\n\", getpid(), getppid()); return 0;}
运行结果如下
- fork之前父进程单独执行,fork之后父子进程二进制代码相同,即父子进程共用代码,并且如果不适用if(id == 0)进行区分父进程和子进程,那么父子进程会执行到相同的地方,父子进程两个执行流分别执行,所以会执行两次after
- 使用if(id == 0)区分父进程和子进程,让父进程和子进程去执行不同的代码块,执行不同的执行流
#include #include #include int main(){ printf(\"i am parent,pid: %d, ppid %d\\n\\n\", getpid(), getppid()); pid_t id = fork(); if(id == 0)//子进程 { while(1) { printf(\"i am child,pid: %d, ppid %d\\n\", getpid(), getppid()); sleep(1); } } else//父进程 { while(1) { printf(\"i am parent,pid: %d, ppid %d\\n\", getpid(), getppid()); sleep(1); } } return 0;}
运行结果如下,父进程和子进程分别执行不同的代码块,执行不同的执行流
- fork也有可能会创建子进程失败,如果fork失败则会返回小于0的数,通常我们不进行判断,因为一般很少情况下会创建子进程失败,当fork失败的原因可能是操作系统的进程过多或者是实际用户的进程数超过了限制
使用for循环同时创建多个进程
#include #include #include #define N 5void runChild(){ int cnt = 10; while(cnt--) { printf(\"i am child, pid: %d, ppid: %d\\n\", getpid(), getppid()); sleep(1); }}int main(){ int cnt = 0; for(; cnt < N; cnt++) { pid_t id = fork(); if(id == 0) { runChild(); exit(0); } } sleep(1000); return 0;}
- 小编使用for循环同时创建5个子进程
- 使用for循环循环5次,在循环内存调用fork函数,最开始只有父进程进行调用fork函数,那么使用变量id接收fork函数的返回值之后,由于fork函数会有两个返回值,给子进程返回0,给父进程返回子进程的pid,那么我们就可以利用这个特性,通过id == 0判断出子进程
- 进而让子进程去调用runChild函数,其中这个runChild函数可以循环10次使用sleep间隔1秒打印进程的pid和ppid
- 当子进程调用完成runChild函数之后,这里我们仅仅希望只有最开始的一个父进程在for循环内调用5次fork创建5个子进程,所以为了避免子进程调用完成runChild函数之后继续循环创建子进程,造成无限套娃,所以这里我们使用exit(0)终止子进程,由于这个exit(0)是在if判断语句内,所以并不会终止父进程,父进程由于调用完成fork之后,得到的id值是子进程的pid,所以id不为0,不会进行if语句内部,会继续执行for循环,继续去fork创建下一个子进程,由于程序的执行速度十分十分快,那么执行5次循环,所以几乎可以说是在同一时间内,父进程调用fork这5个子进程就被创建出来了
- 所以会在同一时间内出现5个子进程同时运行
- 我们可以复制ssh渠道,使用如下脚本指令,监视我们的进程myproc,这样可以进一步直观的确定的确是有5个子进程被创建出来了,并且也可以观察到父进程和子进程的进程状态
//脚本指令while :; do ps ajx | head -1 && ps axj | grep myproc | grep -v grep; echo \"-------------------------------\"; sleep 1; done
运行结果如下
- 并且如果在同一进程中使用fork连续创建子进程,那么子进程的pid一般都是连续的,所以右就可以监视到有9358,9359,9360,9361,9362这5个子进程,并且这5个子进程的父进程都是9357
- 这时候我们再观察左边,但是左边中并不是按照子进程被创建出来的顺序,即子进程的pid9358,9359,9360,9361,9362的顺序进行调度的,相反调度顺序是乱序的
- 其实进程的调度顺序并不能由我们来决定,而是由调度器决定的,因为子进程被创建出来后,它们的优先级是一样的,所以它们执行的顺序就取决于谁被调度器优先放到CPU的运行队列里,谁先被调度器放到CPU的运行队列的早,那么谁就优先被调度,由于子进程的优先级都相同,所以究竟是谁先被放入,小编也无从得知,具体是由调度器决定的,所以fork之后,进程谁先执行,完全是由调度器决定的
- 并且当子进程调用完成runChild函数之后,都被小编终止了,由于当父进程创建完成子进程之后,为了我们观察子进程调用runChild函数,所以不能让父进程直接退出进程,小编对父进程循环结束之后采用sleep(1000),所以父进程会陷入睡眠状态,但是由于子进程被小编终止了,但是父进程由于陷入睡眠状态会迟迟不去回收子进程,所以子进程会陷入僵尸状态,不用管,我们无脑ctrl + c就可以退出了
写时拷贝
通常父子进程代码共享,当父子进程不写入数据的时候,数据是共享的,当任意一方试图写入的时候,便另外开一份空间,以拷贝的方式拷贝一份数据进行修改,原有的空间的数据属于未进行修改的那一方,这样两方便各自有一份数据了
- 关于写时拷贝的大部分内容,小编在之前的文章中已经进行了详细的讲解了,详情请点击<——
- 那么接下来小编再带大家研究一下写时拷贝中操作系统是如何知道的父进程和子进程中的任意一个进行修改数据的时候,怎么样触发的写时拷贝
- 其实当子进程创建出来的时候,由于父进程的数据和代码和子进程共享,并且子进程的内核数据结构(task_struct(PCB) && mm_struct(进程地址空间)&& 页表)都是以父进程为模板,拷贝的父进程的
- 其中子进程的页表也是拷贝的父进程的,对于子进程的页表中的虚拟地址以及物理地址都是拷贝的父进程的,对于子进程的进程地址空间上的代码映射的虚拟地址以及物理地址拷贝的是父进程的,代码的虚拟地址对应的物理地址的权限设置为只读(没毛病代码父子进程共享不能修改)
- 对于子进程的进程地址空间上的数据映射到页表上的虚拟地址以及物理地址也是拷贝的父进程的(其实将父进程页表的虚拟地址和物理地址拷贝给子进程的页表,子进程就已经和父进程共用一份代码和数据了),这里操作系统做了特殊处理,原本父进程中页表的数据虚拟地址对应的物理地址的权限为可读可写,此时将子进程和父进程中页表的数据虚拟地址对应的物理地址的权限设置为只读
- 那么当子进程或者父进程尝试对数据的物理地址上的内容做写入的时候,这里小编以子进程为例,当子进程尝试对子进程页表的数据的物理地址上对应物理内存的内容做写入的时候,由于物理地址的权限是只读的,此时做写入会触发异常读写,但是操作系统这里又做了一个特殊处理,对于这种情况触发的异常不进行报错,转化为缺页中断,进行写时拷贝,会重新申请内存空间,将数据拷贝到新空间上,在新空间上进行修改数据,修改完成之后将父进程和子进程修改数据的虚拟地址对应的物理地址的权限设置为可读可写,父进程的权限修改后,此时原空间的数据就属于父进程了,此时如果父进程想要对数据进行修改,那么就不会触发异常读写了,可以直接在原空间上进行数据的修改,此时对于进行修改的数据父进程和子进程就有了自己独立的数据的空间了,对于未进行修改的数据父进程仍然和子进程共用同一块数据空间
- 思考一下,代码是不会进行修改的,所以父进程和子进程共用同一份代码也不会对代码进行修改,那么父子进程共用一份代码我可以理解,那么对于数据由于子进程有可能会有修改数据的需求,我不想使用写时拷贝,那么我在技术层面上可不可以无脑的将父进程的数据给子进程拷贝一份,不要这个写时拷贝?
- 其实在技术层面上是完全可以实现的,但是我们还应该看到另外一方面,如果父进程有100个字节的数据,父进程和子进程共用数据,子进程想要修改的数据只有一个,那么采用写时拷贝,那么仅仅多开一个数据的空间(其实对于要修改的数据是一定要进行开空间拷贝进行修改的,只是对于写时拷贝是一种延时拷贝的方式),如果子进程采用无脑拷贝的方式,那么就会去拷贝100个数据,相对于写时拷贝,就会多拷贝了98个数据,此时在内存中会出现重复数据,而且拷贝这些数据相对于拷贝一个数据多花费了很多时间,并且这多拷贝的数据我子进程还不会进行修改,甚至还会有数据我子进程根本不会进行修改,所以对于内存空间来讲这是一种很大的浪费,我们知道现代操作系统是不会去做任何一件浪费时间和空间的事情,所以操作系统一定会去采用前一种写时拷贝的方式,而不会采用无脑拷贝方式
- 采用写时拷贝,相对于无脑拷贝的方式,可以有效的节省内存空间,并且减少拷贝,提高效率
二、进程终止
进程退出场景
代码运行完毕,结果正确
代码运行完毕,结果不正确
代码异常终止
代码运行完毕,结果正确或不正确
#include int main(){ printf(\"hello world\\n\"); return 0;}
运行结果如下
- 在我们最初学习编程的时候,总是要在main函数的最后编写一个return 0,可是我们貌似没有去探索过为什么要编写这个return 0,这个return 0的作用是什么,这个return 0返回的0给谁了,我可不可以return 1,return 2等等
- 其实这个return 0返回值,返回的数字叫做进程的退出码,用来表征进程是否运行成功,是否是正确的结果,如果是正确的结果,那么返回0,如果是不正确的结果,那么用不同的数字,来表示不同的出错原因
- 我们可以在命令行中使用echo $?来获取最近一次进程运行返回的退出码
- 最初我们可能了解过一个全局变量errno,它是用来存储最近一次的错误码,这里应该注意区分一下,一个程序可能出现多次错误码,但是只能有一个退出码,当程序出现错误的时候,例如:除零错误,空指针的访问,以及申请内存错误等,当程序出现一次错误就会发出一次错误码,而errno就可以捕获并存储最近一次的错误码,错误码用于程序内部获取出现错误的编号,从0开始编号,那么我们究竟有多少个退出码呢?strerror用于打印错误码的对应字符串描述信息,我们可以借助strerror,使用循环来打印一下
#include #include int main(){ int i = 0; for(; i < 200; i++) { printf(\"%d: %s\\n\", i, strerror(i)); } return 0;}
运行结果如下
这里由于全部截图出来,篇幅较多,所以小编仅截出开头和结尾
我们可以看出错误码从0到133,共有134个错误码
其实操作系统提供退出码和错误码,它们的描述是有对应关系的
- 例如程序出现的错误,这里小编演示申请内存错误,那么程序出现了错误之后,errno就会自动更新当前错误的错误码,其实我们可以将错误码的值赋值给退出码,这样我们就可以使用strerror打印错误码,然后将退出码以进程的角度返回
#include #include #include #include #include int main(){ int ret = 0; char* p = (char*)malloc(1000 * 1000 * 1000 * 4); if(p == NULL) { ret = errno; printf(\"%d: %s\\n\",errno, strerror(errno)); } else { printf(\"malloc success\\n\"); } return ret;}
运行结果如下
这样就使我们程序出现的错误原因回显,并且退出码也返回对应的错误码表示错误的编号,在有些场景中,我们并不需要类似于小编这样打印错误信息,而是采用返回退出码的形式告诉用户程序的错误信息
- 除零错误演示
#include #include #include #include #include int main(){ int ret = 0; int a = 10; a /= 0; ret = errno; printf(\"%d: %s\\n\",errno, strerror(errno)); return ret;}
运行结果如下
Floating point exception即除零错误
- 空指针访问错误演示
#include #include #include #include #include int main(){ int ret = 0; int* p = NULL; *p = 10; ret = errno; printf(\"%d: %s\\n\",errno, strerror(errno)); return ret;}
运行结果如下
Segmentation fault即段错误,即越界访问,空指针的访问
- 关于进程退出的场景有代码运行完毕,结果正确,以及代码运行完毕,结果不正确,那么代码运行结束,对于结果的正确与否,都统一使用进程的退出码来进行判定,如果进程的退出码为0,那么结果正确,如果进程的退出码为非0,那么结果错误,对于具体的错误的原因是由return返回不同值的数字(退出码)来表示
- 这个退出码是给谁的,其实是给父进程的,那么在进程中通常是父进程需要关心当前进程的运行情况,其实父进程也是替用户办事的,创建子进程其实是用户想要进行创建,父进程为用户创建子进程,那么对于一些不进行打印的场景,可以使用进程返回退出码的形式将进程出错的对应退出码返回给父进程(在命令行中创建的进程的父进程是bash),这样用户就可以使用echo $?查看最近一次的进程的退出码,这样当进程出现错误的时候,用户获取到退出码,便于用户针对退出码对应的错误信息做出下一步的策略,例如调整代码逻辑重新运行等
代码异常终止
- 对于进程退出的场景还有进程异常终止的场景,异常的本质就是代码可能没有跑完,当代码没有跑完进程便异常终止了,此时进程仍然没有执行到main函数的return语句,所以此时进程的退出码便没有了意义,那么此时我们就不关心进程的退出码了
- 进程出现异常,本质是由于我们的进程出现了异常,下面小编带领大家探索一下
//kill的使用kill -选项 进程的pid
- 如上图,我们可以使用kill指令给进程发送信号,进程接收到我们的信号之后就会出现异常终止的情况
#include #include #include #include int main(){ while(1) { printf(\"hello linux , pid: %d\\n\", getpid()); sleep(1); } return 0;}
- 如上,使用我们使用循环间隔1秒打印进程的pid的代码,接下来我们复制ssh渠道,在让进程跑起来,使用kill给进程发送信号的形式使进程收到信号异常终止
- 在运行之前,我们还应该使用kill -l看一下kill可以发送什么信号
- 其中我们观察到这些信号都是以SIG开头的,其实SIG对应的signal信号这个单词,我们看到9号信号可以杀死对应进程,找一下Segmentation fault段错误对应的信号是11号,Floating point exception除零错误对应的信号是8号,采用的SIG+FPE首字母的形式作为信号名
- 那么接下来小编将演示9号信号杀死进程
- 小编将演示使用8号信号给进程发送除零错误的信号,进而进程接收到信号后就会异常终止
- 小编将演示使用11号信号给进程发送段错误的信号,进而进程接收到信号后就会异常终止
- 那么通过上面的一系列演示之后,我们可以得出,进程出现异常终止,本质是由于进程收到了对应的信号
总结
代码运行完毕,结果正确
代码运行完毕,结果不正确
代码异常终止
- 那么对于上面异常出现的三种场景,由于异常终止出现了之后,进程就有可能连return语句都没有执行到,所以进程的退出码就没有了意义
- 所以我们应该使用信号操作检测进程异常终止,如果没有进程的异常终止,说明代码运行完毕,此时进程会返回对应的退出码,这时候进程的退出码有意义,所以我们可以使用退出码了,如果退出码为0,那么说明进程结果正确,如果退出码非0,那么说明结果不正确,进而我们就可以使用这个退出码的数字去对应具体的错误信息,得知我们的进程发生了什么错误,去根据错误信息处理对应的错误
进程退出方法
正常退出
- 从main函数return返回
- 调用exit
- 调用_exit
return和exit的区别
- exit可以引起一个进程终止,传入exit的参数其实就是进程的退出码
- 那么我们先看return的情况
#include #include int main(){ printf(\"hello linux\\n\"); return 7;}
运行结果如下
- 那么我们再看exit
#include #include int main(){ printf(\"hello linux\\n\"); exit(17); //return 7;}
运行结果如下
- 那么我们可以看到,在main函数内,return和exit实际上并没有任何区别,都是用于终止进程,返回退出码
- 那么在其它函数呢?return和exit的作用仍然相同吗?下面小编带领大家探索一下
- 同样的,我们先看在其它函数return的情况
#include #include #include void show(){ printf(\"hello linux, begin\\n\"); printf(\"hello linux, begin\\n\"); printf(\"hello linux, begin\\n\"); return; printf(\"hello linux, end\\n\"); printf(\"hello linux, end\\n\"); printf(\"hello linux, end\\n\");}int main(){ show(); printf(\"hello linux\\n\"); return 0;}
运行结果如下
我们可以看出在其它函数return之后,return不会直接终止进程,而是会返回当前函数,回到进行调用的函数中去继续执行
- 那么接下来我们看一下在其它函数执行exit的情况
#include #include #include #include #include void show(){ printf(\"hello linux, begin\\n\"); printf(\"hello linux, begin\\n\"); printf(\"hello linux, begin\\n\"); //return; exit(11); printf(\"hello linux, end\\n\"); printf(\"hello linux, end\\n\"); printf(\"hello linux, end\\n\");}int main(){ show(); printf(\"hello linux\\n\"); return 0;}
运行结果如下
我们可以看到在其它函数中执行了exit函数之后,此时会直接在调用exit的地方终止进程,并且返回退出码
- 所以在main函数中return和exit没有区别,都是终止进程,返回退出码
- 在任意位置exit被调用都表示进程直接退出,但是return在其它函数中被调用只表示当前函数返回
exit和_exit的区别
- 先看_exit
#include #include #include int main(){ printf(\"hello linux\"); _exit(15);}
运行结果如下
- 我们的hello linux并没有打印,原因我们调用的printf是将数据写入缓冲区,即hello linux被放入到了缓冲区,当遇到\\n或者进程return或者exit会自动刷新缓冲区的内容
- 这里我们既没有\\n也没有return也没有exit,所以自然而然我们的hello linux并不会打印
- 所以我们可以得出_echo并不会刷新缓冲区的内容
- 再看exit
#include #include #include int main(){ printf(\"hello linux\"); exit(16);}
运行结果如下
- 我们可以看到我们的hello linux被打印出来了,即hello linux由于我们调用了exit之后,从缓冲区中将hello linux刷新出来了
-
其实从头文件中我们就可以看出来了,exit的头文件是#include ,而_exit的头文件是#include ,即_exit是系统调用函数,exit只是一个普通函数
-
底层exit的实现是封装_exit实现的,exit是在上层,_exit是在下层,我们可以猜测一下写入数据的缓冲区绝对不在哪里?
-
我们可以肯定的得出绝对不在内核空间中,因为如果缓冲区在内核中,那么当进程对应写入的数据暂时放到了内核中,其中这些数据始终是要占用空间的,缓冲区的作用就是暂时存放数据,在合适的时候,将数据刷新出来,如果在内核中,进程结束不刷新出来,那么就会占用内核的空间,我们现代操作系统是不会做任何一件浪费时间和空间的事情,所以如果假设缓冲区在内核中,并且进程结束缓冲区中有数据,操作系统一定会将数据刷新出来
-
这里我们调用_exit的时候,我们的hello linux数据并没有从缓冲区中刷新出来,所以缓冲区一定不在内核中
-
那么缓冲区不在内核空间中,当我们调用_exit的时候,_exit是在下层,由于_exit是系统调用函数,所以其对应的代码是在内核空间中,内核空间中并没有缓冲区,_exit连缓冲区的影子都看不到,所以_exit自然而然不会去刷新缓冲区,所以我们的hello linux数据并没有从缓冲区中刷新出来,也就并没有打印hello linux
-
而exit是在上层,exit封装的_exit实现的,由于exit是在上层,不存在于内核空间,所以当调用exit的时候,exit会先执行用户定义的清理函数,关闭所有打开的流,并且exit可以看到缓冲区,那么还会去刷新缓冲区,所以我们的hello linux数据就从缓冲区中刷新出来了,于是我们hello linux打印完成,刷新完成之后再去调用系统调用_exit终止进程,返回退出码
异常退出
- ctrl + c,信号终止
总结
以上就是今天的博客内容啦,希望对读者朋友们有帮助
水滴石穿,坚持就是胜利,读者朋友们可以点个关注
点赞收藏加关注,找到小编不迷路!