TLPI 第25 章 读书笔记:Process Termination
笔记和练习博客总目录见:开始读TLPI。
本章描述了当一个进程终止时会发生什么。我们首先介绍使用 exit() 和 _exit() 来终止进程。然后我们讨论在进程调用 exit() 时自动执行清理的退出处理函数的使用。最后,我们考虑 fork()、stdio 缓冲区和 exit() 之间的一些相互作用。
25.1 Terminating a Process: _exit() and exit()
一个进程可能以两种常见的方式终止。其中一种是异常终止,这通常由发送信号引起,而该信号的默认动作是终止进程(可以带或不带核心转储),如第20.1节所述。另一种方式是进程通过_exit()系统调用正常终止。
#include<unistd.h>void_exit(intstatus);传递给_exit()的status参数定义了进程的终止状态,当父进程调用wait()时可以获得该状态。虽然status被定义为int类型,但实际上只有最低的8位可供父进程使用。按照惯例,终止状态为0表示进程成功完成,而非零状态值表示进程非正常终止。对于非零状态值的解释没有固定规则;不同的应用程序遵循各自的惯例,这些惯例应在其文档中说明。SUSv3指定了两个常量,EXIT_SUCCESS(0)和EXIT_FAILURE(1),这些常量在本书的大多数程序中都使用。
进程总是通过_exit()成功终止(也就是说,_exit()从不返回)。
虽然可以通过 _exit() 的 status 参数向父进程传递 0 到 255 范围内的任何值,但指定大于 128 的值可能会在 shell 脚本中引起混淆。原因是,当一个命令被信号终止时,shell 会通过将变量 $? 的值设置为 128 加上信号数值来指示这一事实,而这个值与进程调用 _exit() 并使用相同的状态值时产生的值无法区分。
程序通常不会直接调用 _exit(),而是调用 exit() 库函数,该函数在调用 _exit() 之前会执行各种操作。
#include<stdlib.h>voidexit(intstatus);exit() 执行以下操作:
- 调用退出处理程序(使用 atexit() 和 on_exit() 注册的函数),按注册的相反顺序调用(第 25.3 节)。
- 刷新 stdio 流缓冲区。
- 调用 _exit() 系统调用,使用 status 提供的值。
与特定于 UNIX 的 _exit() 不同,exit() 被定义为标准 C 库的一部分;也就是说,它在每个 C 实现中都可用。
另一种进程可能终止的方式是从 main() 返回,既可以显式返回,也可以隐式返回,即执行到 main() 函数的末尾。执行显式的 returnn通常等同于调用 exit(n),因为调用 main() 的运行时函数会在调用 exit() 时使用 main() 的返回值。
在某些情况下,调用 exit() 与从 main() 返回并不等同。如果在 exit 处理过程中执行的任何步骤访问了 main() 的局部变量,那么从 main() 返回会导致未定义行为。例如,如果 main() 的局部变量在调用 setvbuf() 或 setbuf()(第 13.2 节)时被指定,就可能发生这种情况。
在没有指定返回值的情况下进行 return,或者执行到 main() 函数的末尾,也会导致 main() 的调用者调用 exit(),但其结果会根据所支持的 C 标准版本和所使用的编译选项而有所不同:
- 在 C89 中,在这些情况下的行为是未定义的;程序可以以任意状态值终止。这是 Linux 上使用 gcc 时默认发生的行为,其中程序的退出状态取自堆栈上或特定 CPU 寄存器中的某个随机值。应避免以这种方式终止程序。
- C99 标准要求主程序执行到末尾相当于调用 exit(0)。如果我们使用 gcc –std=c99 编译程序,这就是在 Linux 上获得的行为。
25.2 Details of Process Termination
在进程正常或异常终止期间,会发生以下操作:
- 打开的文件描述符、目录流(见第18.8节)、消息目录描述符(参见 catopen(3) 和 catgets(3) 手册页)以及转换描述符(参见 iconv_open(3) 手册页)将被关闭。
- 由于文件描述符的关闭,该进程持有的任何文件锁(见第55章)都将被释放。
- 进程所附着的所有 System V 共享内存段都会被解除附着,且每个共享内存段对应的引用计数
shm_nattch减一。(参见 48.8 节) - 对于每个由进程设置了 semadj 值的 System V 信号量,该 semadj 值将被加到信号量的值上。(参见第47.8节)
- 如果这是控制终端的控制进程,则向控制终端前台进程组中的每个进程发送 SIGHUP 信号,并且终端与会话解除关联。我们将在第34.6节进一步讨论这一点。
- 在调用进程中打开的任何 POSIX 命名信号量都将被关闭,就像调用 sem_close() 一样。
- 调用进程中打开的任何 POSIX 消息队列都会被关闭,就好像调用了 mq_close()。
- 如果由于该进程退出而导致一个进程组成为孤儿,并且该组中有任何停止的进程,则该组中的所有进程都会收到 SIGHUP 信号,随后是 SIGCONT 信号。我们将在第 34.7.4 节进一步讨论这一点。
- 该进程使用 mlock() 或 mlockall()(第 50.2 节)建立的任何内存锁都会被移除。
- 该进程使用 mmap() 建立的任何内存映射都会被解除映射。
25.3 Exit Handlers
有时,应用程序需要在进程终止时自动执行一些操作。考虑这样一个应用库的例子,如果在进程生命周期中使用它,在进程退出时需要自动执行一些清理操作。由于库无法控制进程何时以及如何退出,也无法强制主程序在退出前调用库特定的清理函数,因此无法保证清理操作一定会发生。在这种情况下的一种方法是使用退出处理程序(较早的 System V 手册使用术语程序终止例程)。
退出处理程序是程序员提供的函数,在进程生命周期的某个时刻注册,然后在通过 exit() 正常终止进程时自动调用。如果程序直接调用 _exit() 或进程被信号异常终止,则不会调用退出处理程序。
在某种程度上,当进程被信号终止时退出处理程序不会被调用,这限制了其使用价值。我们所能做的最好尝试是为可能发送给进程的信号建立处理程序,并让这些处理程序设置一个标志,从而使主程序调用 exit()。(因为 exit() 不是第 426 页表 21-1 中列出的异步信号安全函数,我们通常不能在信号处理程序中调用它。)即便如此,这也无法处理 SIGKILL 的情况,其默认操作无法更改。这又是我们应避免使用 SIGKILL 终止进程的一个原因(如第 20.2 节所述),应改用 SIGTERM,它是 kill 命令发送的默认信号。
Registering exit handlers
GNU C 库提供了两种注册退出处理程序的方法。第一种方法,在 SUSv3 中指定,是使用 atexit() 函数。
#include<stdlib.h>intatexit(void(*func)(void));atexit() 函数将 func 添加到进程终止时要调用的函数列表中。函数 func 应定义为不带参数且不返回值,因此具有以下通用形式:
voidfunc(void){/* Perform some actions */}请注意,atexit() 在出错时返回非零值(不一定是 -1)。
可以注册多个退出处理程序(甚至同一个退出处理程序可以多次注册)。当程序调用 exit() 时,这些函数会按注册的逆序被调用。这种顺序是合理的,因为通常较早注册的函数负责执行更基本类型的清理操作,这些操作可能需要在后注册的函数之后执行。
本质上,在退出处理程序中可以执行任何所需的操作,包括注册额外的退出处理程序,这些新注册的处理程序将被放置在尚未被调用的退出处理程序列表的头部。然而,如果某个退出处理程序未能返回——无论是因为它调用了 _exit(),还是因为进程被信号终止(例如,退出处理程序调用了 raise())——则剩余的退出处理程序将不会被调用。此外,exit() 通常执行的剩余操作(例如刷新 stdio 缓冲区)也不会执行。
SUSv3 规定,如果退出处理程序本身调用 exit(),则结果是未定义的。在 Linux 上,剩余的退出处理程序会照常调用。然而,在某些系统上,这会导致所有退出处理程序再次被调用,从而可能引发无限递归(直到栈溢出导致进程终止)。可移植的应用程序应避免在退出处理程序中调用 exit()。
SUSv3 要求实现必须允许一个进程至少注册 32 个退出处理程序。通过调用 sysconf(_SC_ATEXIT_MAX),程序可以确定实现定义的可注册退出处理程序的上限。(但是,没有办法知道已经注册了多少个退出处理程序。)通过将已注册的退出处理程序链在一个动态分配的链表中,glibc 允许注册几乎无限数量的退出处理程序。在 Linux 上,sysconf(_SC_ATEXIT_MAX) 返回 2,147,482,647(即最大有符号 32 位整数)。换句话说,在达到可注册函数数量限制之前,其他问题(例如内存不足)就会发生。
通过 fork() 创建的子进程会继承父进程的退出处理程序注册的副本。当进程执行 exec() 时,所有退出处理程序注册都会被移除。(这是必要的,因为 exec() 会替换退出处理程序的代码以及现有程序代码的其他部分。)
我们不能取消使用 atexit()(或下面描述的 on_exit())注册的退出处理程序的注册。然而,我们可以让退出处理程序在执行操作之前检查全局标志是否被设置,并通过清除该标志来禁用退出处理程序。
使用 atexit() 注册的退出处理程序有几个限制。第一个限制是,当被调用时,退出处理程序不知道传递给 exit() 的状态是什么。有时,知道状态可能是有用的;例如,我们可能希望根据进程是成功还是失败退出执行不同的操作。第二个限制是,我们不能为退出处理程序指定调用时的参数。这种功能可能有用,例如定义一个根据其参数执行不同操作的退出处理程序,或者多次注册同一个函数,每次使用不同的参数。
为了应对这些限制,glibc 提供了一种(非标准的)注册退出处理程序的替代方法:on_exit()。
#define_BSD_SOURCE/* Or: #define _SVID_SOURCE */#include<stdlib.h>inton_exit(void(*func)(int,void*),void*arg);on_exit() 的 func 参数是指向以下类型函数的指针:
voidfunc(intstatus,void*arg){/* Perform cleanup actions */}当被调用时,func() 会接收两个参数:传递给 exit() 的 status 参数,以及在注册函数时传递给 on_exit() 的 arg 参数的副本。虽然定义为指针类型,但 arg 可以由程序员自行解释。它可以用作指向某个结构的指针;同样,通过谨慎使用类型转换,也可以将其视为整数或其他标量类型。
像 atexit() 一样,on_exit() 在出错时返回非零值(不一定是 -1)。
与 atexit() 一样,可以通过 on_exit() 注册多个退出处理函数。使用 atexit() 和 on_exit() 注册的函数会被放在同一列表中。如果在同一程序中使用这两种方法,退出处理函数将按注册的逆序被调用。
尽管比 atexit() 更灵活,但 on_exit() 在打算编写可移植程序时应避免使用,因为它不受任何标准覆盖,并且在其他少数 UNIX 实现中才可用。
Example program
列表 25-1 演示了使用 atexit() 和 on_exit() 注册退出处理程序。当我们运行这个程序时,我们会看到以下输出:
$ ./exit_handlers on_exitfunctioncalled:status=2,arg=20atexitfunction2called atexitfunction1called on_exitfunctioncalled:status=2,arg=10Listing 25-1: Using exit handlers
// procexec/exit_handlers.c// 代码略25.4 Interactions Between fork(), stdio Buffers, and _exit()
清单25-2中程序产生的输出显示了一个起初令人困惑的现象。当我们将该程序的标准输出定向到终端运行时,我们看到预期的结果:
$ ./fork_stdio_buf Hello world Ciao但是,当我们将标准输出重定向到文件时,我们会看到以下内容:
$ ./fork_stdio_buf>a $cata Ciao Hello world Hello world在上面的输出中,我们看到两件奇怪的事情:printf() 写入的行出现了两次,而且 write() 的输出排在 printf() 之前。
Listing 25-2: Interaction of fork() and stdio buffering
// procexec/fork_stdio_buf.c// 代码略要理解为什么使用 printf() 写的消息会出现两次,请回忆一下 stdio 缓冲区是在进程的用户空间内存中维护的(参见第 13.2 节)。因此,这些缓冲区在 fork() 时会在子进程中被复制。当标准输出指向终端时,默认是行缓冲的,因此由 printf() 写入的以换行符结尾的字符串会立即显示。然而,当标准输出指向文件时,默认是块缓冲的。因此,在我们的示例中,printf() 写入的字符串在 fork() 发生时仍在父进程的 stdio 缓冲区中,并且这个字符串会被复制到子进程中。当父进程和子进程随后调用 exit() 时,它们会分别刷新各自的 stdio 缓冲区,从而导致输出重复。
我们可以通过以下方法之一防止输出重复:
- 作为针对 stdio 缓冲问题的具体解决方案,我们可以在 fork() 调用之前使用 fflush() 刷新 stdio 缓冲区。或者,我们也可以使用 setvbuf() 或 setbuf() 禁用 stdio 流的缓冲。
- 子进程可以调用 _exit() 而不是 exit(),这样它就不会刷新 stdio 缓冲区。这一技术体现了一个更一般的原则:**在创建子进程的应用程序中,通常只有一个进程(通常是父进程)应通过 exit() 终止,而其他进程应通过 _exit() 终止。**这确保只有一个进程调用退出处理程序并刷新 stdio 缓冲区,这通常是我们期望的。
允许父进程和子进程都调用 exit() 的其他方法也是可能的(有时也是必要的)。例如,可能可以设计 exit 处理程序,使其即使在多个进程中调用也能正确运行,或者让应用程序仅在调用 fork() 之后再安装 exit 处理程序。此外,有时我们实际上希望所有进程在 fork() 之后刷新它们的 stdio 缓冲区。在这种情况下,我们可以选择使用 exit() 终止进程,或者根据需要在每个进程中显式调用 fflush()。
列出在清单 25-2 的程序中 write() 的输出没有出现两次,是因为 write() 将数据直接传输到内核缓冲区,而此缓冲区在 fork() 时不会被复制。
到现在为止,当程序的输出被重定向到文件时,另一个奇怪现象的原因应该很清楚了。write() 的输出出现在 printf() 之前,因为 write() 的输出会立即传输到内核缓冲区缓存,而 printf() 的输出只有在调用 exit() 刷新 stdio 缓冲区时才会传输。(通常,当在同一文件上混合使用 stdio 函数和系统调用进行 I/O 时,需要小心,如第 13.7 节所述。)
25.5 Summary
一个进程可以以异常方式或正常方式终止。异常终止发生在接收到某些信号时,其中一些信号还会导致进程生成核心转储文件。
正常终止是通过调用 _exit() 或更常用的 exit() 来完成的,exit() 是建立在 _exit() 之上的。_exit() 和 exit() 都接受一个整数参数,其最低有效的 8 位定义了进程的终止状态。按照惯例,状态 0 用于表示成功终止,而非零状态表示不成功的终止。
作为正常和异常进程终止的一部分,内核会执行各种清理步骤。通过调用 exit() 正常终止进程还会导致使用 atexit() 和 on_exit() 注册的退出处理程序被调用(按注册的相反顺序),并且会刷新 stdio 缓冲区。
Further information
参考第24.6节列出的进一步信息来源。