我该花点时间整理 Linux 信号的历史债了。动机如下:

  • perfbook 展示了信号驱动的原子计数器,并且提到了可行的 RCU-SIGNAL 实现,这些技巧说明信号并不只是一个从 UNIX 搬过来的读配置和杀程序的开关,它是值得深入探讨的。
  • 我在信号上翻车过不只一遍,耍杂技前还是学好基本功吧(重点)……

测试题

这是最近碰到的一个简单问题,如果你能迅速找出根因,那就没必要看这篇文章。

import subprocess
import time
import argparse

# 忽略一些参数……

print("Server:", args.server)
print("Client:", args.client)
print("==========")
for thread in [2, 4, 8]:
    for session in [10, 100, 1000]:
        print(">> thread:", thread, "session:", session)
        time.sleep(1)
        common_args = [port, str(thread), blocksize, str(session)]
        # 兼容两个不同的构建系统
        # 通过 xmake.lua 转换命令行字符串为 ./build/<server_name> --xmake ... 提供内部参数
        if use_xmake:
            server_cmd = ["xmake", "run", server_name]
            client_cmd = ["xmake", "run", client_name]
        # 而 makefile 版本不需要转换命令行字符串
        else:
            server_cmd = ["./build/" + server_name]
            client_cmd = ["./build/" + client_name]
        server_cmd += common_args
        client_cmd += common_args + [timeout]
        # 创建并执行子程序
        server_handle = subprocess.Popen(server_cmd)
        time.sleep(.256) # Start first.
        client_handle = subprocess.Popen(client_cmd)
        # 自身具有超时管理能力,不需要等待 server 响应
        client_handle.wait()
        # 但是这里需要修改
        server_handle.wait()
        print("==========")

脚本程序如上所示,这是一个简单的 ping-pong 基准测试脚本,分别拉起 client 和 server 打乒乓。目前这种 wait() 做法在高压下是不可靠的,并且 server 实现具有多个,想要一致地简单地处理问题就是从外部脚本入手。

所以直觉是 wait() 改为 kill()。但是问题在于:即使是 kill -9(SIGKILL),你也不能杀死 server。

(防剧透折叠)这是为什么呢?


因为 xmake run 不是直接执行对应进程,它具有自身的进程。

使用 pstree 可以看到:

... ─bash(131155)───xmake(138070)─┬─python3(138072)─┬─xmake(138086)─┬─pong(138088)─┬─{pong}(138090)
                                  │                 │               │              ├─{pong}(138091)
                                  │                 │               │              ├─{pong}(138092)
                                  │                 │               │              └─{pong}(138093)
                                  │                 │               └─{xmake}(138089)
                                  │                 └─xmake(138094)─┬─ping(138096)─┬─{ping}(138098)
                                  │                                 │              ├─{ping}(138099)
                                  │                                 │              ├─{ping}(138100)
                                  │                                 │              └─{ping}(138101)
                                  │                                 └─{xmake}(138097)
                                  └─{xmake}(138073)

使用 ps -eo tid,pid,ppid,tgid,sid,comm 可以进一步确认进程间的结构关系:

 137870  137870  137863  137870  137870 bash
 138070  138070  131155  138070  131155 xmake
 138073  138070  131155  138070  131155 xmake
 138072  138072  138070  138070  131155 python3
 138086  138086  138072  138070  131155 xmake
 138089  138086  138072  138070  131155 xmake
 138088  138088  138086  138070  131155 pong
 138090  138088  138086  138070  131155 pong
 138091  138088  138086  138070  131155 pong
 138092  138088  138086  138070  131155 pong
 138093  138088  138086  138070  131155 pong
 138094  138094  138072  138070  131155 xmake
 138097  138094  138072  138070  131155 xmake
 138096  138096  138094  138070  131155 ping
 138098  138096  138094  138070  131155 ping
 138099  138096  138094  138070  131155 ping
 138100  138096  138094  138070  131155 ping
 138101  138096  138094  138070  131155 ping
 138189  138189     755  138189     755 ps

简单来说就是你只杀了 xmake 父进程(pid=138086),且它并非 session leader,所以子进程(真正的 server,pid=138088)仍在逍遥法外。

实际现场会复杂一点,比如下一轮的 client 和上一轮残留的 server 打乒乓会导致每两轮 kill 会有一轮成功的假象,不过这和本文没啥关系。

解决办法很简单,Popen() 时指定为新的 process group(以避免杀到 python3 自身进程),然后 os.killpg(server_handle.pid)(其 pid 肯定是 pgid)一网打尽。

事后复盘:由于它的行为刚好和先前写好的 makefile 版本相反,后者是直接拉起对应进程,所以我在没打开 pstree 前确实没想到这方面的因素,以至于在这里花费了很不应该的时间……

正文说明

本文主要参考 TLPI 第二十到二十七章,以及 glibc manual 的 signal 部分,只是记录一些阅读过程的备注,可能带有严重的偏见。

信号的处置

信号的处置(disposition)具有默认行为(SIG_DFL),其中共有 ignored、terminated/killed、core dump、stopped 和 resumed 五种行为。但是处置也允许用户自行定制,通俗点说就是搞一个 signal handler。

NOTE: 默认 ignored 的信号是极其少数的,比较有名气的是 SIGCHLD。

不考虑跨平台特性和错误处理,先看 signal() 处置修改函数:

// old_handler 和 new_handler 均为 sighandler_t 类型
// sighandler_t 定义见 man 2 signal
old_handler = signal(sig_num, new_handler);

安装一个 handler,其接口会返回旧的(已卸载的) handler。这可能引起一个奇想:我能不能做 hook?也就是说,我企图在处理函数(handler)中,先调用自己的一部分操作,最后再重新回到默认行为上。毕竟我们知道有 old_handler,可以尝试这样做:

// 标准要求(与信号)共享的变量使用 volatile 修饰
// 详见 glibc manual 25.4.6
volatile sighandler_t old_handler = [](auto){};

void my_handler(int sig_num) {
    // 先做自己的事情,
    // do_stuff();

    // 然后再……
    old_handler(sig_num);
}

int main() {
    // 为了保持原子性,可能需要在修改前先屏蔽信号,修改后再释放(sigmask,略)
    // ...
    old_handler = signal(SIGUSR1, my_handler);
    // ...
    // 后面由用户发出一个 SIGUSR1 信号
}

然而这是错误的。默认的处理「函数」可以是 SIG_DFLSIG_IGN 等宏定义,前者是数值 0,后者是数值 1,他们都不是一个真正的函数指针。这样做不过是访问非法的虚拟地址。

Note that it isn’t possible to set the disposition of a signal to terminate or dump core (unless one of these is the default disposition of the signal). The nearest we can get to this is to install a handler for the signal that then calls either exit() or abort().

If we change the disposition of a pending signal, then, when the signal is later unblocked, it is handled according to its new disposition.

来源:TLPI

一种解决方案是通过查表显式设置每个信号对应的「默认」行为,比如重新映射到 exit()abort(),前者是常规退出,后者是产生 core dump 的标准实现,注意它仍然依赖于 SIGABRT,详见 TLPI 21.2.2;另一种解决方案可以尝试使用 oneshot 特性配合同步信号 raise(),这点 TLPI 没有明说(因为非常不实用),但是指出了下一次传递的信号会按照新安装的处置去执行,而 oneshot 后正好回落到默认实现。原文中关于 pending/unblock 的描述可以见下面信号的执行作为补充说明。

The exit() function uses a global variable that is not protected, so it is not thread-safe.
来源:man 3 exit

If a process calls the quick_exit() function more than once, or calls the exit() function in addition to the quick_exit() function, the behavior is undefined.
来源:POSIX.1-2024

When a new process is created (see Section 27.4 [Creating a Process], page 857), it inherits handling of signals from its parent process. However, when you load a new process image using the exec function (see Section 27.6 [Executing a File], page 859), any signals that you’ve defined your own handlers for revert to their SIG_DFL handling.
来源:glibc manual

And if you‘re an Android app, the zygote has already installed a whole host of signal handlers before your code even starts to run. (And, no, you can’t ignore them instead, because some of them are critical to how ART works. For example: Java NullPointerExceptions are optimized by trapping SIGSEGV signals so that the code generated by the JIT doesn’t have to insert explicit null pointer checks.)
来源:bionic documentation

NOTES:

  • 然而 exit() 函数并非线程安全,也不满足 async-signal-safe 函数 的要求。更多可以对比 _exit()/_Exit()quick_exit() 函数,前者满足全部要求,后者情况比较复杂:如果不注册任何 at_quick_exit(),那么等价于 _Exit() 即视为 async-signal-safe 函数;否则需要满足 at_quick_exit() 注册的函数均为 signal-safe 才能应用于 signal handler(这是 C++ 标准的概念,实际差不多意思),但是并发调用仍然是 UB。别问我为什么 TLPI 20.1 推荐 exit() 函数,它后来也自我否认了。
  • 方案二不实用是因为没法做第二遍 hook。当然大部分信号默认行为是 terminated/dumped,TLPI 在 26.1.4 也用了类似的操作,感觉写代码其实不用那么讲究?
  • 另有(不明)出处表示,在 signal handler 内部也可以使用处置修改函数,但是只允许是相同的信号类型。这种设计是合理的,在后续信号的名词可以得知旧时代的信号是 oneshot 实现,因此需要在 signal handler 内部修改以达成 multishot 特性。
  • 注意一下 exec 家族的系统调用,信号的处置会被重置为 SIG_DFL 而不具有用户定义的处置函数,或者是保持 SIG_IGN。这很好理解,因为代码段被刷了。
  • 也注意一下 fork 系统调用的继承信号机制。继承可能是隐式的,比如 Android zygote(你可以理解为近似不走 exec 系统调用的 JVM 进程池孵化器)有权在你的程序运行前就注册好了一堆 signal handler,而非默认行为。
  • 出于兼容性的考虑(不只是跨平台),通常建议优先使用 sigaction() 处置修改函数。glibc manual 25.3.3 指出混用 signal/sigaction 可能导致无法通过 signal 返回的已卸载 signal handler 再次应用于 sigaction。在 Linux 你可以把「优先」当作「总是」,而非 POSIX 兼容的操作系统有可能是优先使用 signal 而非 sigaction,因为前者是 C 标准,后者是 POSIX 标准。

信号的执行

linux-signals-delivery 传递和执行

#include <signal.h>
#include <iostream>
int main() {
    // 执行栈上的变量
    int c;
    std::cout << (&c) << std::endl;
    ::signal(SIGQUIT, +[](int) {
        // 在哪里的变量?
        int c;
        // 见后面 async-signal-safe 函数的说明
        std::cout << (&c) << std::endl;
    });
    while(1);
}

上图包含了传递和执行两个步骤,执行在这里过于简化了。总之,调用方是 Linux 内核,但是执行上下文仍然处于用户空间的执行栈上。

SA_ONSTACK
       Call the signal handler on an alternate signal stack
       provided by sigaltstack(2).  If an alternate stack is not
       available, the default stack will be used.  This flag is
       meaningful only when establishing a signal handler.

NOTE: sigaltstack() 允许自行定制 signal handler 所在的栈。

linux-signals-delivery-multi 嵌套执行

「调用方是 Linux 内核」意味着经过上下文切换(系统调用或者中断的间隙)时,Linux 内核会选择调用合适 signal handler。如果函数执行耗时过于长(或者主动陷入)引起了上下文切换,内核有权做到嵌套调用 signal handler。通常来说,仅限不同类型的信号之间嵌套。

sa_mask specifies a mask of signals which should be blocked (i.e., added to the signal mask of the thread in which the signal handler is invoked) during execution of the signal handler. In addition, the signal which triggered the handler will be blocked, unless the SA_NODEFER flag is used.

来源:man 2 sigaction

NOTES:

  • TLPI 认为会有相同类型的信号之间的嵌套。需要澄清,Linux 在默认情况下不会有相同类型的嵌套,除非使能 SA_NODEFER 标记;并且这种默认情况包含了在 signal handler 内产生同步信号 raise() 的场景。
  • 显式避免 signal handler 嵌套的解决方案是善用 struct sigaction 当中的 sa_mask 成员。

async-signal-safe 函数

man 7 signal-safety 指出:能被 signal handler 安全执行的函数称为 async-signal-safe 函数。不可重入的基本都不算 async-signal-safe。这里面最麻烦的是连一个简单输出(printf()std::cout)都算不上安全。

一个曲折的弥补方案是使用 write() 系统调用,它在 POSIX 标准中满足 async-signal-safe 函数。因此可以通过标准输出文件描述符配合使用。至于格式化……自己想办法吧,至少你不能使用动态内存分配的手段,因为不可重入。

总之,不要在 signal handler 里面写带有状态的函数。当你不确定是否满足要求时,man 手册查表。

a standards paper is just so much toilet paper when it conflicts with reality. (暴论 ❤️ 来自 Linus

NOTE: 当然只是作为简单调试用途的话……不管这些也可以,反正 TLPI 大量示例是直接违反要求。当标准过于复杂的时候,它就是一张废纸。

信号的名词

TL;DR: 不可靠信号是老黄历。

这里总结一些有历史气息的名词:

  • 普通信号:编号为 1-31 号的信号。
  • 标准信号:同普通信号。
  • 实时信号:编号为 31 号以上的信号。
  • 可靠信号:这是历史上的名词,只要准确送达,就是可靠信号。
  • 排队信号:在可靠信号的基础上,传递多少次,就处理多少次。

When a signal is generated, it becomes pending. Normally it remains pending for just a short period of time and then is delivered to the process that was signaled. However, if that kind of signal is currently blocked, it may remain pending indefinitely—until signals of that kind are unblocked. Once unblocked, it will be delivered immediately.
来源:glibc manual 25.1.3

Linux 不存在不可靠信号。古事记中如此记载不可靠信号:

  • 没有临时屏蔽(指后面传递的被屏蔽信号实际会保持 pending)机制,只能忽略丢弃。
  • signal()/sigaction() 只能处置一次(oneshot 设计),重新处置的间隙中可能会丢失信号。

显然,这两点在 Linux 中是不可能的,第二点更是以 sigaction() 当中的 SA_RESETHAND 标记作为特性提供使用,表示用户定制处置并使用一次后就重置为默认行为。

NOTES:

  • 关于排队信号,其排队次数是有上限的,需要参考 SIGQUEUE_MAX 数值。已知 C 库内部的 _POSIX_SIGQUEUE_MAX 为 32,这是 SIGQUEUE_MAX 可能的最小值。
  • 关于可靠信号和排队信号,这两个名词在不同语境中可能是混用的,实际不值得花时间去澄清。
  • 关于实时信号和排队信号,这两个名词并非等价,只是 Linux 场景下是等价的。
  • 关于 sigqueue(),虽然它的语义是提供排队特性,但是它支持标准信号但是不排队。
  • 关于临时屏蔽,它表示的是待决的信号,而绝非忽略的信号。这里有两层意思,一个是指信号传递过程中再次传递,内核至少提供一个 pending bit 表示仍有同类型信号待决;另一个是指 SIG_BLOCK 主动标记操作。
#include <signal.h>
#include <iostream>
#include <thread>
#include <chrono>
int main() {
    ::signal(SIGINT, +[](int) {
        using namespace std::chrono_literals;
        std::this_thread::sleep_for(2.5s);
        std::cout << "hi" << std::endl;
    });
    while(1);
}
~/tmp$ ./a.out 
^C^C^C^C^Chi
hi
^\Quit

结论:标准信号支持 pending 特性且符合 multishot 直觉,这并非不可靠信号。

信号的传递

这块比较复杂(历史留下的琐碎事),简单写点草稿,具体请自行按图索骥:

  • pthread_ 前缀的信号函数能传递到指定线程中,其余前缀的大概率不能。比如:pthread_kill
  • SI_USER 无法区分 kill/raise/abort 来源,但是肯定能与 siqeueue 对应的 SI_QUEUE 做区分。
  • 一种比较潮的传递方式是 pidfd_send_signal(),动机是解决异步杀错人的尴尬。
  • 父子进程的默认传递是不对称的。子进程处于 terminated 时会传递信号给父进程,反过来不会。
  • 从 pending bit 可知,多个相同类型信号的传递具有自动合并的性质,不要企图用作计数器。这个结论是 glibc manual 25.4.5 给出的,但是个人觉得 sigval 也许真的能当作计数器?做好 ARQ 协议应该可行。
  • SIG_IGN 的优先级高于信号屏蔽特性。也就是说,如果信号处置设为 SIG_IGN,且处于被屏蔽(blocked)的状态,那么等价于丢失信号而非保持 pending,即信号恢复(unblocked)后也不会重新传递。详见 glibc manual 25.1.3。
  • exec 系统调用仍然保留屏蔽信号的设置和待决信号集。TLPI 27.5 对此建议:不确定情况的进程在 exec 前需由用户解除信号屏蔽。
  • 信号的传递会导致大量(慢速)系统调用的中断,因此一个常规做法是 while(syscall() == -1 && errno == EINTR); 封装,但是唯独 close 例外。另外也有 SA_RESTART 标记能避免信号传递引起的中断(内部重启),但是 TLPI 21.5 指出不严格靠谱。总结:没有好办法。
  • 信号的传递一般具有权限限制,但是唯独 SIGCONT 例外。
  • 并非 setsid 成为 session leader 并死掉就能发出 SIGHUP。
  • 其实我还是不太理解 SIGHUP 的定义,glibc manual 25.2.2 写道:The SIGHUP (“hang-up”) signal is used to report that the user’s terminal is disconnected, perhaps because a network or telephone connection was broken. 这个 network 我能理解它并非 socket 意义的网络,但是 telephone 具体是啥玩意……(后来看到维基百科的图就懂了,是我误解了「终端」的含义)
  • 调用 abort() 和手动传递 SIGABRT 意义不同,前者能无视 block 状态。
  • 虽然 SIGCHLD 默认行为近似于 SIG_IGN,但是 Linux 通过处置修改函数显式设置为 SIG_IGN 意义不同,后者对于设置后再 fork 的子进程死亡时不会产生 zombie。兼容性详见 TLPI 26.3.3。
  • 尽管 SIGUSR 语义是该信号绝不由内核产生(TLPI 20.2),但是难免有驱动悄悄在用。
  • and more!

写到后面越来越倾向于考古了。但是资料确实太多看不完,后续有补充再写吧。

图文无关

war3 由于不具备 CAP_KILL,乌瑟尔并不接收阿尔萨斯传递的信号

按照惯例,文末随机附有内容无关的图。