
🫧 励志不掉头发的内向程序员 :个人主页
✨️ 个人专栏: 《C++语言》《Linux学习》
🌅偶尔悲伤,偶尔被幸福所完善
👓️博主简介:

文章目录
- 前言
- 一、进程创建
- 二、进程终止
-
- 2.1、进程退出场景
- 2.2、进程常见退出方法
-
- (1)退出码
- [(2)exit 函数](#(2)exit 函数)
- [(3)_exit 函数](#(3)_exit 函数)
- [(4)return 退出](#(4)return 退出)
- 三、进程等待
-
- 3.1、进程等待必要性
- 3.2、进程等待方法
-
- [(1)wait 方法](#(1)wait 方法)
- [(2)waitpid 方法](#(2)waitpid 方法)
- [(3)获得子进程 status](#(3)获得子进程 status)
- (4)阻塞与非阻塞等待
- 四、进程程序替换
- 总结
前言
我们之前就学习了如何去创建一个进程,这也是我们控制进程的一部分,本章节我们就接着创建进程来讲讲如何终止进程以及我们怎么处理僵尸进程等问题,我们一起来看看吧。

一、进程创建
进程创建就是 fork 函数的使用,我们在前面章节就已经了解且讲解过了,所以此处就不过多赘述了。
二、进程终止
2.1、进程退出场景
-
代码运行完毕,结果正确。
-
代码运行完毕,结果不正确。
-
代码异常终止。
我们的子进程也是进程,由父进程创建,我们的父进程创建子进程的目的肯定是为了达成某种目的的。所以我们的父进程就应该知道子进程的运行结果来判断子进程是怎么终止的。所以我们的进程终止就是要给父进程一个交代。
2.2、进程常见退出方法
我们的退出方法有很多。
首先就是我们的 main 函数结束,就表示进程的结束,其他的函数结束只表示自己的函数调用完成。
其次我们可以调用我们的 exit 接口。
(1)退出码
我们的 main 函数作为我们的程序的入口,它所返回的 0 或者非 0 肯定就是代表我们程序的执行情况的。由于我们的程序运行结束时有三种场景,而我们的返回值通常就是表达了我们的前两种情况,只有我们的 main 函数运行完毕并且结果正确,才会返回我们的 0,如果我们的结果不对,但是运行完毕就会返回我们的非零。 既然是非零了,那我们就能给不同的数字赋予不同的含义去返回。
我们去创建一个进程,可能是有返回的内容,比如 printf 给我们看到结果,但是我们也有进程是方面都不展示的,我们怎么知道它的返回值呢?我们的子进程的返回值其实是返回给我们的父进程的,如果我们要去查看我们的进程的返回数字,就只要输入 echo $? 就可以查出。

我们运行了一个进程,它正常的退出了,所以返回值是 0。
所以说我们的子进程退出了,我们的父进程 bash 想要知道我们的子进程的执行情况,就要获得我们子进程所对应的退出码。而我们的 echo $? 的指令就表示我们打印最近一个进程退出时的退出码。main 函数的返回值也就叫做进程退出码。我们的进程退出码是要在退出时写到我们的 task_struct 内部的。
我们来试着查看一下我们的退出码试试。
c
#include <stdio.h>
#include <string.h>
int main()
{
int i = 0;
for(; i < 200; i++)
{
printf("%d->%s\n", i, strerror(i));
}
return 0;
}
我们通过运行上面的代码就可以试着查看我们的退出码。

我们可以看到我们的退出码是有很多的,一个是有 134 个退出码。
但是如果是异常该如何返回呢?
c
#include<stdio.h>
int main()
{
int a = 10;
a /= 0;
return 89;
}
这个代码就是异常的,我们看看如果它运行到 return,就会返回 89,我们运行试试看。
我们可以看到它的结果是 136 而不是 89。这里要说的是,如果我们的代码异常了,那我们的退出码就无意义了。而我们的进程一旦异常了,异常就是我们的进程收到的信号。
(2)exit 函数
这个函数的主要作用就是引起一个进程终止。

要给它传输一个 statue(状态),也就是进程退出时的退出码。在代码中调用 statue,它的作用等价于 return。
我们来试着用用 exit。
c
#include<stdlib.h>
#include<stdio.h>
int main()
{
exit(23);
}

c
#include<stdio.h>
#include<stdlib.h>
void fun()
{
printf("fun begin!\n");
exit(40);
printf("fun end!\n");
}
int main()
{
fun();
printf("main!\n");
return 0;
}
运行后我们发现,它只打印了一句话,后面的却不打印了。

所以说我们在任何地方调用 exit 都表示进程结束。所以进程就直接退出了。
(3)_exit 函数

这个和 exit 的区别在于谁调用 _exit,_exit 就终止谁。它的头文件是 unistd。
c
#include<stdio.h>
#include<unistd.h>
void fun()
{
printf("fun begin!\n");
_exit(4);
printf("fun end!\n");
}
int main()
{
fun();
printf("main!\n");
return 0;
}

我们可以发现它也是只跑了一半,退出码是 4。
我们的 exit 和 _exit,一个是 C 语言提供的,一个是系统提供的。
c
// 1、
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
int main()
{
printf("main!");
sleep(2);
exit(23);
}
// 2、
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
int main()
{
printf("main!");
sleep(2);
_exit(23);
}
我们来运行一下我们的 1 和 2。
我们可以发现,我们的 1 在经过两秒的等待后系统就把我们的数据从缓冲区刷新到我们的显示器上,但是我们的 2 却什么都没有。
所以说它们的区别就在于进程调用 exit 退出的时候,会进行缓冲区的刷新。但是如果是 _exit,就不会进行缓冲区的刷新。
我们的 exit 是库函数提供的,而我们的 _exit 是系统调用提供的,我们能够终止进程的就只有 OS,所以说我们的 exit 内部一定封装了我们的 _exit。由此观之,我们的缓冲区一定不是操作系统内部的缓冲区。它其实是库级别的缓冲区。
(4)return 退出
return 是一种更常见的退出进程方法,执行 return n 等同于执行 exit(n),因为调用 main 的运行时函数会将 main 的返回值当作 exit 的参数。
三、进程等待
3.1、进程等待必要性
之前讲过了,我们的子进程退出时,如果我们的父进程不管,就会造成子进程一直处于僵尸进程的问题,从而导致内存泄漏。这个状态下,我们的进程就刀枪不入了。而且我们的父进程也有必要知道我们的子进程任务完成的如何。所以我们的父进程就得通过进程等待的方式回收子进程的资源(必须的)和获取子进程退出信息(可选的)。
3.2、进程等待方法
(1)wait 方法
c
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt = 5;
while(cnt)
{
printf("我是一个子进程,pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
cnt--;
}
exit(0); //子进程退出
}
// 父进程
sleep(100);
return 0;
}
我们可以看到,我们的子进程在运行结束后一直处于僵尸状态。

我们这个时候来试着把僵尸状态解决掉,我们用 wait 接口来解决。

我们的 wait 函数只有一个参数 *status,输出型参数,需要我们传一个整型变量的地址,调用成功这个参数后,会把子进程的退出信息放到 status 中,然后给上层(父进程)拿到。
我们的 wait 是等待任意个退出的子进程。如果我们的进程创建了 1 个子进程,那我们的 wait 就等待一个子进程的退出,如果是 10 个,wait 就会阻塞,直到我们的 10 个中的任意一个退出了,他就把我们退出的进程返回。
wait 的成功返回后,返回值是成功返回的子进程的 pid,也就是目标 Z 进程的 pid,如果失败了,就返回 -1。
c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt = 5;
while(cnt)
{
printf("我是一个子进程,pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
cnt--;
}
exit(0);
}
sleep(10);
pid_t rid = wait(NULL);
if(rid > 0)
{
printf("wait success, rid: %d\n", rid);
}
sleep(10);
return 0;
}
运行后。

我们可以看到,在子进程运行完但是 wait 还没运行时是僵尸进程,但是 wait 运行后子进程就退出了。
(2)waitpid 方法
我们如果想要更多的控制,我们可以使用 waitpid,它比 wait 更高级。
返回值和 wait 的返回值一样。这个 options 参数是进行阻塞控制的。和 status 一起后面细谈。这个 pid 参数是指要等待什么子进程,就把它的 pid 传给 waitpid。

如果传入 -1 给 pid,就相当于 wait。0 和小于 -1 暂时不考虑。
c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt = 5;
while(cnt)
{
printf("我是一个子进程,pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
cnt--;
}
exit(0);
}
sleep(10);
pid_t rid = waitpid(-1, NULL, 0);
if(rid > 0)
{
printf("wait success, rid: %d\n", rid);
}
sleep(10);
return 0;
}
这一串代码等价于 wait 的代码。
当然,我们也可以把 -1 改成任意子进程的 pid,这样就实现的定向等待指定子进程的功能。
(3)获得子进程 status
我们的父进程要通过我们的 status 来获取子进程返回的数据从而得到退出信息。从而知道我们的子进程把任务完成的怎么样,也就是我们的进程退出码,即 main 返回值来判断退出结果是否正确。
我们试着使用一下 status。
c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<errno.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt = 3;
while(cnt)
{
printf("我是一个子进程,pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
cnt--;
}
exit(1);
}
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
{
printf("wait success, rid: %d, status: %d\n", rid, status);
}
else
{
printf("wait failed: %d: %s\n", errno, strerror(errno));
}
return 0;
}
我们运行后可以看到。

但是不是 exit(1) 吗,为什么这里显示 256 了呢?实际上我们的 status 不是我们想想的那样直接拿到我们整数的退出码。

实际上,我们的 status 看上去是一个普通的整数,实际上这个 status 是被划分成若干区域的,可以把它看成一个位图。共 32 个 bit 位,我们的高 16 位不考虑,而低 16 位中,我们的 8 ~ 15 才是表示我们的退出码,所以说我们的 exit(1) 中的 1 就是表示 0000000100000000 也就是 256 了。而后面的两部分,我们到信号时再谈。所以我们想要获取整型变量,我们得将我们的 status 右移 8 位,再提取我们中间的 8 位,就可以拿到我们的 1 了。
c
(status >> 8) & 0xff;
这样就可以获取我们的退出码了。

但是我们还有一种退出叫做异常退出是什么呢?其实我们的异常涉及到我们的信号,这里就不多涉及。
但是我们的 waitpid 和 wait 是从哪里拿的退出信息呢?我们在之前就明白了我们的子进程在进入僵尸进程时会释放内存,页表代码和数据等,所以我们就能够猜到其实退出信息存储在我们的 PCB 中,事实也的确如此。

我们的 waitpid 通过我们的系统调用提供的接口获取到了我们的子进程的信息后,在交给我们父进程的地址空间上,我们的 getpid() 和 getppid() 也是类似的原理。
我们刚才提取我们的退出码是用位操作,但是我们计算机不希望我们这么做,所以提供了若干个宏来进行提取,一个是 WEXITSTATUS(status),其实就是用宏的方式封装了一下我们的位操作。还有一个是 WIFEXITED(status),表示我们的子进程是不是正常退出,就是检测我们的信号是不是为真。
(4)阻塞与非阻塞等待
在我们子进程等待的时候还有一个参数就是我们的 options。

我们的 options 可以设置一些选项,当我们的默认为 0 时就是阻塞等待。
还有一个是 WNOHANG,它的作用是如果我们的子进程没有退出的话,我们就立即返回。我们把这种特性称作非阻塞调用。我们 WNOHANG 中的 W 就是 wait 的意思,HANG 是指,当我们计算机中,如果卡死了,就称为 HANG(夯)住了。
我们这里来聊聊什么是非阻塞。
我们假如要去找一个人,到了他家楼下,我们打电话给他去询问他下来了没有,他告诉我们他还没有下来,我们就把电话挂了,等一会儿再打电话给他询问,如果还没有那就再挂了,一直重复直到他下来了,这就是非阻塞调用,在挂断电话后和再次打电话之间我们可以做一些自己的事情,比如玩玩手机,刷刷视频等(这是非阻塞调用的好处)。我们的打电话询问又挂电话的行为称之为非阻塞轮询。我们就是父进程,他就是子进程,打电话就是一次调用,这就是非阻塞。在这种情况中,我们一般是用到循环去轮询,这个时候我们的返回值就可以判断结束了没有,如果大于 0 就说明等待结束。等于 0 说明我们的 waitpid 调用结束,但是子进程没有退出。小于 0 说明等待失败。
c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt = 3;
while(1)
{
sleep(3);
printf("我是一个子进程,pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
cnt--;
}
exit(10);
}
while(1)
{
int status = 0;
pid_t rid = waitpid(id, &status, WNOHANG);
if(rid > 0)
{
printf("wait success, rid: %d, exit code: %d, exit signal: %d\n", rid, (status >> 8) & 0xff, status & 0xff);
break;
}
else if(rid == 0)
{
printf("本轮调用结束, 子进程没有退出\n");
sleep(1);
}
else
{
printf("等待失败\n");
break;
}
}
}
我们试着运行可以看到。

我们的父进程一直在询问我们的子进程结束没有。
当我们试着kill掉这个子进程时可以看到。

我们等待就成功了。
而阻塞就是我们打电话去问他下来没,得知没有下来后不挂电话,一直打着,直到下来为止。在下来期间,我们一直阻塞在这里。
四、进程程序替换
4.1、替换原理

进程替换的原理就是通过系统提供的 exec 系列接口,将想要的代码和数据与当前进程的代码和数据进行替换。在程序替换的过程中,并没有创建新的进程,只是把当前进程的代码和数据用新的程序的代码和数据覆盖式的进行替换!我们的替换是可以替换一切可以转化成进程的程序,而不单单是一些命令。
c
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("我的程序运行了!\n");
execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
printf("我的程序运行完毕了\n");
return 0;
}
运行后。
我们可以看到我们的程序没有运行到我们的程序运行完毕那里,说明我们的程序一旦替换成功了,就去执行新代码了,原始代码的后半部分已经不存在了。
如果 exec 函数替换失败时,就会执行后半部分代码,并且返回 -1。如果成功了就不会有返回值,因为会给替换掉。所以 exec 函数不用对返回值做判断,一旦返回就是失败。
4.2、替换函数
(1)函数解释
- int execl(const char *path, const char *arg, ...);
这个函数的第一个参数要以路径 + 程序名的方式写入,作用就是进程要执行谁。
第二个参数我们在命令行怎么填,我们就怎么填第二个参数。而结尾必须以 NULL 结尾,表明参数传递完成。

第二个参数就是表示进程怎么执行它。我们像这样类似于链表的方式将参数一个一个的传入,所以 execl 的 l 就是 list 的意思。
如果我们在 exec 系列接口运行时,会把我们当前进程的内容全部覆盖掉,但是我们不希望这么做,这个时候就可以创建一个子进程,让我们的子进程去完成 exec 的工作而不影响我们的父进程的工作。原因是子进程的代码和数据在改变时发生了写实拷贝,使得子进程和父进程完全区分开来了。
- int execlp(const char *file, const char *arg, ...);
我们的 execlp 中的 p 就是相当于我们环境变量中的 PATH 的意思。说明我们要执行的目标程序不需要告诉我们路径,只需要告诉我们要执行的文件名就行了。因为 execlp 会自动在环境变量 PATH 中查找指定的命令。我们第二个参数的传递方式同 execl。
c
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
printf("我的程序运行了!\n");
if(fork() == 0)
{
printf("I Am Child, My Pid Is: %d\n", getpid());
sleep(1);
execlp("ls", "ls", "-l", "-a", NULL);
exit(1);
}
waitpid(-1, NULL, 0);
printf("我的程序运行完毕了\n");
return 0;
}
运行后。
- int execv(const char *path, char *const envp[]);
我们的这个 exec 没有带 p,所以我们的第一个参数是同 execl 的,要告诉我们要执行谁,所以要把我们的路径带上,要么是绝对,要么是相对的。我们的 execv 的 v 其实可以理解为 vector,所以第二个参数就是以数组的形式呈现。如果我们要用 execv 执行某个命令,我们就得给它提供一个命令行参数表,也就是一个指针数组。
c
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
printf("我的程序运行了!\n");
if(fork() == 0)
{
printf("I Am Child, My Pid Is: %d\n", getpid());
sleep(1);
char *const argv[] = {
"ls",
"-l",
"-a",
NULL
};
execv("/usr/bin/ls", argv);
exit(1);
}
waitpid(-1, NULL, 0);
printf("我的程序运行完毕了\n");
return 0;
}
运行后。

所以说 v 和 l 只是以不同形式呈现罢了。
- int execvp(const char *file, char *const argv[]);
这个接口的两个参数在前面都有出现。
c
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
printf("我的程序运行了!\n");
if(fork() == 0)
{
printf("I Am Child, My Pid Is: %d\n", getpid());
sleep(1);
char *const argv[] = {
"ls",
"-l",
"-a",
NULL
};
execvp(argv[0], argv);
exit(1);
}
waitpid(-1, NULL, 0);
printf("我的程序运行完毕了\n");
return 0;
}
运行后。
- int execvpe(const char *file, char *const argv[], char *const envp[]);
p 表示不用带变量路径,v 表示传入一个命令行参数表,而我们的 e,也就是第三个参数则是表示我们的环境变量。
我们先创建一个 other.cc 程序。
cpp
#include<iostream>
#include<cstdio>
#include<unistd.h>
int main(int argc, char *argv[], char *env[])
{
std::cout << "hello C++, My Pid Is: " << getpid() << std::endl;
for(int i = 0; i < argc; i++)
{
printf("argv[%d]: %s\n", i, argv[i]);
}
printf("\n");
for(int i = 0; env[i]; i++)
{
printf("env[%d]: %s\n", i, env[i]);
}
return 0;
}
运行后。

这个时候我们可以让我们的 other 替换掉 proc.c 的子进程。
c
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
printf("我的程序运行了!\n");
if(fork() == 0)
{
printf("I Am Child, My Pid Is: %d\n", getpid());
sleep(1);
char *const argv[] = {
"other",
"-a",
"-b",
"-c",
"-d",
NULL
};
char *const env[] = {
"MYVAL=123456789",
NULL
};
execvpe("./other", argv, env);
exit(1);
}
waitpid(-1, NULL, 0);
printf("我的程序运行完毕了\n");
return 0;
}
运行后。

我们可以发现,我们的环境变量就只剩下我们导入的一个环境变量了。所以我们的这个环境变量指的是替换的子进程,使用全新的 env 列表。如果我们想要以新增的方式增加环境变量怎么办呢?
c
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
printf("我的程序运行了!\n");
if(fork() == 0)
{
printf("I Am Child, My Pid Is: %d\n", getpid());
sleep(1);
char *const argv[] = {
"other",
"-a",
"-b",
"-c",
"-d",
NULL
};
char *const env[] = {
"MYVAL=123456789",
NULL
};
execvp("./other", argv);
exit(1);
}
waitpid(-1, NULL, 0);
printf("我的程序运行完毕了\n");
return 0;
}
如果我们直接使用 execvp,运行后。

我们发现我们的子进程是可以获得我们的环境变量。其实我们的 exec 内部默认自己传了。
我们如果想要在原基础上增加我们的 env,可以使用 putenv 函数。

哪个进程调它,就在哪个进程增加一个环境变量。这个进程导入新的环境变量后,它的父进程是看不到的,它的子进程才能看到。
c
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
char *newenv = "MYVAL=123456789"; //putenv必须这样新增env
int main()
{
printf("我的程序运行了!\n");
if(fork() == 0)
{
printf("I Am Child, My Pid Is: %d\n", getpid());
sleep(1);
char *const argv[] = {
"other",
"-a",
"-b",
"-c",
"-d",
NULL
};
char *const env[] = {
"MYVAL=123456789",
NULL
};
putenv(newenv);
execvp("./other", argv);
exit(1);
}
waitpid(-1, NULL, 0);
printf("我的程序运行完毕了\n");
return 0;
}
运行后。

所以其实我们想要带入环境变量没有那么复杂,我们带 e 的接口是覆盖式的加入,直接使用 putenv 即可。但是如果硬是要使用 execvpe 呢?
c
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
char *const addenv[] = {
"MYVAL=123456789",
NULL
};
int main()
{
printf("我的程序运行了!\n");
if(fork() == 0)
{
printf("I Am Child, My Pid Is: %d\n", getpid());
sleep(1);
char *const argv[] = {
"other",
"-a",
"-b",
"-c",
"-d",
NULL
};
for(int i = 0; addenv[i]; i++)
{
putenv(addenv[i]);
}
extern char **environ; //声明
execvpe("./other", argv, environ);
exit(1);
}
waitpid(-1, NULL, 0);
printf("我的程序运行完毕了\n");
return 0;
}
运行后。

- int execle(const char *path, const char *arg, ..., char *const envp[]);
同上。
- execve(const char *filename, char *const argv[], char *const envp[]);
这个没有和前面的 6 个放在一块,它是在 2 号手册中的,但是前 6 个是在 3 号手册。也就是这个是系统调用。前 6 个是系统调用的语言层面的封装。前 6 个的底层都是这个。这也就是为什么我们不传环境变量也能获取,因为 execve 已经传了。如果传了就用传的,没有就用默认的。这也就是为什么传了就用新的环境变量替代了。
(2)命名解释
exec 有 l,p,e,v 四个后缀:
-
l 表示 list,指我们传参数像链表一样一个一个的传。
-
p 表示 PATH,就是说我们不用指明我们的路径,只需要说明要做什么即可。
-
e 表示 env,就是环境变量。
-
v 表示 vector,就是指我们在指明做什么时可以用一个命令行参数表指明。
总结
这就是我们进程控制的主要内容了,我们了解完如何控制进程,我们会发现我们现在可以试着实现一个自定义的 shell 了。我们下一章节便来讲解原理,我们下一章再见。
🎇坚持到这里已经很厉害啦,辛苦啦🎇 ʕ • ᴥ • ʔ づ♡ど