TLPI读书笔记第25章:进程的终止

本章所述为进程的退出过程。首先说明如何调用 exit()和_exit()以终止一个进程。接着讨论运用退出处理程序(exit handler),在进程调用 exit()时自动执行清理动作。最后,将探讨fork()、 stdio 缓冲区以及 exit()之间的某些交互。

25.1 进程的终止: _exit()和 exit()

通常,进程有两种终止方式。其一为异常(abnormal)终止,如 20.1 节所述,由对一信号的接收而引发,该信号的默认动作为终止当前进程,可能产生核心转储(core dump)。此外,进程可使用_exit()系统调用正常(normally)终止。

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

_exit()的 status 参数定义了进程的终止状态,父进程可调用 wait()以获取该状态。虽然将其定义为 int 类型,但仅有低 8 位可为父进程所用。按照惯例,终止状态为 0 表示进程“功成身退”,而非 0 值则表示进程因异常而退出。对非 0 返回值的解释则并无定例;不同的应用程序自成一派,并会在文档中加以描述。

SUSv3 规定有两个常量: EXIT_SUCCESS(0)和 EXIT_FAILURE(1),本书中大部分程序就采用了这一约定。 调用_exit()的程序总会成功终止(即,_exit()从不返回)。

程序一般不会直接调用_exit(),而是调用库函数 exit(),它会在调用_exit()前执行各种动作。

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

exit()会执行的动作如下。

1.调用退出处理程序(通过 atexit()和 on_exit()注册的函数),其执行顺序与注册顺序相反(见 25.3 节)。

2.刷新 stdio 流缓冲区。

3.使用由 status 提供的值执行_exit()系统调用。

程序的另一种终止方法是从 main()函数中返回( return),或者或明或暗地一直执行到main()函数的结尾处。

执行 return n 等同于执行对 exit(n)的调用,因为调用 main()的运行时函数会将 main()的返回值作为 exit()的参数。

执行未指定返回值的 return,或是无声无息地执行到 main()函数结尾,同样会导致 main()的调用者执行 exit()函数,不过,视所支持的不同 C 语言标准版本,以及所使用的不同编译器选项,其结果也有所不同。

25.2 进程终止的细节

无论进程是否正常终止,都会发生如下动作。

1.关闭所有打开文件描述符、目录流、信息目录描述符,以及(字符集)转换描述符。

2.作为文件描述符关闭的后果之一,将释放该进程所持有的任何文件锁。

3.分离(detach)任何已连接的 System V 共享内存段,且对应于各段的 shm_nattch 计数器值将减一。

4.进程为每个 System V 信号量所设置的 semadj 值将会被加到信号量值中。

5.如果该进程是一个管理终端( terminal)的管理进程,那么系统会向该终端前台(foreground)进程组中的每个进程发送 SIGHUP 信号,接着终端会与会话(session)脱离。

6.将关闭该进程打开的任何 POSIX 有名信号量,类似于调用 sem_close()。

7.将关闭该进程打开的任何 POSIX 消息队列,类似于调用 mq_close()。

8.作为进程退出的后果之一,如果某进程组成为孤儿,且该组中存在任何已停止进程(stopped processes),则组中所有进程都将收到 SIGHUP 信号,随之为 SIGCONT 信号。

9.移除该进程通过 mlock()或 mlockall()所建立的任何内存锁。

10.取消该进程调用 mmap()所创建的任何内存映射(mapping)。

25.3 退出处理程序

有时,应用程序需要在进程终止时自动执行一些操作。试以一个应用程序库为例,如果进程使用了该程序库,那么在进程终止前该库需要自动执行一些清理动作。因为库本身对于进程何时以及如何退出并无控制权,也无法要求主程序在退出前调用库中特定的清理函数,故而也不能保证一定会执行清理动作。

解决这一问题的方法之一是使用退出处理程序( exithandler)。老版 System V 手册则使用术语“程序终止过程”(program termination routine)。 退出处理程序是一个由程序设计者提供的函数,可于进程生命周期的任意时点注册,并在该进程调用 exit()正常终止时自动执行。如果程序直接调用_exit()或因信号而异常终止,则不会调用退出处理程序。

注册退出处理程序

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

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

函数 atexit()将 func 加到一个函数列表中,进程终止时会调用该函数列表的所有函数。应 将函数 func 定义为不接受任何参数,也无返回值,一般格式如下:

void func(void ){
   /**/
}

注意 atexit()在出错时返回非 0 值(不一定是-1)。 可以注册多个退出处理程序(甚至可以将同一函数注册多次)。当应用程序调用 exit()时, 这些函数的执行顺序与注册顺序相反。这一设计很符合逻辑,因为,一般情况下较早注册的函数所执行的是更为基本的清理动作,可能需要在调用后续注册的函数后再执行。

本质上,可以在退出处理程序中执行任何希望的动作,包括注册附加的退出处理程序,并将其置于留待调用的剩余函数列表的头部。不过,一旦有任一退出处理程序无法返回—无论因为调用了SC_ATEXIT_MAX),应用程序即可确定由实现所定义的可注册退出处理程序的数量上限。(但是,并无方法获知有多少已注册的处理程序。)通过运用动态分配链表将已注册的处理程序串接起来, glibc 允许注册的退出处理程序数量近乎于无限。对于 Linux,sysonf(_SC_ATEXIT_MAX)返回 2147482647(即, 32 位有符号整型数的最大值)。换言之,在触及可注册函数数量的这一上限前,总会有其他原因(例如,内存不足)导致程序崩溃。

通过 fork()创建的子进程会继承父进程注册的退出处理函数。而进程调用 exec()时,会移除所有已注册的退出处理程序。(这是结果势所必然,因为 exec()会替换包括退出处理程序在内的所有原程序代码段。)

经由 atexit()注册的退出处理程序会受到两种限制。

其一,退出处理程序在执行时无法获知传递给 exit()的状态。有时候,知道状态是必要的;例如,退出处理程序会视进程退出成功与否而执行不同的动作。

其二,无法给退出处理程序指定参数。如果拥有这一特性,退出处理程序能根据传入参数的不同而执行不同动作,或使用不同参数多次注册同一个函数。为摆脱这些限制, glibc 提供了一个(非标准的)替代方法: on_exit()。

#defind _BSD_SOURCE
#include<stdlib.h>
int on_exit(void (*func)(int,void*),int arg);

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

void func(int status,void *arg){
   /*...*/
}

调用时,会传递两个参数给 func():提供给 exit()的 status 参数和注册时供给 on_exit()的一份 arg 参数拷贝。虽然定义为指针类型,参数 arg 的意义仍然可由设计者支配。可将其用作指向结构的指针,同样,通过审慎地强制转换,也可将其作为整型或其他标量类型使用。

类似于 atexit(), on_exit()出错时返回非 0 值(不一定是-1)。如同 atexit()一样,通过 on_exit()可以注册多个退出处理程序。使用 atexit()和 on_exit()注册的函数位于同一函数列表。如果在程序中同时用到了这两种方式,则会按照使用这两个方法注册的相反顺序来执行相应的退出处理程序。

虽然比 atexit()更灵活,但对于要保障可移植性的程序来说,还是应避免使用 on_exit()。因为并无标准涵盖到它,并且几乎也没有其他 UNIX 实现支持这一用法。

25.4 fork()、 stdio 缓冲区以及_exit()之间的交互

程序清单 25-2 生成的输出结果乍看颇令人费解。当程序标准输出定向到终端时,会看到预期的结果: 不过,当重定向标准输出到一个文件时,结果如下:以上输出中有两件怪事: printf()的输出行出现了两次,且 write()的输出先于 printf()。

int main(int argc,char *argv[]){
   printf("hello,world ");
   write(STDOUT_FILENO,"ciao ",5);
   if(fork()==-1)
       errExit();
   exit(EXIT_SUCCESS);
}

要理解为什么 printf()的输出消息出现了两次,首先要记住,是在进程的用户空间内存中维护 stdio 缓冲区的。因此,通过 fork()创建子进程时会复制这些缓冲区。当标准输出定向到终端时,因为缺省为行缓冲,所以会立即显示函数 printf()输出的包含换行符的字符串。

不过,当标准输出重定向到文件时,由于缺省为块缓冲,所以在本例中,当调用 fork()时, printf()输出的字符串仍在父进程的 stdio 缓冲区中,并随子进程的创建而产生一份副本。父、子进程调用 exit()时会刷新各自的 stdio 缓冲区,从而导致重复的输出结果。

可以采用以下任一方法来避免重复的输出结果。 1.作为针对 stdio 缓冲区问题的特定解决方案,可以在调用 fork()之前使用函数 fflush()来刷新 stdio 缓冲区。作为另一种选择,也可以使用 setvbuf()和 setbuf()来关闭 stdio 流的缓冲功能。

2.子进程可以调用_exit()而非 exit(),以便不再刷新 stdio 缓冲区。这一技术例证了一个更为通用的原则:在创建子进程的应用中,典型情况下仅有一个进程(一般为父进程)应通过调用 exit()终止,而其他进程应调用_exit()终止,从而确保只有一个进程调用退出处理程序并刷新 stdio 缓冲区,这也算是众望所归吧。

程序清单 25-2 中 write()的输出并未出现两次,这是因为 write()会将数据直接传给内核缓冲区, fork()不会复制这一缓冲区。

程序输出重定向到文件时出的第二件怪事,原因现在也清楚了。 write()的输出结果先于printf()而出现,是因为 write()会将数据立即传给内核高速缓存,而 printf()的输出则需要等到调用 exit ()刷新 stdio 缓冲区时。(如 13.7 节所述,通常,在混合使用 stdio 函数和系统调用对同一文件进行 I/O 处理时,需要特别谨慎。)

25.5 总结

进程的终止分为正常和异常两种。异常终止可能是由于某些信号引起,其中的一些信号还可能导致进程产生一个核心转储文件。 正常的终止可以通过调用_exit()完成,更多的情况下,则是使用_exit()的上层函数 exit()完成。_exit()和 exit()都需要一个整型参数,其低 8 位定义了进程的终止状态。依照惯例,状态 0用来表示进程成功完成,非 0 则表示异常退出。 不管进程正常终止与否,内核都会执行多个清理步骤。调用 exit()正常终止一个进程,将会引发执行经由 atexit()和 on_exit()注册的退出处理程序(执行顺序与注册顺序相反),同时刷新 stdio 缓冲区。

原文地址:https://www.cnblogs.com/wangbin2188/p/14809722.html