[apue] 等待子进程的那些事儿
谈到等待子进程,首先想到的就是SIGCHLD信号与wait函数族,本文试图厘清二者的方方面面,以及组合使用时可能不小心掉进去的坑。
1. 首先谈单独使用SIGCHLD的场景。下面是一段典型的代码片段:
1 #include "../apue.h" 2 #include <sys/wait.h> 3 4 #define CLD_NUM 2 5 static void sig_cld (int signo) 6 { 7 pid_t pid = 0; 8 int status = 0; 9 printf ("SIGCHLD received\n"); 10 if (signal (SIGCHLD, sig_cld) == SIG_ERR) 11 perror ("signal error"); 12 if ((pid = wait (&status)) < 0) 13 perror ("wait(in signal) error"); 14 printf ("pid (wait in signal) = %d\n", pid); 15 } 16 17 int main () 18 { 19 pid_t pid = 0; 20 __sighandler_t ret = signal (SIGCHLD, sig_cld); 21 if (ret == SIG_ERR) 22 perror ("signal error"); 23 else 24 printf ("old handler %x\n", ret); 25 26 for (int i=0; i<CLD_NUM; ++ i) 27 { 28 if ((pid = fork ()) < 0) 29 perror ("fork error"); 30 else if (pid == 0) 31 { 32 sleep (3); 33 printf ("child %u exit\n", getpid ()); 34 _exit (0); 35 } 36 37 sleep (1); 38 } 39 40 for (int i=0; i<CLD_NUM; ++ i) 41 { 42 pause (); 43 printf ("wake up by signal %d\n", i); 44 } 45 46 printf ("parent exit\n"); 47 return 0; 48 }
父进程启动了两个子进程,在SIGCHLD信号处理器中调用wait等待已结束的子进程,回收进程信息,防止产生僵尸进程(zombie)。上面的代码会有如下的输出:
old handler 0 child 28542 exit SIGCLD received pid (wait in signal) = 28542 wake up by signal 0 child 28543 exit SIGCLD received pid (wait in signal) = 28543 wake up by signal 1 parent exit
当然捕获SIGCHLD,也可以使用sigaction接口:
1 #include "../apue.h" 2 #include <sys/wait.h> 3 4 #define CLD_NUM 2 5 static void sig_cld (int signo, siginfo_t *info, void* param) 6 { 7 int status = 0; 8 if (signo == SIGCHLD) 9 { 10 if (info->si_code == CLD_EXITED || 11 info->si_code == CLD_KILLED || 12 info->si_code == CLD_DUMPED) 13 { 14 //printf ("child %d die\n", info->si_pid); 15 if (waitpid (info->si_pid, &status, 0) < 0) 16 perror ("wait(in signal) error"); 17 printf ("pid (wait in signal) = %d\n", info->si_pid); 18 } 19 else 20 { 21 printf ("unknown signal code %d\n", info->si_code); 22 } 23 } 24 } 25 26 int main () 27 { 28 pid_t pid = 0; 29 struct sigaction act; 30 sigemptyset (&act.sa_mask); 31 act.sa_sigaction = sig_cld; 32 act.sa_flags = SA_SIGINFO | SA_NOCLDSTOP; 33 int ret = sigaction (SIGCHLD, &act, 0); 34 if (ret == -1) 35 perror ("sigaction error"); 36 37 for (int i=0; i<CLD_NUM; ++ i) 38 { 39 if ((pid = fork ()) < 0) 40 perror ("fork error"); 41 else if (pid == 0) 42 { 43 sleep (3); 44 printf ("child %u exit\n", getpid ()); 45 _exit (0); 46 } 47 48 sleep (1); 49 } 50 51 for (int i=0; i<CLD_NUM; ++ i) 52 { 53 pause (); 54 printf ("wake up by signal %d\n", i); 55 } 56 57 printf ("parent exit\n"); 58 return 0; 59 }
输出是一样的。
关于signal与sigaction的区别,有以下几点:
a) 使用sigaction可以避免重新安装信号处理器的问题;
b) 使用sigaction可以在wait之前得知是哪个子进程结束了,这是通过指定SA_SIGINFO标志位,并提供带siginfo_t参数的信号处理器来实现的(info->si_pid就是结束的进程号);
c) 使用sigaction可以获取除子进程结束以外的状态变更通知,例如挂起、继续,默认接收相应通知,除非指定SA_NOCLDSTOP标志。而对于signal而言,没有办法不接收子进程非结束状态的通知(此时调用wait可能会卡死);
d) 使用sigaction可以自动wait已结束的子进程,只要指定SA_NOCLDWAIT标志即可。此时在信号处理器中不用再调用wait函数了。
当使用SA_NOCLDWAIT标志位时,使用systemtap可以观察到子进程还是向父进程发送了SIGCHLD信号的:
30049 cldsig 30048 cldsig 17 SIGCHLD 30050 cldsig 30048 cldsig 17 SIGCHLD
很有可能是系统内部自动wait了相关子进程。
另外在使用SA_NOCLDWAIT时,可以不指定信号处理器,此时sa_sigaction字段可以设置为SIG_DFL。
关于SIGCHLD信号,有以下几点需要注意:
a) 如果在注册信号之前,就已经有已结束但未等待的子进程存在,则事件不会被触发;
b) 可以为SIGCHLD注册一个处理器,也可以忽略该信号(SIG_IGN),忽略时系统自动回收已结束的子进程;
当正常捕获SIGCHLD时,使用systemtap是可以观察到子进程向父进程发送的SIGCHLD信号的:
29877 cldsig 29876 cldsig 17 SIGCHLD 29878 cldsig 29876 cldsig 17 SIGCHLD 29876 cldsig 27771 bash 17 SIGCHLD
当忽略SIGCHLD时,是看不到的,只能看到父进程结束时向bash发送的SIGCHLD信号:
29893 cldsig 27771 bash 17 SIGCHLD
这里注意一下二者在细节处的一点区别。
c) 还有一个SIGCLD信号,在大多数unix like系统中与SIGCHLD表现一致,在某些古老的unix系统上,可能有独特的表现需要注意,这方面请参考 apue 第十章第七节
在我测试的环境上(CentOS 6.7),该信号被定义为SIGCHLD,因此是完全相同的;
关于使用信号等待子进程最后需要谈的一点就是信号的竞争行为,对上面的例子稍加修改,就可以演示一下:
1 #include "../apue.h" 2 #include <sys/wait.h> 3 4 #define CLD_NUM 2 5 void pid_remove (pid_t pid) 6 { 7 printf ("remove pid %u\n", pid); 8 } 9 void pid_add (pid_t pid) 10 { 11 printf ("add pid %u\n", pid); 12 } 13 14 static void sig_cld (int signo) 15 { 16 pid_t pid = 0; 17 int status = 0; 18 printf ("SIGCHLD received\n"); 19 if (signal (SIGCHLD, sig_cld) == SIG_ERR) 20 perror ("signal error"); 21 if ((pid = wait (&status)) < 0) 22 perror ("wait(in signal) error"); 23 printf ("pid (wait in signal) = %d\n", pid); 24 pid_remove (pid); 25 } 26 27 int main () 28 { 29 pid_t pid = 0; 30 __sighandler_t ret = signal (SIGCHLD, sig_cld); 31 if (ret == SIG_ERR) 32 perror ("signal error"); 33 else 34 printf ("old handler %x\n", ret); 35 36 for (int i=0; i<CLD_NUM; ++ i) 37 { 38 if ((pid = fork ()) < 0) 39 perror ("fork error"); 40 else if (pid == 0) 41 { 42 //sleep (3); 43 printf ("child %u exit\n", getpid ()); 44 _exit (0); 45 } 46 47 sleep (1); 48 pid_add (pid); 49 } 50 51 sleep (1); 52 printf ("parent exit\n"); 53 return 0; 54 }
父进程在启动子进程后需要将它的信息通过pid_add添加到某种数据结构中,当收到SIGCHLD信号后,又通过pid_remove将它从这个数据结构中移出。
在上面的例子中,子进程一启动就退出了,快到甚至父进程还没有来得及执行pid_add就先执行了pid_remove,这必然导致某种问题。
(注意,为了能更好的呈现信号竞争的问题,这里故意在父进程sleep之后调用pid_add),执行结果如下:
old handler 0 child 31213 exit SIGCLD received pid (wait in signal) = 31213 remove pid 31213 add pid 31213 child 31214 exit SIGCLD received pid (wait in signal) = 31214 remove pid 31214 add pid 31214 parent exit
可以看到,remove总是在add之前执行。而解决方案也很直接,就是在pid_add完成之前,我们需要屏蔽SIGCHLD信号:
1 #include "../apue.h" 2 #include <sys/wait.h> 3 4 #define CLD_NUM 2 5 void pid_remove (pid_t pid) 6 { 7 printf ("remove pid %u\n", pid); 8 } 9 void pid_add (pid_t pid) 10 { 11 printf ("add pid %u\n", pid); 12 } 13 14 static void sig_cld (int signo) 15 { 16 pid_t pid = 0; 17 int status = 0; 18 printf ("SIGCHLD received\n"); 19 if (signal (SIGCHLD, sig_cld) == SIG_ERR) 20 perror ("signal error"); 21 if ((pid = wait (&status)) < 0) 22 perror ("wait(in signal) error"); 23 printf ("pid (wait in signal) = %d\n", pid); 24 pid_remove (pid); 25 } 26 27 int main () 28 { 29 pid_t pid = 0; 30 __sighandler_t ret = signal (SIGCHLD, sig_cld); 31 if (ret == SIG_ERR) 32 perror ("signal error"); 33 else 34 printf ("old handler %x\n", ret); 35 36 for (int i=0; i<CLD_NUM; ++ i) 37 { 38 sigset_t mask; 39 sigemptyset(&mask); 40 sigaddset(&mask, SIGCHLD); 41 sigprocmask(SIG_BLOCK, &mask, NULL); 42 if ((pid = fork ()) < 0) 43 perror ("fork error"); 44 else if (pid == 0) 45 { 46 sigprocmask(SIG_UNBLOCK, &mask, NULL); 47 //sleep (3); 48 printf ("child %u exit\n", getpid ()); 49 _exit (0); 50 } 51 52 sleep (1); 53 pid_add (pid); 54 sigprocmask(SIG_UNBLOCK, &mask, NULL); 55 } 56 57 sleep (1); 58 printf ("parent exit\n"); 59 return 0; 60 }
这里用到了sigprocmask去屏蔽以及解除某种信号的屏蔽。新的代码运行结果如下:
old handler 0 child 31246 exit add pid 31246 SIGCLD received pid (wait in signal) = 31246 remove pid 31246 child 31247 exit SIGCLD received pid (wait in signal) = 31247 remove pid 31247 add pid 31247 parent exit
可以看到一切正常了,add这次位于remove之前。
总结一下,使用SIGCHLD信号适合异步等待子进程的场景,并且通常搭配wait来回收子进程。
2. 然后谈单独使用wait函数族的场景。典型代码如下:
1 #include "../apue.h" 2 #include <sys/wait.h> 3 4 #define CLD_NUM 2 5 int main () 6 { 7 pid_t pid = 0; 8 for (int i=0; i<CLD_NUM; ++ i) 9 { 10 if ((pid = fork ()) < 0) 11 perror ("fork error"); 12 else if (pid == 0) 13 { 14 sleep (3); 15 printf ("child %u exit\n", getpid ()); 16 _exit (0); 17 } 18 19 sleep (1); 20 } 21 22 int status = 0; 23 for (int i=0; i<CLD_NUM; ++ i) 24 { 25 if ((pid = wait (&status)) < 0) 26 perror ("wait error"); 27 28 printf ("pid = %d\n", pid); 29 } 30 31 printf ("parent exit\n"); 32 return 0; 33 }
与之前场景不同的是,这里父进程同步等待启动的子进程结束。上面的代码会有如下输出:
child 28583 exit child 28584 exit pid = 28583 pid = 28584 parent exit
关于wait函数族,需要注意以下几点:
a) wait用于等待任何一个子进程,相当于waitpid(-1, status, 0); 当没有任何子进程存在时,返回-1,errno设置为ECHILD;
b) waitpid相对于wait的优势在于:
i) 可以指定子进程(组)来等待;
ii) 可以捕获子进程除结束以外的其它状态变更通知,如挂起(WUNTRACED)、继续(WCONTINUED)等;
iii) 可以不阻塞的测试某个子进程是否已结束(WNOHANG);
c) wait函数族可被信号中断,此时返回-1,errno设置为EINTR,必要时需要重启wait;
总结一下,使用wait函数族适合同步等待子进程,例如某种命令执行器进程,通常配合waitpid来回收子进程。
3. 最后谈谈混合使用同步wait与异步wait函数族的场景。
其实前面已经提到SIGCHLD要搭配wait使用,但那是异步使用wait的单一场景,而这里讲的混合,是指同时在信号处理器与执行流程中使用wait。
例如bash,它除了在主流程中同步等待前台正在运行的子进程,还必需在信号处理器中异步接收后台运行子进程的状态反馈,这样就不得不混合使用wait。
同步等待某个子进程一般使用waitpid,而在信号处理器中一般使用wait,典型的代码如下所示:
1 #include "../apue.h" 2 #include <sys/wait.h> 3 #include <errno.h> 4 5 #define CLD_NUM 2 6 7 static void sig_cld (int signo) 8 { 9 pid_t pid = 0; 10 int status = 0; 11 printf ("SIGCLD received\n"); 12 if (signal (SIGCLD, sig_cld) == SIG_ERR) 13 perror ("signal error"); 14 15 if ((pid = wait (&status)) < 0) 16 perror ("wait(in signal) error"); 17 else 18 printf ("pid (wait in signal) = %d\n", pid); 19 } 20 21 int main () 22 { 23 pid_t pid = 0; 24 __sighandler_t ret = signal (SIGCLD, sig_cld); 25 if (ret == SIG_ERR) 26 perror ("signal error"); 27 else 28 printf ("old handler %x\n", ret); 29 30 for (int i=0; i<CLD_NUM; ++ i) 31 { 32 if ((pid = fork ()) < 0) 33 perror ("fork error"); 34 else if (pid == 0) 35 { 36 if (i % 2 == 0) { 37 // simulate background 38 sleep (3); 39 } 40 else { 41 // simulate foreground 42 sleep (4); 43 } 44 45 printf ("child %u exit\n", getpid ()); 46 _exit (0); 47 } 48 49 sleep (1); 50 } 51 52 int status = 0; 53 printf ("before wait pid %u\n", pid); 54 if (waitpid (pid, &status, 0) < 0) 55 printf ("wait %u error %d\n", pid, errno); 56 else 57 printf ("wait child pid = %d\n", pid); 58 59 sleep (2); 60 printf ("parent exit\n"); 61 return 0; 62 }
父进程启动两个子进程,第一个休眠3秒后退出,第二个休眠4秒后退出,由于父进程同步等待的是第二个子进程,因此第二个进程模拟前台进程,第一个进程模拟后台进程。运行输出如下:
old handler 0 before wait pid 2481 child 2480 exit SIGCLD received pid (wait in signal) = 2480 wait 2481 error 4 child 2481 exit SIGCLD received pid (wait in signal) = 2481 parent exit
此时同步等待的waitpid被信号中断了(EINTR),此种情况下,我们需要重启waitpid:
1 int status = 0; 2 while (1) { 3 printf ("before wait pid %u\n", pid); 4 if (waitpid (pid, &status, 0) < 0) 5 { 6 int err = errno; 7 printf ("wait %u error %d\n", pid, err); 8 if (err == EINTR) 9 continue; 10 } 11 else 12 printf ("wait child pid = %d\n", pid); 13 14 break; 15 }
如果因EINTR引发的错误,则重新调用waitpid;否则,退出。新的代码输出如下:
old handler 0 before wait pid 2513 child 2512 exit SIGCLD received pid (wait in signal) = 2512 wait 2513 error 4 before wait pid 2513 child 2513 exit SIGCLD received wait(in signal) error: No child processes wait child pid = 2513 parent exit
可以看到两个进程退出时,都收到了SIGCHLD信号,只是前台进程被waitpid优先等待到了,所以信号处理器中的wait返回的ECHILD错误,但是如果还有其它子进程在运行,这里将会在信号处理器的wait中卡死。
之前提到,可以使用SIG_IGN来自动回收子进程,这里试一下使用SIG_IGN来代替sig_cld,看看有什么改观。
old handler 0 before wait pid 2557 child 2556 exit child 2557 exit wait 2557 error 10 parent exit
同样的,两个子进程都走了忽略信号,而同步等待的waitpid因没有进程可等返回了ECHILD。因为waitpid是指定进程等待的,所以即使还有其它子进程存在,这个也会返回错误,不会卡死在那里。
相比上面的方法,似乎好了一点,但是因为我们没有安装处理器,所以无从得知哪个后台进程结束了,这并不是我们想到的结果。
之前提到,可以使用sigaction代替signal以获取更多的控制,我们看看换新的方式捕获信号,会不会有一些改变,新的代码逻辑如下:
1 #include "../apue.h" 2 #include <sys/wait.h> 3 #include <errno.h> 4 5 #define CLD_NUM 2 6 7 static void sig_cld (int signo, siginfo_t *info, void* param) 8 { 9 int status = 0; 10 if (signo == SIGCHLD) 11 { 12 if (info->si_code == CLD_EXITED || 13 info->si_code == CLD_KILLED || 14 info->si_code == CLD_DUMPED) 15 { 16 if (waitpid (info->si_pid, &status, 0) < 0) 17 err_ret ("wait(in signal) %u error", info->si_pid); 18 else 19 printf ("pid (wait in signal) = %d\n", info->si_pid); 20 } 21 else 22 { 23 printf ("unknown signal code %d\n", info->si_code); 24 } 25 } 26 } 27 28 int main () 29 { 30 pid_t pid = 0; 31 struct sigaction act; 32 sigemptyset (&act.sa_mask); 33 act.sa_sigaction = sig_cld; 34 act.sa_flags = SA_SIGINFO | SA_NOCLDSTOP; 35 int ret = sigaction (SIGCHLD, &act, 0); 36 if (ret == -1) 37 perror ("sigaction error"); 38 39 for (int i=0; i<CLD_NUM; ++ i) 40 { 41 if ((pid = fork ()) < 0) 42 perror ("fork error"); 43 else if (pid == 0) 44 { 45 if (i % 2 == 0) { 46 // simulate background 47 sleep (3); 48 } 49 else { 50 // simulate foreground 51 sleep (4); 52 } 53 54 printf ("child %u exit\n", getpid ()); 55 _exit (0); 56 } 57 58 sleep (1); 59 } 60 61 int status = 0; 62 while (1) { 63 printf ("before wait pid %u\n", pid); 64 if (waitpid (pid, &status, 0) < 0) 65 { 66 int err = errno; 67 printf ("wait %u error %d\n", pid, err); 68 if (err == EINTR) 69 continue; 70 } 71 else 72 printf ("wait child pid = %d\n", pid); 73 74 break; 75 } 76 77 sleep (2); 78 printf ("parent exit\n"); 79 return 0; 80 }
运行输出如下:
before wait pid 2585 child 2584 exit pid (wait in signal) = 2584 wait 2585 error 4 before wait pid 2585 child 2585 exit wait(in signal) 2585 error: No child processes wait child pid = 2585 parent exit
结果与使用signal很相似,但是因为在信号处理器中我们能明确的知道是哪个子进程终结了,使用的是waitpid而不是wait,所以即使还有其它子进程未结束,也不会在信号处理器的waitpid中卡住。
结论是无论使用signal还是sigaction,同步等待的waitpid总比SIGCHLD信号处理器中的wait(xxx)具有更高的优先级。当然,这个前提是在父进程同步waitpid之前,子进程还没有结束;
如果要等待的子进程先结束了,SIGCHLD当然先被执行,这种情况下,建议先使用sigprocmask屏蔽SIGCHLD信号,然后在waitpid之前解除屏蔽。虽然不能保证完全解决信号竞争的问题,
也能极大的缓解此种情况,即使出现了信号竞争,导致同步等待的waitpid返回ECHILD,我们也能从这些错误码中得知发生的事情,不会出现卡死的情况。
出于好奇,我们看一下改使用SIG_IGN后的运行效果:
before wait pid 2613 child 2612 exit child 2613 exit wait 2613 error 10 parent exit
与使用signal时并无二致,仍然是忽略信号占了上风。结论是无论使用signal还是sigaction,当忽略SIGCHLD信号时,信号优先于wait被忽略。出于同样的原因,这种方式我们并不采纳。
之前提到,sigaction还有一种高级的忽略SIGCHLD的方式,即指定SA_NOCLDWAIT标志位,同时给信号处理器指定SIG_DFL,这种情况下,我们看看输出会有什么变化:
before wait pid 2719 child 2718 exit child 2719 exit wait 2719 error 10 parent exit
可以看到,与使用SIG_IGN并无二致。
与SIG_IGN不同的是,我们可以为SIGCHLD提供一个处理器,虽然在此信号处理器中无需再次等待子进程,但是我们拥有了获取子进程信息的能力,相对而言,比SIG_IGN更有用一些。新的输出如下:
before wait pid 2737 child 2736 exit pid (auto wait in signal) = 2736 wait 2737 error 4 before wait pid 2737 child 2737 exit pid (auto wait in signal) = 2737 wait 2737 error 10 parent exit
可以看到,同步waitpid仍然返回ECHILD,显然是信号更具有优先级。
好了,到这里就全明了了,对于混合使用同步与异步wait的应用来说,最佳的方法应该是同步waitpid等待前台进程,异步使用sigaction注册SIGCHLD信号处理器等待后台进程,且不设置SA_NOCLDWAIT标志位。
在处理器中也应使用waitpid等待子进程,如返回ECHILD错误,证明该子进程是前台进程,已经被同步wait掉了,不需要后续处理;否则作为后台进程处理。
最后,我们发现同步等待的waitpid没有被中断的情况只在忽略信号的时候产生,而之前也证明了忽略信号时,系统压根不产生SIGCHLD信号,这两者似乎到现在是对上了…… :)