💖作者:小树苗渴望变成参天大树🎈
🎉作者宣言:认真写好每一篇博客💤
🎊作者gitee:gitee✨
💞作者专栏:C语言,数据结构初阶,Linux,C++ 动态规划算法🎄
如 果 你 喜 欢 作 者 的 文 章 ,就 给 作 者 点 点 关 注 吧!
文章目录
- 前言
- 一、进程创建
- 1.1写时拷贝(数据)
- 1.2 代码层面
- 1.3 fork的常规用法
- 1.4 fork调用失败的原因
- 二、进程终止
- 三、进程等待
- 3.1 为什么要进程等待
- 3.2进程等待怎么做的
- 3.3 进程的非阻塞轮询
- 四、进程程序替换
- 4.1看看什么是进程程序替换
- 4.2替换函数
- 五、总结
前言
今天博主又来更好新的文章了,我们之前把进程的概念已经讲完了,相信大家对进程应该了解的差不多了,今天我们来讲进程的另一个知识点—进程空间,这节的难度比地址空间还理解一些,在系统编程这一环节,进程概念算是比较难的一座大山,我们翻过去,后面可以缓缓了,所以这节难度不是很大,这节我讲分为四个部分给大家讲解进程控制
本节重点:
1.进程创建
2.进程 终止
3.进程等待
4.进程替换
一、进程创建
相信这个大家之前已经知道了,我们在学习fork的时候就已经知道了,这个函数就是创建进程的,不知道的看这篇博客fork看完这篇博客在来理解我们后所讲的内容会比较好理解。
1.1写时拷贝(数据)
我们拿出之前的代码回顾一下:
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("我是一个父进程!\n");
pid_t id=fork();
if(id==0)
{
while(1)
{
printf("我是一个子进程:pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
else
{
while(1)
{
printf("我是一个父进程:pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
}
}
return 0;
}
现在大家已经知道为什么一个变量可以接收两个返回值,为什么要返回两次了,所以这方面就不跟大家多解释了。
现在的问题是,我们fork创建子进程后,os都干了那些事??
之前给大家解释过为什么父子进程的代码是共享的,数据一开始也是共享的,当子进程修改数据就是发生写时拷贝,当时只是说这样做为了防止空间不足时,拷贝的有些数据子进程暂时用不上,就会造成那块空间会严重被占用,所以采取写时拷贝,现在我们也学了地址空间,也知道内存为了节省空间做出的贡献,接下来在深刻带大家来理解一下fork的写时拷贝
我们fork之后我们的os里面多了一个进程,将代码和数据加载到内存中,代码和数据都是从磁盘中加载来的,但是我们通过fork创建的子进程是没有经过加载的,是父进程在运行的时候创建的,所以这个子进程不存在从磁盘中加载数据和代码到内存上的,也就是说子进程没有自己的代码和数据,所以子进程只能使用父进程的代码和数据,因为进程之间有独立性,意味着子进程使用父进程的代码和数据不能影响父进程
代码: 因为在代码区页表有权限的限制,所以代码是是只读的,那么共享代码是不影响父进程的代码的,所以代码共享符合进程之间的独立性原则的
数据:可能会被修改,所以父子进程的数据要分离开
数据有两种分离方式:
1.创建子进程的时候,os拿一块空间给子进程,把父进程的数据拷贝一份给子进程
这样做不好的是,第一、你创建的时候就把数据拷贝给你,你能保证你立马就使用嘛,如果有的数据长时间不使用,就造成那块空间资源的浪费,第二、不是所有的数据都会被使用到,这样就会造成数据在内存种会有两份,造成空间浪费,第三、就算你使用到数据了,能保证就一定是修改数据嘛,万一是读取呢,以上种种原因都导致数据不能再创建的时候就给子进程拷贝一份
2.写时拷贝法:
上面那种用法都不能保证再内存的数据再任何是时刻都是一直在使用,就不能保证内存的百分之百的有效使用率,整机效率就没有那么高,所以不需要把不会访问的或者只会读取的数据拷贝过去,但是有时候子进程修改数据,又不得不拷,那什么样的数据值得拷贝过去呢??
答:os也不知道哪些数据可能会被修改,就算使用一些方式,例如const这些把值得拷的数据拷过去,也会又遗漏,又回到最开始的问题,你能保证拷过去的数据会立即使用嘛,所以提前拷贝过去没有必要,通过以上的说明,我们的os采取了写时拷贝技术,但子进程确实要修改数据的时候,os在创建空间将父进程的数据拷贝过去,在进行修改,这样就解决考虑上面说的资源浪费,以及使用率问题
总结:为什么要使用写时拷贝技术
1.用的使用在给你分配,时一种高效使用内存的表现,防止占用空间不使用情况
2.os也不知道哪些数据将来会被子进程进行修改或者访问的。
因为刚给大家讲解了地址空间的知识,大家应该明白os要想办法提高内存有效使用率,这就导致写时拷贝技术的优点,之前只是简单的说明了一下,现在应该更加的清楚了。
1.2 代码层面
上面说了代码是共享,此时的问题是,fork之后是修改的数据的代码部分是共享的,还是所有的代码都是共享的,为什么??
程序在加载到内存的时候,都会有对应的地址,然后由cpu通过虚拟地址映射到物理内存开始执行代码,一个程序cpu以此不能直接运行结束,有时间片,当运行一半的时候,就会发生中断,让其他程序运行,那么cpu怎么知道下次运行从哪个位置开始继续运行呢??不可能从头开始重新运行的,所以cpu内部里面有对应的寄存器,存放当前运行代码的地址,方便下次找到地址。在linux上这个寄存器叫做EIP(pc指针):程序计数器(记录当前指令的下一条指令的地址),这些寄存器上面的数据在进程被拿下来的时候会被进程自己给带走,下次运行在读取出来,这就叫进程的上下文数据,在优先级那一篇说过,创建子进程也是进程,也是需要被cpu调度的,需要自己修改EIP,在创建子进程的时候,子进程的EIP就认为自己的起始值是fork之后的代码了,cpu调用哪个进程就读取哪个进程的上下文数据,通过EIP继续运行程序。
所以fork之后的所有代码都是父子共享
1.3 fork的常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数
1.4 fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
二、进程终止
这个话题不难理解,我会使用一些例子带大家去理解的,进程终止从字面意思就是将进程杀掉,接下来我将解决三个问题来解决进程终止这个话题
1.进程终止后,操作系统做了哪些事??
创建进程就是os给程序分配相应的资源,创建内核数据结构,进程终止,就是释放进程申请的相关内核数据结构和对应的代码数据—本质就是释放系统资源
2.进程终止的常见方式
1.代码跑完,结果正确
2.代码跑完,结果不正确
3.代码没跑完,程序崩溃了
先讲前两个,大家有没有发现我们在写c/c++代码的时候在main函数最后都喜欢写一个 return语句,默认都写0,返回的含义是什么,不返回0可不可以??
- 返回的含义是,告诉父进程用来评判结果用的。
我们父进程创建子进程,是希望子进程帮父进程做一些事情的,事情完成的成功需要让父进程知道,这样才有意义,就好比领导让你做事,他是关心这件事结果的。- 不总是返回0
如果返回0就表示结果正确,非0有很多个,每个非0都表示一个错误信息,Linux默认的错误信息是134个,为什么要有错误信息,这样是方便父进程快速定位错误是什么,就好比你考试没过,你父亲肯定关心你为什么没过。(使用strerror查看错误信息对应的字符串)
使用echo $?
查看最近依次进程的退出码
我们的ls指令也是一个进程,通过这个例子大家应该知道return的含义大致是什么了吧。因为我们平时写的代码运行,是我们自己的行为,没有父进程关心,所以没人关心你为什么错误,导致你可以直接写返回0.我们的退出码也是可以自己规定的,我们之前写的通讯录,打开文件,文件打开失败就返回-1他又自己的含义。
第三种,代码没跑完,我们有时候在运行代码的时候程序崩溃了,此时你看运行窗口退出代码不是0说明程序崩溃了,因为此时代码没有运行到return语句就终止了
3.用代码,演示一个进程终止
什么是一个正确的进程终止
- 在main函数内部使用return就是终止进程
在其他函数里面使用return不算进程终止,只是作为返回值返回给调用他的函数 - exit函数,这也是一个是进程终止的函数,我们来看看这个函数怎么使用
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<stdlib.h>
int main()
{
printf("hello,world\n");
printf("hello,world\n");
printf("hello,world\n");
exit(1);
printf("hello,world\n");
printf("hello,world\n");
printf("hello,world\n");
return 0;
}
运行到exit时进程就退出了,退出码也是1,我们在来写一个函数看看
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<stdlib.h>
int sum(int top)
{
int i=0;
int sum=0;
for(i=0;i<=top;i++)
{
sum+=i;
}
exit(2);
return sum;
}
int main()
{
printf("sum:%d",sum(100));
return 0;
}
我们讲sum函数返回之前加一个exit函数,看看什么效果
通过上面的例子我们的return之后再main内部才会终止程序,而exit再程序的任意位置都会终止程序
- _exit函数,这也是一个终止程序的接口。
通过头文件大家应该知道exit是库函数,_exit是系统函数
,那这两个有什么区别呢,一起来验证一下:
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<stdlib.h>
int sum(int top)
{
int i=0;
int sum=0;
for(i=0;i<=top;i++)
{
sum+=i;
}
_exit(2);
return sum;
}
int main()
{
printf("sum:%d\n",sum(100));
return 0;
}
这种用法是和exit是一样的效果
我们来看看这样的代码:
不加\n会先刷新到缓冲区,等缓冲区满了或者程序终止后刷新出来
int main()
{
printf("hello,world");
sleep(3);
exit(3);
return 0;
}
int main()
{
printf("hello,world");
sleep(3);
_exit(3);
return 0;
}
通过这两个例子对比,我们从exit的退出前把缓冲区的数据刷新出来了,再进行退出了,来看下面这个图:
通过上面的例子也间接说明的
缓冲区是标准库在维护而不是系统
,如果是系统维护,那么_exit也可以刷新出来。
至此我们的进程终止也就讲解完毕,为什么要先说这个了,因为在进程等待的讲解需要用到这个知识,所以提前说,下一节就是进程等待
三、进程等待
我们上一节讲过进程终止,这节单出来讲一会旧讲完了,就演示一下进程终止啥样子就行了,那博主为什么要花时间讲解终止的情况呢,讲解退出码的含义呢??原因就是为进程等待做铺垫,进程等待事一个非常重要的环节,他可以让父进程知道子进程的退出信息,计算机里面的进程有很多,但是划分出来就是一些父子关系,所以这节对我们很重要,话不多说我们开始介绍进程等待。
3.1 为什么要进程等待
大家有没有看过我之前写过的一篇关于僵尸进程的博客,就是子进程比父进程先退出,然后父进程得不到子进程的反馈,才会造成僵尸进程,而僵尸进程连kill -9 都杀不掉,我们只有使用进程等待才可以解决这个问题,我们先来演示一下僵尸进程,看代码:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t id=fork();//创建子进程
if(id<0)
{
perror("fork");//子进程创建失败,通过perror这个函数将错误信息打印出来
return 1;
}
else if(id==0)
{
//子进程
int cnt=5;
while(cnt)
{
printf("i am child ,pid:%d ,ppid:%d ,cnt:%d\n",getpid(),getppid(),cnt);
cnt--;
sleep(1);
}
exit(0);//五秒后退出,此时父进程还在运行
}
else
{
//父进程
int cnt=10;
while(cnt)
{
printf("i am father ,pid:%d ,ppid:%d ,cnt:%d\n",getpid(),getppid(),cnt);
cnt--;
sleep(1);
}
}
return 0;
}
上面的代码大家并不感到陌生,这是我们以前经常写的代码,此时的僵尸进程使用信号都杀不掉,只有等整个程序结束他才退出,有人说,这也没关系,最后在一起退出呗,但是像服务器这种,可能很长时间都不终止,成为僵尸进程后,他还是一个进程,也是占用资源的,这样就导致资源浪费,所以我们要想办法在子进程先退出的情况下,我们要进程获取他的资源,将其释放。
wait()
我们要介绍这两种系统等待函数,通过第一个函数来讲解进程等待的必要性,通过第二种来讲解他事怎么做到的,第一个和第二种里面的参数事一样的,所以在介绍第一个的时候就不具体介绍里面的参数了,可以给默认值null
wait的使用方法
我们的返回值是等待的子进程的进程号,里面的参数先设置为空,一会在waitpid介绍,来看代码:
(1)单个子进程
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
int main()
{
pid_t id=fork();//创建子进程
if(id<0)
{
perror("fork");//子进程创建失败,通过perror这个函数将错误信息打印出来
return 1;
}
else if(id==0)
{
//子进程
int cnt=5;
while(cnt)
{
printf("i am child ,pid:%d ,ppid:%d ,cnt:%d\n",getpid(),getppid(),cnt);
cnt--;
sleep(1);
}
exit(0);//五秒后退出,此时父进程还在运行
}
else
{
//父进程
int cnt=10;
while(cnt)
{
printf("i am father ,pid:%d ,ppid:%d ,cnt:%d\n",getpid(),getppid(),cnt);
cnt--;
sleep(1);
}
pid_t ret=wait(NULL);
if(ret>0)//当个子进程,wait就是等待这个一个子进程,等待成功返回的就是这个子进程的进程号,进程号是大于0,通过打印来看看是不是子进程的pid
{
printf("wait sucessful,pid:%d\n",ret);
}
sleep(5);
}
return 0;
}
我们可以看到在wait之后我们的僵尸进程被回收了,这是因为父进程里面有循环,所以僵尸进程不能立马被回收,但是wait确实可以解决我们父进程还在运行的时候,就可以干掉子进程僵尸的行为。
(2)多个子进程
我们的父进程创建子进程,可以像创建多个,分别干不同的是事,那我们先创建多个进程一起退出的场景:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
void runChild()
{
int cnt = 5;
while(cnt)
{
printf("I am Child Process, pid: %d, ppid:%d\n", getpid(), getppid());
sleep(1);
cnt--;
}
}
int main()
{
int i=0;
for(i=0;i<10;i++)
{
pid_t id=fork();
if(id==0)
{
runChild();//子进程做自己的事
exit(0);
}
printf("create child process: %d success\n", id); // 这句话只有父进程才会执行
}
sleep(10);
return 0;
}
我们来看看这么多子进程要如何让进程等待呢??wait是等待任意一个进程的。
#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>
#include<sys/wait.h>
void runChild()
{
int cnt = 5;
while(cnt)
{
printf("I am Child Process, pid: %d, ppid:%d\n", getpid(), getppid());
sleep(1);
cnt--;
}
}
int main()
{
int i=0;
for(i=0;i<10;i++)
{
pid_t id=fork();
if(id==0)
{
runChild();//子进程做自己的事
exit(0);
}
printf("create child process: %d success\n", id); // 这句话只有父进程才会执行
}
for(i = 0; i < 10; i++)//等待
{
// wait当任意一个子进程退出的时候,wait回收子进程
pid_t id = wait(NULL);
if(id > 0)
{
printf("wait %d success, childid:%d\n",i+1,id);
}
}
return 0;
}
我们发现在监视窗口都没有看到子进程的僵尸状态,原因是刚进入僵尸状态就被等待回收了,而最上面的单个进程有循环所有没有及时回收。
(2)任意一个进程都没有退出
我们将runChild的循环改成死循环,这样就会造成进程阻塞,父进程一直在等子进程退出,所以阻塞不一定是等待硬件资源,还有可能等待软件资源。
父进程默认在wait的时候,子进程一直不退出,然后调用这个系统接口的时候也就不返回,这个在将原理的再说。
总结:通过上面的案例展示,我们的进程等待是必须的,父进程通过进程等待避免了刀枪不入的僵尸进程,回收子进程资源,获得子进程退出信息
3.2进程等待怎么做的
我们之前说过,创建子进程是父进程想要他帮自己做事情,那事情做的咋样我父进程是不是要知道一下,结果对不对,或者是否正常退出得让父进程知道对吧,所以接下来我就开始介绍另一个进程等待函数。
waitpid的使用方法:
前面的wait只是为了讲解为什么要进程等待,我们使用waitpid会更好,他的功能更强大
他的返回值和wait是一样的,都是返回等待子进程的进程号,他有三个参数,第二个和第三个默认传NULL和0
(1)第一个参数
pid:
Pid=-1,等待任一个子进程。与wait等效。
Pid>0.等待其进程ID与pid相等的子进程
(2)第二个参数
这是我们重点介绍的
这个参数是一个输出型参数,希望通过这个参数将内部的数据带出来,这个我们在c语言刷题的时候经常会遇到,就好比两个数字的交换,传地址是一样的道理,我们这个参数是int类型的,但是分成了号几个部分。
我们先来看看使用,通过单个子进程简单演示一下,好看结果:
我们看到status是256,一点头绪都没有,我们的第二个参数就是获取子进程终止的信息,大家还记得我们在进程终止的时候说过的三种退出状态吧
所以我们的status他的数字就可以代表子进程这三种退出状态的信息
我们的int有32位,我们只使用低十六位,0-7这八位表示进程是否正常退出,8-15表示进程正常退出,结果对不对(也就是退出码),我们进程异常退出都是接收到信号才会异常退出的,比如除0,空指针,这都是有对应的信号,我们来看看有哪些信号;
我们的Linux有64个信号,而0-6位刚好可以表示这64个信号,而刚好没有0信息,所以0就表示正常退出,第7位等我们到信号章节在继续介绍
在上面进程终止介绍到,我们的退出码有130多个
既然这样,那我们上面的status为什么出现256,我们来分析一下:
这个结果就是256,我们使用位操作,将低第八位和次第八位取出来:
printf("wait sucessful,pid:%d,exit_signl:%d,exit_code:%d\n",ret,status&0x7F,(status>>8)&0xFF);
我们来修改一下退出码:
让他异常一下:
信号是8刚好是浮点数异常导致的,当出现异常,我们的退出码就默认是0;
我们发现这样使用按位获取太麻烦了,所以库里面给我们提供了宏函数。
printf("wait sucessful,pid:%d,exit_signl:%d,exit_code:%d\n",ret,WIFEXITED(status),WEXITSTATUS(status)
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
效果就不给大家进行演示了。
进程等待失败的演示:
这个很简单,当我们的等待不是自己的子进程就会等待失败
原理:
为什么父进程要获得子进程的任意数据要通过wait和waitpid去获得,因为进程之间事独立的,事不知道对方的存在的,所以我们说有父子关系,这种关系也事操作系统给的,他两想获取数据必须通过操作系统的接口去获得,这也侧面体现为什么要进程等待。
3.3 进程的非阻塞轮询
我们上面说过阻塞等待,如果等待的时候子进程一直不退出就会造成阻塞等待,这样父进程就会一直等着,因为等待不一定再父进程的最后面,他后面可能还有其他的事要做,这样的阻塞等待就不好,所以我们需要进程非阻塞等待,博主接下来要将小故事
第一次数据库考试
小张是一个学霸,小李是一个学渣,但两个人是好朋友,一个住在八层,一个再楼下,到考试的时候,小李很慌张,想要找小张复习,但又不想爬楼,这时候小李就打电话给小张,能不能带我复习,小张说没问题,但是要等我复习完,小李说好,过了一分钟,小张还没有下来,小李又电话给小张,小张说还没有,就这样小李打了十几个电话,小张才下来了,打电话问小张这个过程就是等待查询,父进程也不知道子进程有没有退出,所以去检查,挂掉电话就是返回的过程,这就是非阻塞,打了十几个就叫轮询
第二次数据结构考试
小李学聪明了,但还是要找小张复习,上次打了十几个电话,浪费了好多电话费,这次打电话就说你不要挂,等你复习好,下来了再挂,此时一直没有挂,那么就相当于等待没有返回,此时就一直是等待状态,也就是阻塞等待
第三次操作系统考试
小李心想上两次太浪费时间了,我再下面等他的时候复习复习,然后再问问他小张,就这样小李打一个电话问一下挂掉,自己复习四五分钟,再打,这样的过程就是非阻塞轮询+做自己的事情,第一次考试的非阻塞轮询没啥意义
总结:非阻塞轮询加做自己的事情才是我们想要的结果
怎么做到:
(3)第三个参数,我们上面演示过,waitpid的返回值大于就是等待成功,小于0就是等待失败,还有一个=0,这是等待成功了,只是子进程没结束而已
options:传的是一个宏函数
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进
程的ID。
我们来通过代码演示一下:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
int main()
{
pid_t id=fork();//创建子进程
if(id<0)
{
perror("fork");//子进程创建失败,通过perror这个函数将错误信息打印出来
return 1;
}
else if(id==0)
{
//子进程
int cnt=5;
while(cnt)
{
printf("i am child ,pid:%d ,ppid:%d ,cnt:%d\n",getpid(),getppid(),cnt);
cnt--;
sleep(1);
}
exit(12);//五秒后退出,此时父进程还在运行
}
else
{
//父进程
while(1)//轮询
{
int status=0;
pid_t ret=waitpid(-1,&status,WNOHANG);
if(ret>0)//当个子进程,wait就是等待这个一个子进程,等待成功返回的就是这个子进程的进程号,进程号是大于0,通过打印来看看是不是子进程的pid
{
printf("wait sucessful,pid:%d,exit_signl:%d,exit_code:%d\n",ret,status&0x7F,(status>>8)&0xFF);
break;
}
else if(ret<0)
{
printf("wait failed\n");
break;
}
else
{
//ret==0,检查子进程退出状态
printf("你好了没?子进程还没有退出,我在等等...\n");
sleep(1);
}
}
sleep(5);
}
return 0;
}
上面的代码我们只是实现了非阻塞轮询,接下来就介绍怎么加做自己的事情
我们可以将父进程自己的放再else里面,但是不太规范,接下来写一个比较规范的:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
#define TASK_NUM 10
typedef void(*task_t)();
task_t tasks[TASK_NUM];
void task1()
{
printf("这是一个执行打印日志的任务, pid: %d\n", getpid());
}
void task2()
{
printf("这是一个执行检测网络健康状态的一个任务, pid: %d\n", getpid());
}
void task3()
{
printf("这是一个进行绘制图形界面的任务, pid: %d\n", getpid());
}
int AddTask(task_t t);
// 任务的管理代码
void InitTask()
{
int i=0;
for(i = 0; i < TASK_NUM; i++) tasks[i] = NULL;
AddTask(task1);
AddTask(task2);
AddTask(task3);
}
int AddTask(task_t t)
{
int pos = 0;
for(; pos < TASK_NUM; pos++) {
if(!tasks[pos]) break;
}
if(pos == TASK_NUM) return -1;
tasks[pos] = t;
return 0;
}
void DelTask()
{}
void CheckTask()
{}
void UpdateTask()
{}
void ExecuteTask()
{
int i=0;
for(i = 0; i < TASK_NUM; i++)
{
if(!tasks[i]) continue;
tasks[i]();
}
}
int main()
{
pid_t id=fork();//创建子进程
if(id<0)
{
perror("fork");//子进程创建失败,通过perror这个函数将错误信息打印出来
return 1;
}
else if(id==0)
{
//子进程
int cnt=5;
while(cnt)
{
printf("i am child ,pid:%d ,ppid:%d ,cnt:%d\n",getpid(),getppid(),cnt);
cnt--;
sleep(1);
}
exit(12);//五秒后退出,此时父进程还在运行
}
else
{
InitTask();
while(1)//轮询
{
int status=0;
pid_t ret=waitpid(-1,&status,WNOHANG);
if(ret>0)//当个子进程,wait就是等待这个一个子进程,等待成功返回的就是这个子进程的进程号,进程号是大于0,通过打印来看看是不是子进程的pid
{
printf("wait sucessful,pid:%d,exit_signl:%d,exit_code:%d\n",ret,status&0x7F,(status>>8)&0xFF);
break;
}
else if(ret<0)
{
printf("wait failed\n");
break;
}
else
{
printf("你好了没?子进程还没有退出,我在等等...\n");
ExecuteTask();
usleep(500000);
}
}
sleep(5);
}
return 0;
}
我们的非阻塞轮询和做自己的事情,哪个重要??答案是非阻塞轮询,做自己的事情是顺带的,所以自己的事情一般都是轻量化的,而且子进程退出,成僵尸状态,我晚一点回收也是可以的,叫做延时回收,非阻塞轮询就是等待回收子进程,所以两者各推一步,可以晚点回收,但你做自己的事情不要太重,简单点就行了。
最后再说一下,创建多个子进程,谁先调度不知道,有调度器管理,但是父进程肯定要最后一个终止,这个大家记住就好了,到这里我们的进程等待就讲解完毕了,大家要看看的自己敲敲代码,感受一下,接下来博主就开始讲解进程的程序替换,也是一个比较难理解的话题。
四、进程程序替换
这一节再理解难度不是很大,但是内容非常多,而且前后关联很大,再将一些疑惑的时候都需要理解前面的一些知识才能很好的理解,但是主要的大家还是可以理解的,话不多说我们开始进入正文的讲解
4.1看看什么是进程程序替换
我们首先看看进程程序替换是什么,给大家演示一下,然后给大家讲解原理。
我们进行程序替换要使用到进程替换函数exec系列函数,我先以其中一个做例子给大家演示execl
来看代码:
(1)本身进程调用
int main()
{
printf("before: I am a process, pid: %d, ppid:%d\n", getpid(), getppid());
execl("/usr/bin/ls","ls","-a","-l",NULL);
printf("after: I am a process, pid: %d, ppid:%d\n", getpid(), getppid());
return 0;
}
大家看到我们使用execl函数把我们ls -al 程序执行出来了
(2)子进程调用
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
int main()
{
pid_t id=fork();
if(id==0)
{
printf("before: I am a process, pid: %d, ppid:%d\n", getpid(), getppid());
execl("/usr/bin/ls","ls","-a","-l",NULL);
printf("after: I am a process, pid: %d, ppid:%d\n", getpid(), getppid());
exit(1);
}
pid_t ret=waitpid(id,NULL,0);
if(ret>0) printf("wait success, father pid: %d, ret id: %d\n", getpid(), ret);
sleep(1);
return 0;
}
通过现象可以看到,我们的进程再遇到execl函数之前的代码都可以运行,但是到execl函数之后就换成了其他程序,而这上面两个例子的特点就是execl后续的代码没执行出来。这是为什么呢?我们来看原理,其实很简单
通过上面的两个例子,子进程被程序替换后不会影响父进程,这是写实拷贝技术,也可以说明进程之间是独立的之前说过写实拷贝是针对数据的,那我们程序替换后,代码应该也被修改了,才能执行替换后的数据,而代码是只读不可修改的啊,这是怎么替换的??答案是代码是不可以被修改的,但是要看人,操作系统是不允许用户去修改代码的,他不信任任何人,但是我们这次是调用系统调用函数,就相当于操作系统自己去操作,他想修改就修改,所以写实拷贝再代码和数据层面都会体现
解决疑惑:
- 我们创建子进程execl后的代码被替换,那么说明exit函数也被替换掉了,那父进程怎么还是可以去等待呢,大家可以想想,有没有这个exit函数,对于子进程的退出,效果是不是都是一样的,都是再函数的结尾就结束,就算没有exit函数,子进程运行到最后也会自动退出,符合进程终止的三种情况之一,那么就可以被等待。所以大家不要太死板了。
- 我们看到第二个示例代码的打印结果,我们再进行程序替换后,有没有创建新的进程,答案是没有的,我们看到等待之后获取的进程好,肯定是子进程终止后获得的,但是进程替换是再子进程终止之前的操作,而使用execl函数的子进程号和等待后获得的子进程号是一样的,说明使用的同一个进程task_struct,大家还有一种猜想就是,旧的子进程立马被销毁,创建新进程,刚好进程号和之前的子进程号是一样的,这个是不存在的,旧的子进程一旦被销毁等待立马检测到,直接先把等待成功给打印出来,再来执行新进程。但是结果去不是这样的。所以说明进程替换没有创建新进程。
补充:
- 调用后的代码没有被执行的原因是被替换了,只有exec函数调用失败才会执行后续的代码,所以说明exec函数只有失败的返回值,成功时候没有返回值,返回也不知道给谁接收
- Linux中形成可执行程序的时候是有格式的ELF(可执行程序的表头),刚才的程序替换,实际上一开始不会把ls程序的代码和数据立马替换,表中有程序入口的地址,先把ls的表头替换过去,等调用成功再将数据和代码替换掉,失败就表头地址替换失败,一会介绍每个参数的含义的时候就知道为什么会失败。
4.2替换函数
我们的替换函数实际有7个,每个含义都不一样,但是都有相似之处,来看文档:
只讲解我圈中的五个,其余两个类似
(1)execl
l(list) : 表示参数采用列表
大家看到一个非常熟悉的三个点,这是一个可变参数,我们的第一个参数,是传你要替换程序的位置,得让execl函数找到,后面介绍的四个函数第一个参数都是这个意思,为了找到,后面传的是命令行参数的格式,我们再命令行咋输入就是咋传参的,告诉execl要怎么去执行这个程序
我们再介绍环境变量的博客中介绍到命令行输入的参数最后都是一串字符串,字符串后面默认是NULL,等会再介绍第三个函数的时候就更好理解了。
使用演示:`execl(“/usr/bin/ls”,“ls”,“-a”,“-l”,NULL); 注意usr前面的/不能丢
(2)execlp
p(path) : 有p自动搜索环境变量PATH
通过参数我们也能看出,path是路径,file只需要传一个文件就行了。
这个函数和前面第一个函数几乎一模一样,就在第一个参数上修改了一下,第一函数给了要替换函数的具体地址,而这个函数是通过PATH去找到,PATH就是环境变量
我们使用PATH替我们去找就可以了
使用演示:
execlp("ls","ls","-a","-l",NULL);
(3)execv
v(vector) : 参数用数组
第一个参数还是传替换程序的具体路径,因为没有加p,第二个参数传一个字符串指针数组
我们只需要把刚才再命令行输入的参数写在一个数据里面就行了,用NULL结束,就可以。
使用演示:
char*const argv[]={ "ls", "-a", "-l", NULL }; execv("/usr/bin/ls",argv);
大家应该看到我们为什么要传一个NULL了吧,可以理解把他当作一个结束标志。
通过这个参数列表 int execv(const char *path, char *const argv[]);
我们发现了一个非常熟悉的argv,这个不是和我们命令行参数传参一个道理吗??我们替换的程序ls是一个程序,是不是也要有函数的入口main,也就可以接收命令行参数,其实我们的bash是操作系统的子进程,再bash上跑的都是bash的子进程,那我们命令行运行程序的时候,其实就类似于调用了exec函数来运行我们的程序,所以exec起到的加载器的效果,我们系统的内部,就已经把我们的第一个参数和命令行参数传给对应的exec函数,今天我们只是显示的把exec函数写出来了,那我们可以传命令行参数,说明环境变量也是可以传的,一会再介绍第五个函数的时候再说。
(4)execvp
这个函数我就不做过多的解释了,有vp,所以直接给大家看看使用演示:char*const argv[]={ "ls", "-a", "-l", NULL }; execvp("ls",argv);
(5)execle
再讲解这个函数之前,我要先给大家演示跨语言调用。
1. 一次编译多个程序
我们的makefile暂时只能一次编译一个程序
mycommand:mycommand.c
gcc -o $@ $^
.PHONY:clean
clean:
rm -f mycommand
如果我们想要同时编译形成两个可执行文件呢??
otherfile:otherfile.cpp
g++ -o $@ $^
mycommand:mycommand.c
gcc -o $@ $^
.PHONY:clean
clean:
rm -f mycommand otherfile
谁在前谁先被编译,那我们怎么解决这种问题呢??使用伪目标就可以了:
.PHONY:all
all:otherfile mycommand
otherfile:otherfile.cpp
g++ -o $@ $^
mycommand:mycommand.c
gcc -o $@ $^
.PHONY:clean
clean:
rm -f mycommand otherfile
2. 跨语言调用
(1)c调用py
#!/usr/bin/python3
print("hello python");
execl("/usr/bin/python3", "python3", "test.py", NULL);
(2)c调用shell脚本
#!/usr/bin/bash
function myfun()
{
cnt=1
while [ $cnt -le 10 ]
do
echo "hello $cnt"
let cnt++
done
}
echo "hello 1"
echo "hello 1"
echo "hello 1"
echo "hello 1"
echo "hello 1"
echo "hello 1"
echo "hello 1"
ls -a -l
myfun
execl("/usr/bin/bash", "bash", "test.sh", NULL);
(3)C调用cpp
//otherfile.cpp
#include<iostream>
using namespace std;
int main(int argc, char *argv[])
{
cout << argv[0] << " begin running" << endl;
cout << "这是命令行参数: \n";
for(int i=0; argv[i]; i++)
{
cout << i << " : " << argv[i] << endl;
}
return 0;
}
//mycommand.c
char*const argv[]={
"otherfile",
"-w",
"-z",
NULL
};
execv("./otherfile",argv);
我们发现命令行参数确实传下来了,我们来看看环境变量会不会传下来
//otherfile.cpp
#include <iostream>
using namespace std;
int main(int argc, char *argv[], char *env[])
{
cout << argv[0] << " begin running" << endl;
cout << "这是命令行参数: \n";
for(int i=0; argv[i]; i++)
{
cout << i << " : " << argv[i] << endl;
}
cout << "这是环境变量信息: \n";
for(int i = 0; env[i]; i++)
{
cout << i << " : " << env[i] << endl;
}
cout << argv[0] << " stop running" << endl;
return 0;
}
//mycommand.c
char*const argv[]={
"otherfile",
"-w",
"-z",
NULL
};
execv("./otherfile",argv);
我们发现我没有传环境变量啊,怎么也可以把环境变量打印出来呢??
我们给bash设置一个特有的环境变量,然后再父进程里面创建特有的环境变量,看看替换后程序里面能不能获得环境变量
- bash新增环境变量
也被替换程序获得到了 - 父进程创建环境变量
结论,环境变量是什么时候给进程的,再进程地址空间的时候,我们看到环境变量和命令行参数再虚拟地址上,环境变量也是数据,创建子进程的时候,子进程也有自己的地址空间,此时的环境变量就会有父进程传下去,通过再爷父进程中添加特有的环境变量,发现也替换的程序继承下去了**,所以说明我们再进行程序替换的过程中,环境变量是没有被替换的。**
介绍我们的第五个函数execle
最后一个参数就是传我们的环境变量的字符串指针数组,我们可以传系统自带的:
extern char** environ;//声明一下
execle("./otherfile","otherfile","-a","-b",NULL,environ);
再父类里面自己写环境变量传进去:
结论出来了:采用的覆盖,而不是追加,而environ这个里面本来就有一开始的环境变量,所以看不出来是覆盖的感觉。
总结一下:我们一开始就说exec函数有七个,但是其中六个再3号i手册里面,还有一个execve再2号手册,3号手册里面的是库函数,而2号手册里面是系统函数,所以这六个最终都是调用execve这个函数
至此我们的程序替换旧讲解完毕了。
五、总结
博主将进程控制放在一篇博客里面写了,原因是前后关联性很大,大家越阅读到后面越兴趣,对学习效率也好,里面的细节还是很多的,理解难度我解决还没有进程地址空间难度大,接一下的一篇博主就使用这节知识,带大家写一个自定义shell程序,达到和系统的差不多功能,让大家可以更好的理解shell是怎么做到的,此前大家一定要熟悉这篇的知识点,我们下篇再见。