21 如何正确处理信号?

设计处理器函数

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

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

两个概念

可重入(Reentrant)函数

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

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

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

可以理解为靠锁来保证线程安全破坏了交叉执行的要求吧?

异步信号安全(Async-signal-safe)函数

从信号处理函数调用时,可以保证其实现安全的函数就是异步信号安全函数。如果一个函数可重入,或者信号处理器函数无法将其中断,函数就是异步信号安全的。因此,异步信号安全函数是一个比可重入函数更广的概念。

为了编写异步信号安全的函数,我们有以下的思路:

  1. 保证函数是可重入的,而且只调用 SUS 规定的异步信号安全的系统调用。
  2. 在主程序中要修改信号处理器中也可能访问的数据结构时,将信号暂时阻塞掉。(实践起来比较困难。)

由于在一些条件下(比如 handle 了多个信号,或者使用 SA_NODEFER 允许同一信号的递归处理),信号处理器可能会发生递归,所以即便主程序不访问信号处理器使用的数据结构,(不)可重用性依然不会改变。

Tip

在严格程度上:可重入 > 异步信号安全 > 线程安全。

异步信号安全比线程安全的要求更加严格的一个证据是信号可以由任何一个线程处理,因此至少应该保证线程安全。

如果一个函数中除了自己的代码外,只调用了异步信号安全的函数,而且还想实现得可重入,那么就需要在进入函数时保存 errno,退出时将其恢复。这是因为即便是异步信号安全的函数也可能设置 errno

使用 man signal-safety 可以查阅异步信号安全的系统调用有哪些

处理信号的方式

利用原子标志传递信息

一般使用:

volatile sig_atomic_t x;

前者用来保证数据会写到内存中去。而 sig_atomic_t 用来保证这个整数类型在硬件平台上可以原子写入内存。

Important

这个和原子变量还有点差异。sig_atomic_t 是专门用来在信号处理器内外传递信息的,不是用作多线程原子变量的。原子变量也不能直接拿来做信号处理器的标志信息,因为除了 std::atomic_flag(C11 则是有 struct atomic_flag) 之外,其他的原子变量不保证在任何平台上都无锁。(如果在某个平台上某个类型的原子变量是无锁的,那确实可以拿来在信号处理函数里用。)所以从适用范围来讲,原子变量不是 sig_atomic_t 的超集,sig_atomic_t 也不是原子变量的超集。可以参考这个回答 https://stackoverflow.com/a/56600194/

但是,如果信号被发送给了另外一个线程呢?按照一般的 volatile 语义,无法保证其变化对其他线程的可见性。可能这就是个标准中规定的特殊情况?或者操作系统在信号处理前后通过屏障指令保证了可见性同步?如果这个猜测是错误的,不能保证多线程的可见性,我觉得倒也可以把其他线程的信号都用掩码阻塞掉,只用一个线程来处理信号。

总之要按着定义来写代码,否则代码不是可移植的(尤其是 volatile int 代替 volatile sig_atomic_t 是 UB)。

C99 和 SUSv3 规定,实现应当(在 stdint.h 中)定义两个常量 SIG_ATOMIC_MINSIG_ATOMIC_MAX,用于规定可赋给 sig_atomic_t 类型的值范围。

另外,++-- 这样的自增 / 减运算符的计算是复合的,因此 sig_atomic_t 不保证其原子性。

Important

也就是说只能对 sig_atomic_t 类型的变量赋值和读取了?

用非本地跳转处理信号

书中的例子是:在遇到 SIGINT 的时候,shell 就会用非本地跳转把控制返回到主输入循环中(可能还会打印 ^C)。

标准的 longjmp 函数有个问题:信号处理器在进行处理前会暂时阻塞触发信号处理的那个信号,但是如果用非本地跳转改变了控制流,就不会有结束时信号掩码恢复的过程。这是 System V(Linux 也是这样)的语义。BSD 的 longjmp 则是会有信号掩码的恢复,因为在调用 setjmp 时会将当前的信号掩码保留下来。

如果编译程序时定义了 _BSD_SOURCE 特性检测宏,那么 glibc 的 setjmp() 将遵循 BSD 语义。

为了解决移植性的问题,需要用另外两个非本地跳转的函数:

#include <setjmp.h>
int sigsetjmp(sigjmp_buf env, int savesigs);
[[noreturn]] void siglongjmp(sigjmp_buf env, int val);

参数 env 的类型也从 jmp_buf 变成了 sigjmp_buf(多了 sig 前缀)。

abort 终止进程

SUSv3 要求就算 SIGABRT 被阻塞,也要终止程序,除非信号处理器函数没有返回。使用非本地跳转就不会返回!也就是说如果不希望被 abort 终止程序,就需要注册 SIGABRT 的信号处理器,并且不阻塞该信号,同时在信号处理的过程中使用非本地跳转。

abort 函数的可能实现方式是:先给自己发一个 SIGABRT 信号,然后程序理应结束不会继续执行,如果继续执行了,就设置 SIGABRT 的处理方式为 SIG_DFL,然后再次发送 SIGABRT 信号。所以如果在第一步的时候有非本地跳转,程序就可以免于被终止。

sigaltstack 函数指定备选栈

信号处理默认使用进程的当前栈,只是在上面多加一个栈帧而已。如果栈空间超过了资源上限,程序就会被终止。可以通过 sigaltstack 函数来创建信号处理的备选栈(或者获取旧的备选栈信息)。随后,在注册信号处理函数的时候,提供 SA_ONSTACK 标志,表示该信号要在备选栈上处理。sigaltstack 设置的备选栈是仅用于当前线程的

备选信号栈可以静态分配,也可以在堆上分配。MINSSIGSTKSZ 是调用信号处理器函数所需的最小空间,SIGSTKSZ 是典型空间,在 Linux 上这两个值分别为 2048 和 8192。

书中给了一种例子,主程序栈空间超出上限会收到 SIGSEGV 信号,如果想要处理这种情况,肯定就需要用到备选栈,否则无法创建栈帧。在处理时,要么终止程序,要么使用非本地跳转解开栈,栈空间因此得到释放。当然,非本地跳转很不 RAII。

通过 SA_SIGINFO 标志获取更多信息

如果在使用 sigaction() 创建处理器函数时设置了 SA_SIGINFO 标志,那么在收到信号时处理器函数可以获取该信号的一些附加信息。这个时候,就不要用 struct sigaction 中的 sa_handler,而要用 sa_sigaction。这个函数指针的类型为:

void (*sa_sigaction)(int sig, siginfo_t *siginfo, void *ucontext);

Tip

ucontext 一般在信号处理中不被使用。它和 setcontext() 系列的 API 相关,这类 API 比 setjmp() 提供的功能要更高级。

系统调用的中断和重启

系统调用是可能阻塞的(比如 read 无法读取到输入时),如果这时候一个信号发过来,则会跳转到信号处理器中,返回时,系统调用一般会因为中断而失败,errnoEINTR

显式的循环重试

一种思路是在系统调用的外围加上 while 循环,当因为中断导致系统调用失败时重试。GNU C 库定义了一个宏:

# define TEMP_FAILURE_RETRY(expression) \
  (__extension__							      \
    ({ long int __result;						      \
       do __result = (long int) (expression);				      \
       while (__result == -1L && errno == EINTR);			      \
       __result; }))

只要定义了功能测试宏 _GNU_SOURCE,然后包含了头文件 unistd.h,这个宏就是可用的。这个 __extension__ 语法还挺有意思,它使得 do-while 块能够提供返回值,而一般的 do-while(0) 是做不到有返回值的。使用方式举例:

#include <stdio.h>
int main() {
    int a = (__extension__({6;}));
    printf("a=%d\n", a);
}

通过 SA_RESTART 标志重启系统调用

但是这样做还是不够方便,尤其是有旧的代码库时。而且如果没有静态检查工具,还是可能会忘记使用宏。在用 sigaction 注册信号处理函数时,可以使用 SA_RESTART 标志来告诉系统自动重启被中断的函数。关于哪些系统调用在中断后可以用该标志自动重启,不同系统的实现有不同。在 Linux 中,可以通过 man 7 signal 来查阅相关的说明,搜索文本 "restart" 可以找到对应的段落,里面列举的大多数系统调用都是和读、写、锁相关。

可能等待条件变量时的假唤醒就是因为系统调用被中断?相关页面: 条件变量的工作方式

函数 siginterrupt() 用于改变信号的 SA_RESTART 设置:

#include <signal.h>
int siginterrupt(int sig, int flag);

flag 为 0 表示系统调用会在被中断后自动重启,这是 Linux 的默认行为。为 1 时则系统调用不会被重启,如果这时相关系统调用还没有传输数据,则返回 -1,否则返回实际的数据传输量。也就是说,如果 read 被中断,读取到的字节数可能会少于期望的字节数,但是系统调用仍然成功——返回值依然值得检查,尽管我们大多数时候只将其和 -1 比较。

只要程序可能接受信号,就需要添加代码重启系统调用

并不是只有设置了信号处理器才会导致系统调用被重启,比如进程可能先收到一个 SIGSTOP(或者 SIGTSTPSIGTTINSIGTTOU),然后收到 SIGCONT。有些阻塞的系统调用在这种情况下也会被中断,并且将返回值设置为 EINTR