信号用户态编程 —— 异步事件的处理艺术
信号是内核给你的异步通知:进程正跑在
main()里的任何一行时,信号都可能"插队"。本篇是用户态信号编程的导读——先讲清核心矛盾(异步 vs 受限),再给一句话认知与三大约束,然后分三条线带你钻进去:API 全家桶怎么用、十大关键坑怎么躲、五个实战 Demo 怎么做。
相关:信号在内核侧怎么生成与投递见 signals-kernel;按发送/接收/处理维度的分类见 signals-kernel-classification;signal/eventfd/pipe 三种异步通知机制对比见 signals-alternatives;僵尸进程与 waitpid 见 zombie-case;SIGSEGV 崩溃现场分析见 crash 系列。
零、一句话认知:信号的难点在于"任意时刻插入"
信号处理最难的不是语法,而是心智模型:handler 在任意时刻"插入"你的正常执行流。你在 printf 刚拿到锁时、malloc 改堆链表到一半时、read 正要返回时——信号都可能插进来执行 handler。于是信号编程的几乎所有规则,都是为了处理"插入"带来的三个约束:
| 约束 | 违反的后果 | 对策 |
|---|---|---|
| handler 中只能用 async-signal-safe 函数(内部有锁/malloc/全局状态的都不行) | 死锁(printf 拿 stdout 锁被卡住)、堆损坏(malloc 重入)、数据竞争 | handler 只设 flag / 写 pipe / 转 signalfd,处理放主循环 |
共享变量必须用 volatile sig_atomic_t(单次读写原子) | 数据竞争——主线程读到一半的变量;sig_atomic_t 只保证单次赋值 | 用"设标志"而非"计数",或临时屏蔽信号保护临界区 |
系统调用可能返回 EINTR | 程序把"被打断"误判为"IO 出错",直接退出或报错 | 循环重试(TEMP_FAILURE_RETRY)或设 SA_RESTART(但不万能) |
三个约束不是凭空吓唬人,各自都有看得见的现场:
- handler 里调
printf:printf内部要拿stdout的锁。主线程正在printf执行到一半(锁已持有),信号打断它进入 handler,handler 里再调printf——死锁。看起来只是"打印一行日志",实际上是把自己锁死。 - handler 里调
malloc/free:malloc维护堆链表。主线程正 free 到一半(链表改到一半),handler 进来malloc——堆链表被写坏,可能当场崩溃,也可能悄悄泄漏、地址错乱,几小时后才爆。 - 共享变量不设
volatile:编译器把循环里的变量优化进寄存器,主线程永远看不到 handler 的修改——信号到了,程序却像没收到。
这三个例子对应坑篇的前三个坑,源码级分析在那边展开。
核心心法一句话:handler 只做"通知",不处理业务。 真正的处理放到主循环(flag / pipe / signalfd / sigwait)。违背这一句话,就会踩到下面三篇里讲的各种坑。
一、三线速览
线一:API 全家桶(→ API 速查)
按信号生命周期四类:注册(sigaction,永远别用 signal——行为不可移植且 System V 下 handler 执行一次就失效;sigaction 本质是系统调用,内核拿锁写 sighand->action[] 数组一个元素,全线程共享)· 屏蔽(pthread_sigmask,多线程必须用这个,只影响当前线程)· 发送(kill/raise/killpg,要带数据排队就 sigqueue,必须用 RT 信号)· 等待/投递(分水岭:handler 异步打断 vs sigwait 族/signalfd 同步处理——同步模式无 async-signal-safe 限制)。sa_flags 里 SA_SIGINFO/SA_RESTART/SA_ONSTACK 是三个最常用的。
线二:十大关键坑(→ 十大关键坑)
十个坑分三组:① handler 自身纪律——只用 async-signal-safe 函数(printf/malloc/pthread_mutex_lock 全禁)、EINTR 循环重试、共享变量只做单次赋值、handler 首尾保存/恢复 errno;② handler 环境边界——SA_NODEFER 自嵌套栈爆炸、SA_RESTART 不覆盖 poll/select/epoll_wait/sleep、主栈满要用 sigaltstack + SA_ONSTACK;③ 多线程与子进程——优先 sigwait 专用线程或 signalfd+epoll,fork 三大坑(handler 遗传上下文丢失、多线程 fork 锁死、SIGCHLD 竞态)用"先 block→fork→注册→unblock"化解。
线三:实战 Demo(→ 实战 Demo)
五个完整可编译的 Demo:signalfd + epoll 优雅退出(信号变 fd 混进主循环,停机逻辑畅所欲为)· alarm 超时(故意不设 SA_RESTART,让 read 被打断返回 EINTR 当超时信号)· sigwait 专用线程(同步等信号,屏蔽字要在创建线程前设置)· signalfd 生产级骨架(SIGHUP 重载只设标志 + 一次读完所有信号)· SIGSEGV backtrace(SA_SIGINFO + SA_ONSTACK 拿故障地址和调用栈,_exit 收尾)——唯一需要复杂 handler 的场景就是崩溃诊断。
处理模式决策树
信号到了,用 handler / sigwait / signalfd 哪种?三问定案:
- 我要不要三参数 handler 拿故障现场?(SIGSEGV/SIGBUS 崩溃诊断)→ 用
SA_SIGINFO + SA_ONSTACK(Demo 5)。这是 handler 唯一"正当"的复杂用法。 - 单线程工具还是长期服务? 单线程小工具(脚本响应 SIGTERM 收尾)→
sigwait或简单 handler 都行;有 epoll 事件循环的服务 → signalfd 混进 epoll,统一事件模型(Demo 4)。 - 信号频率高不高? 每秒几百次以上的协作信号 → 换成 eventfd/pipe 等 fd 事件(见 signals-alternatives 篇),信号机制不适合高频。
一句话:能用 fd 事件解决的不用信号,能用 sigwait/signalfd 解决的不用 handler,handler 只留给崩溃诊断。
两段式:handler 通知、主循环消费的四种接法
心法是"handler 只通知",但通知到主循环的路有四条,工程量递增:
| 接法 | handler 里做什么 | 主循环怎么收 | 适用 |
|---|---|---|---|
| 轮询 flag | 置 volatile sig_atomic_t 标志 | 主循环每轮检查标志 | 主循环本来就高频轮询(游戏/网络线程) |
| self-pipe | write() 写管道 | read() 管道(可进 select/poll) | 老代码事件循环,没上 signalfd |
| signalfd | 无 handler,信号直接变 fd | epoll 统一监听 | 新服务(推荐) |
| sigwait 线程 | 无 handler | 专用线程同步等待 | 信号低频、不想动事件循环 |
轮询 flag 最省事但有延迟和轮询开销;self-pipe 是 signalfd 出现前的经典招,新代码直接上 signalfd 即可,不必再学 self-pipe。
二、和本仓库其他文档的关系
- 信号内核机制:signals-kernel——信号在内核侧怎么生成(
send_signal)、怎么投递(get_signal)、sigframe/__kernel_rt_sigreturn的完整路径。本篇是"用户态怎么用",那边是"内核态怎么实现"。 - 信号分类:signals-kernel-classification——按发送/接收/处理三个维度的分类,把信号体系的结构讲清。
- 通知机制对比:signals-alternatives——signal vs eventfd vs pipe 三种异步通知机制的性能与适用场景对比。
- 崩溃现场:crash/signals.md、crash/asan.md——SIGSEGV 等故障信号的生产级排查思路。
- 进程结构:task-struct(signal/sighand/pending 字段)、zombie-case(SIGCHLD + waitpid 回收僵尸)、scheduling(信号到达唤醒睡眠进程)。
什么时候别用信号
信号是低频事件的工具,下面这些需求先想别的:
- 高性能通知:线程间每秒万次以上的同步,用 futex/eventfd/pipe(见 signals-alternatives 对比)。信号路径的 siglock 争用、FPU 保存、sigframe 构造在频率下成本被放大,性能篇里有实测对比。
- 带大量数据的传递:信号只能带一个
si_value(int 或指针)。传数据要用共享内存 + 信号通知,那不如直接用 fd 事件 + 用户态缓冲。 - 严格有序/不丢失:标准信号会合并(同种信号只记一次);RT 信号排队但有上限(
RLIMIT_SIGPENDING,默认 32k)。要保证逐条处理,队列/管道更可靠。
三、一句话总结
信号是内核的异步通知,难点在于 handler 会在任意时刻插入你的执行流——于是有了三大约束(handler 只能用 async-signal-safe 函数、共享变量用 volatile sig_atomic_t、系统调用可能 EINTR)。核心心法:handler 只做通知,处理放主循环。API 分四类:注册 sigaction(不要用 signal)、屏蔽 pthread_sigmask、发送 kill/sigqueue、等待投递(sigwait 族/signalfd 同步 > handler 异步)。十个坑分三组(handler 纪律 / 环境边界 / 多线程与 fork),五个 Demo 覆盖优雅退出、超时、专用线程、生产骨架与崩溃诊断。内核怎么实现这套机制,见 signals-kernel。