目录
5.1 wait和waitpid如何拿到进程的退出信息的呢?为什么不直接用全局变量拿到退出码?
一,进程创建
1.1 fork回顾
fork的详细介绍请见上篇博客第四节——这里只做简单回顾Linux系统编程 —— 进程概念,环境变量,虚拟地址空间总结(收藏向)-CSDN博客
fork()函数可以创建子进程,由于fork也是个函数,所以也有返回值,会对父进程返回子进程的pid,对子进程返回0。具体用法如下:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("我是主进程");
pid_t id =fork();
if(id<0)
{
printf("创建子进程失败\n");
return 1;
}
else if(id==0)
{
printf("我是子进程:pid:%d,ppid:%d\n",getpid(),getppid());
}
else
{
printf("我是父进程:pid:%d,ppid:%d\n",getpid(),getppid());
}
return 0;
}
问题:fork创建子进程时,OS做了什么?
解答:创建子进程,系统里就多了一个进程,所以先创建进程的PCB,地址空间和页表,将程序在磁盘上的地址加载到内存中,然后把程序的代码和数据从磁盘加载到物理内存中,然后把PCB放到CPU的运行队列里等待被调度,当该进程开始调度时,就通过虚拟地址的映射找到在物理内存的相关代码,然后就按照顺序语句从上往下在进程内部执行代码
进程 = 内核数据结构 + 进程代码和数据 (前者OS维护,后者一般从磁盘上来,就是C/C++程序加载之后的结果)
1.2 写时拷贝
进程具有独立性,所以创建子进程时分配给它的内核结构是它独有的,理论上进程也要有自己的代码和数据。可是一般而言,子进程只是随着父进程的加载而创建出来的,也就是说子进程没有属于自己的代码和数据,它只能用父进程的代码和数据,但这又和进程的独立性相违背,为啥?
①代码:对进程来说,代码都是已经加载完成的,只能读取不能写入,所以代码数据父子共享没问题
②数据:由于数据可能会被修改,所以必须分离,但是不能直接拷贝分离,因为可能会拷贝子进程根本用不到的数据空间,而且子进程也可能只读数据不做修改,这种情况再进行拷贝就会造成时间和空间的浪费。所以采用写时拷贝的技术,写时拷贝请参照上篇博客第十节:
Linux系统编程 —— 进程概念,环境变量,虚拟地址空间总结(收藏向)-CSDN博客
如下代码:
#include<stdio.h>
int main()
{
const char *str1 = "aaa";
const char *str2 = "aaa";
printf("%p\n%p\n",str1,str2);
return 0;
}
第一个指针创建常量字符,第二个指针也指向这个常量字符时,就不再额外开空间了,所以连编译器编译时都知道节省空间来优化,OS没有理由不优化,所以创建子进程的时候OS采用了写时拷贝的技术,就是字面意思:“你要写数据的时候再给你拷贝”。
写时拷贝使得父子进程得以彻底分离,保证了进程的独立性,同时也是高效使用内存的一种表现
1.3 fork用处
①一个进程希望复制自己,使执行父进程代码的同时子进程执行不同的代码,例如父进程等待客户端请求数据,子进程处理请求数据
②一个进程要执行一个不同程序,例如子进程创建后来后,调用exec函数,这个后面的进程程序替换再讲
1.4 fork调用失败原因
系统进程过多,或者是用户的进程超过了限制
如下代码,由于下列代码执行后进程满了fork报错,系统可能会出问题,为了各位主机的安全考虑 就不贴代码啦
可以看见创建将近800个进程后再创建就失败了
二,进程退出
2.1 进程退出场景
进程退出只能有三种情况:
①代码运行完成,结果正确
②代码运行完成,结果不对
③代码未运行完成,异常退出(OS通过发送信号的方式强制终止进程)
2.2 mainCRTStartup调用
为什么main函数也有返回值呢?有人会说main函数也是函数所以有返回值,那么既然main是函数,那么是谁调用的main函数呢?下面是VS2022编译器的main函数层层调用的示例图:(下面代码需要在release调试环境下用逐语句依次执行才可显示)
所以其实main函数最开始是被一个叫做mainCRTStartup的函数调用的,而这个函数又是通过加载器被操作系统所调用的,也就是说main函数是间接被操作系统调用的。
main函数只是用户级别的代码的入口,而程序的入口还是操作系统
2.3 进程退出码
2.3.1 main函数返回值
通过2.2,我们知道main函数既然是被操作系统调用的,那么main函数作为被调用者就应该给调用者返回退出信息,而这个退出信息就是以退出码的形式作为main函数的返回值返回,一般以返回0代表代码执行完毕,非0代表代码执行过程中出现错误,但是我们前面写的程序都是小程序,代码数不多,所以一般以0返回。
main函数的返回值就是进程的退出码,我们可以用" echo $? "来查看最后一次进程退出时的退出码
问题:为什么0代表执行成功,非0代表错误?
解答: 代码执行完毕只有两种情况,成功和错误,成功就是我们想要的只有一种情况,但是错误往往是有很多的,这个大家都懂,所以我们就用这些非0的数字代表代码执行错误的原因
2.3.2 strerror
但是退出码是方便计算机返回设定的,而我们并不清楚像“ 1234 ”这样的退出码到底是什么意思,所以我们需要一个能将错误码转化成字符串显示给用户的方案 —— ”strerror“,打印退出码代表的字符信息,如下代码:
#include<stdio.h>
#include<string.h>
int main()
{
int i = 0;
for(i = 0; i < 10; i++)
{
printf("[%d]: %s\n", i, strerror(i));
}
return 0;
}
2.3.3 命令的退出码
Linux中的各种命令也是C语言写的程序,所以也会有退出码,我们使用命令后照样也可以通过“ echo $? ”查看退出码
2.4 进程正常退出
return
main函数使用的return退出就是我们最常用的方法来退出进程
exit
exit函数也是常用的退出进程的方法,但是exit在进程退出前多做了一系列工作:
1,执行用户定义的atexit或on_exit定义的清理函数
2,关闭所有打开的流,所有缓存数据均被写入
3,调用_exit系统调用终止进程
#include<stdio.h>
#include<stdlib.h>
void Hello()
{
printf("hello world"); //未加\n,不会刷新缓冲区
exit(1);
}
int main()
{
Hello();
return 0;
}
_exit
_exit是系统调用的进程退出函数,但是我们一般不用_exit来退出进程,因为_exit推出前不会有exit中的推出前工作,它只是退出,比如上面的代码如果把exit换成_exit就不会刷新缓冲区了,hello world不会打印
而这三个函数的关系呢,就是下面这个图了:
_exit就是进程直接退出,什么也不干,exit就是在推出前做了些善后工作再退出,然后return,在C语言语法实现中就是调用的exit()
2.5 进程异常退出
前面说过:所有的进程异常终止都是操作系统干的,具体有下面两种方式:
①操作系统向进程发送特定信号导致进程异常退出,例如kill -9和Ctrl+C
②代码错误导致进程运行时异常退出,例如野指针,越界访问和除0等(其实代码的逻辑错误异常退出本质也是操作系统检测到了代码的非法操作然后向进程发送信号的方式终止进程的)
三,进程等待
3.1 为什么要有进程等待?
①子进程退出父进程如果不读取子进程的退出信息,子进程就会变成僵尸进程,造成内存泄漏
②子进程被创建出来是要它去做事的,它的事做得咋样父进程需要关心,也就是进程退出的三个结果
③然后就是子进程把结果返回父进程了,那么父进程咋收到子进程的退出信息呢?那么包括回收子进程申请的相关内存资源,还有父进程获得子进程退出结果都需要进程等待来完成
3.2 wait函数和waitpid函数的使用
如下图,是man文档的2号文档对wait接口的说明:
wait函数
如下代码:
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id = fork();
if(id<0)
{
perror("fork");
exit(1);//标识进程运行完毕结果不对
}
else if(id==0)
{
//子进程
int cnt =3;
while(cnt)
{
printf("cnt:%d,我是子进程,pid:%d,ppid:%d\n",cnt,getpid(),getppid());
sleep(1);
cnt--;
}
exit(0);
}
else
{
//父进程
printf("我是父进程,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(6); //子进程跑3秒,父进程等6秒,所以可以看到子进程有3秒的Z状态,然后wait后Z状态就没了
pid_t ret = wait(NULL); //阻塞式等待,简单来说就是卡在这,等子进程完成
//pid_t ret = waitpid(id, NULL, 0); //waitpid的效果和wait一样,wiatpid的第一个参数表示要等待的进程的pid,第三个参数为0表示阻塞等待
if(ret > 0)
{
printf("等待子进程成功,ret:%d\n", ret);
}
}
}
可以看到完美地消除了Z进程,有效解决了僵尸进程问题
3.3 wait函数和waitpid的status参数
那么只解决了僵尸进程问题还不够,因为父进程还需要知道子进程的退出码滴,当然也是通过wait和waitpid函数来获取,这时候就要着重讲讲它们的参数了
该参数是一个输出型参数,是一个指针,为NULL时不会获取到子进程退出码,不为NULL时会传入子进程的退出码。参数这里是一个指针,那么指针指向的是一个16比特位的整型。但是status不能当作一个简单的整型来对待,一共16个比特位,而操作系统对这16个比特位做了划分,如下图:
在status一个int的16个比特位中高8位代表进程的退出状态也就是退出码,后7位表示终止信号
进程最终的结果有两种,一种是正常退出,一种是被信号所杀,而两种退出方式对status做的修改也不同
先看下正常退出对status做的修改:
信号所杀做的修改:
所以获取退出码和退出信号时,我们需要做一些额外的处理,如下:
int status;
wait(&status);
waitpid(pid, &status, 0);
exitCode = (status >> 8) & 0xFF; //退出码
exitSignal = status & 0x7F; //退出信号
//当然上面的方式用起来肯定是比较别扭的而且不美观,所以系统中提供了两个宏来方便用户操作
IfexitSignal = WIFEXITED(status); //返回值为bool类型,用于查看进程是否正常退出,检查是否收到终止信号
exitCode = WEXITSTATUS(status); //获取进程退出码
注意,凡是在C语言中,像这种全大写的特殊字符,很大可能就是宏定义
3.4 多进程阻塞等待
上面的都是父进程创建一个进程,然后单独等待一个子进程退出的场景,但是我们也可以同时创建多个子进程,然后让父进程依次等待子进程退出,如下代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t ids[10]; //该数组存储子进程的pid
int i = 0;
for (i = 0; i < 10; i++)
{
pid_t id = fork(); //创建10个子进程
if (id == 0)
{
printf("child[%d] created success pid:%d, \n",i, getpid());
sleep(3);
exit(i); //使每个子进程的退出码设置为创建它的编号
}
ids[i] = id; //这一条是父进程执行
}
for (i = 0; i < 10; i++)
{
int status = 0;
pid_t ret = waitpid(ids[i], &status, 0);
if (ret >= 0)
{
printf("wiat child success..PID:%d\n", ids[i]);
if (WIFEXITED(status))
{
//正常退出
printf("exit code: %d\n", WEXITSTATUS(status));
}
else
{
//信号终止
printf("signal kill: %d\n", status & 0x7F);
}
}
}
return 0;
}
3.5 非阻塞等待
前面说到过waitpid的第三个参数option,为0时就是代表阻塞等待,那么不为0的时候呢?
阻塞等待:一般都是在内核中阻塞,等待被唤醒 --> 伴随被唤醒 --> scanf,cin --> 必定封装系统调用
非阻塞等待:我们父进程通过调用waitpid来进行等待,如果子进程没有退出,那么waitpid立即返回,waipid的伪代码实现如下:
waitpid(chile_id, status, flag);
if(status == 进程已经退出) {
return child_pid;
}
else if(status == 进程还没退出) {
if(flag == 0) {} //如果falg为0,那么就阻塞
else if(flag == WNOHANG) return 0; //直接返回,不阻塞进程
return 0;
}
else {
//出错了或者其他原因
return -1;
}
当waitpid第三个参数为WNOHANG时,就代表父进程非阻塞等待,这个其实也是宏定义,我们可以查看,如下图:
所以我们可以用这个特性, 让子进程忙的时候父进程做点其它的事,但是又能让父进程获取到子进程的退出信息,如下代码:
#include<iostream>
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<sys/types.h>
#include<vector>
typedef void (*handler_t) (); //函数指针类型
std::vector<handler_t> handlers ; //函数指针数组
void fun_one() { printf("这是一个临时任务1\n"); }
void fun_two() { printf("这是一个临时任务2\n"); }
//设置对应的方法回调
//以后想让父进程闲的时候执行其他方法时,只要向Load里面注册,就可以让父进程执行对应的方法
void Load()
{
handlers.push_back(fun_one);
handlers.push_back(fun_two);
}
int main()
{
pid_t id = fork();
if(id==0)
{
//子进程
int cnt = 5;
while(cnt)
{
printf("我是子进程:%d\n",cnt--);
sleep(1);
}
exit(11);
}
else
{
int quit=0;
while(!quit)
{
int status =0;
pid_t res = waitpid(-1,&status,WNOHANG); //非阻塞等待
if(res>0)
{
//等待成功,子进程退出
printf("等待子进程退出成功,退出码:%d\n",WEXITSTATUS(status));
quit = 1;
}
else if(res==0)
{
//等待成功,但子进程未退出
printf("子进程还在运行还未退出,父进程再等一等或者做其他事情\n");
if(handlers.empty()) Load();
for(auto e : handlers)
{
//执行处理其他任务
e();
}
}
else{
//等待失败
printf("wait失败");
quit=1;
}
sleep(1);
}
}
}
四,进程程序替换
4.1 什么是进程程序替换?为什么要有这个?
fork之后,父子进程各自执行代码的一部分,代码数据只读,父子进程共享;变量数据写时拷贝
那么如果子进程我不执行原来的代码了,我想执行一份全新的程序呢?就是父子代码不共享了,子进程执行自己的代码。
程序替换是通过特定的接口,加载磁盘上一个全新的程序(代码和数据),加载到子进程的地址空间中
问题:进程替换的时候有没有创建新的子进程?
解答:没有创建进程。进程为内核数据结构+代码和数据,把一个全新的程序加载到内存里,仅仅是重新建立了映射关系,进程的内核数据结构,状态和优先级等未发生变化,所以没有创建进程
4.2 execl函数
先看man execl的查询:
可以看到exec系列函数一共有6个,我们先搞第一个execl。
第一个参数是一个const char指针代表其它可执行程序的绝对路程或相对路径,第二个参数是一个可变参数列表,表示你要替换的可执行程序后面要带的参数,以NULL结尾。比如我要执行ls函数,如下代码:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
int main(int argc,char* argv[],char *env[])
{
pid_t id = fork();
if(id == 0)
{
//子进程
printf("子进程开始运行,pid:%d\n",getpid());
sleep(3);
execl("/usr/bin/ls","ls","--color=auto", "-a","-l",NULL);
exit(1);
}
else{
//父进程
printf("父进程开始运行,pid:%d\n",getpid());
int status =0;
pid_t id =waitpid(-1,&status,0);//阻塞等待
if(id>0)
{
printf("wait success,exit code:%d\n",WEXITSTATUS(status));
}
}
}
刚开始父子进程都开始运行,但是3秒后才有ls -a -l的内容,虽然不知道父子进程谁先开始的,但是肯定是父进程最后结束
问题:为什么要创建子进程让子进程来执行execl函数?
解答:如果不创建子进程,那么替换的就是父进程,我们需要让父进程聚焦在读取数据解析数据,指派子进程执行代码的功能(类似工头和工人的关系),所以我们要保证子进程在使用execl替换的时候不影响父进程。
(加载程序之前,父子进程代码共享,数据写时拷贝,当子进程替换的时候,其实也是一种写入,这时候代码也要写时拷贝,并且要将父子进程的代码分离)
4.3 其它exec函数
①execlp
这个函数就是在execl后面加了个“ p ” ,p通常表示环境变量,这个函数作用和execl一样,只是第一个参数可以不传路径了,我们输入可执行程序名,它会直接在环境变量当中找,比如我要替换ls:
execlp("ls","ls", "--color=auto", "-l", NULL);
②execle
这个函数和execlp相比多了一点东西又少了一点东西。第一个参数和execl仍然是路径,第二个参数为可变模板列表,第三个参数是新增的,该参数可以由用户自己定义,并在替换后被另一个进程使用
char* myenvp = {"MYVAL=2024", NULL};
execle("./mytest", "mytest", NULL, myenvp);
③execv
execl最后的l我们可以看成list的意思,那么execv的v我们就可以看成vector。
第一个参数是路径,后面跟的是一个指针数组了,数组里面每一个指针都指向一个字符串,每个字符串都是替换过后的进程的参数,比如我要用execv将当前进程替换成ls,如下代码:
char* myargv[] = {"ls", "--color=auto", "-l"};
execv("/usr/bin/ls", myargv);
④execvp
和execlp一样,第一个参数不再是路径,可以直接传可执行程序名,然后系统回去环境变量当中找,比如我要执行ls,如下代码
char* myargv[] = {"ls", "--color=auto", "-l"};
execv("ls", myargv);
⑤execvpe
算是这几个函数里最难用的一个吧,也是把自定义环境变量传给替换后的进程。如下代码:
char* myargv[] = {"mytest", NULL}
char* myenvp[] = {"MYVAL=2024", NULL};
execvpe("./mytest", myargv, myenvp);
4.4 execve
上面的要想在man文档查,发现只能“man 3 execl”,而不能“man 2 execl”,这说明上面的6个函数都不是系统调用,那么真正实现替换的是哪个系统调用呢?
man 2 execve后可以看到下面内容:
可以发现execve的参数和execvpe一模一样,上面的6个函数都是都是对系统调用的execve做了封装,因为这么做是为了满足上层用户的不同调用场景
五,一些问题解答
5.1 wait和waitpid如何拿到进程的退出信息的呢?为什么不直接用全局变量拿到退出码?
僵尸进程是已经死掉的进程,代码和数据可以释放,但是进程的内核数据结构也就是PCB不能释放,而PCB内部也保留了进程退出时的各种对出结果,所以wait和waitpid本质就是去读取子进程的task_struct里的退出信息来获取子进程的退出信息。
不能用全局变量是因为进程具有独立性,而且对全局变量做修改时会发生写时拷贝
5.2 僵尸进程中曾经new和malloc的空间会释放码?
前面讲过,僵尸进程是已经死掉的进程,代码和数据会被释放,进程new和malloc申请的空间也会随着进程死掉被释放,因为new和malloc是用户申请的空间,僵尸进程的PCB是操作系统开辟的空间,前者属于用户层的,后者是属于内核层的,所以僵尸进程的内存泄漏是属于操作系统内部的内存泄漏,与用户层无关。