进程终止
不知道大家想过没有,我们写的main()函数的返回值是返回给谁的呢?其实是返回给父进程或者系统的。
int main()
{
std::cout << "hello" << std::endl;
return 10;
}
运行该代码,输入hello,没问题!当我们在命令行使用echo $? 发现结果是10。echo $?的作用是获得最近一个程序退出时的退出码。这个退出码通常用来表明错误的原因:返回0,说明程序执行成功;返回非0,程序执行错误。
当返回不同的非0数字时,约定或者表明不同出错的原因。其实系统是为我们提供了一批错误码的,通过函数errno就可获取,但是呢,这个错误码只是一个数字,系统可以看懂,但我们看不懂啊,所以我们通过strerror()获取对应错误码的错误信息!下面是,两个函数的函数原型和使用举例
#include <iostream>
#include <string>
#include <cstdio>
#include <string.h>
#include <errno.h>
int main()
{
// 正常执行
printf("before: errno:%d, errstring: %s\n",errno, strerror(errno));
FILE* fp = fopen("./log.txt","r"); // 本路径下没有log.txt文件,所以打开一定会失败
if(fp == nullptr)
{
printf("after: errno:%d, errstring: %s\n",errno, strerror(errno));
return errno;
}
return 10;
}
进程终止的方式
main函数的return
对于这种终止方式,大家都比较熟悉,这里不再解释
exit() - 最常见的终止方式
先看函数原型和代码举例
void fun(){
std::cout << "hello world" << std::endl;
exit(100);
}
int main()
{
fun();
std::cout << "进程正常退出" << std::endl;
}
上述代码运行之后,将语句exit(100);改为 return 100;下面是两次运行的结果对比
从结果我们可以发现,使用exit函数之后,该程序就终止了,不再执行后面的语句!!所以return表示函数结束,而在代码的任何地方调用exit()函数,都表示进程结束。
_exit()
_exit与exit非常的类似,在用法上参考exit。下面我们主要谈谈它们的区别
- _exit不会刷新缓冲区,exit会刷新缓冲区。
- exit属于3号手册,属于语言级别;_exit属于2号手册,属于系统级别(系统调用)
- exit与_exit是上下层的关系。
结合以上3点,我们可以得出结论:我们之前认为的缓冲区一定不在操作系统内部,这个缓冲区叫做语言级缓冲区,由C / C++提供。
进程等待
为什么要有进程等待?
- 当子进程退出,如果父进程不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
- 进程一旦变成僵尸状态,那就变得刀枪不入,就算是“杀人不眨眼”的kill -9 也无能为力,毕竟谁也没有办法杀死一个已经死去的进程。
- 父进程派给子进程的任务完成的如何,我们需要知道。如:子进程运行完成,结果对还是不对,或者是否正常退出。
结合以上三点,我们可以得出结论:父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。
进程等待的方法
wait方法与waitpid方法
来看函数原型
wait():返回值:成功就返回被等待进程pid,失败则返回-1。status为输出型参数,获取子进程退出状态,如果不关心则可以设置成为nullptr。
<重点>waitpid():
返回值:
- 当正常返回的时候waitpid返回收集到的子进程的进程ID;
- 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
- 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
参数:
- pid:
Pid = -1,等待任一个子进程。与wait等效。
Pid > 0.等待其进程ID与pid相等的子进程。
- status:
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出,即退出信号)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
- options:
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。
对于我们创建出来的子进程,作为父进程,必须得等待子进程,直到子进程结束。即对子进程负责。如果子进程不退,父进程就要阻塞在wait函数内部(类比scanf())。我们来认识一下
获取子进程status
- wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
- 如果传递nullptr,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status不能简单的当作整形来看待,它由32个比特位构成,其中8 - 15位为退出码。可以把它当作位图来看待,具体细节如下图(只研究status低16比特位):
重谈进程退出
- 代码跑完,结果对,return 0
- 代码跑完,结果不对,return !0
- 进程异常了,OS提前使用信号终止了你的进程
上述的前两种情况,通过退出码就可判定。但第三种情况只有在进程的退出信息中的退出信号里才可以判定。
一般我们想知道一个进程运行结果是否正确,前提条件是这个信号的 退出信号 值为0(证明代码是正常跑完的),但 结果对还是不对 是通过退出码来进一步判断
当你创建出来一个子进程,让它帮你完成任务。如果你不关心子进程做的怎么样,那么你可以不用status(即将status设为nullptr);当你想要获取子进程的退出结果等信息,此时我们必须要通过status来获取子进程的退出信息。
// 使用宏,获取进程的退出信息
if(WIFEXITED(status)) // 退出信号不为空,进程正常执行
{
printf("wait sub process success, rid: %d, status code: %d\n"
,rid, WEXITSTATUS(status));
}
else
{
printf("child process quit error!\n");
}
阻塞与非阻塞
waitpid()中的第三个参数options就是与阻塞相关的。当options的值为0时,为阻塞等待;当options的值为WNOHANG时,为非阻塞等待。在非阻塞等待时,由父进程循环调用非阻塞接口,完成轮询检测,以完成更多的事情。
// 非阻塞测试代码
typedef std::function<void()> task_t;
void LoadTask(std::vector<task_t> &tasks){
tasks.push_back(PrintLog);
tasks.push_back(Download);
tasks.push_back(Backup);
}
int main()
{
std::vector<task_t> tasks;
LoadTask(tasks);
pid_t id = fork();
if(id == 0)
{
//子进程
while(true)
{
printf("我是子进程, pid : %d\n", getpid());
sleep(1);
}
exit(0);
}
while(true)
{
pid_t rid = waitpid(id, nullptr, WNOHANG);
if(rid > 0)
{
printf("等待子进程%d成功\n",rid); // 返回目标子进程的pid
break;
}
else if(rid < 0)
{
printf("等待子进程失败\n");
break;
}
else
{
printf("子进程尚未退出\n");
// 在等待子进程期间,父进程可以做自己的事情
for(auto &task : tasks)
{
task();
}
}
}
}
当waitpid()的返回值 > 0 表示等待成功,返回目标子进程的pid;
当waitpid()的返回值 == 0 表示等待成功,但是子进程没有退出;
当waitpid()的返回值 < 0 表示等待失败。
进程程序替换
什么是程序替换
我们在使用fork()系统调用之后,创建出来的子进程是对父进程的复制,也就是说子进程和父进程执行的是相同的程序,虽然说父子进程可能执行的是不同的代码分支(if else语句),但是程序流程是一样。所以我们要想让新创建的子进程中执行其他程序,就需要子进程调用一种exec函数来达到执行另一个程序的目的。
当进程调用一种exec函数的时候,该进程的用户空间代码和数据全部被新程序替换掉,从新程序的启动例程开始执行。需要注意的是,调用exec并不会创建新进程,而是一种进程替换,所以调用exec前后,进程本身的pid不会改变。
程序替换的原理
// myexec.cc文件
#include <unistd.h>
int main()
{
execl("/bin/ls", "ls", "-l", "-a", nullptr);
return 0;
}
当我们 ./myexec.cc 之后该文件就变为一个进程,拥有自己的PCB,页表等。磁盘里还存在另一个程序,当我们在代码里调用execl函数,磁盘里另一个程序会覆盖当前进程的数据段和代码段!这就叫做程序替换。哪一个进程调用execl,哪一个进程的代码和数据就会被execl中参数的相关信息覆盖。
由上图可知,execl并不会创建新的进程,只是把代码和数据替换了!但是不会影响命令行参数和环境变量,虽然它们也是数据。
exec函数族
exec函数族一共有6种,下面是函数原型 和 需要包含的头文件
#include <unistd.h>
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
函数参数
path:可执行程序路径。
file:要执行的程序名。
arg:参数列表,最后需要一个nullptr作为结尾,这个nullptr实际上就是一个哨兵,来告诉程序参数列表到此结束。另外参数arg是从arg[0]开始的,而arg[0]是这个程序本身,所以在写参数列表的时候需要先写一个程序本身来占位(实际上是个占位参数)。
- 返回值
成功的时候是没有返回值的;只有失败了才会返回-1。由此我们可以得出结论:只要返回,就说明exec失败了!(exit函数也不需要考虑返回值)
exec函数族介绍
exec函数族的命令是有一定的规律的,l表示list,就是参数列表的意思;p代表PATH,所以带p的参数都是file,不带p的参数都是path;e代表环境变量,我们可以设置这个环境变量,比如execle()有一个参数envp[]就是设置环境变量的;v表示vector,我们可以把参数放到一个数组中(我们就不需要一个一个写参数了),然后把数组传给execv()。结合下图理解可能更清晰一点
exec函数族本质就相当于把可执行程序加载到内存。
exec函数族的详细说明
execl函数的第一个参数是一条路径,后面是可变参数,也就是说你在命令行怎样输出命令,在这里就怎么写。
举个例子:
execl函数与execv函数没有本质区别,execv只是把在execl中需要传递的参数,放在了一个vector里面,直接传vector就可以了。所有函数名中带p的第一个参数只需要传可执行程序的名字。
// 部分exec函数的使用举例 -- 以ls为例
execl("/bin/ls", "ls", "-l", "-a", nullptr);
execv("/bin/ls", argv);
execlp("ls", "ls", "-l", "-a", nullptr);
execvp("ls", argv);
关于环境变量
环境变量我们可以不传,使用默认的;也可以使用execvpe函数自主决定要传什么样的环境变量,就是使file使用全新的环境变量。
- 可以让子进程继承父进程全部的环境变量
- 如果要传递全新的环境变量(需要自己定义,自己传递)
- 新增环境变量需要用putenv函数
exec函数族的调用关系
事实上,只有execve是真正的系统调用,其它五个函数最终都调用 execve,所以execve在man手册 第2节,其它函数在man手册第3节。
#include <unistd.h>
int execve(const char *filename, char *const argv[], char *const envp[]);
上述函数只有在传参方式上有明显差别,是为了满足不同的应用场景。
shell进程执行命令的原理
exec函数族的作用是用来进行进程替换的,但是exec函数有个特点:一旦执行成功就不会再返回了。如果我们shell需要执行某种功能,直接对shell进程进行替换,成功后就直接执行该功能。很好,没毛病,但是我执行完该功能,我想再返回shell进程,这时候怎么办?
实际上shell是先使用fork()创建一个子进程,然后让子进程使用exec函数进行进程替换,从而完成某种功能。这两个进程互不干扰,既解决了上面的返回问题,又解决了执行某种功能的问题。这才是真实的exec函数的应用场景,也就是说exec函数族是和fork()函数一块使用的。实际上这就是shell执行命令的原理。