使用SIGCHLD信号解决僵尸进程

SIGCHLD信号产生的条件通常有三种:

  • 子进程终止
  • 子进程接收到SIGSTOP信号停止的时候
  • 子进程处于停止态,接收到SIGCONT后唤醒的时候

 以上三种条件都会给父进程发送SIGCHLD信号,父进程默认会忽略掉这个信号。那么怎么样去用这个信号解决僵尸进程呢?

既然子进程终止会产生这个SIGCHLD信号,那么在父进程中捕捉这个信号,然后回收子进程的资源。

测试代码:

#include <stdio.h>

#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>

#include <signal.h>

#include <sys/wait.h>

void myFun(int num) {
    printf("捕捉到的信号 :%d\n", num);
    // 回收子进程PCB的资源
    // while(1) {
    //     wait(NULL); 
    // }
    while(1) {
       int ret = waitpid(-1, NULL, WNOHANG);
       if(ret > 0) {
           printf("child die , pid = %d\n", ret);
       } else if(ret == 0) {
           // 说明还有子进程活着
           break;
       } else if(ret == -1) {
           // 没有子进程
           break;
       }
    }
}

int main() {

    // 提前设置好阻塞信号集,先阻塞SIGCHLD,因为有可能子进程很快结束,父进程还没有注册完信号捕捉
    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGCHLD);
    sigprocmask(SIG_BLOCK, &set, NULL);  //写到内核中

    // 创建一些子进程
    pid_t pid;
    for(int i = 0; i < 20; i++) {
        pid = fork();
        if(pid == 0) {   //子进程
            break;
        }
    }

    if(pid > 0) {
        // 父进程

        // 捕捉子进程死亡时发送的SIGCHLD信号
        struct sigaction act;

        act.sa_flags = 0;
        act.sa_handler = myFun;
        sigemptyset(&act.sa_mask);

        sigaction(SIGCHLD, &act, NULL);


        // 注册完信号捕捉以后,解除阻塞
        sigprocmask(SIG_UNBLOCK, &set, NULL);

        while(1) {
            printf("parent process pid : %d\n", getpid());
            sleep(2);
        }
    } else if( pid == 0) {
        // 子进程
        printf("child process pid : %d\n", getpid());
    }

    return 0;
}

在上述代码中:

首先通过fork函数创建了20个子进程

pid_t pid;
    for(int i = 0; i < 20; i++) {
        pid = fork();
        if(pid == 0) {   //子进程
            break;
        }
    }

比如在i = 5 的时候创建出来一个子进程,子进程中的代码执行到的位置和创建它的父进程的代码执行的位置一样,但是在子进程中pid返回的是0,因此需要做一个判断 if (pid == 0)就跳出循环,意思就是说,子进程就不继续创建子进程了,这样就能通过一个父进程创建出20个子进程。

 在父进程中,需要捕捉子进程死亡时发送的SIGCHLD信号(通过sigaction函数),捕捉之后,调用waitpid函数(在myFun函数中设置)释放子进程的资源。

父进程一直执行打印父进程pid然后睡觉2秒

if(pid > 0) {
        // 2.父进程

        // 捕捉子进程死亡时发送的SIGCHLD信号
        struct sigaction act;

        act.sa_flags = 0;
        act.sa_handler = myFun;
        sigemptyset(&act.sa_mask);

        sigaction(SIGCHLD, &act, NULL);


        // 注册完信号捕捉以后,解除阻塞
        sigprocmask(SIG_UNBLOCK, &set, NULL);

        while(1) {
            printf("parent process pid : %d\n", getpid());
            sleep(2);
        }
    }

信号处理函数myFun

void myFun(int num) {
    printf("捕捉到的信号 :%d\n", num);
    // 3.回收子进程PCB的资源
    // while(1) {
    //     wait(NULL); 
    // }
    while(1) {
       int ret = waitpid(-1, NULL, WNOHANG);
       if(ret > 0) {
           printf("child die , pid = %d\n", ret);
       } else if(ret == 0) {
           // 说明还有子进程活着
           break;
       } else if(ret == -1) {
           // 没有子进程
           break;
       }
    }
}

在捕捉到SIGCHLD信号之后,去myFun中进行相应的处理,此函数参数列表中的num就是捕捉到的信号。我们在这个函数中回收子进程的资源,可以使用wait函数,但是wait函数一次就能清理一个子进程,清理多个子进程应该使用循环

但是wait函数是阻塞的,也就是说调用wait函数的进程会被阻塞,现在是父进程在调用,因此父进程被阻塞,不能继续执行父进程中的其他内容。

因此可以使用waitpid函数,并设置为非阻塞。

int ret = waitpid(-1, NULL, WNOHANG);

第一个参数-1表示回收父进程的所有子进程。

第二个参数是进程退出时的状态信息,传入的是一个int类型的地址,是一个传出参数,这里用不到设置为NULL。

通过第三个参数将waitpid设置为非阻塞(0表示阻塞,WNOHANG表示非阻塞)

返回值:

  • >0:处理的子进程的pid
  • =0:还有子进程活着
  • -1:没有子进程了

>0通过循环一直处理所有死掉的进程,还有子进程活着或者没有子进程了,那么就跳出这个处理死掉的子进程的函数。如果还有子进程死了,再进来进行处理。

 在子进程中,打印子进程的pid,然后结束

else if( pid == 0) {
        // 4.子进程
        printf("child process pid : %d\n", getpid());
    }

到目前为止,我们还没有介绍标号为0的代码中的作用,我们可以试着去掉它们,看看执行结果会是什么。

程序有时候会正常执行,但是有时候也会产生段错误。至于为什么会产生这段错误,这里有一些解释:

出现段错误的原因在于在信号处理函数调用了不可重入的函数

使用gdb调试跟踪函数调用栈

最下层f 23可以看到是在main函数中,再往上f 22是在父进程中调用了printf

再往上f 10可以看到调用了信号处理函数,这里是我们在main函数中调用printf但是printf还没有调用完成,直接转到了信号处理函数,我这里的信号处理函数为handler,见f 9,再往上f 8调用printf,可以看到f 8 和f 22是一样的

SIGSEGV是因为printf会分配临时空间,在主函数调用printf至malloc时,中断处理函数调用,在其中也调用了printf至malloc时就出现了错误。(没看懂是什么意思啊!!!)

解决办法:

// 0.提前设置好阻塞信号集,先阻塞SIGCHLD,因为有可能子进程很快结束,父进程还没有注册完信号捕捉
    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGCHLD);
    sigprocmask(SIG_BLOCK, &set, NULL);  //写到内核中

// ......

 // 0.注册完信号捕捉以后,解除阻塞
    sigprocmask(SIG_UNBLOCK, &set, NULL);

如果从开始注册信号到注册成功这段时间里,有n个SIGCHID信号产生的话,那么第一个产生的SIGCHID会抢先将未决位置设置为1,余下的n-1个SIGCHID被丢弃,然后当阻塞解除之后,信号处理函数发现这时候对应信号的未决位为1,继而执行函数处理该信号,处理函数中的while循环顺带将其他n-1子进程也一网打尽了,在这期间未决位的状态只经历了两次变化,即0->1->0。

一些可能存在的问题以及解答:牛客 @研究僧0

1.为什么加了while可以回收之前被忽略掉SIGCHLD的僵尸进程。

小伙伴们不要有这样的误解,A子进程产生信号,调用了myfun函数,waitpid(wait函数同理)就只会去回收A进程。waitpid函数是个劳模,它只要见到僵尸进程就忍不住要回收,但能力有限,一次只能回收一次。只要给它机会,它可以把所有的僵尸进程一网打尽。所以只要有while循环,就可以不断执行waitpid函数,直到break。

2.如果信号阻塞以后不能被捕获,那么是如何做到 “先阻塞SIGCHLD信号,当注册完信号捕捉以后,再解除阻塞,这样就会继续执行处理函数回收资源”?

要弄懂这个问题,我们需要理清内核是如何处理信号的。信号的产生是异步的,A子进程产生SIGCHLD信号,不意味着父进程要立刻捕捉然后去做一些反应。当信号产生时,内核中未决信号集第17位会置1,它会等待父进程拥有cpu权限再去执行捕获信号处理函数,在去处理的瞬间17号位就会由1变为0,代表该信号有去处理了。

当我们提前设置了堵塞SIGCHLD信号,那未决集中就会一直保持1,不会调用捕获信号处理函数(也可以说信号不能被捕获),等待堵塞解除。所以并不是说,我们把信号堵塞了,然后解除堵塞,这个信号就消失了,它还是在未决集中的,值为1。捕捉函数捕获的其实就是这个1。信号捕捉不是钓鱼,钓鱼的话如果不及时处理,鱼就会跑掉。更像是网鱼,只要信号入网了,就跑不掉了。等我们准备好工具去捕获,会看到网上的鱼还是在的。

最后为什么要提前堵塞SIGCHLD信号?加了阻塞之后是什么情况?假设极端情况,20个子进程老早就终止了,内核收到SIGCHLD信号,会将未决信号集中的17号位置为1,就算他们是接连终止,该信号位也不会计数,只有保持1 。但同时该信号被提前阻塞,所以该17号位置保持1(阻塞是保持1,不是变回0),等待处理。当注册完信号捕捉函数以后,再解除阻塞。内核发现此时第17号位居然是1,那就去执行对应的捕获处理函数。在处理函数中,waitpid函数发现:“哎呦,这怎么躺着20具僵尸呀”,然后它就先回收一具僵尸,返回子进程id,循环第二次,继续回收第2具僵尸,直到所以僵尸被回收,此时已经没有子进程了,waitpid函数返回-1,break跳出循环。

while循环中,返回值0对应的是没有僵尸但有正常的儿子,返回值-1代表压根没有儿子。所以只要子进程中存在僵尸,这个while就不会break,waitpid就可以悠哉悠哉地一次回收一具。

《Linux/UNIX系统编程手册》指出为了保障可移植性,应用应在创建任何子进程之前就设置信号捕捉函数。【牛客789400243号】提出了这个观点,应该在fork之前就注册信号捕捉的。其实就是对应了书上这句话。

3.为什么捕捉到了信号后没有进行处理就直接继续执行父进程后面的程序了呢?

信号产生,内核中未决信号集SIGCHLD信号置1,内核调用信号捕捉函数myfun的同时把该信号置0,也就是说进入myfun函数后,内核依然是可以接收到SIGCHLD信号的。但是Linux为了防止某一个信号重复产生,在myfun函数进行多次递归导致堆栈空间爆了,它在调用myfhun函数会自动(内核自己完成)堵塞同类型信号。当然也可以用参数,自己指定要堵塞其他类型的信号。要注意的是,这里堵塞不是不接收信号,而是接收了不处理。当myfun函数结束,堵塞就会自动解除,该信号会传递给父进程。想象一个场景,20个子进程,先瞬间终止10个,父进程捕获到信号,进入myfun函数wait回收。这里有个点就是,父进程在执行myfun函数的时候,其他子进程不是挂起的,也是会运行的,至于怎么调度,那就看神秘莫测的调度算法了。在回收过程中,其余10个子进程也终止了,发出呼喊:“爹,快来回收我!”。父进程:“我没空,我还在myfun函数中干活”。于是内核将未决集中SIGCHLD信号置1等待处理,父进程在myfun函数中使用waitpid函数回收僵尸,”怎么越回收越多呀”,在while函数的加持下,他成功回收了20个僵尸。当它回到主函数打算休息下,内核叮的一声,有你的SIGCHLD信号,父进程以为有僵尸再次进入myfun函数,执行waipid函数,发现压根没有僵尸(上一次都回收完了),甚至儿子都没了(返回-1,break),骂骂咧咧返回了主函数。这就是为什么父进程捕获到了信号,进入了myfun函数,一个僵尸都没回收的真相。

4.段错误究竟是怎么发生的?段错误的复现为什么这么难?

段错误是个迷,有的人碰到过几次,有的人怎么也碰不到,这是由于神秘莫测的调度算法导致的。上面有解释这是调用了不可重入的函数。《Linux/UNIX系统编程手册》第21.1.2节 对可重入函数进行了详细的解释,有兴趣的可以去翻一下。

可重入函数的意思是:函数由两条或多条线程调用时,即便是交叉执行,其效果也与各线程以未定义顺序依次调用时一致。通俗点讲,就是存在一个函数,A线程执行一半,B线程抢过CPU又来调用该函数,执行到1/4倍A线程抢回执行权。在这样不断来回执行中,不出问题的,就是可重入函数。多线程中每个线程都有自己的堆栈,所以如果函数中只用到局部变量肯定是可重入的,没问题的。但是更新了全局变量或静态数据结构的函数可能是不可重入的。假设某线程正在为一个链表结构添加一个新的链表项,而另外一个线程也视图更新同一链表。由于中间涉及多个指针,一旦另一线程中断这些步骤并修改了相同指针,结果就会产生混乱。但是并不是一定会出现,一定是A线程刚好在修改指针,另外一线程又去修改才会出现。这就是为什么该问题复现难度较高的原因。

作者在文中指出,将静态数据结构用于内部记账的函数也是不可重入的。其中最明显的例子就是stdio函数库成员(printf()、scanf()等),它们会为缓冲区I/O更新内部数据结构。所以,如果在捕捉信号处理函数中调用了printf(),而主程序又在调用printf()或其他stdio函数期间遭到了捕捉信号处理函数的中断,那么有时就会看到奇怪的输出,设置导致程序崩溃。虽然printf()不是异步信号安全函数,但却频频出现在各种示例中,是因为在展示对捕捉信号处理函数的调用,以及显示函数中相关变量的内容时,printf()都不失为一种简单而又便捷的方式。真正的应用程序应当避免使用该类函数。

printf函数会使用到一块缓冲区,这块缓冲区是使用malloc或类似函数分配的一块静态内存。所以它是不可重入函数。

  • 11
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值