信号用户态编程 · 实战 Demo——优雅退出、超时、专用线程与崩溃诊断
这是 signals-user(用户态编程总纲)的拆分篇之一,用五个完整可编译的 Demo 把信号从"语法"变成"能力":
signalfd + epoll优雅退出、alarm超时、sigwait专用线程、signalfd完整集成、SIGSEGVbacktrace。每个 Demo 都回答了前面 API 篇/坑篇里讲的某个问题——代码就是最好的注脚。配套拆分篇:API 速查 · 十大关键坑。
一句话(来自总纲):"handler 只做通知,处理放主循环"——下面五个 Demo 里,1/2/4 是这句话的直接体现,3 用专用线程绕开了 handler,5 是 handler 的唯一例外(诊断现场)。
破题:五个 Demo 各解决什么问题
| Demo | 场景 | 用的核心机制 | 回答了坑篇的哪个问题 |
|---|---|---|---|
| 3.1 优雅退出 | 服务收到 SIGTERM 要干净地停机(刷缓冲、关连接、通知下游) | signalfd + epoll | 不用 handler,无 async-signal-safe 限制 |
| 3.2 alarm 超时 | read() 阻塞太久,要限时 | alarm() + EINTR | 坑二 EINTR 必须处理 |
| 3.3 sigwait 专用线程 | 主线程专心业务,信号让专人处理 | sigwait | 坑六模式 1 |
| 3.4 signalfd 完整集成 | 生产级:epoll 主循环 + 优雅停机 + 日志 | signalfd + epoll + SIGHUP 重载 | 坑六模式 2 |
| 3.5 SIGSEGV backtrace | 崩溃时打印调用栈再退出 | SA_SIGINFO + SA_ONSTACK | 坑九栈溢出 + si_addr |
拆开看,五个 Demo 其实是同一套五步骨架:① sigemptyset/sigaddset 明确要处理的信号集合;② 决定处理模式(handler / sigwait / signalfd);③ 屏蔽字先行——signalfd 和 sigwait 都要求信号先被 block;④ 处理逻辑远离 handler(放主循环或专用线程);⑤ 验证真的收到了信号(strace/perf/输出)。把这套骨架记下来,任何服务接信号都能直接套用。
一、Demo 1:signalfd + epoll 优雅退出
问题:服务进程被 kill/systemctl stop 发 SIGTERM,如何"优雅退出"——把在途请求处理完、刷新日志、关闭连接、再退出?
不优雅的做法:handler 里做所有事(违反坑一,handler 只能 async-signal-safe)。
优雅的做法:信号转成 fd,混进主循环的 epoll——信号到达时 epoll_wait 自然醒来,走主循环的正常路径执行停机逻辑,想调什么函数就调什么(free/printf/close 都行)。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <signal.h>
#include <sys/signalfd.h>
#include <sys/epoll.h>
#include <sys/wait.h>
#define MAX_EVENTS 64
int main(void)
{
// 1. 屏蔽要处理的信号 —— signalfd 要求这些信号必须被 block
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, NULL);
// 2. 创建 signalfd —— 信号从此变成 fd
int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
if (sfd < 0) { perror("signalfd"); exit(1); }
// 3. 把 sfd 加入 epoll —— 和业务 fd 统一调度
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev);
printf("server running, pid=%d, send 'kill %d' to stop\n", getpid(), getpid());
volatile sig_atomic_t running = 1;
struct epoll_event events[MAX_EVENTS];
while (running) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (n < 0) {
if (errno == EINTR) continue; // 即使 SA_RESTART 也会 EINTR (坑五)
perror("epoll_wait");
break;
}
for (int i = 0; i < n; i++) {
if (events[i].data.fd == sfd) {
struct signalfd_siginfo si;
ssize_t r = read(sfd, &si, sizeof(si));
if (r != sizeof(si)) continue;
printf("got signal %d (from pid %d)\n", si.ssi_signo, si.ssi_pid);
if (si.ssi_signo == SIGTERM || si.ssi_signo == SIGINT) {
// ★ 主循环里可以随便调函数做优雅停机:
// 关监听 fd → 排干在途请求 → 刷日志 → 停 worker
printf("graceful shutdown...\n");
running = 0;
}
}
}
}
close(sfd);
close(epfd);
printf("bye\n");
return 0;
}编译运行:
gcc -O2 -Wall -o sfd_demo sfd_demo.c
./sfd_demo &
kill -TERM %1 # 触发优雅退出要点回顾:信号只在 signalfd 的读端体现为可读事件——ssi_signo/ssi_pid/ssi_uid 就是 siginfo_t 的简化版。这里没有 handler、没有 EINTR 重试噩梦(epoll 自己的 EINTR 处理一次 continue 即可,和信号无关)、停机逻辑全在主循环里,畅所欲为。
二、Demo 2:alarm 超时
问题:read() 可能一直阻塞(管道没数据、终端没输入),想加个超时上限——超过 N 秒就放弃。
思路:alarm(n) 在 n 秒后给进程发 SIGALRM → 默认处置是终止进程 → 换成自定义 handler → read() 被信号打断返回 EINTR → 判断 errno == EINTR 就知道是超时了。
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <signal.h>
#include <string.h>
static void alarm_handler(int sig)
{
/* 什么都不用做 —— 目的只是打断阻塞的 read() */
}
int read_with_timeout(int fd, char *buf, size_t len, int seconds)
{
// 1. 注册 handler: 注意不要 SA_RESTART! 否则 read 会被自动重启(坑五)
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = alarm_handler; // 故意不设 SA_RESTART
sigemptyset(&sa.sa_mask);
sigaction(SIGALRM, &sa, NULL);
// 2. 设闹钟
alarm(seconds);
// 3. read —— 超时会被 SIGALRM 打断返回 EINTR
ssize_t n = read(fd, buf, len);
// 4. 关闹钟(若已读满提前返回), 清除可能残留的 SIGALRM
alarm(0);
if (n < 0 && errno == EINTR) {
fprintf(stderr, "read timed out after %d sec\n", seconds);
return -1; // 超时
}
return (int)n; // 成功读到的字节数
}
int main(void)
{
char buf[128];
printf("type something within 3 seconds (pipe/terminal):\n");
int n = read_with_timeout(STDIN_FILENO, buf, sizeof(buf), 3);
if (n > 0) printf("read %d bytes: %.*s\n", n, n, buf);
return 0;
}编译运行:
gcc -O2 -Wall -o alarm_demo alarm_demo.c
./alarm_demo # 不输入任何东西 → 3 秒后打印 "read timed out"要点回顾:这个 demo 把坑二(EINTR)和坑五(SA_RESTART)用到了极致——故意不设 SA_RESTART 才能让 read() 返回 EINTR 作为超时信号。如果误设了 SA_RESTART,read() 会被自动重启、继续阻塞,超时完全失效。这也是"SA_RESTART 不万能"的活例子:它救不了所有场景,你要按场景决定设不设。
三、Demo 3:sigwait 专用线程
问题:多线程程序想响应 SIGTERM/SIGINT,但主线程在做 IO、业务线程忙得不可开交——谁专门负责接信号?
思路:专用信号线程。主线程创建业务线程之前屏蔽目标信号(子线程自动继承屏蔽字),然后起一个线程只做 sigwait——信号到达时这个线程同步返回,可以随便调用任何函数(printf/malloc/清理逻辑都不受限)。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <signal.h>
/* 业务线程 —— 主逻辑 */
static void *worker(void *arg)
{
int n = *(int *)arg;
printf("worker %d: doing work...\n", n);
/* 模拟长期运行: sleep(100); */
return NULL;
}
/* 信号专用线程 */
static void *signal_thread(void *arg)
{
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGTERM);
sigaddset(&set, SIGINT);
int sig;
while (1) {
if (sigwait(&set, &sig) != 0) { perror("sigwait"); continue; }
printf("[signal thread] got %s\n",
sig == SIGTERM ? "SIGTERM" : "SIGINT");
if (sig == SIGTERM || sig == SIGINT) {
/* ★ 这里可以安全调用任何函数: */
printf("[signal thread] initiating graceful shutdown...\n");
/* 通知 worker 线程退出, 收尾, ... */
exit(0); /* 演示用: 直接退出 */
}
}
return NULL;
}
int main(void)
{
/* 关键: 在创建任何线程之前屏蔽目标信号! */
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGTERM);
sigaddset(&set, SIGINT);
pthread_sigmask(SIG_BLOCK, &set, NULL); /* 本线程屏蔽, 子线程继承 */
/* 启动信号线程 —— 它继承屏蔽字, 成为 sigwait 的唯一消费者 */
pthread_t stid;
pthread_create(&stid, NULL, signal_thread, NULL);
/* 启动业务线程 */
pthread_t wtid;
int arg = 42;
pthread_create(&wtid, NULL, worker, &arg);
printf("main: pid=%d, send 'kill -TERM %d' to trigger\n", getpid(), getpid());
pthread_join(stid, NULL); /* 信号线程不退出, 这里一直等 */
return 0;
}编译运行:
gcc -O2 -Wall -pthread -o sigwait_demo sigwait_demo.c
./sigwait_demo & # 后台运行
kill -TERM %1 # 触发 → 信号线程打印并优雅退出要点回顾:sigwait 是"同步等待信号"——这是处理信号的最干净模式:没有 handler 的异步打断、没有 async-signal-safe 清单、没有 EINTR。注意 sigwait 返回的信号不会被投递到进程的任何 handler(信号被"消费"了),所以不能让 handler 和 sigwait 同时盯着同一个信号。
sigwait vs signalfd 怎么选? 两者都能绕开 handler 的异步打断,差别在事件模型:sigwait 是"信号专线"——专用线程阻塞等待,适合信号种类少、频率低的场景,代码最省事;signalfd 把信号混进 epoll,适合本身就有事件循环的服务,一个 epoll_wait 同时管业务 fd 和信号 fd,信号堆积时还能用 while(read(...)) 批量读掉。习惯上:单线程工具用 sigwait,长期运行的服务用 signalfd + epoll 是主流。
四、Demo 4:signalfd + epoll 完整集成(生产级)
问题:真实服务往往有多个 fd(监听 socket、epoll、日志、健康检查),把信号也变成 fd 就能统一事件模型——一次 epoll_wait 同时处理业务事件和信号事件。加上 SIGHUP 重载配置,就是一个典型的"生产级信号骨架"。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <signal.h>
#include <sys/signalfd.h>
#include <sys/epoll.h>
static volatile sig_atomic_t reload_config = 0;
static void handle_signal(struct signalfd_siginfo *si)
{
switch (si->ssi_signo) {
case SIGTERM:
case SIGINT:
printf("[event loop] got %s, shutting down\n",
si->ssi_signo == SIGTERM ? "SIGTERM" : "SIGINT");
exit(0); /* 优雅停机: 主循环退出 */
break;
case SIGHUP:
printf("[event loop] SIGHUP: reloading config...\n");
reload_config = 1; /* 设置标志, 主循环里再加载 */
break;
case SIGUSR1:
printf("[event loop] SIGUSR1 (custom signal)\n");
break;
default:
printf("[event loop] unhandled signal %d\n", si->ssi_signo);
break;
}
}
int main(void)
{
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGHUP);
sigaddset(&mask, SIGUSR1);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, NULL); /* 先 block 再 signalfd */
int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
if (sfd < 0) { perror("signalfd"); return 1; }
int epfd = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = sfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev);
printf("server pid=%d\n", getpid());
struct epoll_event events[16];
while (1) {
int n = epoll_wait(epfd, events, 16, -1);
if (n < 0) { if (errno == EINTR) continue; perror("epoll_wait"); break; }
for (int i = 0; i < n; i++) {
if (events[i].data.fd == sfd) {
struct signalfd_siginfo si;
while (read(sfd, &si, sizeof(si)) == sizeof(si))
handle_signal(&si); /* 一次性读光所有信号 */
}
/* 其余 fd 的 EPOLLIN 处理在这里 */
}
if (reload_config) {
reload_config = 0;
/* 真正重载配置: 读文件 → 换配置结构 → 广播通知 */
}
}
}编译运行:
gcc -O2 -Wall -o sfd_epoll_demo sfd_epoll_demo.c
./sfd_epoll_demo & # 启动
kill -HUP %1 # 重载配置
kill -TERM %1 # 优雅停机要点回顾:这个骨架可以直接搬进真实服务。几个生产级细节——① 用 while (read(...) == sizeof(si)) 把排队的所有信号一次读完(多个信号同时到达时不漏);② SIGHUP 只设标志不做事,重载逻辑放主循环(避免 handler 里读文件这种非安全操作);③ 信号 fd 非阻塞 + epoll 边缘/水平触发都兼容。
五、Demo 5:SIGSEGV 崩溃 backtrace(handler 的正当用途)
问题:程序崩溃(段错误)时,默认行为是内核打印 Segmentation fault 后终止。能不能在崩溃的瞬间自己打印调用栈、寄存器、故障地址,方便线上定位?
思路:SA_SIGINFO + SA_ONSTACK 三参数 handler——info->si_addr 拿到故障地址、ucontext 拿到寄存器现场、备用栈保证栈已满时 handler 还有地方跑(坑九),最后 _exit()(不能 return——栈已经毁了)。
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <ucontext.h>
#include <execinfo.h>
static void segv_handler(int sig, siginfo_t *info, void *ucontext)
{
int saved_errno = errno;
fprintf(stderr, "\n=== SIGSEGV caught ===\n");
fprintf(stderr, "faulting address: %p\n", info->si_addr);
fprintf(stderr, "signal: %d (code %d)\n", info->si_signo, info->si_code);
/* 打印调用栈: backtrace_symbols_fd 直接写 fd, 无需内存分配 */
void *bt[64];
int n = backtrace(bt, 64);
fprintf(stderr, "backtrace (%d frames):\n", n);
backtrace_symbols_fd(bt, n, STDERR_FILENO);
/* 从 ucontext 里读寄存器(以 x86_64 为例) */
ucontext_t *uc = (ucontext_t *)ucontext;
greg_t *regs = uc->uc_mcontext.gregs;
fprintf(stderr, "RIP=%016lx RSP=%016lx\n",
regs[REG_RIP], regs[REG_RSP]);
errno = saved_errno;
_exit(128 + sig); /* ★ 不能 return —— 栈可能已满/损坏 */
}
static void install_segv_handler(void)
{
/* 备用栈: 主栈已满时 handler 在这里执行 (坑九) */
static char altstack[SIGSTKSZ] __attribute__((aligned(16)));
stack_t ss = { .ss_sp = altstack, .ss_size = SIGSTKSZ, .ss_flags = 0 };
sigaltstack(&ss, NULL);
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_sigaction = segv_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_SIGINFO | SA_ONSTACK; /* 三参数 handler + 备用栈 */
sigaction(SIGSEGV, &sa, NULL);
}
static void recursive_crash(int depth)
{
char pad[1024]; /* 每次递归吃 1KB 栈 */
(void)pad;
if (depth > 0) {
printf("depth %d (stack ~%p)\n", depth, (void *)&pad);
recursive_crash(depth - 1);
} else {
int *p = NULL;
*p = 42; /* 故意空指针解引用 → SIGSEGV */
}
}
int main(void)
{
install_segv_handler();
printf("pid=%d, crashing...\n", getpid());
recursive_crash(20); /* 崩在栈深 20 处 */
return 0;
}编译运行:
gcc -g -O0 -rdynamic -Wall -o segv_backtrace segv_backtrace.c
./segv_backtrace输出示例:
pid=12345, crashing...
depth 20 (stack ~0x7ffc1a2fef40)
...
=== SIGSEGV caught ===
faulting address: (nil)
signal: 11 (code 1)
backtrace (5 frames):
./segv_backtrace(_Z17recursive_crashi+0x4e) [0x4007d6]
./segv_backtrace(main+0x2d) [0x4008b3]
/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0xf0) [0x7f5...]
./segv_backtrace(_start+0x29) [0x400799]
RIP=00000000004007f6 RSP=00007ffc1a2fef50要点回顾:faulting address: (nil) 直接印证了空指针解引用。-rdynamic 让 backtrace 能解析出符号名。这是 handler 唯一"正当"的复杂用法——诊断现场。注意三点:_exit() 而非 return(栈已毁)、备用栈(坑九)、保存/恢复 errno(坑十)。真实工程里 Chrome 的 Breakpad、glibc 的 backtrace、Java 的 hs_err 都是这个思路的延伸。
六、怎么验证这些 demo 真的"走了信号路径"
这几个手段不只给 demo 用——把 strace 那行里的 ./sfd_demo 换成你自己的服务,就能不动代码确认线上信号的投递链路是否通畅。
运行 demo 时用两个工具从内核视角确认信号真的投递了:
# 1. strace 看系统调用级的信号交互(demo 1/4 的 signalfd 场景)
strace -e trace=signalfd,epoll_wait,read -o sfd.trace ./sfd_demo
# 然后另开终端 kill -TERM <pid>,看 trace 里 signalfd 后的 read 返回了 signalfd_siginfo
grep -A5 signalfd sfd.trace
# 2. 看 /proc/<pid>/status 的未决信号(demo 5 崩溃前)
while true; do grep -E "SigBlk|SigIgn|SigCgt" /proc/$(pgrep segv_backtrace)/status; sleep 0.1; done
# 3. 内核侧: perf 抓信号事件(对应 performance 篇的观测手段)
sudo perf stat -e 'signal:signal_generate,signal:signal_deliver' ./sfd_demo如果 kill 之后程序毫无反应,按顺序排查:① 信号没进屏蔽字(signalfd/sigwait 都读不到);② 屏蔽了但没人消费(handler 和 sigwait 不能同时盯一个信号);③ 消费了但处理逻辑写错(先打一条日志确认 ssi_signo 对不对)。这三个原因占了 90% 的"信号失效"问题。
三个 demo 的验证点:
| Demo | 验证方式 | 期望结果 |
|---|---|---|
| 1/4(signalfd) | strace -e trace=signalfd | signalfd(-1, [TERM INT ...]) = 3,kill 后 read(3, ...) 返回 128 字节 |
| 3(sigwait) | strace -e trace=rt_sigwaitinfo | rt_sigwaitinfo([TERM INT], ...) = 15(收到 SIGTERM) |
| 5(backtrace) | 看 stderr 输出 | faulting address: (nil) + 完整 backtrace 栈帧 |
这些验证手段就是 signals-kernel-performance 篇"性能观测与排查"的日常版——先确认信号真的走了,再谈性能。
代码位置:五个 demo 的完整可编译代码在公开仓库 demos/signals-demos(sfd_demo.c/alarm_demo.c/sigwait_demo.c/sfd_epoll_demo.c/segv_backtrace.c),make run 一键构建运行,也可按文档里的单条 gcc 命令逐个编译。
七、一句话总结
五个 Demo 是"handler 只做通知、处理放主循环"的完整示范:signalfd + epoll 优雅退出(信号变 fd,停机逻辑畅所欲为);alarm + EINTR 超时(故意不设 SA_RESTART 让 read 被打断当超时信号);sigwait 专用线程(同步等信号,无 async-signal-safe 限制,屏蔽字要在线程创建前设置);signalfd 生产级骨架(SIGHUP 重载只设标志 + 一次读完所有信号);SIGSEGV backtrace(SA_SIGINFO + SA_ONSTACK 拿故障地址和调用栈,_exit 收尾)——唯一需要复杂 handler 的场景就是崩溃诊断,其余一律把处理逻辑放回主循环。 编译运行方式都写在每个 Demo 里,可全部直接
gcc编译跑通。