文章目录
进程终止
写c/c++代码时,main函数的最后通常会使用return 0,这个0的意义是什么?是返回给谁的?为什么需要返回0?这个问题的本质与进程退出有关,进程退出通常有三种情况:
1.进程跑完,结果是正确的
2.进程跑完,结果是不正确的
3.进程没跑完就直接退出了
之前说进程有Z状态:进程结束,等待被父进程回收。父进程要收集子进程的运行信息,所以return 0是一条信息,0是退出码,用来告知父进程自己的运行情况。在linux中,命令行下运行的进程的父进程一般都是bash,用echo $?可以输出最近一次的进程执行后,其退出码
第一次的退出码是123,是因为最近一次执行的test文件返回了123。后一次的0是因为echo $?成功执行了(echo也是一个程序,也有退出码),其退出码是0,所以输出0。
一般而言,非0的退出码表示的具体含义是由我们自己设置的。
进程终止的方法
在main函数中,调用return是返回进程的退出码(main函数的return意味着整个进程结束),在其他函数中调用return,返回的是函数返回值,不是进程的退出。通常使用exit或者_exit退出一个进程。
在代码的任意地方调用exit或者_exit都表示进程的退出。两者有什么区别?exit退出进程时会刷新缓存区,并且exit在man的3号手册中,是一个语言级别的函数,_exit退出时不刷新缓冲区,_exit在man的2号手册中,是一个系统级别的函数
在图片的最开始,我运行了test可执行程序(./test),但是只有使用exit的程序输出了hello world,原因是_exit不刷新缓存区,hello world被写到缓冲区,在被刷新到屏幕之前,进程终止了,所以不会输出,exit在进程终止前,则会将缓冲区的数据刷新到屏幕(标准输出)上
操作系统是怎么终止进程的?
进程是由对应数据结构+代码构成的,对于经常被分配和释放的对象,操作系统可能不会直接释放它,而是将该进程假释放(做个标记,表示该进程结束了),然后放入一个维护结束进程的链表中,当有对应数据结构的对象要创建时,只需要重置链表中的数据即可,不用再分配空间,节省了时间成本(创建对象需要:分配空间+初始化,每一步都需要消耗资源),这样的分配机制叫做Linux的slab机制。
进程等待
为何需要等待进程?
之前说进程在被释放之前会进入僵尸状态Z,等待自身的退出信息被父进程获取。一个进程如果处于僵尸状态,即不能被杀死的状态(kill -9 pid也不能终止进程),并且一直没被回收,可能造成系统无法创建新进程的情况。而该进程的执行结果如何,执行失败还是成功?以及该进程的其他退出消息都有必要需要告知父进程,所以进程结束不能立即退出,需要额外维护一个僵尸态,维护进程的退出信息
怎么等待一个进程?
使用wait/waitpid函数可以使父进程回收处于僵尸状态的子进程。
waitpid的参数解释:
pid:等待pid进程,如果为-1,就表示等待任意进程
status:输出型参数,即调用该函数,从函数中拿出的特定数据
option:为0,表示阻塞等待
waitpid函数测试
输出型参数status的含义:
如果进程正常终止,输出型参数status的低16位中的8 ~ 15位比特数,表示进程的退出状态,0 ~ 7表示进程的终止信号,由发生异常的进程接收(代码还没跑完,进程提前退出是因为接收到了终止信号),而第8位比特位表示core dump,这个与这次的内容无关,暂时不介绍。
子进程要退出时,将自己的退出信息保存在自己的进程控制块中,等待被回收,父进程读取子进程task_struct中的退出信息后,系统释放子进程(包括子进程的task_struct和代码数据)。
所以status & 0x7F得到的就是进程的退出信号,如果是正常退出得到就是0,(status >> 8) & 0xFF得到的是进程的退出码。对于进程的退出码可以使用WEXITSTATUS这个宏来提取,使用WIFEXITED这个宏来判断进程是否正常退出,如果正常退出该宏返回非零值,异常退出返回0。通常用WIFEXITED这个宏先判断进程是否正常退出再使用WEXITSTATUS这个宏提取进程的退出码。
非阻塞式等待
当waitpid的option为0时,表示阻塞等待一个进程,而option为WNOHANG这个宏时,waitpid表示非阻塞式等待一个进程。
何为阻塞等待? 父进程要回收子进程时,从运行态R进入阻塞态S,即从运行队列跳转到等待队列中,以等待子进程的退出,当子进程运行完成退出时,父进程被唤醒,从等待队列进入运行队列,回收子进程的信息,回收完成后系统再释放子进程的资源。就像生活中打电话的动作,在有急事时,一方会使电话不挂断(父进程进入等待队列),在电话的一头等待对方的消息,自己却不做任何事,只是等待,当得到对方的回应时,能够立即回应(对应着父进程从等待队列被唤醒,进入运行队列,回收子进程消息)。而非阻塞式等待呢,就是打电话询问消息,在得知对方没法立即回应你时,就挂断电话,做自己的事,过一会再打电话(这个再次打电话的操作由编写代码的人给出),也就是说waitpid只会执行一次,得到0的返回值,表示对方没法立即回应你,但你可以再次调用,直到对方能够回应你。两种等待方式最大的区别就是:父进程不会卡住(进入等待队列),非阻塞等待的父进程会在运行时进行多次轮询检测,只要子进程退出,就回收其信息。
下面写一个段程序大概的模拟父进程非阻塞等待子进程的过程
(当option为WNOHANG时,如果子进程还未退出,waitpid将返回0)
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <vector>
// 将一个函数指针重定义
typedef void (*solution)();
// solutions为一个方法集,存储父进程在轮询检测时要运行的函数
std::vector<solution> solutions;
void fun1()
{
printf("hello fun1\n");
}
void fun2()
{
printf("hello fun2\n");
}
void load()
{
solutions.push_back(fun1);
solutions.push_back(fun2);
}
int main()
{
int id = fork();
if (id == 0)
{
printf("这是子进程,我将休眠四秒再退出\n");
sleep(4);
exit(123);
}
else
{
// 父进程
int status = 0;
while (1)
{
int ret = waitpid(id, &status, WNOHANG);
if (ret > 0) // 等待成功
{
if (WIFEXITED(status))
{
printf("等待子进程成功, 其退出码为:%d\n", WEXITSTATUS(status));
sleep(1);
}
break;
}
else if (ret == 0)
{
// 等待成功,但不能回收子进程,父进程做自己的事
if (solutions.empty())
{
load();
}
for (auto f : solutions)
{
f();
}
sleep(1);
}
else
{
printf("等待失败\n");
}
}
}
return 0;
}
父进程死循环执行waitpid函数,死循环周期为1秒,也就是说在1秒的时间中,询问子进程后,如果子进程没有退出,剩下的时间将由父进程自己分配(执行其他代码)。如果等待成功了,就break跳出死循环,回收子进程资源。
上面的代码还模拟了父进程的方法集,即创建一个存储函数指针的容器,将父进程在非阻塞等待期间要做的事封装成函数(如fun1,fun2)再存储到容器中。当子进程未退出时,判断方法集是否为空,如果为空,调用load函数将方法加载到方法集中,然后执行其中的函数。
进程替换
什么是进程替换?
通常来说,子进程与父进程的代码和数据共享,子进程执行与父进程相同的代码片段,但子进程有时不需要执行与父进程相似的代码,而是需要执行其他代码,我们把子进程执行全新的代码而不执行父进程的代码这一操作称为进程替换
为什么要进程替换?
因为在Linux编程时,子进程可能需要执行与父进程相同的代码,也可能需要执行一个全新的程序,这样的场景经常出现,比如一个用c/c++写的父进程创建出的子进程可以执行其他语言(Java,Python…)写的程序。
进程替换的原理理解: 在父进程创建子进程后,子进程与父进程代码和数据共享,当修改子进程的数据时,子进程发生写时拷贝,即由于进程具有独立性,子进程不能修改父进程的数据,修改子进程的数据时,子进程的页表会重新建立该数据的地址映射关系,使其指向新的物理空间,不再指向与父进程相同的物理空间。而进程替换实际上是使进程指向全新的代码,是属于代码的写时拷贝,所以不止数据具有写时拷贝的特点,代码也是如此。进程替换实际上就是代码的写时拷贝,发生进程替换时,os从磁盘中将新的程序加载到内存中,接着重新对子进程的页表建立映射关系,使虚拟内存的代码区通过页表指向物理内存中的全新代码,当然其他区域也重新建立了映射关系。注意这个过程没有创建新的进程,这个操作只是将进程的页表重新建立映射关系,使之指向全新的代码并执行。
怎样替换一个进程?
当我们在命令行上执行指令时,一般包括两个部分:指令名称和指令选项,比如ls是查看当前目录下的文件,ls -l表示以更详细的方式查看当前目录下的文件,而指令的本质又是一个可执行程序,所以操作系统要执行一个程序需要知道程序名以及执行方式,但是我们写的程序的路径如果不在环境变量中就需要告知操作系统程序的位置,所以程序替换分为两步:
- 找到要替换的程序路径
- 告诉os要执行的程序名以及以什么方式执行程序(携带什么选项)
Linux对外提供了六种接口以进行进程替换,这些替换函数都以exec开头,程序替换的两步则作为接口的两个参数,我们需要告知操作系统这两个参数,让操作系统找到新的程序并执行。
为了适配不同的使用场景,每种函数的参数都不相同,先说第一个函数execl
- path:类型为const char*,是一个可执行程序的路径
- arg:同样是一个字符串,确定路径存在,执行路径下的arg程序
- …:作为可变参数,用来表示执行arg程序携带的选项
exec系列函数
先简单的使用execl函数,将"/usr/bin/pwd"传入path变量,"pwd"传入arg变量,表示要执行pwd程序,接着是可变参数列表,如果指令需要带选项就将选项传入,pwd不用带选项,所以这里不用传入参数,最后要以NULL结尾表示参数传递结束
execl("/usr/bin/ls", "ls", "-l", NULL); // 该进程替换表示执行ls指令,并携带-l选项
上面的程序在执行execl前会先打印一句话表示要开始执行进程替换,执行完execl函数后再打印一句话,以示进程替换完成,代码运行的结果:
可以看到进程替换成功的执行了:当前进程被pwd指令替换,打印出当前文件所在的路径,但进程替换后还应该打印 “程序替换完成”,可运行结果却表示最后的打印没有被执行,其中的原因与进程替换的原理有关。
execl函数找到要执行的程序后,将该程序加载到内存中,接着使当前进程的页表重新建立映射关系,其中的一个操作是虚拟地址的代码区会映射到全新的代码上并执行新的代码,所以进程不再映射之前的代码而是映射新的代码,放在刚才的程序中就是:printf语句不会被执行,因为代码区的代码被新代码替换。
而进程替换后不会返回执行替换前的代码,所以只要执行进程替换函数,一旦替换成功,之后的代码便不会被执行。
但之前查看exec系列函数的声明时,它们都有一个int返回值
如果替换进程失败,函数返回-1,那么该返回值需要判断吗?其实是不需要的,因为进程一旦替换失败,替换函数之后的代码是一定会被执行的,进程替换成功时,execl执行return语句,说明execl函数的工作已经完成,进程替换完了,此时的代码是被替换后的代码,数据和代码都被替换了,之前用来接收返回值的变量不仅不存在,并且接收返回的语句也被替换,所以一旦进程替换成功,被替换的程序是接收不到execl的返回值的,那么接收返回值就没有意义,最多只是在替换失败后得知失败的原因。
execv与execl相似,path都是程序的路径,不同的是execl以可变参数列表的方式接收要执行的程序名以及可能携带的选项,execv以指针数组的方式接收程序名以及选项。
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
printf("这是子进程, pid为:%d, 准备进程替换\n", getpid());
if (id == 0)
{
char* const argv_[] = {
(char*)"ls",
(char*)"-l",
NULL
};
execv("/usr/bin/ls", argv_);
exit(-1); // 如果程序替换失败,子进程返回-1
}
else
{
int status = 0;
int ret = waitpid(id, &status, 0);
if (ret == id)
{
printf("等待子进程成功, 退出码:%d\n", (status >> 8) & 0xFF);
}
sleep(2);
}
return 0;
}
execlp与execl相似,唯一不同的是execlp有个默认搜索路径PATH,如果要执行的程序路径保存在PATH环境变量中,那么只需要将可执行程序的文件名传入execlp函数的path形参。
比如pwd命令,所有Linux中的指令都存储在usr/bin目录下,作为指令之一的pwd,该程序也存储在usr/bin目录下,使用echo $PATH输出PATH变量中存储的路径
usr/bin的确存储在PATH变量中,那么使用execlp将程序替换成系统指令时,就不需要传入完整的路径,只要传入一个程序名,函数会在PATH变量保存的路径下查找哪个路径包含了该程序名
execl("/usr/bin/pwd", "pwd", NULL);
execlp("pwd", "pwd", NULL);
execlp不需要完整的路径,因为PATH环境变量保存了要替换的程序路径。前一个pwd表示在PATH环境变量中查找pwd文件,后一个pwd表示的是执行该文件的方式。
execle比execl多了一个参数envp,envp是一个环境变量数组,将envp作为环境变量传递给替换进程
int main()
{
pid_t id = fork();
printf("这是子进程, pid为:%d, 准备进程替换\n", getpid());
if (id == 0)
{
char *const env_[] = {
(char*)"MYPATH=ThisIsMypath",
NULL
};
execle("./mycmd", "mycmd", NULL, env_);
exit(-1); // 如果程序替换失败,子进程返回-1
}
else
{
int status = 0;
int ret = waitpid(id, &status, 0);
if (ret == id)
{
printf("等待子进程成功, 退出码:%d\n", (status >> 8) & 0xFF);
}
sleep(2);
}
return 0;
}
以上代码将子进程替换成一个自己写的c++程序,mycmd.cpp
int main()
{
cout << "------------------------" << endl;
extern char** environ;
int i = 0;
for (i = 0; environ[i]; i++)
{
cout << environ[i] << endl;
}
cout << "------------------------" << endl;
return 0;
}
// makefile文件
.PHONY:all
all:exec mycmd
exec:exec.c
gcc exec.c -o exec
mycmd:mycmd.cpp
g++ mycmd.cpp -o mycmd
.PHONY:clean
clean:
rm -f exec
(make指令只会生成makefile文件的第一个目标文件,要用make指令一次性生成两个文件,定义一个伪目标all,该目标没有依赖方法,只有依赖关系,使用make时,make要生成第一个目标文件all,但all的依赖文件要经过编译器编译,那么make就会去执行exec和mycmd这两个依赖文件的依赖方法,编译生成依赖文件)
需要注意的是execlp传递的环境变量会覆盖之前的环境变量,也就是说系统中的环境变量都会被envp取代,如果不想覆盖系统的环境变量,可以使用environ指针,该指针是系统中的一个全局变量,指向了系统中的环境变量数组。
extern char** environ; // 声明环境变量指针
int main()
{
pid_t id = fork();
printf("这是子进程, pid为:%d, 准备进程替换\n", getpid());
if (id == 0)
{
execle("./mycmd", "mycmd", NULL, environ); // 将系统环境变量指针作为参数传递
exit(-1); // 如果程序替换失败,子进程返回-1
}
else
{
int status = 0;
int ret = waitpid(id, &status, 0);
if (ret == id)
{
printf("等待子进程成功, 退出码:%d\n", (status >> 8) & 0xFF);
}
sleep(2);
}
return 0;
}
此时想要向环境变量中添加变量,只需要在命令行中使用export导入环境变量
环境变量
为什么执行自己写的exe文件需要代上./?不能直接输入文件名称执行吗?
首先需要了解PATH环境变量,系统中搜索可执行程序的环境变量叫做PATH,用来存储可执行文件的路径,echo $PATH可以看到PATH的内容
执行指令时,系统会去PATH的存储的路径下,查找同名可执行文件,因为自己写的可执行文件不在这些路径下,所以Shell无法执行
echo $PATH,打印PATH变量的值
如何将自己写的程序添加到PATH变量中?
用export设置环境变量,export PATH=xxx,添加PATH环境变量,如果变量存在,会取代之前的变量,所以xxx要包括之前的PATH值,以保存之前的内容, $PATH就是PATH值,用:分隔两个不同路径,:后写要添加的路径
用命令行参数写一个程序
main函数有三个参数,argc表示argv数组中元素的个数,argv是一个char类型的指针数组,env也是指针数组,存储环境变量。
argv是一个指针数组,argv[0]是程序的文件名,往后的元素是调用可执行程序代的选项或数据,argv以NULL结尾。用main函数的这两个参数可以做一个计算器,判断可执行文件后的选项,不同的选项进入不同的代码块
系统中有那么多的指令,指令后的选项就是由这样的方式实现的。
第三个参数存储了系统中的环境变量
直接查看env环境变量与在main函数中打印env数组的内容,得到的结果是相同的,这说明了在main函数里可以使用env环境变量。
除了使用main函数的参数,还能使用getenv获取环境变量
获取环境变量的三种方式,getenv,使用envirom全局变量,main函数的env参数。
通过main函数的第三个参数env,可以得出一个结论:环境变量是会被传入进程的,那么平时写的main函数都是无参的
int main()
函数没有形参接收env,env也会被传入进程吗?答案是会的,
函数没有形参接收实参,程序也会为实参创建栈帧拷贝实参,只是拷贝的实参没有被使用,但函数的括号中写明了void,在Linux环境下,给函数传参不能调用参数是void的函数,在windows环境下,给函数传参可以调用参数是void的函数,这个看具体的编译器。
但是函数无参却没有声明参数是void,就可以给该函数传参。所以环境变量可以被传入一个进程
环境变量和本地变量
直接在命令行中定义的变量是本地变量,用set可以查看本地变量,但用env(查看环境变量的方式)不能查找到本地变量
所谓的本地变量,本质就是在bash(所有在命令行中运行的进程的父进程都是bash)中定义的变量,本地变量不能被子进程继承,环境变量可以被子进程继承
(所有在命令行中的父进程都是bash)
子进程可以继承环境变量(子进程的子进程也能继承环境变量),不能继承本地变量
特殊的问题
命令行下的所有命令都是bash的子进程,bash的子进程不能继承本地变量,本地变量的作用域在bash范围内,那么使用echo能不能输出本地变量?(echo属于bash的子进程,既然是bash的子进程就不能查看本地变量)
echo属于bash的子进程却能输出本地变量,为何?因为Linux下大部分命令是通过bash的子进程执行的,但还有一部分命令是由bash自己执行的,很显然echo就是由bash自己执行的。所以echo不是bash的子进程,而是一种内建命令。
最后记录一个命令,alias,alias也是一个内建命令,可以给一个程序起别名
alias xxx=‘xxxxx’
比如alias ww=‘ls -l’,执行ww命令的效果就等同于ls -l