TLPI读书笔记第26章:监控子进程
26.1 等待子进程
对于许多需要创建子进程的应用来说,父进程能够监测子进程的终止时间和过程是很有必要的。 wait()以及若干相关的系统调用提供了这一功能。
26.1.1 系统调用 wait()
系统调用 wait()等待调用进程的任一子进程终止,同时在参数 status 所指向的缓冲区中返回该子进程的终止状态。
系统调用 wait()执行如下动作。
1. 如果调用进程并无之前未被等待的子进程终止,调用将一直阻塞,直至某个子进程终止。如果调用时已有子进程终止, wait()则立即返回。
2. 如果 status 非空,那么关于子进程如何终止的信息则会通过 status 指向的整型变量返回。
3. 内核将会为父进程下所有子进程的运行总量追加进程 CPU 时间( 10.7 节)以及资源使用数据。
4. 将终止子进程的 ID 作为 wait()的结果返回。
出错时, wait()返回-1。可能的错误原因之一是调用进程并无之前未被等待的子进程,此时会将 errno 置为ECHILD。换言之,可使用如下代码中的循环来等待调用进程的所有子进程退出。
while((childPid=wait(NULL))!=-1)
continue;
if(errno!=ECHILD)
errExit('wait');
程序清单 26-1 演示了 wait()的用法。该程序创建多个子进程,每个子进程对应于一个(整型)命令行参数。每个子进程休眠若干秒后退出,休眠时间分别由相应各命令行参数指定。
与此同时,在创建所有的子进程之后,父进程循环调用 wait()来监控这些子进程的终止。而直到 wait()返回-1 时才会退出循环。 (这并非唯一的手段,另一种退出循环的方法是当记录终止子进程数量的变量 numDead 与创建的子进程数目相同时,也会退出循环。 )
以下 shell 会话日志显示了使用该程序创建 3 个子进程时的情况
26.1.2 系统调用 waitpid()
系统调用 wait()存在诸多限制,而设计 waitpid()则意在突破这些限制。
1.如果父进程已经创建了多个子进程,使用 wait()将无法等待某个特定子进程的完成,只能按顺序等待下一个子进程的终止。
2.如果没有子进程退出, wait()总是保持阻塞。有时候会希望执行非阻塞的等待:是否有子进程退出,立判可知。
3.使用 wait()只能发现那些已经终止的子进程。对于子进程因某个信号(如 SIGSTOP 或SIGTTIN)而停止,或是已停止子进程收到 SIGCONT 信号后恢复执行的情况就无能为力了。
waitpid()与 wait()的返回值以及参数 status 的意义相同。 参数 pid 用来表示需要等待的具体子进程,意义如下:
1.如果 pid 大于 0,表示等待进程 ID 为 pid 的子进程。
2.如果 pid 等于 0,则等待与调用进程(父进程)同一个进程组( process group)的所有子进程。 34.2 节将描述进程组的概念。
3.如果 pid 小于-1,则会等待进程组标识符与 pid 绝对值相等的所有子进程。
4.如果 pid 等于-1,则等待任意子进程。 wait(&status)的调用与 waitpid(-1, &status, 0)等价
参数 options 是一个位掩码( bit mask),可以包含(按位或操作) 0 个或多个如下标志
WUNTRACED 除了返回终止子进程的信息外,还返回因信号而停止的子进程信息。 WCONTINUED 返回那些因收到 SIGCONT 信号而恢复执行的已停止子进程的状态信息。 WNOHANG 如果参数 pid 所指定的子进程并未发生状态改变, 则立即返回, 而不会阻塞, 亦即 poll(轮询)。在这种情况下, waitpid()返回 0。如果调用进程并无与 pid 匹配的子进程,则 waitpid()报错,将错误号置为 ECHILD。
26.1.3 等待状态值
由 wait()和 waitpid()返回的 status 的值,可用来区分以下子进程事件。
1.子进程调用_exit()(或 exit())而终止,并指定一个整型值作为退出状态。
2.子进程收到未处理信号而终止。
3.子进程因为信号而停止,并以 WUNTRACED 标志调用 waitpid()。
4.子进程因收到信号 SIGCONT 而恢复,并以 WCONTINUED 标志调用 waitpid()。
此处用术语“等待状态”(wait status)来涵盖上述所有情况,而使用“终止状态”(terminationstatus)的称谓来指代前两种情况。(在 shell 中,可通过读取$?变量值来获取上次执行命令的终止状态。 ) 虽然将变量 status 定义为整型( int),但实际上仅使用了其最低的 2 个字节。对这 2 个字节的填充方式取决于子进程所发生的具体事件,如图 26-1 所示。
头文件<sys/wait.h>定义了用于解析等待状态值的一组标准宏。对自 wait()或 waitpid()返回的status 值进行处理时,以下列表中各宏只有一个会返回真(true)值。
如列表所示,另有其他宏可对 status 值做进一步分析。下面这些不是常量,都是函数
WIFEXITED (status)
若子进程正常结束则返回真( true)。此时,宏 WEXITSTATUS(status)返回子进程的退出状态。
WIFSIGNALED (status)
若通过信号杀掉子进程则返回真(true)。此时,宏 WTERMSIG(status)返回导致子进程终止的信号编号。若子进程产生内核转储文件,则宏 WCOREDUMP(status)返回真值( true)。 SUSv3并未规范宏 WCOREDUMP(),不过大部分 UNIX 实现均支持该宏。
WIFSTOPPED (status)
若子进程因信号而停止,则此宏返回为真值( true)。此时,宏 WSTOPSIG(status)返回导致子进程停止的信号编号。 WIFCONTINUED (status)
若子进程收到 SIGCONT 而恢复执行,则此宏返回真值( true)。自 Linux 2.6.10 之后开始支持该宏。 注意:尽管上述宏的参数也以 status 命名,不过此处所指只是简单的整型变量,而非像wait()和 waitpid()所要求的那样是指向整型的指针。
26.1.4 从信号处理程序中终止进程
如表 20-1 所列,默认情况下某些信号会终止进程。有时,可能希望在进程终止之前执行一些清理步骤。为此,可以设置一个处理程序( handler)来捕获这些信号,随即执行清理步骤,再终止进程。如果这么做,需要牢记的是:通过 wait()和 waitpid()调用,父进程依然可以获取子进程的终止状态。例如,如果在信号处理程序中调用_exit(EXIT_SUCCESS),父进程会认为子进程是正常终止。 如果需要通知父进程自己因某个信号而终止,那么子进程的信号处理程序应首先将自己废除,然后再次发出相同信号,该信号这次将终止这一子进程。信号处理程序需包含如下代码:
void handler(int sig){
/*clean*/
signal(sig,SIG_DFL);
raise(sig);
}
26.1.5 系统调用 waitid()
与 waitpid()类似, waitid()返回子进程的状态。不过, waitid()提供了 waitpid()所没有的扩展功能。该系统调用源于系统 V(System V),不过现在已获 SUSv3 采用,并从版本 2.6.9 开始,将其加入 Linux 内核。
参数 idtype 和 id 指定需要等待哪些子进程,如下所示。 1.如果 idtype 为 P_ALL,则等待任何子进程,同时忽略 id 值。 2.如果 idtype 为 P_PID,则等待进程 ID 为 id 进程的子进程。 3.如果 idtype 为 P_PGID,则等待进程组 ID 为 id 各进程的所有子进程。
waitpid()与 waitid()最显著的区别在于,对于应该等待的子进程事件, waitid()可以更为精确地控制。可通过在 options 中指定一个或多个如下标识(按位或运算)来实现这种控制。 WEXITED 等待已终止的子进程,而无论其是否正常返回。 WSTOPPED 等待已通过信号而停止的子进程。 WCONTINUED 等待经由信号 SIGCONT 而恢复的子进程。 以下附加标识也可以通过按位或运算加入 options 中。 WNOHANG 与其在 waitpid()中的意义相同。如果匹配 id 值的子进程中并无状态信息需要返回,则立即返回(一个轮询)。此时, waitid()返回 0。如果调用进程并无子进程与 id 的值相匹配,则waitid 调用失败,且错误号为 ECHILD。 WNOWAIT 通常,一旦通过 waitid()来等待子进程,那么必然会去处理所谓“状态事件”。不过,如果指定了 WNOWAIT,则会返回子进程状态,但子进程依然处于可等待的( waitable)状态,稍后可再次等待并获取相同信息。 执行成功, waitid()返回 0,且会更新指针 infop 所指向的 siginfo_t 结构,以包含子进程的相关信息。以下是结构 siginfo_t 的字段情况。 si_code 该字段包含以下值之一: CLD_EXITED,表示子进程已通过调用_exit()
而终止; CLD_KILLED,表示子进程为某个信号所杀; CLD_STOPPED,表示子进程因某个信号而停止; CLD_CONTINUED,表示(之前停止的)子进程因接收到( SIGCONT)信号而恢复执行。 si_pid 该字段包含状态发生变化子进程的进程 ID。 si_signo 总是将该字段置为 SIGCHLD。 si_status 该字段要么包含传递给_exit()的子进程退出状态,要么包含导致子进程停止、继续或终止的信号值。可以通过读取 si_code 值来判定具体包含的是哪一种类型的信息。 si_uid 该字段包含子进程的真正用户 ID。大部分其他 UNIX 实现不会设置该字段
waitid()操作的一处细节需要进一步澄清。 如果在 options 中指定了 WNOHANG, 那么 waitid()返回 0 意味着以下两种情况之一:
1.在调用时子进程的状态已经改变;
2.或者没有任何子进程的状态有所改变。
对于没有任何子进程改变状态的情况,一些 UNIX 实现(包括 Linux)会将 siginfo_t 结构内容清 0。这也是区分两种情况的方法之一:检查 si_pid 的值是否为 0。不幸的是, SUSv3 并未规范这一行为,一些 UNIX 实现此时会保持结构 siginfo_t 原封不动。(未来针对 SUSv4 的勘误表可能会增加在这种情况下将 si_pid 和 si_signo 置 0 的要求。) 区分这两种情况唯一可移植的方法是: 在调用 waitid()之前就将结构 siginfo_t 的内容置为 0,正如以下代码所示:
26.1.6 系统调用 wait3()和 wait4()
系统调用 wait3()和 wait4()执行与 waitpid()类似的工作。主要的语义差别在于, wait3()和 wait4() 在参数 rusage 所指向的结构中返回终止子进程的资源使用情况。 其中包括进程使用的 CPU 时间总量以及内存管理的统计数据。 36.1 节将在介绍系统调用 getrusage()时详细讨论 rusage 结构。
除了对参数 rusage 的使用之外,调用 wait3()等同于以如下方式调用 waitpid():
waitpid(-1,&status,options);
与之相类似,对 wait4()的调用等同于对 waitpid()的如下调用:
waitpid(pid,&status,options);
换言之, wait3()等待的是任意子进程,而 wait4()则可以用于等待选定的一个或多个子进程。在一些 UNIX 实现中, wait3()和 wait4()仅返回已终止子进程的资源使用情况。而对于 Linux 系统,如果在 options 中指定了 WUNTRACED 选项,则还可以获取到停止子进程的资源使用信息。 这两个系统调用的名称来自于它们所使用参数的个数。虽然源自 BSD 系统,不过现在大部分的 UNIX 实现都支持它们。这两个系统调用均未获得 SUSv3 标准的接纳。 ( SUSv2 标准纳入了 wait3(),但将其标记为“已过时”。 ) 本书一般会避免使用 wait3()和 wait4()。通常情况下,此类调用所返回的额外信息没有什么价值。此外,未获业界标准的接纳也会限制其可移植性
26.2 孤儿进程与僵尸进程
父进程与子进程的生命周期一般都不相同,父、子进程间互有长短。这就引出了下面两个问题。 1.谁会是孤儿(orphan)子进程的父进程?进程 ID 为 1 的众进程之祖—init 会接管孤儿进程。换言之,某一子进程的父进程终止后,对 getppid()的调用将返回 1。这是判定某一子进程之“生父”是否“在世”的方法之一(前提是假设该子进程由 init 之外的进程创建)。
2.在父进程执行 wait()之前,其子进程就已经终止,这将会发生什么?此处的要点在于,即使子进程已经结束,系统仍然允许其父进程在之后的某一时刻去执行 wait(),以确定该子进程是如何终止的。内核通过将子进程转为僵尸进程(zombie)来处理这种情况。这也意味着将释放子进程所把持的大部分资源,以便供其他进程重新使用。该进程所唯一保留的是内核进程表中的一条记录,其中包含了子进程 ID、终止状态、资源使用数据(36.1 节)等信息。 至于僵尸进程名称的由来,则源于 UNIX 系统对电影情节的效仿—无法通过信号来杀死僵尸进程,即便是(银弹) SIGKILL。这就确保了父进程总是可以执行 wait()方法。当父进程执行 wait()后,由于不再需要子进程所剩余的最后信息,故而内核将删除僵尸进程。
另一方面,如果父进程未执行 wait()随即退出,那么 init 进程将接管子进程并自动调用 wait(), 从而从系统中移除僵尸进程。 如果父进程创建了某一子进程,但并未执行 wait(),那么在内核的进程表中将为该子进程永久保留一条记录。如果存在大量此类僵尸进程,它们势必将填满内核进程表,从而阻碍新进程的创建。既然无法用信号杀死僵尸进程,那么从系统中将其移除的唯一方法就是杀掉它们的父进程(或等待其父进程终止),此时 init 进程将接管和等待这些僵尸进程,从而从系统中将它们清理掉。 在设计长生命周期的父进程(例如:会创建众多子进程的网络服务器和 Shell)时,这些语义具有重要意义。换句话说,在此类应用中,父进程应执行 wait()方法,以确保系统总是能够清理那些死去的子进程,避免使其成为长寿僵尸。如 26.3.1 节所述,父进程在处理 SIGCHLD 信号时,对 wait()的调用既可同步,也可异步。 程序清单 26-4 展示了一个僵尸进程的创建,以及发送 SIGKILL 信号无法杀死僵尸进程的例子。运行这一程序的输出如下:
26.3 SIGCHLD 信号
子进程的终止属异步事件。父进程无法预知其子进程何时终止。(即使父进程向子进程发送 SIGKILL 信号,子进程终止的确切时间还依赖于系统的调度:子进程下一次在何时使用CPU。 )之前已经论及,父进程应使用 wait()来防止僵尸子进程的累积,以及采用如下两种方法来避免这一问题。
1.父进程调用不带 WNOHANG 标志的 wait(),或 waitpid()方法,此时如果尚无已经终止的子进程,那么调用将会阻塞。
2.父进程周期性地调用带有 WNOHANG 标志的 waitpid(),执行针对已终止子进程的非阻塞式检查(轮询)。
这两种方法使用起来都有所不便。一方面,可能并不希望父进程以阻塞的方式来等待子进程的终止。另一方面,反复调用非阻塞的 waitpid()会造成 CPU 资源的浪费,并增加应用程序设计的复杂度。为了规避这些问题,可以采用针对 SIGCHLD 信号的处理程序。
26.3.1 为 SIGCHLD 建立信号处理程序
无论一个子进程于何时终止,系统都会向其父进程发送 SIGCHLD 信号。对该信号的默认处理是将其忽略,不过也可以安装信号处理程序来捕获它。在处理程序中,可以使用 wait()来收拾僵尸进程。不过,使用这一方法时需要掌握一些窍门。 由 20.10 节和 20.12 节可知,当调用信号处理程序时,会暂时将引发调用的信号阻塞起来(除非为 sigaction()指定了 SA_NODEFER 标志),且不会对 SIGCHLD 之流的标准信号进行排队处理。 这样一来,当 SIGCHILD 信号处理程序正在为一个终止的子进程运行时,如果相继有两个子进程终止,即使产生了两次 SIGCHLD 信号,父进程也只能捕获到一个。结果是,如果父进程的SIGCHLD 信号处理程序每次只调用一次 wait(),那么一些僵尸子进程可能会成为“漏网之鱼”。
解决方案是:在 SIGCHLD 处理程序内部循环以 WNOHANG 标志来调用 waitpid(),直至再无其他终止的子进程需要处理为止。通常 SIGCHLD 处理程序都简单地由以下代码组成,仅仅捕获已终止子进程而不关心其退出状态。
while(waitpid(-1,NULL,WNOHANG)>0)
continue;
上述循环会一直持续下去,直至 waitpid()返回 0,表明再无僵尸子进程存在,或-1,表示有错误发生(可能是 ECHILD,意即再无更多的子进程)。
SIGCHLD 处理程序的设计问题
假设创建 SIGCHLD 处理程序的时候,该进程已经有子进程终止。那么内核会立即为父进程产生 SIGCHLD 信号吗? SUSv3 对这一点并未规定。一些源自系统 V( System V)的实现在这种情况下会产生 SIGCHLD 信号;而另一些系统,包括 Linux,则不这么做。为保障可移植性, 应用应在创建任何子进程之前就设置好 SIGCHLD 处理程序, 将这一隐患消解于无形。
需要更深入考虑的问题是可重入性( reentrancy)。 21.1.2 节特别指出,在信号处理程序中使用系统调用(如 waitpid())可能会改变全局变量 errno 的值。当主程序企图显式设置 errno(参考 35.1 节对 getpriority()的讨论)或是在系统调用失败后检查 errno 值时,这一变化会与之发生冲突。出于这一原因,有时在编写 SIGCHLD 信号处理程序时,需要在一进入处理程序时就使用本地变量来保存 errno 值,而在返回前加以恢复。请参考程序清单
26.3.2 向已停止的子进程发送 SIGCHLD 信号
正如可以使用 waitpid()来监测已停止的子进程一样,当信号导致子进程停止时,父进程也就有可能收到 SIGCHLD 信号。调用 sigaction()设置 SIGCHLD 信号处理程序时,如传入 SA_NOCLDSTOP 标志即可控制这一行为。若未使用该标志,系统会在子进程停止时向父进程发送 SIGCHLD 信号;反之,如果使用了这一标志,那么就不会因子进程的停止而发出 SIGCHLD信号。 因为默认情况下会忽略信号 SIGCHLD, SA_NOCLDSTOP 标志仅在设置 SIGCHLD 信号处理程序时才有意义。而且, SA_NOCLDSTOP 只对 SIGCHLD 信号起作用。 SUSv3 也允许,当信号 SIGCONT 导致已停止的子进程恢复执行时,向其父进程发送 SIGCHLD信号。(相当于 waitpid()的 WCONTINUED 标志。)始于版本 2.6.9, Linux 内核实现了这一特性。
26.3.3 忽略终止的子进程
更有可能像这样处理终止子进程: 将对 SIGCHLD 的处置( disposition)显式置为 SIG_ IGN,系统从而会将其后终止的子进程立即删除,毋庸转为僵尸进程。这时,会将子进程的状态弃之不问,故而所有后续的 wait()调用不会返回子进程的任何信息。
如同许多 UNIX 实现一样,在 Linux 系统中将对 SIGCHLD 信号的处置置为 SIG_IGN 并不会影响任何既有僵尸进程的状态,对它们的等待仍然要照常进行。在其他一些 UNIX 实现中,将对 SIGCHLD 的处置设置为 SIG_IGN 确实会删除所有已有的僵尸进程。 信号 SIGCHLD 的 SIG_IGN 语义由来已久,源于系统 V( System V)。 SUSv3 也规定了此处所描述的行为,不过原始的 POSIX.1 标准对此则未作表述。因此,在一些较老的 UNIX 实现中,忽略 SIGCHLD 并不影响僵尸进程的创建。要防止产生僵尸进程,唯一完全可移植的方法就是调用 wait()或者 waitpid()。
老版本 Linux 内核实现与 SUSv3 标准的差异
SUSv3 规定,如果将对 SIGCHLD 的处置设置为 SIG_IGN,那么将丢弃子进程的资源使用信息,且若指定 RUSAGE_CHILDREN 标志调用 getrusage()函数,其返回总量中也将不包含该项信息(36.1 节)。然而,在版本 2.6.9 之前的 Linux 内核中,还是会记录子进程的 CPU 使用时间以及资源的使用情况,并可通过 getrusage()调用获取。这一违规行为直至 Linux 2.6.9 才得以修正。
将对 SIGCHLD 的处置设置为 SIG_IGN 还会阻止 times()(10.7 节)返回的结构中包含子进程的 CPU 使用时间。不过,在 Linux 2.6.9 之前, times()所返回的信息同样存在违规行为。 SUSv3 规定,如果将对 SIGCHLD 的处置设置为 SIG_IGN,同时,父进程已终止的子进程中并无处于僵尸状态且未被等待的情况,那么 wait()(或 waitpid())调用将一直阻塞,直至所有子进程都终止,届时该调用将返回错误 ECHILD。 Linux 2.6 符合这一要求。不过在 Linux 2.4 以及更早期的版本中, wait()只会阻塞到下一个子进程终止的时刻,随即返回该子进程的进程 ID及状态(亦即,此行为与未将对 SIGCHLD 信号的处置置为 SIG_IGN 时一样)
sigaction()的 SA_NOCLDWAIT 标志
SUSv3 规定了 SA_NOCLDWAIT 标志, 可在调用 sigaction()对 SIGCHLD 信号的处置进行设置时使用此标志。 设置该标志的作用类似于将对 SIGCHLD 的处置置为 SIG_IGN 时的效果。 Linux 2.4 及其早期版本并未实现该标志,直至 Linux 2.6 才实现对其支持。 将对 SIGCHLD 的处置置为 SIG_IGN 与采用 SA_NOCLDWAIT 之间最主要的区别在于, 当以 SA_NOCLDWAIT 设置信号处理程序时, SUSv3 并未规定系统在子进程终止时是否向其父进程发送 SIGCHLD 信号。换言之,当指定 SA_NOCLDWAIT 时允许系统发送 SIGCHLD 信号,则应用程序即可捕捉这一信号(尽管由于内核已经丢弃了僵尸进程,造成 SIGCHLD 处理程序无法用 wait()来获得子进程状态)。在包括 Linux 在内的一些 UNIX 实现中,内核确实会为父进程产生 SIGCHLD 信号。而在另一些 UNIX 实现中,则不会
系统 V 的 SIGCLD 信号
Linux 系统中,信号 SIGCLD 与信号 SIGCHLD 意义相同。之所以两个名称并存,是由历史原因造成的。 SIGCHLD 信号源自 BSD, POSIX 标准采用了这一名称,同时对 BSD 信号模型做了大量标准化工作。系统 V 则提供了相应的 SIGCLD 信号,在语义上稍许有些不同。 BSD SIGCHLD 信号与系统 V SIGCLD 间的主要差别在于,将信号处置为 SIG_IGN 时不同的处理方式。 1.在历史(和一些现代)的 BSD 实现中,即使忽略了 SIGCHLD 信号,系统仍会继续将无人等待的子进程变为僵尸进程。 2.在系统 V 上,使用 signal()(而非 sigaction())忽略 SIGCLD 信号将导致子进程在终止时不会转为僵尸进程。 如前所述,原始的 POSIX.1 标准对于忽略 SIGCHLD 的后果未作规定,从而也认可了系统V 的行为。 而今, 系统 V 的行为已成为 SUSv3 标准的一部分(不过将仍然使用 SIGCHLD 的名称)。 衍生自系统 V 的现代系统实现中对该信号使用了 SIGCHLD 这一标准名称,同时继续提供具 有相同含义的 SIGCLD 信号。。
26.4 总结
使用 wait()和 waitpid(),父进程可以得到其终止或停止子进程的状态。该状态表明子进程是正常终止(带有表示成功或失败的退出状态),还是异常中止,因收到某个信号而停止,还是因收到 SIGCONT 信号而恢复执行。
如果子进程的父进程终止,那么子进程将变为孤儿进程,并为进程 ID 为 1 的 init 进程接管。
子进程终止后会变为僵尸进程,仅当其父进程调用 wait()(或类似函数)获取子进程退出状态时,才能将其从系统中删除。在设计长时间运行的程序,诸如 shell 程序以及守护进程( daemon)时,应总是捕获其所创建子进程的状态,因为系统无法杀死僵尸进程,而未处理的僵尸进程最终将塞满内核进程表。
捕获终止子进程的一般方法是为信号 SIGCHLD 设置信号处理程序。当子进程终止时(也可选择子进程因信号而停止时),其父进程会收到 SIGCHLD 信号。还有另一种移植性稍差的处理方法,进程可选择将对 SIGCHLD 信号的处置置为忽略( SIG_IGN),这时将立即丢弃终止子进程的状态(因此其父进程从此也无法获取到这些信息),子进程也不会成为僵尸进程