【Linux】Linux环境下的进程终止与进程等待问题

前言:这篇介绍进程终止与进程等待,介绍退出码、资源回收以及父子进程之间的等待关系。


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、终止状态等信息。如果父进程一直不处理,这个已退出的子进程就会停留在僵尸状态,这点在前面的文章也有过介绍。

留下的是等待回收所需的信息和相应资源,长时间积累大量僵尸进程,仍然会占用系统资源,影响后续进程的创建。

所以,进程等待主要解决两件事:

  1. 回收已退出子进程留下的资源。
  2. 获取子进程的终止信息,判断它是正常退出还是被信号终止,以及正常退出时的退出码。

如果只想回收、不关心具体结果,可以不给等待接口提供保存状态的位置,但不能因此把回收这件事也省掉。

退出信息的传递过程可以先这样理解:

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

在task_struct中,确实能看到退出相关的字段:

其中,exit_signal表示进程退出时用于通知父进程的信号。具体判断仍然通过等待接口返回的状态来做。

Linux内核中的相关字段

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时完成子进程的回收。


完

相关推荐
机核研创社2 小时前
短裤自动化工序与设备清单:十个点位、三项技术要点与选型裁决顺序
运维·自动化
尹人入圣2 小时前
市面上IP驱动产业新场景新工具
运维·网络·python·tcp/ip
衍玊2 小时前
常见i/o模型以及高性能服务器设计
服务器
Dovis(誓平步青云)3 小时前
多个链接不等于多份证据,新闻核验看板怎样合并来源
java·服务器·前端·javascript·人工智能·pdf·电脑
LRL_3 小时前
【实战指南】Node.js 跨平台依赖下载:如何在 Windows/Linux 环境下互跨下载目标系统的 npm/pnpm 包
linux·windows·node.js
高山有多高3 小时前
【Linux笔记】网络命令
linux
Dovis(誓平步青云)3 小时前
几个方案来回选不定?做一个随时切换的候选推荐页
android·java·服务器·开发语言·javascript·数据库·智能化
一条破秋裤3 小时前
Linux 线程同步:读写锁
java·linux·jvm
JieDavid4 小时前
奇智创达知识产权管理系统期限监控模块实操,告别人工期限疏漏,实现管理闭环!
大数据·运维·人工智能·经验分享·重构