7.2 POSIX 信号与异步安全
地心控制台需要在不重启进程的情况下处理中断、超时和子进程退出,信号由此进入调查范围。
异常与系统调用说明了内核入口。signal 是 Unix 暴露给进程的另一种异常控制流:内核安排用户态 handler,或者执行默认 disposition。
signal 不是普通回调
控制台按下 Ctrl+C,terminal driver 通常向 foreground process group 产生 SIGINT。若进程安装了 handler,内核会在合适的用户态返回边界修改执行上下文,使 handler 看起来在原程序中异步插入。
handler 可以打断主流程的几乎任意用户代码位置。它不是 event loop 中按顺序排队的普通函数,也不能假设被打断的库函数已处于一致、可重入状态。
signal 的常见 disposition 有:
- 默认动作:忽略、终止、终止并 core dump、停止或继续;
- 显式忽略;
- 调用进程安装的 handler。
SIGKILL 与 SIGSTOP 不能被捕获、屏蔽或忽略,这是内核保留控制能力的一部分。其他 signal 是否可捕获及默认动作要查平台文档。
generated、pending、blocked、delivered
一条信号的生命周期可分为:
generated
-> pending
-> 若被 blocked,继续等待
-> 若可递达,执行 disposition / handlermask 决定当前线程暂时阻塞哪些 signal。阻塞不是丢弃:signal 保持 pending,直到解除屏蔽或进程结束。
传统标准 signal 在 pending 时通常不排队,同一种 signal 产生多次可能合并成一次 pending 状态。POSIX real-time signal 可排队并携带值,但也有资源上限。不能用一个普通 handler 调用次数精确统计外部事件。
signal 可以是 process-directed,也可以是 thread-directed。多线程进程中,进程定向 signal 会选择一个没有屏蔽它的合适线程递达;同步硬件异常产生的 signal 通常针对 faulting thread。每个线程有自己的 mask,disposition 通常属于整个进程。
用 sigaction 安装最小 handler
下面的 POSIX 程序阻塞 SIGINT,安装 handler,再用 sigsuspend 原子地暂时换成允许 SIGINT 的 mask。这样避免“检查标志后、调用 pause 前 signal 已到达”造成永久睡眠的竞态。
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
static volatile sig_atomic_t stop_requested = 0;
static void handle_sigint(int signal_number) {
(void)signal_number;
stop_requested = 1;
}
int main(void) {
sigset_t blocked;
sigset_t original_mask;
sigset_t wait_mask;
if (sigemptyset(&blocked) != 0 || sigaddset(&blocked, SIGINT) != 0) {
perror("build signal set");
return 1;
}
if (sigprocmask(SIG_BLOCK, &blocked, &original_mask) != 0) {
perror("sigprocmask");
return 1;
}
struct sigaction action;
memset(&action, 0, sizeof action);
action.sa_handler = handle_sigint;
if (sigemptyset(&action.sa_mask) != 0
|| sigaction(SIGINT, &action, NULL) != 0) {
perror("sigaction");
return 1;
}
wait_mask = original_mask;
if (sigdelset(&wait_mask, SIGINT) != 0) {
perror("sigdelset");
return 1;
}
puts("press Ctrl+C once");
while (!stop_requested) {
if (sigsuspend(&wait_mask) == -1 && errno != EINTR) {
perror("sigsuspend");
return 1;
}
}
if (sigprocmask(SIG_SETMASK, &original_mask, NULL) != 0) {
perror("restore signal mask");
return 1;
}
puts("SIGINT observed in normal control flow");
return 0;
}sig_atomic_t 保证对该对象的简单访问在 signal context 中不会被撕裂;volatile 防止普通控制流把值永久缓存成旧观察。它不提供 C11 thread atomics 的完整同步语义,也不能承载复杂数据结构。
示例 handler 只设置标志。puts、错误处理和退出都在正常控制流完成。
为什么 printf 和 malloc 不能放进 handler
handler 可能在主流程执行 malloc、stdio 或持有内部 mutex 时插入。若 handler 再调用同一非可重入实现,可能死锁或破坏 allocator/stream 状态。
POSIX 列出 async-signal-safe 函数;只有标准明确保证的操作才可从 handler 调用。常见可用项包括 _exit、write 的规定用法和少量 signal 操作,但完整清单与限制应查目标平台的 signal-safety(7) / POSIX 文档。
即使调用 write,也要注意:
- file descriptor 可能 nonblocking,写会失败或部分完成;
- handler 改变
errno会干扰被打断代码,应按需保存和恢复; - 多个 handler/线程写同一 stream 的输出可能交织;
- 管道满时阻塞 handler 可能让程序停住。
所以“handler 中打印一行”可作最小诊断,却不是可靠日志系统。更稳妥的做法是只设置标志,或通过经过设计的 nonblocking self-pipe/eventfd 通知主循环。
self-pipe 把突然响起的警铃接回值班队列
signal handler 像在任意时刻响起的警铃,不适合当场做复杂维修。更稳妥的做法是只留下一个 async-signal-safe 标记,再让正常 event loop 在自己的控制流里处理。
self-pipe trick 的结构是:
signal handler --write one byte--> nonblocking pipe
|
main loop -------- poll/select -------+handler 只执行 async-signal-safe 的 write,event loop 在普通上下文读取 pipe,再做日志、allocation、状态转换。pipe 写端必须 nonblocking;若已满,可把“至少有事件待处理”保存在 sig_atomic_t 标志中,不能让 handler 卡住。
Linux 的 signalfd 可把被屏蔽 signal 转换成 file-descriptor 事件,适合 Linux-only event loop;使用时仍要正确设置每个线程的 mask。portable POSIX 程序通常使用 self-pipe 或专门 sigwait 线程。
多线程服务常在创建 worker 前统一屏蔽目标 signal,再由一个 dedicated thread 调用 sigwait / sigwaitinfo 同步接收。这样复杂处理发生在普通线程上下文,不受 async-signal-safe 限制。
signal 会怎样影响阻塞 system call
signal 递达时,正在阻塞的 system call 可能:
- 被自动重启;
- 返回
-1并设置errno=EINTR; - 已完成部分工作,直接返回正的 byte/item 数。
结果取决于具体 API、signal disposition、SA_RESTART 和已经完成的副作用。不能对所有失败一律无条件重试:例如带 timeout 的操作要重算剩余时间,非幂等调用可能已经完成一部分。
pselect、ppoll、sigsuspend 等 API 能把“暂时替换 signal mask”和“进入等待”组合成一个原子步骤,专门避免检查条件与睡眠之间的 lost wakeup。
安装 SA_RESTART 也不是“再也没有 EINTR”。并非所有 system call 都可重启,平台规则不同;程序仍需遵守所调用 API 的错误合同。
synchronous fault signal 不是恢复控制流的万能异常
SIGSEGV、SIGBUS、SIGFPE、SIGILL 等可能源自当前指令的同步异常。handler 若直接返回,faulting instruction 常会再次执行并再次 fault,形成循环。
高级运行时、debugger、JIT 或 crash reporter 可能使用 SA_SIGINFO、alternate signal stack 与架构上下文做受控处理。但普通应用不应在 SIGSEGV handler 中调用 printf、malloc、解锁 mutex 或试图“跳过错误继续跑”。进程内存和业务 invariant 可能已经无法信任。
安全的崩溃处理通常只采集预先准备的最小信息,写入可靠通道,然后用 _exit 或恢复默认 disposition 后重新触发。core dump 与外部 supervisor 往往比进程内复杂恢复更可信。
signal mask 与 critical section
单线程程序可暂时 block 某个 signal,保护会被 handler 同时访问的最小状态:
block signal
update shared-with-handler state
restore old mask这不等于多线程 mutex。mask 是 per-thread;其他线程仍可能接收 process-directed signal。对线程共享数据,应使用 pthread/C11 synchronization,并设计 signal 由哪个线程处理。
handler 中使用 volatile sig_atomic_t 标志,只适合 handler 与被中断执行流之间的简单通信。若普通 worker threads 也读写该标志,需要额外的 thread-synchronization 设计;不要把 volatile 当原子或 memory fence。
signal disposition 会跨越哪些进程操作
fork 后子进程继承 signal disposition 与 mask 的相关状态,但 pending 集合有规定的独立语义;多线程程序的 child 只保留调用 fork 的 thread,锁状态可能来自已消失线程,因此 exec 前能安全调用的函数受到严格限制。
exec 成功后,被捕获 signal 的 disposition 通常恢复默认,被忽略 signal 通常保持忽略,signal mask 则按 POSIX 规则保留。daemon/supervisor 若错误继承 blocked mask,可能让新程序一直收不到预期 termination signal。
child process 的状态变化还会产生 SIGCHLD,但可靠回收需要循环 waitpid,因为标准 signal 可以合并;一次 handler 调用不等于只退出一个 child。
动手验证异步边界
- 删除示例中的预先 block,写出 signal 恰好落在检查与
pause之间的 lost-wakeup 时间线。 - 给 self-pipe 设置 nonblocking,设计 pipe 已满时不丢失“至少有一个事件”的语义。
- 比较
SA_RESTART开关对read、nanosleep的实际影响,记录目标 OS。 - 在多线程程序中让所有 worker block
SIGTERM,由sigwaitthread 发起 graceful shutdown。 - 解释为什么普通 signal 不能精确统计 100 次事件,并比较 real-time signal 的队列语义。
- 分析
SIGCHLDhandler 为什么要循环waitpid(-1, ..., WNOHANG)。
下一步是保存哪一个执行上下文
system call、fault 与 signal 都会让内核接管当前 thread。下一章进入进程上下文,区分 process、thread、address space、file descriptor table,并追踪 fork、exec、wait 与 context switch。