前言:这篇介绍进程终止与进程等待,介绍退出码、资源回收以及父子进程之间的等待关系。
1.进程终止
1.1进程退出与退出码
进程终止,意味着这个进程不再继续执行,操作系统会释放它运行时占用的大部分资源。但在通常的父子进程关系中,子进程退出后还需要保留少量退出信息,供父进程后续获取,所以"停止运行"和"完全回收"之间还有一个过程,这点在前面也有过演示。
先从程序执行的结果来看,常见的退出情况可以分成三种:
| 情况 | 含义 |
|---|---|
| 代码运行完毕,结果正确 | 程序完成了预期任务 |
| 代码运行完毕,结果不正确 | 程序发现错误,按自己安排的流程退出 |
| 代码异常终止 | 例如被某个信号终止,没有按原来的流程执行到结束 |
比如程序检查到参数不合适,于是主动退出,虽然任务没有完成,但它仍然可以通过正常终止的方式告诉外界出了什么问题。
这个用来说明执行结果的整数,就是我们经常说的退出码。
通常约定,0表示成功,非0表示失败或其他需要区分的情况。至于不同的非零值具体代表什么,要看程序自己的规定,要看具体的文档。
在Bash中,执行完一个前台命令后,可以紧接着使用echo $?查看上一条命令的退出状态。这里要注意"上一条":如果中间又执行了其他命令,$?就会更新。
Shell本身还约定了一些常见状态:
| 状态 | Bash中的常见含义 |
|---|---|
0 |
命令执行成功 |
126 |
找到了命令,但无法执行 |
127 |
没有找到命令 |
128 + N |
命令被编号为N的信号终止 |
例如,在默认处理方式下,Ctrl+C使前台程序因SIGINT终止,在Linux上通常会看到130,也就是128 + 2。这个数是Bash对终止结果的表示,不能据此认为程序执行了exit(130)。具体约定可以参考Bash的退出状态说明。
另外,退出码与errno是两回事 。strerror()解释的是系统或库函数使用的错误编号,不能用它通用地解释某个程序自己规定的退出码。这里贴出查考手册strerror接口说明
1.2return与exit
最熟悉的退出方式,就是在main函数最后写return 0;。在通常的C程序中,从main返回,相当于把返回值交给exit(),由它完成正常退出。
但如果return出现在普通函数中,它只会结束这一次函数调用,回到调用它的位置,不会因为一个普通函数返回,整个进程就跟着结束。
exit()就不一样了。即使在普通函数中调用它,也会终止当前进程。它的函数声明是void exit(int status);,使用时需要包含<stdlib.h>。
看下面这个例子:

main调用了f(),f()先打印一行内容,接着执行exit(1)。因此,f()里后面的打印不会执行,main中调用f()之后的打印和return 0也不会执行。
最终看到的退出码是1,由真正执行到的exit(1)决定。
返回退出码给父进程可以理解为进程终止时留下退出状态,再由父进程通过等待取得。它不是普通函数返回,exit()本身不会再回到调用位置继续执行。
1.3exit与_exit的区别
还有一个名字很接近的接口:_exit()。它的声明是void _exit(int status);,使用时需要包含<unistd.h>。
两者都能终止进程,但终止前做的事情不同:
| 对比项 | exit() |
_exit() |
|---|---|---|
执行通过atexit注册的退出清理函数 |
会 | 不会 |
| 刷新C标准I/O的输出缓冲,关闭流 | 会 | 不会进行这层标准I/O清理 |
| 最终结束进程 | 会 | 会 |
可以先把exit()理解成C库提供的退出处理:先做用户态清理,再进入底层的进程终止过程。_exit()则跳过这些C库清理步骤,通过系统调用接口结束进程。_exit接口说明
这里最容易观察的区别,就是缓冲区是否被刷新。
下面两个例子分别只看main函数。单独编译时,printf需要<stdio.h>,exit需要<stdlib.h>,_exit需要<unistd.h>。
先看exit():
c
int main()
{
printf("hello");
exit(0);
}
再看_exit():
c
int main()
{
printf("hello");
_exit(0);
}
这里特意没有写换行。在通常连接终端、采用行缓冲的情况下,printf("hello")可以先把内容留在C库管理的缓冲区里,暂时还没有输出到终端。
第一段通过exit()退出,会刷新缓冲区,所以能看到hello。第二段直接调用_exit(),没有刷新这部分缓冲,仍留在里面的hello就不会被输出。
所以,不是_exit()让printf没有执行,而是**printf已经执行,数据却还没有从用户态缓冲区送出去**。
如果输出在退出前已经被刷新,现象就会不同。
还有一点,虽然传给exit()和_exit()的参数类型是int,父进程通过wait或waitpid取得的退出码只有低8位 ,也就是0~255。因此exit(257)最终取得的是1,_exit(-1)最终取得的是255看,关于这点下面再介绍。
2.进程等待的意义
子进程已经退出了,父进程为什么还要专门等它?
一方面,父进程往往需要知道任务完成得怎么样;另一方面,子进程退出后留下的那部分信息,也需要有人来取走并完成回收。
通常情况下,子进程终止时,大部分运行资源已经释放,但内核会暂时保留它的PID、终止状态等信息。如果父进程一直不处理,这个已退出的子进程就会停留在僵尸状态,这点在前面的文章也有过介绍。
留下的是等待回收所需的信息和相应资源,长时间积累大量僵尸进程,仍然会占用系统资源,影响后续进程的创建。
所以,进程等待主要解决两件事:
- 回收已退出子进程留下的资源。
- 获取子进程的终止信息,判断它是正常退出还是被信号终止,以及正常退出时的退出码。
如果只想回收、不关心具体结果,可以不给等待接口提供保存状态的位置,但不能因此把回收这件事也省掉。
退出信息的传递过程可以先这样理解:

子进程结束时,由内核保存它的终止信息。父进程调用等待接口,再由内核把相应信息写到父进程提供的变量中,所以双方不需要共享一个普通的全局变量,不然就难以解决写时拷贝的问题。
在task_struct中,确实能看到退出相关的字段:

其中,exit_signal表示进程退出时用于通知父进程的信号。具体判断仍然通过等待接口返回的状态来做。
3.wait与waitpid
3.1wait的基本使用
先看wait():
c
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int* status);
它等待当前进程的某一个子进程退出。成功时,返回被回收子进程的PID;失败时,返回-1。
这里的status是一个输出型参数 。父进程准备一个int变量,把地址传进去,操作系统就可以把子进程的终止状态写到这个变量中。如果不关心状态,可以传NULL,这仍然会进行等待和回收。
不要混淆这两个结果:
- 函数返回值告诉我们,等到了哪个子进程,或者这次调用是否失败。
status保存的内容告诉我们,这个子进程是怎样结束的。
如果有子进程正在运行,还没有退出,父进程会阻塞在wait()处;如果已经有退出但尚未回收的子进程,就可以立即取得结果;如果根本没有可等待的子进程,则会出错返回。

需要看的是父进程与子进程的执行关系:父进程在等待位置停下来,子进程仍然可以继续执行,子进程退出后父进程才继续。
等待不是让子进程结束,而是等它结束以后取得结果。 另外,一次成功的wait()只回收一个子进程;如果创建了多个子进程,就需要相应地处理多个等待结果。
3.2waitpid的参数与返回值
wait()适合等待任意一个子进程。如果想指定等谁,或者暂时不愿意停下来等,就可以使用waitpid():
c
pid_t waitpid(pid_t pid, int *status, int options);
这个同样需要<sys/wait.h>,三个参数分别控制等谁、把结果放在哪里、怎样等待。
pid最常用的是前两种取值:
pid的取值 |
等待对象 |
|---|---|
大于0 |
PID等于这个值的子进程 |
等于-1 |
任意一个子进程 |
等于0 |
与调用者属于同一进程组的任意一个子进程 |
小于-1 |
进程组ID等于pid绝对值的任意一个子进程 |
后两种涉及进程组后面的文章再去做介绍,本篇先看前两个。
status的用途与前面相同。options这次主要看两种设置:
0:采用阻塞等待,没有可取得的终止状态时,等待目标子进程结束。WNOHANG:采用非阻塞方式,如果暂时没有可取得的终止状态,就立即返回。
因此,wait(&status)与waitpid(-1, &status, 0)的效果相同。
返回值也要分成三种情况:
| 返回值 | 含义 |
|---|---|
大于0 |
成功取得某个子进程的状态,返回它的PID |
等于0 |
使用了WNOHANG,符合条件的子进程存在,但暂时没有终止状态可取 |
等于-1 |
调用失败,通过errno了解原因 |
比如没有符合条件、尚未回收的子进程时,常见错误是ECHILD;阻塞等待也可能被信号中断而以EINTR返回。
返回0不是等待失败,也不是子进程以退出码0结束。 此时还没有拿到退出结果,不应该解析status。返回-1时同样不能把status当成新的有效结果。具体的接口细节可以参考wait与waitpid手册,我也记不住需要时再查吧。
3.3status原理
前面一直说status保存终止状态,但它并不直接等于传给exit()的那个整数。
可以把它理解成一个组合后的状态值:里面既要区分进程的终止方式,也要保存相应的退出码或信号信息。以Linux上这里讨论的终止状态为例,观察它的低16位:

如果进程正常退出,低8位为0,第8~15位保存退出码。如果进程被信号终止,低7位保存终止信号编号,第7位与core dump标志有关。这里先认识位置,因为涉及到信号部分,我想到时候统一写到一篇里不想过于分散。
上图中展示低16位,不是说status变量只有16位,它的类型仍然是int。
因此,一个执行exit(1)的子进程,在这里正常退出后,父进程直接打印原始status,可能看到的是256。这个1位于第8~15位,对应1 << 8,所以不能直接把256当成退出码。
下面通过位运算观察这个过程,只保留main部分。单独编译需要包含<stdio.h>、<stdlib.h>、<unistd.h>和<sys/wait.h>:
c
int main( void )
{
pid_t pid;
if ( (pid=fork()) == -1 )
perror("fork"),exit(1);
if ( pid == 0 )
{
sleep(20);
exit(10);
}
else
{
int st;
int ret = wait(&st);
if ( ret > 0 && ( st & 0X7F ) == 0 )
{ // 正常退出
printf("child exit code:%d\n", (st>>8)&0XFF);
}
else if( ret > 0 )
{ // 异常退出
printf("sig code : %d\n", st&0X7F );
}
}
}
子进程先休眠20秒,然后执行exit(10)。父进程阻塞在wait(&st),等到子进程结束,再检查返回值和保存下来的状态。
如果子进程在休眠期间被SIGKILL终止,就会走另一个分支,打印的信号编号是9。这个9表示导致终止的信号,不是子进程主动返回的退出码。
位运算能帮助我们理解布局,但实际使用接口时,更适合通过提供好的宏读取状态:
| 宏 | 用途 |
|---|---|
WIFEXITED(status) |
判断子进程是否正常终止 |
WEXITSTATUS(status) |
在正常终止的前提下,提取退出码 |
WIFSIGNALED(status) |
判断子进程是否被信号终止 |
WTERMSIG(status) |
在信号终止的前提下,提取终止信号编号 |
顺序就是先确认等待成功,再判断终止方式,最后读取对应信息。传给这些宏的是status这个值,不是它的地址。
尤其要注意,WIFEXITED(status)为真,只表示进程通过从main返回、调用exit或_exit等方式正常终止。即使子进程执行的是exit(1),它也可以为真。任务是否成功,还要继续看退出码的含义的。
4.阻塞等待与非阻塞等待
4.1阻塞等待
先看阻塞等待的例子。同样保留main部分,所需头文件与前面的等待示例相同:
c
int main()
{
pid_t pid;
pid = fork();
if(pid < 0)
{
printf("%s fork error\n",__FUNCTION__);
return 1;
}
else if( pid == 0 )
{ //child
printf("child is run, pid is : %d\n",getpid());
sleep(5);
exit(257);
}
else
{
int status = 0;
pid_t ret = waitpid(-1, &status, 0);//阻塞式等待,等待5S
printf("this is test for wait\n");
if( WIFEXITED(status) && ret == pid )
{
printf("wait child 5s success, child return code is :%d.\n",WEXITSTATUS(status));
}
else
{
printf("wait child failed, return.\n");
return 1;
}
}
return 0;
}
子进程打印自己的PID,然后休眠5秒。父进程调用waitpid(-1, &status, 0),第三个参数是0,如果子进程尚未结束,就阻塞在这里。
因此,父进程的this is test for wait要等到等待调用返回后才会打印。正常结束时,输出会类似这样:
text
child is run, pid is : 45110
this is test for wait
wait child 5s success, child return code is :1.
子进程明明调用的是exit(257),这里却得到1,正好对应前面提到的低8位规则。
这个程序只创建了一个子进程,所以使用-1等待任意子进程,取得的就是它。
阻塞期间,父进程不会沿着这一条执行流继续往下做其他事情,也不需要自己不断循环询问。
4.2非阻塞等待
如果父进程在子进程执行期间,还有其他事情要做,就可以使用WNOHANG。
它的思路是先询问一次:结果已经准备好就取走;还没有准备好就先返回,让父进程继续执行,之后再来询问。
下面的例子用函数指针保存临时任务。这里的vector只是用来存放任务,重点仍然是waitpid的返回值和父进程的执行过程。
cpp
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>
typedef void (*handler_t)(); // 函数指针类型
std::vector<handler_t> handlers; // 函数指针数组
void fun_one()
{
printf("这是一个临时任务1\n");
}
void fun_two()
{
printf("这是一个临时任务2\n");
}
void Load()
{
handlers.push_back(fun_one);
handlers.push_back(fun_two);
}
void handler()
{
if (handlers.empty())
Load();
for (auto iter : handlers)
iter();
}
int main()
{
pid_t pid;
pid = fork();
if (pid < 0) {
printf("%s fork error\n", __FUNCTION__);
return 1;
}
else if (pid == 0)
{ // child
printf("child is run, pid is : %d\n", getpid());
sleep(5);
exit(1);
}
else
{
int status = 0;
pid_t ret = 0;
do
{
ret = waitpid(-1, &status, WNOHANG); // 非阻塞式等待
if (ret == 0)
{
printf("child is running\n");
}
handler();
}
while (ret == 0);
if (WIFEXITED(status) && ret == pid)
{
printf("wait child 5s success, child return code is :%d.\n",
WEXITSTATUS(status));
}
else
{
printf("wait child failed, return.\n");
return 1;
}
}
return 0;
}
子进程仍然先休眠5秒,再以退出码1结束。父进程这次没有一直停在waitpid()内部,而是在循环中不断取得它的返回值。
当返回0时,说明子进程暂时还没有终止状态可取,于是打印child is running。之后调用handler(),依次执行保存的两个临时任务,再进入下一轮等待。
当子进程结束,waitpid()成功返回它的PID,循环条件ret == 0就不再成立。父进程随后判断是否正常终止,再提取退出码。如果返回-1,也会离开循环,进入失败处理。
这就是非阻塞轮询 :父进程每隔一段自己的执行流程,就来询问一次子进程的状态。WNOHANG只改变这一回等待调用的行为,不会自动替父进程安排其他任务,也不会在返回0时完成子进程的回收。
完