操作系统基础 | 5.5 终止进程

终止进程:_exit()exit()

进程可以通过两种通用方式终止。其中一种是异常终止,由接收到一个默认动作为终止进程(可能伴随核心转储)的信号引起。另一种方式是,进程可以使用 _exit() 系统调用进行正常终止

1
2
#include <unistd.h>
void _exit(int status);

传递给 _exit()status 参数定义了进程的终止状态,该状态在此进程的父进程调用 wait() 时可用。虽然定义为 int 类型,但实际上只有 status 的低 8 位会提供给父进程。按照惯例,终止状态 0 表示进程成功完成,而非零状态值表示进程未成功终止。对于如何解释非零状态值没有固定规则;不同的应用程序遵循自己的惯例,这些惯例应在它们的文档中描述。SUSv3 规定了两个常量 EXIT_SUCCESS (0) 和 EXIT_FAILURE (1),本书中的大多数程序都使用它们。进程总是被 _exit() 成功终止(即 _exit() 从不返回)。

尽管任何在 0 到 255 范围内的值都可以通过 _exit()status 参数传递给父进程,但指定大于 128 的值可能会在 shell 脚本中引起混淆。原因是,当一个命令被信号终止时,shell 通过将变量 $? 的值设置为 128 加上信号编号 来表明这一事实,而这个值与进程以相同的状态值调用 _exit() 所产生的值无法区分。

程序通常不直接调用 _exit(),而是调用 exit() 库函数,该函数在调用 _exit() 之前会执行各种操作。

1
2
#include <stdlib.h>
void exit(int status);

exit() 执行以下操作: * 调用退出处理程序(使用 atexit()on_exit() 注册的函数),调用顺序与注册顺序相反。 * 刷新 stdio 流缓冲区。 * 使用 status 中提供的值调用 _exit() 系统调用。

与 UNIX 特有的 _exit() 不同,exit() 被定义为标准 C 库的一部分;也就是说,它在每个 C 实现中都可用。

进程终止的另一种方式是从 main() 返回,无论是通过显式 return 语句,还是通过执行到 main() 函数末尾而隐式返回。执行显式的 return n 通常等同于调用 exit(n),因为调用 main() 的运行时函数会在调用 exit() 时使用 main() 的返回值。

在一种情况下,调用 exit() 和从 main() 返回并不等效。如果在退出处理期间执行的任何步骤访问了 main() 的局部变量,那么从 main() 返回会导致未定义行为。例如,如果在调用 setvbuf()setbuf()(第13.2节)时指定了 main() 的局部变量,就可能发生这种情况。

执行不指定值的 return,或者执行到 main() 函数末尾,也会导致 main() 的调用者调用 exit(),但结果会根据所支持的 C 标准版本和所使用的编译选项而有所不同: * 在 C89 中,这些情况下的行为是未定义的;程序可能以任意状态值终止。这是在 Linux 上使用 gcc 时的默认行为,程序的退出状态取自栈上或特定 CPU 寄存器中的某个随机值。应避免以这种方式终止程序。 * C99 标准要求执行到主程序末尾应等同于调用 exit(0)。如果我们在 Linux 上使用 gcc –std=c99 编译程序,就会得到这种行为。

进程终止的细节

在进程的正常和异常终止期间,会发生以下操作: * 打开的文件描述符目录流(第18.8节)、消息目录描述符(参见 catopen(3)catgets(3) 手册页)和转换描述符(参见 iconv_open(3) 手册页)被关闭。 * 作为关闭文件描述符的后果,此进程持有的任何文件锁(第55章)都会被释放。 * 任何附加的 System V 共享内存段都会被分离(detach),并且相应每个段的 shm_nattch 计数器减一(参见第48.8节)。 * 对于进程已设置了 semadj 值的每个 System V 信号量,该 semadj 值会被添加到信号量值中(参见第47.8节)。 * 如果此进程是某个控制终端的控制进程,则 SIGHUP 信号会被发送到该控制终端前台进程组中的每个进程,并且该终端与会话分离。我们将在第34.6节进一步讨论这一点。 * 调用进程中打开的任何 POSIX 命名信号量都会被关闭,就像调用了 sem_close() 一样。 * 调用进程中打开的任何 POSIX 消息队列都会被关闭,就像调用了 mq_close() 一样。 * 如果由于此进程退出导致一个进程组变为孤儿进程组,并且该组中存在任何停止的 (stopped) 进程,则该组中的所有进程都会收到一个 SIGHUP 信号,随后是一个 SIGCONT 信号。我们将在第34.7.4节进一步讨论这一点。 * 此进程使用 mlock()mlockall()(第50.2节)建立的任何内存锁会被移除。 * 此进程使用 mmap() 建立的任何内存映射会被取消映射(unmapped)。

退出处理程序 (Exit Handlers)

有时,应用程序需要在进程终止时自动执行一些操作。考虑这样一个例子:一个应用程序库,如果在进程的生命周期中被使用,需要在进程退出时自动执行一些清理操作。由于该库无法控制进程何时以及如何退出,也不能强制主程序在退出前调用库特定的清理函数,因此无法保证清理一定会发生。在这种情况下,一种方法是使用退出处理程序(exit handler)(较老的 System V 手册使用术语“程序终止例程”)。

退出处理程序是由程序员提供的函数,在进程生命周期的某个时间点注册,然后在进程通过 exit() 正常终止时被自动调用。如果程序直接调用 _exit() 或者进程被信号异常终止,则不会调用退出处理程序。

在某种程度上,进程被信号终止时不调用退出处理程序这一事实限制了它们的实用性。我们能做的最好方式是为可能发送给进程的信号建立处理程序,并让这些处理程序设置一个标志,促使主程序调用 exit()。(因为 exit() 不在表21-1(第426页)列出的异步信号安全函数中,所以我们通常不能从信号处理程序中调用它。)即使这样,也无法处理 SIGKILL 的情况,因为它的默认动作无法更改。这是我们应避免使用 SIGKILL 终止进程(如第20.2节所述)而应使用 SIGTERM(这是 kill 命令发送的默认信号)的又一个理由。

注册退出处理程序 GNU C 库提供了两种注册退出处理程序的方法。第一种方法,由 SUSv3 规定,是使用 atexit() 函数。

1
2
#include <stdlib.h>
int atexit(void (*func)(void));

成功返回 0,错误返回非零值

atexit() 函数将 func 添加到一个函数列表中,这些函数在进程终止时被调用。函数 func 应定义为不接收参数且不返回值,因此具有以下一般形式:

1
2
3
void func(void) {
/* 执行一些操作 */
}
注意,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() 的状态(status)是什么。偶尔,了解这个状态可能有用;例如,我们可能希望根据进程是成功退出还是不成功退出执行不同的操作。第二个局限性是,我们无法在调用退出处理程序时为其指定参数。这种功能可能有助于定义一个根据其参数执行不同操作的退出处理程序,或者用不同的参数多次注册同一个函数。

为了解决这些局限性,glibc 提供了一种(非标准的)注册退出处理程序的替代方法:on_exit()

1
2
3
#define _BSD_SOURCE           /* 或者: #define _SVID_SOURCE */
#include <stdlib.h>
int on_exit(void (*func)(int, void *), void *arg);

成功返回 0,错误返回非零值

on_exit()func 参数是一个指向如下类型函数的指针:

1
2
3
void func(int status, void *arg) {
/* 执行清理操作 */
}
当被调用时,func() 被传入两个参数:提供给 exit()status 参数,以及注册该函数时提供给 on_exit()arg 参数的副本。虽然定义为指针类型,但 arg 可由程序员自由解释。它可以被用作指向某个结构的指针;同样地,通过明智地使用类型转换,它可以被视为整数或其他标量类型。

atexit() 一样,on_exit() 出错时返回非零值(不一定是 -1)。与 atexit() 一样,可以使用 on_exit() 注册多个退出处理程序。使用 atexit()on_exit() 注册的函数被放在同一个列表中。如果在同一个程序中同时使用这两种方法,则退出处理程序按使用这两种方法注册顺序的逆序调用。

虽然比 atexit() 更灵活,但 on_exit() 在旨在可移植的程序中应避免使用,因为它不受任何标准涵盖,并且在其他 UNIX 实现上很少可用。

示例程序 以下代码演示了使用 atexit()on_exit() 注册退出处理程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#define _BSD_SOURCE     /* 从 <stdlib.h> 获取 on_exit() 声明 */
#include <stdlib.h>
#include "tlpi_hdr.h"

static void atexitFunc1(void){
printf("atexit function 1 called\n");
}
static void atexitFunc2(void){
printf("atexit function 2 called\n");
}
static void onexitFunc(int exitStatus, void *arg){
printf("on_exit function called: status=%d, arg=%ld\n",
exitStatus, (long) arg);
}

int main(int argc, char *argv[]){
if (on_exit(onexitFunc, (void *) 10) != 0)
fatal("on_exit 1");
if (atexit(atexitFunc1) != 0)
fatal("atexit 1");
if (atexit(atexitFunc2) != 0)
fatal("atexit 2");
if (on_exit(onexitFunc, (void *) 20) != 0)
fatal("on_exit 2");
exit(2);
}
当我们运行这个程序时,会看到以下输出:
1
2
3
4
5
$ ./exit_handlers
on_exit function called: status=2, arg=20
atexit function 2 called
atexit function 1 called
on_exit function called: status=2, arg=10

(输出顺序解释) 处理程序按注册顺序的逆序调用: * 最后注册的是 on_exit (arg=20),所以最先调用。 * 然后是 atexitFunc2。 * 然后是 atexitFunc1。 * 最后是第一个注册的 on_exit (arg=10)。

fork()、stdio 缓冲区与 _exit() 之间的交互

1
2
3
4
5
6
7
8
9
10
11
#include "tlpi_hdr.h"
int main(int argc, char *argv[]){
printf("Hello world\n");
write(STDOUT_FILENO, "Ciao\n", 5); // 直接写入当前打开的文件描述符

if (fork() == -1)
errExit("fork");

/* 父子进程都会执行到这里 */
exit(EXIT_SUCCESS);
}

以上程序的输出展示了一个起初令人费解的现象。当我们直接在终端运行此程序时,会看到预期的结果:

1
2
3
$ ./fork_stdio_buf
Hello world
Ciao
然而,当我们将标准输出重定向到一个文件时,却看到以下情况:
1
2
3
4
5
$ ./fork_stdio_buf > a
$ cat a
Ciao
Hello world
Hello world
在上面的输出中,我们看到两件奇怪的事情:由 printf() 写入的行出现了两次,并且 write() 的输出先于 printf() 的输出出现。

要理解为什么用 printf() 写入的消息会出现两次,需要回忆一下:stdio 缓冲区是在进程的用户空间内存中维护的(参见第13.2节)。因此,这些缓冲区在 fork() 时会被子进程复制

当标准输出指向终端时,默认是行缓冲的,因此由 printf() 写入的以换行符终止的字符串会立即显示。然而,当标准输出重定向到文件时,默认是块缓冲的。因此,在我们的例子中,在 fork() 发生时,由 printf() 写入的字符串仍然位于父进程的 stdio 缓冲区中,并且这个字符串被子进程复制。当父进程和子进程随后调用 exit() 时,它们都会刷新各自的 stdio 缓冲区副本,从而导致重复的输出

我们可以通过以下方法之一来防止出现这种重复输出: * 作为解决 stdio 缓冲问题的特定方案,我们可以在调用 fork() 之前使用 fflush() 来刷新 stdio 缓冲区。或者,我们可以使用 setvbuf()setbuf()禁用 stdio 流的缓冲。 * 子进程可以调用 _exit() 而不是 exit(),这样它就不会刷新 stdio 缓冲区。这项技术阐明了一个更通用的原则:在创建子进程的应用程序中,通常只有一个进程(最常见的是父进程)应该通过 exit() 终止,而其他进程应该通过 _exit() 终止。这确保了只有一个进程调用退出处理程序并刷新 stdio 缓冲区,这通常是可取的。

也存在其他允许父进程和子进程都调用 exit() 的方法(有时是必要的)。例如,可以设计退出处理程序,使得即使从多个进程调用也能正确运行;或者让应用程序在调用 fork() 之后才安装退出处理程序。此外,有时我们可能确实希望所有进程在 fork() 后都刷新其 stdio 缓冲区。在这种情况下,我们可以选择使用 exit() 终止进程,或者根据情况在每个进程中使用显式的 fflush() 调用。

示例程序中 write() 的输出没有出现两次,是因为 write() 将数据直接传输到内核缓冲区,而该缓冲区在 fork() 期间不会被复制

现在,程序输出重定向到文件时的第二个奇怪之处的原因应该很清楚了。write() 的输出出现在 printf()输出之前,是因为 write() 的输出会立即传输到内核缓冲区缓存,而 printf() 的输出只有在调用 exit() 刷新 stdio 缓冲区时才会被传输。(通常,如第13.7节所述,在同一文件上混合使用 stdio 函数和系统调用来执行 I/O 时需要小心。)