0%

_exit() 系统调用

虽然参数是 int 类型,但是只有低 8 位可用。而且由于终端中用信号值 + 128 表示进程因信号退出的状态码($?),所以最好也不要使用超过 127 的退出值。

exit() 函数

会做这几件事:

  1. 执行退出处理程序,这些程序是用 atexit()on_exit() 注册的。
  2. 刷新 stdio 流缓冲区。
  3. 调用 _exit()

由于在执行退出处理程序时,main() 函数已经退出,所以退出处理函数不能使用 main() 函数中定义的变量。

如果用户程序没有调用 _exit()exit(),并正常从 main() 函数中返回,则使用 main() 函数的返回值作为对 exit() 调用时传入的参数,这个过程是由运行时函数处理的。如果 main() 函数没有显式提供返回值,则在 C89 中属于未定义行为,在 C99/C++ 中会隐式返回 0。

子进程的状态

1. 状态的分类

  • 调用 _exit() 正常退出(无论是手动调用还是运行时在 main() 函数退出后自动调用)。
  • 收到信号而终止。
  • 收到信号而暂停执行。
  • 收到信号而恢复执行。

2. 如何在父进程中区分子进程状态

为了区分这 4 种状态,等待函数中 int 类型的输出参数实际上会有低 2 个字节被使用(虽然实际上返回状态只需要用 1 个字节表示):

可以分别通过 WIFEXITED (status)WIFSIGNALED (status)WIFSTOPPED (status)WIFCONTINUED (status) 来判断是否属于这几种情况。这几种情况是互斥的。在进程被信号所杀时,内核转储标志可能会被设置,这时如果宏 WCOREDUMP 被定义,可以用 WCOREDUMP 来检查内核转换标志是否被设置。

3. 在信号处理函数中终止子进程

如果需要对某个信号做出一定的处理再终止子进程,则不能调用 _exit() 函数,否则子进程会被视为正常终止。如果想要让子进程终止时可以反应真实的终止原因,需要在信号处理器函数中做出善后处理之后,再将该信号的处置方式设置成默认,然后用 raise() 系统调用发送相同的信号。

几个系统调用 / 库函数的介绍

  • fork():创建子进程。
  • exit(status):库函数,退出当前进程,是 _exit 的包装。
  • wait(&status):挂起当前进程并等待一个子进程。
  • execve(pathname, argv, envp):加载一个新的程序。

Tip

SUSv3 还规定了 posix_spawn() 函数,相当于 fork + exec,在有些实现中速度会快一些,有的实现中则不是。网上对此的讨论也很多。

fork 和文件共享

fork 后父子进程是可以共享打开的文件描述符的,而且这些文件描述符都指向内核打开文件表的同一个项目,共享文件属性和偏移量。不过可以在打开文件时设置 O_CLOEXEC 选项,使得子进程将来调用 exec 时关闭文件。

vfork 和写时复制

早前 fork 函数没有 copy-on-write 功能,因而在子进程要调用 exec 时,显得不划算。vfork 让父子进程共享内存,只是复制极小部分用来管理进程的数据,并且将父进程挂起,直到子进程调用 exec 或者 _wait 才恢复。如果子进程在此期间做出了别的行为,则行为未定义。

这里的别的行为指:1. 修改了除了 vfork 返回值以外的任何数据;2. 从调用 vfork 的函数中返回;3. 在调用 exec / _wait 之前调用了任何函数。

定时器 API

1. setitimergetitimer(不建议)

SYNOPSIS
       #include <sys/time.h>

       int getitimer(int which, struct itimerval *curr_value);
       int setitimer(int which, const struct itimerval *restrict new_value,
                     struct itimerval *_Nullable restrict old_value);

which 参数指定创建的定时器的类型

  • 可以是真实时间,到期的信号是 SIGALARM。
  • 可以是用户 CPU 时间(进程虚拟时间),到期的信号为 SIGVTALRM。
  • 可以是内核 + 用户 CPU 时间(profiling 定时器),到期的信号是 SIGPROF。

以上三种信号默认行为是终止进程,所以定时器要结合信号处理函数使用。

struct itimerval 参数解释

struct itimerval {
   struct timeval it_interval; /* Interval for periodic timer */
   struct timeval it_value;    /* Time until next expiration */
};

其中 timeval 是带有秒和微秒的结构体,可以参考 10.01 时间类型 tm time_t timeval timespec

核心转储

核心转储的产生

有非常多的原因会导致核心转储不发生,最容易出现的是没有用 ulimit 对进程设置核心转储大小上限(默认是 0)。此外,对核心转储路径没有写权限、set-group-ID 或 set-user-ID 程序由非属组 / 主执行、对可执行文件没有读权限等原因都会导致核心转储不发生。

在 Linux 2.4 之后,prctl 函数的 PR_SET_DUMPABLE 操作可以为进程设置 dumpable 标志,如果有这个标志,set-group-ID 或 set-user-ID 程序在非属组 / 主执行时也能核心转储。Linux 2.6.13 之后,/proc/sys/fs/suid_dumpable 为此标志提供系统级的控制,默认值是 0。

Linux 2.6.23 之后,/proc/PID/coredump_filter 能控制对应进程核心转储中可以包含的内存类型。文件的值是 4 位掩码,表示私有匿名映射、私有文件映射、共享匿名映射以及共享文件映射是否受允许,默认值是仅允许匿名映射、不允许文件映射。如今,我的 WSL 内核版本是 5.15.153.1-microsoft-standard-WSL2,该文件中存储的是 00000033

核心转储的路径

可以用 /proc/sys/kernel/core_pattern 查看核心转储文件的存储路径。

系统对一些特殊信号的处理

SIGKILL 和 SIGSTOP 的行为无法被改变,调用 signalsigaction 以图改变其行为总是会返回错误。

说明

20 信号 讲了信号的基本概念、列举了一部分信号,并且说明了发送信号的方式。这一节主要是讲和信号处理相关的 API。

信号集

信号集对应类型 sigset_t,操作它的函数都是以其指针为参数的。相关的函数有:

#include <signal.h>

int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);

int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);

int sigismember(const sigset_t *set, int signum);

必须用 sigemptyset 或者 sigfillsetsigset_t 类型的数据完成初始化,直接对其赋值 0 会有可移植性的问题。

GNU C 库还实现了三个函数:sigandset / sigorset / sigisemptyset

设计处理器函数

处理器函数应该尽可能简单,尤其是不要调用 stdio 库函数(因为它们一般不是异步信号安全的)。以下是几种处理方式:

  1. 修改全局数据结构。程序周期性检查这些结构。
  2. 清理资源并终止程序,或者使用非本地跳转返回到主程序中的预定位置。

两个概念

可重入(Reentrant)函数

SUSv3 对可重入函数的定义是:函数由两条或多条线程调用时,即便是交叉执行,其效果也与各线程以未定义顺序依次调用时一致。

有点不好理解的是:malloc 族函数是线程安全的,但是不是可重入的,因为它管理的是全局的数据结构,这个数据结构不能在多个线程上同时修改。Glibc 2.x 的 malloc 是首先选择一个 arena,然后试图获取它的锁,然后从其中分配内存;如果不同线程获取的 arenas 不同,那么可以同时得到锁(不是同一个锁),也自然不会冲突。printfscanf 这些 stdio 的函数也是不可重入的,在执行的时候会加锁,因为它们要管理全局的缓冲区。还有一些通过静态分配的数据结构返回信息的函数,它们也是不可重入的,比如 crypt()getpwnam()gethostbyname() 以及 getservbyname()

举个例子:如果在调用了 malloc,获取到锁之后,被信号打断,转入信号处理器中,在信号处理器中又调用 malloc,而且是尝试获取同一个锁,就会因为无法获得锁而一直卡在信号处理器中无法退出!所以可重入是比线程安全更加严格的概念

信号

信号是对进程的通知机制,也被称为软中断,分成标准信号(传统信号,编号范围是 1~31)和实时信号。

部分信号:

  1. SIGINT:ctrl + c
  2. SIGQUIT:ctrl + </kbd>,和 SIGINT 比多了核心转储。
  3. SIGTERM:kill 命令默认发送的信号,程序可以注册处理这个信号,因此可以实现优雅退出。
  4. SIGKILL:必杀信号。
  5. SIGSTOP:必停信号。
  6. SIGPIPE:写管道有错,可能是读的进程把管道关了。
  7. SIGFPE:名字是浮点数错误,实际上泛指除 0 错误。而且 x86-64 上浮点数除 0 默认不出错,需要用 feenableexcept() 启用异常。
  8. SIGALRM:实时计时器过期。
  9. SIGVTALRM::虚拟计时器过期。
  10. SIGHUP:会话结束(终端断开)时发送给程序的信号,默认行为为杀死程序。在一些守护程序上还有重载配置的作用。
  11. SIGTSTP:ctrl + z
  12. SIGUSR1 和 SIGUSR2:留给程序员自己用的,系统绝对不会发送的信号。
  13. SIGWINCH:终端环境的窗口尺寸发生变化。

一个信号默认的处理行为是三者之一:1. 忽略;2. 终止;3. 内核转储。

处理信号

使用 signal 或者 sigaction 注册信号处理函数,一般条件下 sigaction 是更好的选择。处理信号时一般不要调用 printf 等来自 stdio.h 的函数。

发送信号

kill 系统调用可以向指定进程发送给定的信号:

  1. 进程号 > 0:指定进程。
  2. 进程号 = 0:同组所有进程,包括自己。
  3. 进程号 < -1:取绝对值,然后向指定的进程组中所有进程。
  4. 进程号 = -1:系统范围内除了 init 和进程自己以外的、所有的当前进程有权限可以发送信号的目标进程。
  5. 信号值 = 0:不发送信号,但是检查是否能够成功向目标进程发送信号。可能的 errno 值有:EPERM 表示进程存在但是没有权限;ESRCH 表示进程不存在。

其他发送信号的规则:

  1. 特权级(CAP_KILL)进程可以向任何进程发送信号。
  2. init(1 号)进程是一个特例,只有它注册了某个信号的处理函数,才能向它发送这个信号。(原文是:It can be sent only signals for which it has a handler installed. 我觉得中文版翻译的不是很好,所以这里写的是我自己的理解。)
  3. 如果信号发送者和接收者满足一定关系,则信号是可以发送的。条件是:发送者的实际用户 ID 或者有效用户 ID 是接收者的实际用户 ID 或者保存的 set-user-ID,如下图所示。如果允许以接收者的有效用户 ID 作为判断依据,特权进程可能会设置自己的有效用户 ID 为普通用户,但是这时普通用户就能够向这个进程发送信号了。(书中说的是:将目标进程有效用户 ID 排除在检查范围之外,这一举措的辅助作用在于防止用户某甲向用户某乙的进程发送信号,而该进程正在执行的 set-user-ID 程序又属于用户某甲。我觉得这个说法很牵强,因为判断机制里面已经允许按照保存的 set-user-ID 来判断了。)

  1. SIGCONT 信号很特殊。非特权进程始终可以向同一个会话中的其他任何一个进程发送这个信号。

检查进程的存在

前面说了,使用 kill 向进程发送信号 0(实际上没有这个信号,但是 0 可以作为参数)可以测试是否能够成功发送信号。只要 errno 不是 ESRCH 就说明进程存在。

其他检查进程是否存在的方式:

  1. wait() 系统调用:用来监控子进程。
  2. 信号量和文件锁:如果能获取到锁说明进程已经终止。
  3. FIFO 等 IPC 通道:写入关闭说明进程终止。
  4. /proc/PID 接口:这一项由于用到了 PID,所以会受到循环使用 PID 的影响,导致结果在极端条件下不准确(但是大多数时候不会)。

其他发送信号的方式

raise() 函数可以用来进程自己发送信号。当系统只支持单线程时,相当于 kill(getpid(), sig),当系统支持多线程时,相当于 pthread_kill(pthread_self(), sig)。相比起来,kill 总是将信号发给进程,具体线程可以是进程的任何一个。由于 raise() 函数是向进程自己发送信号,信号将立即传递。处理完成之后才会回到调用者。

killpg() 可以用于向某一进程组的所有成员发送信号,相当于 kill(-pgrp, sig)。如果给的 pgrp 为 0,那么就会向当前进程所在进程组的所有进程发送信号。

显示信号字符串

和 errno 一样,我们可以调用函数来显示描述信号的字符串。

char *strsignal(int sig);
const char *sigdescr_np(int sig);
const char *sigabbrev_np(int sig);

书里只讲到 strsignal,它还会获取当前的 locale,但是在获取 locale 这一点上不是线程安全的。后面两个前者是获取详细描述,后者是获取简略描述(比如 SIGINT 的描述是 "INT"),它们都不考虑 locale,是线程安全的。

psignalperror 的功能则类似。psignal 也是考虑 locale 的。

核心转储介绍

GDB 可以加载核心转储以复现程序终止时的内存,也可以为给定的进程创建核心转储。

核心转储是可以通过发送信号实现的。调用 abort() 时,会向进程自身发送 SIGABRT 信号,该信号的默认行为是终止进程并为其生成核心转储。在终端中,SIGQUIT(ctrl + /)和 SIGINT(ctrl + c)相比,也多出来了生成核心转储的行为。可以从以下尝试中体会出两个信号的差异:

(py310) xxx /data/apue $ ulimit -c unlimited
(py310) xxx /data/apue $ ./build/main 
^C
# 返回码 130
(py310) xxx /data/apue $ ./build/main 
^\Quit (core dumped)
# 返回码 131

可以检查核心转储文件的路径。既可以通过 /proc 文件系统查看路径,也可以通过 sysctl 命令查看路径。在 WSL 中打印出来的结果见下方:

(py310) xxx $ cat /proc/sys/kernel/core_pattern
/mnt/wslg/dumps/core.%e
(py310) xxx $ sysctl kernel.core_pattern
kernel.core_pattern = /mnt/wslg/dumps/core.%e

基本流程

  1. 使用 inotify_init 创建一个 inotify 实例,返回值是文件描述符,用来读取 inotify 监控项信息。
  2. 使用 inotify_add_watch 对 inotify 实例创建或者修改监控项目。参数 fd 指代 inotify 实例,参数 pathname 表示要监控的文件或者目录,用户必须有其读取权限(只检查一次,创建监控项目成功之后即便权限发生变化监控也不会被移除)。函数的返回值是一个监控描述符(watch descriptor,wd)。
  3. 不断使用 read 系统调用去读取 fd 关联文件的信息,然后将字节流中的地址转换成 struct inotify_event * 类型,然后判断其信息。给 read 提供的缓冲区需要足够大,至少需要容纳 sizeof(struct inotify_event) + NAME_MAX + 1 个字节,不过 read 缓冲区越大读取的效率越高。
struct inotify_event {
   int      wd;       /* Watch descriptor */
   uint32_t mask;     /* Mask describing event */
   uint32_t cookie;   /* Unique cookie associating related
                         events (for rename(2)) */
   uint32_t len;      /* Size of name field */
   char     name[];   /* Optional null-terminated name */
};
  1. 使用 inotify_rm_watch 移除监控事件。
  2. 使用 close 关闭 inotify_init 创建的 fd。

inotify_init1

Linux 还有一个系统调用 inotify_init1,它比 inotify_init 多出来一个 flags 参数,可以用来指定 IN_CLOEXEC 或者 IN_NONBLOCK。这避免了稍后用 fcntl 设置 flags 的繁琐操作。

读取监控事件

受监控目录有文件发生事件时,name 字段是一个零字符结尾字符串;受监控目录本身发生事件时,len 为 0,name 没有有效内容。可以监控的事件类型相当多,具体可以参考手册。特殊事件包括:IN_IGNORED(监控项被内核或应用移除)、IN_Q_OVERFLOW(事件队列溢出)、IN_UNMOUNT(包含监控对象的文件系统被卸载)等。