1. 基本信息
SIGCHLD信号产生的条件:
- 子进程终止时
- 子进程接收到SIGSTOP信号停止时
- 子进程处在停止态,接受到SIGCONT后唤醒时
以上三种条件都会给父进程发送SIGCHLD信号,父进程默认会忽略该信号。
2.僵尸进程的产生
1 #include<stdio.h>2 #include<unistd.h>3 #include<sys/types.h>4 #include<sys/stat.h>5 6 int main() {7 8 pid_t pid;9 for(int i = 0; i < 20; ++i){10 pid = fork();11 if (pid == 0)12 break;13 }14 15 if (pid > 0) {16 while(1){17 printf("父进程ID=%d\n", getpid());18 sleep(2);19 }20 } else if(pid == 0) {21 22 printf("子进程ID=%d\n", getpid());23 }24 }
创建20个子进程,父进程每隔2秒循环输出一句话。
可以看到当子进程结束后,父进程没有及时回收子进程的资源,所以导致子进程都变成了僵尸进程。
3.SIGCHLD信号解决僵尸进程问题
3.1 初级版本
//信号捕捉处理函数8 void myfun(int num) {9 printf("捕捉到的信号编号:%d\n", num);10 wait(NULL);11 }//在父进程添加信号捕捉
21 if (pid > 0) {
22 struct sigaction act;
23 act.sa_flags = 0;
24 act.sa_handler = myfun;
25 sigemptyset(&act.sa_mask);
26 sigaction(SIGCHLD, &act, NULL);
27 while(1){
28 printf("父进程ID=%d\n", getpid());
29 sleep(2);
30 }
31 } else if(pid == 0) {
32 printf("子进程ID=%d\n", getpid());
33 }
sigaction是配置信号捕捉,要捕捉的信号是SIGCHLD,捕捉之后进入处理函数myfun,在函数中输出信号编号和执行wait函数。
wait函数
子进程退出时,内核将子进程置为僵尸状态,这个进程称为僵尸进程,它只保留最小的一些内核数据结构,以便父进程查询子进程的退出状态。父进程调用wait后,会将父进程挂起,然后分析释放当前某个子进程已经退出。如果它找到了一个僵尸子进程,就会收集该子进程的退出状态,并释放资源,返回对应的子进程id。如果它没有找到这样一个子进程,就会一直等待。wait函数一次只能回收一个子进程的资源。
把子进程产生数量改为10个,20个太多了,看花眼。从上图可以看到,信号捕捉处理函数的确捕捉到了17号信号,就是SIGCHLD信号。子进程的ID是从34-43共10个。
使用命令ps aux
查看进程。发现34、35成功回收了,其他还是僵尸进程。为什么会出现这个原因呢?
信号集
图源自《牛客大学》。在内核区存在两个表来记录信号的状态,其中一个叫未决信号集,另外一个叫堵塞信号集。当SIGCHLD信号产生,内核会将第17位置1,代表SIGCHLD信号处于未决状态,即产生了但是还没处理。
用户态产生信号后,内核会接收该信号,并且转向用户态的处理函数,就是那个myfun函数。当执行完myfun函数后,再回到内核将未决信号集第17位置0,内核负责回到产生信号时的代码原位置。
当用户在执行myfun函数时,其他子进程,其他子进程也是正常运行的。所以当某一个子进程终止,也会产生SIGCHLD信号。内核同样也会接受到该信号,但是发现未决信号集第17位已经是1,就会忽略该信号。子进程产生的信号被忽略后,是不会重复发送的,就会变成僵尸进程,得不到释放。
从输出也能看到,父进程只成功捕捉到了2个信号,释放两个子进程,其他子进程的信号都被忽略掉了。
3.2 改进版本
8 void myfun(int num) {9 printf("捕捉到的信号编号:%d\n", num);10 while(1) {11 int ret = waitpid(-1, NULL, WNOHANG);12 if (ret > 0)13 printf("终止子进程的id:%d\n", id);14 else if (ret == 0)15 break;16 else if (ret == -1)17 break;18 }19 }
将数据捕捉处理函数改了使用waitpid函数,并且加了while循环,所以如果在处理中间,有其他子进程终止了,waitpid函数也能在循环中帮忙回收资源。
waitpid函数
waitpid函数和wait函数很像,只是功能上更加灵活。当第一个参数取-1时,代表等待任何一个子进程退出,等于wait函数。第三个参数WNOHANG代表是非挂起状态,这是与wait函数不同的点,即waitpid函数没等到终止的子进程,不会一直等待。
当正常返回,并释放了某个子进程,返回该子进程id。
当该父进程存在子进程,但是没有终止状态的,就返回0.
当压根不存在子进程,返回-1 。
55087是父进程,可以看到已经没有子进程了,所有子进程都已经释放了。
3.3 为什么加了while可以回收之前被忽略掉SIGCHLD的僵尸进程?
小伙伴们不要有这样的误解,A子进程产生信号,调用了myfun函数,waitpid(wait函数同理)就只会去回收A进程。waitpid函数是个劳模,它只要见到僵尸进程就忍不住要回收,但能力有限,一次只能回收一次。只要给它机会,它可以把所有的僵尸进程一网打尽。所以只要有while循环,就可以不断执行waitpid函数,直到break。
3.4 为什么捕捉到了信号后没有进行处理就直接继续执行父进程后面的程序了呢?
观察输出图可以看到一个问题,父进程第一次输出【捕捉到的信号编号:17】,代表进入了信号捕捉处理函数,然后在里面循环终止了10个子进程,这个时候已经没有子进程了。但是后面还是输出【捕捉到的信号编号:17】,代表父进程再次进入了信号捕捉处理函数,那为什么已经不存在了子进程,父进程还是捕捉到了信号。
信号产生,内核中未决信号集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函数,一个僵尸都没回收的真相。
3.5 为什么出现段错误?
该实例有可能意外终止,并显示段错误。段错误是个迷,有的人碰到过几次,有的人怎么也碰不到,这是由于神秘莫测的调度算法导致的。究其原因是调用了不可重入的函数。《Linux/UNIX系统编程手册》第21.1.2节 对可重入函数进行了详细的解释,有兴趣的可以去翻一下。
可重入函数的意思是:函数由两条或多条线程调用时,即便是交叉执行,其效果也与各线程以未定义顺序依次调用时一致。通俗点讲,就是存在一个函数,A线程执行一半,B线程抢过CPU又来调用该函数,执行到1/4倍A线程抢回执行权。在这样不断来回执行中,不出问题的,就是可重入函数。多线程中每个线程都有自己的堆栈,所以如果函数中只用到局部变量肯定是可重入的,没问题的。但是更新了全局变量或静态数据结构的函数可能是不可重入的。假设某线程正在为一个链表结构添加一个新的链表项,而另外一个线程也视图更新同一链表。由于中间涉及多个指针,一旦另一线程中断这些步骤并修改了相同指针,结果就会产生混乱。但是并不是一定会出现,一定是A线程刚好在修改指针,另外一线程又去修改才会出现。这就是为什么该问题复现难度较高的原因。
作者在文中指出,将静态数据结构用于内部记账的函数也是不可重入的。其中最明显的例子就是stdio函数库成员(printf()、scanf()等),它们会为缓冲区I/O更新内部数据结构。所以,如果在捕捉信号处理函数中调用了printf(),而主程序又在调用printf()或其他stdio函数期间遭到了捕捉信号处理函数的中断,那么有时就会看到奇怪的输出,设置导致程序崩溃。虽然printf()不是异步信号安全函数,但却频频出现在各种示例中,是因为在展示对捕捉信号处理函数的调用,以及显示函数中相关变量的内容时,printf()都不失为一种简单而又便捷的方式。真正的应用程序应当避免使用该类函数。
printf函数会使用到一块缓冲区,这块缓冲区是使用malloc或类似函数分配的一块静态内存。所以它是不可重入函数。
牛客大学
《Linux/UNIX系统编程手册》