用户态信号处理

本页目录

信号既可以表达终止、暂停等进程控制,也可以通知某个线程发生事件或异常。正确使用的关键是:信号发给谁、在哪个线程递送、何时被阻塞,以及 handler 能安全地做什么。

处置、屏蔽与 pending

信号处置(默认、忽略、调用 handler)是进程共享属性,屏蔽集合 mask 则是每线程属性。向进程发送的信号可以递送给一个允许它的线程;硬件异常或 pthread_kill 等产生的线程定向信号则有指定目标。

阻塞不是忽略:信号可能进入 pending,待解除阻塞或被同步等待接口消费。SIGKILL 和 SIGSTOP 不能捕获、忽略或阻塞。多线程程序使用 pthread_sigmask 协调屏蔽;新线程继承创建者的 mask,不会因为另一个线程后来修改了 mask 就自动一起改变。signal(7)

普通信号通常不按每次发送计数,同一信号 pending 期间多次产生可合并。把 SIGUSR1 当成无损任务队列是不可靠的设计,真正任务数应保存在队列或计数器中,信号只负责提示检查。

发送次数为什么不等于处理次数?

比较的关键条件是信号是否仍在 pending。保持阻塞连发三次,普通信号只留下一个实例;若每发一次就让 handler 消费一次,则可以处理三次。实时信号保留的是排队实例,也必须检查发送是否成功。

正在呈现知识画面
发送次数为什么不等于处理次数?

普通信号在 pending 期间合并;实时信号可排队但有资源上限。解除屏蔽不为普通信号恢复已经合并的次数。

sigaction 与执行上下文

sigaction 明确指定 handler、执行期间额外屏蔽集合和行为标志。sigset_t 应通过 sigemptyset/sigaddset 等接口初始化,不能假设它是可直接赋值为整数的可移植字段。

标志含义
SA_SIGINFO使用带 siginfo_t 与上下文参数的 handler
SA_RESTART对支持重启的接口请求相应行为,不涵盖所有系统调用
SA_NODEFER不因正在处理就自动屏蔽同种信号,允许更复杂的重入
SA_RESETHAND进入处理时恢复相应默认处置,涉及后续递送行为
SA_ONSTACK若该线程有可用备用栈,使用它执行处理函数
handler 在被中断线程中执行,返回时恢复保存的上下文 正常执行 线程当前寄存器与栈 执行 handler 内核先构造用户态 signal frame 恢复执行 trampoline → rt_sigreturn 可按线程配置备用信号栈;处理函数不是新线程,不能假定被中断的库状态可重入。

signal frame 保存恢复执行所需的寄存器、mask 等上下文,其具体布局由架构与 ABI 决定。handler 正常返回后经用户态 trampoline 调用相关 sigreturn 路径。不要把某个 x86-64 栈布局当成 arm64 的固定结构,也不要直接像普通函数一样调用 sigreturn。

异步信号安全比线程安全更严格

handler 可能在 malloc、printf 或普通锁正修改内部状态时打断当前线程,再次调用相同设施会遇到不一致或自死锁。普通“线程安全”保证不能替代 async-signal-safe 保证。

一种常见设计是 handler 只设置 volatile sig_atomic_t 标志,由正常控制流处理任务。该类型用于受限的信号交互,不是任意多线程共享协议的替代品;复杂计数、自增和多个字段的一致性仍需单独分析。

需要唤醒事件循环时,可使用预先建立的非阻塞 self-pipe,并在 handler 中调用允许的 write;管道满时必须接受通知合并且在正常路径重查状态。write 虽属异步安全接口,阻塞 fd 上仍可能等待;安全不等于没有延迟。使用 errno 的 handler 应保存并恢复它。signal-safety(7)

用 sigsuspend 避免检查与等待之间的竞态

while (!seen) pause() 有窗口:检查之后信号已经处理,随后 pause 却继续等下一次。下面的单线程例子先阻塞信号,再用 sigsuspend 原子替换 mask 并等待。

#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <signal.h>
#include <unistd.h>

static volatile sig_atomic_t seen;
static void on_signal(int signo) {
    (void)signo;
    seen = 1;
}

int main(void) {
    sigset_t blocked, previous, waiting;
    sigemptyset(&blocked);
    sigaddset(&blocked, SIGUSR1);
    if (sigprocmask(SIG_BLOCK, &blocked, &previous) == -1) return 1;

    struct sigaction action = {0};
    action.sa_handler = on_signal;
    sigemptyset(&action.sa_mask);
    if (sigaction(SIGUSR1, &action, NULL) == -1) return 1;

    if (raise(SIGUSR1) != 0) return 1; // 此时进入 pending
    waiting = previous;
    sigdelset(&waiting, SIGUSR1);
    while (!seen) {
        if (sigsuspend(&waiting) == -1 && errno != EINTR) return 1;
    }
    if (sigprocmask(SIG_SETMASK, &previous, NULL) == -1) return 1;
    return 0;
}

等待时允许 SIGUSR1 递送,handler 返回后 sigsuspend 恢复原来的阻塞状态,并以 EINTR 返回。循环重新检查 seen,可应对其他信号唤醒。示例自发信号只是让流程可重复,实际程序可以由其他授权来源通知。sigsuspend(2)

pselect、ppoll、epoll_pwait 也能在等待 I/O 时临时替换 mask,解决类似窗口,而不是先手动解除阻塞再调用普通 wait。

先阻塞再检查,原子解除阻塞并等待 SIGUSR1 已阻塞 检查 seen == 0 信号在此时到达 进入 pending,尚未执行 handler sigsuspend 临时解除阻塞并等待 pending 被递送 → handler 设置 seen → 等待返回 EINTR 恢复阻塞 mask 后重新检查 seen;不会先消费通知再无条件睡眠 修复的关键是 mask 与等待的原子切换,不是把 pause 换成一个更短的轮询间隔。

把错误时序与正确时序逐步对照

时刻先检查再 pause阻塞 + sigsuspend
1读到 seen=0在信号阻塞时读到 seen=0
2handler 运行,写 seen=1信号到达,只记为 pending
3pause 开始等下一次信号原子换 mask,递送已有 pending
4若没有新信号,继续睡眠handler 设置 seen,返回后重新检查

“handler 已经把标志设为 1”不代表正在进入的等待会自动看到它。等待系统调用并不读取应用的 seen 变量。这个例子也说明,通知和被等待的条件是两件事:循环负责确认条件,原子等待负责避免错过条件变化对应的通知。

标志已经是1,线程为什么还会睡?

尝试“检查→发送→等待”:错误方案里 seen 已经为 1,线程却等待下一次信号;正确方案里信号先留在 pending,进入 sigsuspend 时被递送。再尝试“检查→等待→发送”,两种方案都能返回,说明只验证正常顺序不足以检查等待协议。

正在呈现知识画面
标志已经是1,线程为什么还会睡?

pause 不读取应用标志。信号若在检查后、等待前处理,就可能丢失唤醒;阻塞后用 sigsuspend 原子换 mask 可保留 pending 通知。

SA_RESTART 与 EINTR

Linux 上某些慢设备 read/write、wait 系列以及满足条件的 socket 操作可受 SA_RESTART 影响;poll、select、epoll_wait、pause、sigsuspend 等不会因为设置该标志就自动重启。socket 超时选项等还会改变具体行为,不能只靠函数名做一张无条件二分表。

一次 read/write 已传输部分数据时,可能返回正的短计数,应用应保留进展,而不是从头重复发送整条消息。超时等待若使用相对时长,EINTR 后重试也需扣除已经过去的时间。

SIGSEGV 等同步故障的 handler 返回后,若原因未消除,可能再次执行同一故障指令。安装 handler 不会把任意内存错误变成可恢复异常。

备用信号栈是每线程资源

sigaltstack 注册一块仍有效的内存,SA_ONSTACK 让相应 handler 使用它。这样正常栈已经不足时仍可能有空间记录最低限度信息,但备用栈本身不会无限增长,也不能让 malloc、复杂栈展开或日志库突然变成异步安全。

大小需考虑架构扩展状态、嵌套信号和 handler 需求,不能硬编码“通常 8 KiB 就够”。在每个相关线程建立并正确管理生命周期;替换或释放时不得仍在使用。sigaltstack(2)

同步消费:sigwait 与 signalfd

专用信号线程可阻塞目标信号,再用 sigwaitinfo/sigwait 同步接收;正常线程上下文中的后续处理不受异步 handler 的同一限制。Linux signalfd 则把信号记录接入 fd 事件循环。

应在所有相关线程建立一致的路由策略,避免一部分信号跑去传统 handler。signalfd 也不能把另一个线程专属的硬件故障任意搬到管理线程。配置示例见 epoll 与事件驱动。

普通信号合并,可以在一个进程内确定复现

下面先阻塞 SIGUSR1,连续向自己发送三次,再同步消费。三次发生时它都尚未递送,所以 pending 只保留一个普通信号实例。使用零超时 sigtimedwait 检查是否还有第二个,而不是再无期限等待。

#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <time.h>
#include <unistd.h>
int main(void) {
    sigset_t set, old;
    sigemptyset(&set);
    sigaddset(&set, SIGUSR1);
    if (sigprocmask(SIG_BLOCK, &set, &old) == -1) return 1;
    for (int i = 0; i < 3; ++i)
        if (kill(getpid(), SIGUSR1) == -1) return 1;
    struct timespec zero = {0, 0};
    siginfo_t info;
    int first = sigtimedwait(&set, &info, &zero);
    errno = 0;
    int second = sigtimedwait(&set, &info, &zero);
    int saved = errno;
    if (sigprocmask(SIG_SETMASK, &old, NULL) == -1) return 1;
    printf("one received=%d; next empty=%d\n",
           first == SIGUSR1, second == -1 && saved == EAGAIN);
    return !(first == SIGUSR1 && second == -1 && saved == EAGAIN);
}

保存为 pending-demo.c,编译运行预期 one received=1; next empty=1。这个单线程、自发信号实验不需要安装 handler;sigwaitinfo/sigtimedwait 在信号保持阻塞时同步取走它。零超时 EAGAIN 表示此刻没有可接收的目标信号。sigtimedwait(2)

不能把这个结果泛化为“无论何时发送三次都只处理一次”。如果每次发送之间前一个已经被消费,就可能分别观察到三次。决定合并的是 pending 期间的行为;需要可靠任务数量时,应读取真正的任务队列。

实时信号与 timerfd

实时信号可携带 sigval 数据并排队,但队列受资源限制,sigqueue 可以失败,不能承诺永不丢失。相同实时信号的排队顺序与不同信号编号的递送优先级有规定,不等于所有编号按统一发送时间 FIFO。SIGRTMIN 还可能由线程库调整,应使用接口宏并核对范围。

timerfd 通知不需要 signal handler。read 成功返回的通常是 sizeof(uint64_t) 字节,读出的整数才是累计到期次数,可能大于 1;不要把“返回值为 8”误读成到期 8 次。单调时钟适合测时间间隔,是否包含系统挂起还取决于选择的时钟。

重试等待要保留总时间预算

假设等待预算是 100 ms,第 30 ms 被信号打断,稍后又消耗 40 ms。如果每次 EINTR 都重新等待 100 ms,总等待就被延长;连续信号甚至可能不断把完成时间向后推。应用应以选定的单调时钟计算一个固定 deadline,再按当前时间计算剩余预算。

deadline_ms = 100
def remaining(now_ms):
    return max(0, deadline_ms - now_ms)
assert remaining(0) == 100
assert remaining(30) == 70
assert remaining(70) == 30
assert remaining(105) == 0

这只是整数时间模型;实际 C 程序要处理 timespec 的秒/纳秒归一化、时钟读取错误及具体等待接口的超时规则。SA_RESTART 不能替你维护应用层 deadline。即使某个调用能够重启,也不应据此混淆“单次阻塞时长”与“整项操作允许的总时长”。

调试时观察什么

strace -e trace=rt_sigaction,rt_sigprocmask,rt_sigsuspend -e signal=all ./prog

/proc/PID/status 的 SigBlk、SigIgn、SigCgt 描述屏蔽与处置,SigPnd 和 ShdPnd 区分线程与进程 pending;多线程问题还需查看对应 task 的状态。

先确认信号目标、mask 和 pending,再分析 handler 是否执行和等待是否重启。不要用信号频率直接估算事件发生次数,也不要把一次 handler 成功当作整个程序的同步协议已经正确。

关键词:signal, sigaction, signal mask, pending, async-signal-safe, sigsuspend, SA_RESTART, sigaltstack, signalfd, real-time signal