1. 从一次线上事故说起:为什么标准信号处理不够用
三年前我负责维护一套高并发的日志采集服务,某天凌晨收到告警:采集进程僵死,日志堆积超过两千万条。登上去一看,进程状态是D(不可中断睡眠),kill -9都杀不掉。排查了半天才发现,问题出在一个第三方库的SIGTERM处理函数里——它在信号处理函数中调用了malloc,而当时主线程正好持有堆锁,信号处理函数在等锁,主线程在等信号处理函数返回,死锁了。
这件事让我彻底意识到:信号处理不是"注册一个回调函数"那么简单。很多人对信号的理解停留在"signal(SIGINT, handler)就能捕获 Ctrl+C"这个层面,但真正在生产环境里,信号处理涉及异步信号安全、信号屏蔽、信号集操作、可重入性、竞态条件等一系列深水区问题。标准 C 库提供的signal()函数在不同平台上的行为差异巨大,甚至同一平台不同版本之间都有微妙区别。
这篇内容就是把我这些年踩过的坑、总结出来的自定义信号处理方案完整梳理一遍。核心围绕sigaction、信号集(sigset_t)、信号屏蔽字、sigprocmask、sigsuspend这些关键工具展开,同时会讲到如何设计一套可复用的信号处理框架。适合已经会用signal()但想深入理解底层机制的开发者,也适合正在做系统编程、嵌入式开发、服务端开发的朋友。读完你应该能搞清楚:为什么sigaction比signal靠谱、信号处理函数里到底能做什么不能做什么、如何优雅地处理信号驱动的进程退出和重载配置。
2. signal 与 sigaction 的本质差异:不只是"新老 API"那么简单
2.1 signal 函数的三大历史遗留问题
很多人觉得signal()和sigaction()就是新旧两套 API,功能一样,用哪个都行。这个认知是错的。signal()在 POSIX 标准里的行为是"未指定"(unspecified),意思是各个实现可以自由发挥。具体来说有三个大坑:
第一个坑是信号处理函数的复位行为。在 System V 风格的实现中,每次信号被处理之后,处理函数会自动复位成默认行为。也就是说,如果你用signal(SIGINT, handler)注册了处理函数,第一次 Ctrl+C 会调用handler,但第二次 Ctrl+C 就会直接终止进程。而在 BSD 风格实现中,处理函数会一直有效。Linux 的 glibc 默认走 BSD 语义,但如果你编译时定义了_GNU_SOURCE或者链接了特定版本的库,行为可能又不一样。这种不确定性在生产环境里是致命的。
第二个坑是信号处理期间是否屏蔽自身。当SIGINT的处理函数正在执行时,如果又来了一个SIGINT,会怎样?System V 默认不屏蔽,新信号会打断当前处理函数,导致重入问题。BSD 默认会屏蔽,处理完再处理下一个。这个差异直接影响你的处理函数是否需要考虑可重入性。
第三个坑是signal()无法获取和设置信号屏蔽字。你没办法在注册处理函数的同时指定"处理这个信号时,顺便屏蔽掉另外几个信号"。这在需要保证某些操作原子性的场景下非常要命。
2.2 sigaction 的结构体设计与参数含义
sigaction()的核心在于struct sigaction这个结构体,它的典型定义如下:
struct sigaction { void (*sa_handler)(int); // 简单处理函数 void (*sa_sigaction)(int, siginfo_t *, void *); // 带信息的处理函数 sigset_t sa_mask; // 处理期间要屏蔽的信号集 int sa_flags; // 行为标志 void (*sa_restorer)(void); // 已废弃,不用管 };这里有几个关键点需要说清楚:
sa_handler和sa_sigaction是互斥的,通过sa_flags里的SA_SIGINFO标志来决定用哪个。如果设置了SA_SIGINFO,就用sa_sigaction,它能拿到更多信息,比如发送信号的进程 PID、UID、信号产生的原因(si_code)等。这个在调试和精细化处理时非常有用。
sa_mask是"在处理这个信号期间,额外要屏蔽的信号集"。注意,当前正在处理的信号本身默认会被自动屏蔽(除非设置了SA_NODEFER),sa_mask里填的是除此之外还要屏蔽哪些。比如你处理SIGTERM时不想被SIGINT打断,就把SIGINT加到sa_mask里。
sa_flags里最常用的几个标志:
| 标志 | 作用 | 典型场景 |
|---|---|---|
SA_RESTART | 被信号打断的系统调用自动重启 | 避免read/write返回EINTR |
SA_SIGINFO | 使用三参数处理函数 | 需要获取发送者信息 |
SA_NODEFER | 处理期间不屏蔽当前信号 | 极少使用,容易导致递归 |
SA_RESETHAND | 处理后复位为默认行为 | 模拟 System V 语义 |
SA_NOCLDSTOP | 子进程停止时不发SIGCHLD | 只关心子进程退出 |
2.3 一个对比表格看清两者差异
| 特性 | signal() | sigaction() |
|---|---|---|
| 处理函数复位 | 平台相关,不确定 | 明确可控(SA_RESETHAND) |
| 自动屏蔽自身 | 平台相关 | 默认屏蔽,可用SA_NODEFER关闭 |
| 设置屏蔽字 | 不支持 | 支持sa_mask |
| 获取信号信息 | 不支持 | 支持SA_SIGINFO |
| 系统调用重启 | 平台相关 | 明确可控(SA_RESTART) |
| 可移植性 | 差 | 好(POSIX 标准) |
结论很明确:只要不是写玩具程序,一律用sigaction。signal()只应该在维护祖传代码时见到,新代码里不应该出现。
3. 信号集与屏蔽字:控制信号的"开关面板"
3.1 sigset_t 的本质与操作函数
sigset_t是一个不透明类型,你不能直接操作它的内部位。所有操作必须通过标准函数:
sigset_t set; sigemptyset(&set); // 清空信号集 sigfillset(&set); // 填满所有信号 sigaddset(&set, SIGINT); // 添加 SIGINT sigdelset(&set, SIGINT); // 移除 SIGINT sigismember(&set, SIGINT); // 判断 SIGINT 是否在集合中,返回 1/0/-1为什么设计成不透明?因为不同平台的信号数量不同,sigset_t的内部表示可能是unsigned long数组,也可能是别的。用函数操作保证了可移植性。
这里有个容易忽略的点:sigfillset填满所有信号后,SIGKILL和SIGSTOP依然无法被屏蔽。这两个信号是内核级别的"硬信号",任何进程都不能捕获、屏蔽或忽略它们。这是操作系统设计的底线,防止进程完全失控。
3.2 sigprocmask 的三种操作模式
sigprocmask用来设置或获取当前进程的信号屏蔽字:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);how有三个取值:
SIG_BLOCK:把set里的信号加到当前屏蔽字中(并集)SIG_UNBLOCK:从当前屏蔽字中移除set里的信号(差集)SIG_SETMASK:直接把当前屏蔽字替换为set(赋值)
oldset如果不为 NULL,会返回修改前的屏蔽字,方便后续恢复。
一个典型用法是"临界区保护":在操作共享数据结构前屏蔽相关信号,操作完再恢复:
sigset_t newmask, oldmask; sigemptyset(&newmask); sigaddset(&newmask, SIGINT); sigaddset(&newmask, SIGTERM); sigprocmask(SIG_BLOCK, &newmask, &oldmask); // 这里操作共享数据,不会被 SIGINT/SIGTERM 打断 sigprocmask(SIG_SETMASK, &oldmask, NULL);注意:
sigprocmask在多线程程序中的行为是"未指定"的。多线程应该用pthread_sigmask,虽然底层实现类似,但语义更明确。
3.3 信号屏蔽与竞态条件:一个经典陷阱
考虑这个场景:你想让进程在收到SIGTERM时退出,但退出前要完成一些清理工作。你可能会这样写:
volatile sig_atomic_t stop = 0; void handler(int sig) { stop = 1; } int main() { signal(SIGTERM, handler); while (!stop) { // 主循环 } // 清理工作 }这段代码有个隐蔽的竞态:如果SIGTERM在while (!stop)判断之后、进入循环体之前到达,stop被设为 1,但循环体还是会执行一次。如果循环体里有阻塞操作,进程可能卡住。
更严重的是,如果信号在sigprocmask解除屏蔽和sigsuspend调用之间到达,进程会错过这个信号,导致永久挂起。正确的做法是用sigsuspend原子地"解除屏蔽并挂起":
sigset_t mask, oldmask; sigemptyset(&mask); sigaddset(&mask, SIGTERM); sigprocmask(SIG_BLOCK, &mask, &oldmask); while (!stop) { sigsuspend(&oldmask); // 原子地恢复 oldmask 并挂起 } sigprocmask(SIG_SETMASK, &oldmask, NULL);sigsuspend会原子地做两件事:把信号屏蔽字设为参数指定的集合,然后挂起进程。当信号到达时,处理函数执行,返回后sigsuspend返回,屏蔽字恢复为调用前的值。这样就消除了竞态窗口。
4. 异步信号安全:处理函数里到底能做什么
4.1 什么是异步信号安全函数
信号处理函数是在任意时刻被异步调用的,它可能打断主线程正在执行的任何代码。如果主线程正在操作malloc的内部数据结构,而信号处理函数也调用malloc,就会破坏数据结构,导致崩溃或死锁。
POSIX 定义了一组"异步信号安全"(async-signal-safe)函数,这些函数保证在信号处理函数中调用是安全的。常见的包括:
write、read(对某些类型文件)open、close_exit、_Exitkill、raisesigaction、sigprocmaskwait、waitpid
不在列表里的函数都不能用,包括printf、malloc、free、exit、strlen(某些实现)、memcpy(某些实现)等。这个列表在man 7 signal-safety里可以查到。
4.2 为什么 printf 在信号处理函数里是危险的
printf内部会操作stdout的缓冲区,可能调用malloc分配缓冲区,可能加锁。如果主线程正在printf时被信号打断,信号处理函数又调用printf,就会死锁或输出错乱。
正确的做法是:信号处理函数只设置一个volatile sig_atomic_t标志,或者往一个预先打开的管道/eventfd 里write一个字节,让主循环去处理实际逻辑。这就是所谓的"self-pipe trick":
int pipefd[2]; void handler(int sig) { int saved_errno = errno; write(pipefd[1], "x", 1); // write 是异步信号安全的 errno = saved_errno; } int main() { pipe(pipefd); // 设置 pipefd[0] 为非阻塞 fcntl(pipefd[0], F_SETFL, O_NONBLOCK); struct sigaction sa; sa.sa_handler = handler; sa.sa_flags = SA_RESTART; sigemptyset(&sa.sa_mask); sigaction(SIGTERM, &sa, NULL); // 主循环用 poll/select 监听 pipefd[0] // 收到数据就知道有信号来了 }注意handler里保存和恢复errno的细节。信号处理函数可能修改errno,如果不保存恢复,主线程后续的错误判断会出错。这是个很容易忽略的坑。
4.3 可重入性与 volatile sig_atomic_t
volatile sig_atomic_t是信号处理函数和主线程之间通信的标准类型。volatile防止编译器优化掉对变量的读写,sig_atomic_t保证读写是原子的(通常是int)。
但要注意,volatile sig_atomic_t只能保证单次读写原子,不能保证复合操作原子。比如counter++就不是原子的,需要额外保护。
另外,如果主线程和信号处理函数共享更复杂的数据结构,光靠volatile不够,需要用信号屏蔽来保护临界区。
5. 实战:设计一套可复用的信号处理框架
5.1 需求分析与设计目标
假设我们要为一个长期运行的服务设计信号处理框架,需求如下:
- 支持
SIGTERM/SIGINT优雅退出 - 支持
SIGHUP重载配置 - 支持
SIGUSR1/SIGUSR2自定义操作 - 处理函数中不做复杂操作,只通知主循环
- 支持多线程环境
- 退出时保证清理工作完成
5.2 核心数据结构与初始化
typedef struct { int signal_pipe[2]; volatile sig_atomic_t pending_signals[NSIG]; pthread_t main_thread; } signal_ctx_t; static signal_ctx_t g_sigctx; static void signal_handler(int sig) { int saved_errno = errno; g_sigctx.pending_signals[sig] = 1; write(g_sigctx.signal_pipe[1], &sig, sizeof(sig)); errno = saved_errno; } int signal_framework_init(void) { if (pipe(g_sigctx.signal_pipe) < 0) return -1; // 设置读端非阻塞 int flags = fcntl(g_sigctx.signal_pipe[0], F_GETFL); fcntl(g_sigctx.signal_pipe[0], F_SETFL, flags | O_NONBLOCK); // 设置写端非阻塞,防止管道满时阻塞信号处理函数 flags = fcntl(g_sigctx.signal_pipe[1], F_GETFL); fcntl(g_sigctx.signal_pipe[1], F_SETFL, flags | O_NONBLOCK); struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = signal_handler; sa.sa_flags = SA_RESTART; sigemptyset(&sa.sa_mask); // 注册需要处理的信号 sigaction(SIGTERM, &sa, NULL); sigaction(SIGINT, &sa, NULL); sigaction(SIGHUP, &sa, NULL); sigaction(SIGUSR1, &sa, NULL); sigaction(SIGUSR2, &sa, NULL); // 忽略 SIGPIPE,防止写已关闭的 socket 导致进程退出 signal(SIGPIPE, SIG_IGN); return 0; }这里有几个设计决策值得说明:
为什么用管道而不是 eventfd?管道更通用,跨平台性好。eventfd 是 Linux 特有的,虽然更高效,但可移植性差。如果确定只在 Linux 上跑,eventfd 是更好的选择。
为什么写端也要非阻塞?如果管道满了(比如信号来得太频繁),写端阻塞会导致信号处理函数卡住,进而阻塞整个进程。非阻塞写虽然可能丢信号,但pending_signals数组已经记录了状态,不会丢失语义。
为什么忽略 SIGPIPE?网络服务经常遇到对端关闭连接的情况,默认的SIGPIPE会直接终止进程。忽略它,让write返回EPIPE错误,由代码处理更可控。
5.3 主循环集成与信号分发
void signal_framework_run(void) { struct pollfd pfd; pfd.fd = g_sigctx.signal_pipe[0]; pfd.events = POLLIN; while (1) { int ret = poll(&pfd, 1, -1); if (ret < 0) { if (errno == EINTR) continue; break; } if (pfd.revents & POLLIN) { int sig; while (read(g_sigctx.signal_pipe[0], &sig, sizeof(sig)) > 0) { dispatch_signal(sig); } } } } void dispatch_signal(int sig) { switch (sig) { case SIGTERM: case SIGINT: g_running = 0; break; case SIGHUP: reload_config(); break; case SIGUSR1: dump_status(); break; case SIGUSR2: rotate_logs(); break; } }这个设计的核心思想是:信号处理函数只做最少的事(记录标志+写管道),所有实际逻辑都在主循环里执行。这样reload_config、dump_status这些函数就可以放心使用malloc、printf等非异步信号安全的函数。
5.4 多线程环境下的信号处理策略
多线程程序的信号处理有个基本原则:信号只会被递送到一个不屏蔽该信号的线程。具体是哪个线程,取决于内核调度,不可预测。
推荐的做法是:
- 在主线程创建所有工作线程之前,先屏蔽所有需要处理的信号
- 工作线程继承主线程的信号屏蔽字,所以它们不会收到这些信号
- 主线程用
pthread_sigmask解除屏蔽,专门负责信号处理
int main() { sigset_t mask; sigemptyset(&mask); sigaddset(&mask, SIGTERM); sigaddset(&mask, SIGINT); sigaddset(&mask, SIGHUP); // 主线程先屏蔽 pthread_sigmask(SIG_BLOCK, &mask, NULL); // 创建工作线程(它们会继承屏蔽字) create_worker_threads(); // 主线程解除屏蔽,专门处理信号 pthread_sigmask(SIG_UNBLOCK, &mask, NULL); signal_framework_init(); signal_framework_run(); }这样信号只会递送到主线程,避免了多线程竞争处理信号的问题。
6. 那些年我踩过的信号处理坑
6.1 坑一:在信号处理函数里调用非安全函数导致死锁
前面提到的malloc死锁就是典型。还有一个更隐蔽的:在信号处理函数里调用syslog。syslog内部会加锁,如果主线程正在syslog时被信号打断,信号处理函数又调用syslog,就会死锁。
解决方案:信号处理函数里只用write往管道写数据,日志由主循环统一输出。
6.2 坑二:SA_RESTART 不是万能的
SA_RESTART可以让被信号打断的系统调用自动重启,但不是所有系统调用都支持。比如poll、select、epoll_wait在收到信号后即使设置了SA_RESTART也会返回EINTR。nanosleep也是,会返回剩余时间。
解决方案:对这些调用要显式处理EINTR:
while (poll(&pfd, 1, timeout) < 0) { if (errno != EINTR) break; // 检查是否有信号需要处理 }6.3 坑三:信号队列与实时信号
标准信号(SIGINT、SIGTERM等)不支持排队。如果同一个信号连续来两次,而处理函数还没执行完,第二次信号会被丢弃。实时信号(SIGRTMIN到SIGRTMAX)支持排队,但用法更复杂,需要sigqueue发送。
解决方案:如果业务需要保证信号不丢失,用实时信号。但大多数场景下,标准信号的"合并"语义是可以接受的——比如连续收到多个SIGTERM,处理一次就够了。
6.4 坑四:fork 之后的信号处理
fork之后,子进程会继承父进程的信号处理函数和屏蔽字。如果子进程要exec,处理函数会被重置为默认,但屏蔽字会保留。这可能导致子进程意外屏蔽了某些信号。
解决方案:fork之后、exec之前,用sigprocmask恢复屏蔽字,或者用posix_spawn代替fork+exec。
6.5 坑五:SIGCHLD 处理不当导致僵尸进程
如果父进程不处理SIGCHLD,子进程退出后会变成僵尸进程。但如果在SIGCHLD处理函数里调用wait,又可能因为多个子进程同时退出而漏掉一些。
解决方案:在SIGCHLD处理函数里循环调用waitpid(-1, NULL, WNOHANG)直到返回 0 或 -1:
void sigchld_handler(int sig) { int saved_errno = errno; while (waitpid(-1, NULL, WNOHANG) > 0); errno = saved_errno; }7. 信号处理框架的测试与验证方法
7.1 单元测试:模拟信号发送
测试信号处理逻辑,可以用raise给自己发信号,或者用kill给指定进程发:
void test_sigterm_handling(void) { signal_framework_init(); raise(SIGTERM); // 等待主循环处理 usleep(100000); assert(g_running == 0); }但raise是同步的,信号会立即递送,可能打断测试代码本身。更好的方式是用单独的线程发送信号,或者用sigqueue带数据发送。
7.2 压力测试:高频信号轰炸
验证框架在高频信号下的稳定性:
# 每秒发送 1000 个 SIGUSR1 while true; do kill -USR1 $PID sleep 0.001 done观察进程是否崩溃、是否丢信号、CPU 占用是否正常。如果管道满了导致write失败,pending_signals数组应该能兜底。
7.3 竞态检测:用 ThreadSanitizer
编译时加上-fsanitize=thread,可以检测信号处理函数和主线程之间的数据竞争。虽然 TSan 对信号的支持有限,但能发现明显的volatile缺失问题。
7.4 实际验证清单
| 测试项 | 方法 | 预期结果 |
|---|---|---|
| 优雅退出 | 发送 SIGTERM | 清理完成后退出,退出码 0 |
| 配置重载 | 发送 SIGHUP | 配置生效,进程不退出 |
| 信号合并 | 连续发送 10 个 SIGTERM | 只处理一次,正常退出 |
| 高频信号 | 每秒 1000 个 SIGUSR1 | 不崩溃,不丢关键信号 |
| 多线程 | 工作线程运行时发信号 | 只有主线程处理 |
| 管道满 | 大量信号且主循环阻塞 | 不阻塞信号处理函数 |
8. 进阶话题:实时信号与信号驱动 IO
8.1 实时信号的使用场景
实时信号(SIGRTMIN到SIGRTMAX)相比标准信号有几个优势:支持排队、可以携带数据(通过sigqueue)、有优先级(编号越小优先级越高)。
典型使用场景是进程间通信:一个进程用sigqueue发送带数据的实时信号,另一个进程在SA_SIGINFO处理函数里通过siginfo_t获取数据。
// 发送端 union sigval value; value.sival_int = 42; sigqueue(target_pid, SIGRTMIN, value); // 接收端 void rt_handler(int sig, siginfo_t *info, void *ctx) { int data = info->si_value.sival_int; // 处理 data }但要注意,实时信号的排队数量有上限(/proc/sys/kernel/rtsig-max),超过后会丢弃。而且实时信号的处理函数依然要遵守异步信号安全规则。
8.2 信号驱动 IO 的局限
O_ASYNC标志可以让文件描述符在可读/可写时发送SIGIO信号。听起来很美,但实际使用限制很多:只对特定类型的 fd 有效(socket、终端),不支持普通文件,而且信号处理函数里不能直接做 IO(非异步信号安全)。
现在更推荐用epoll或io_uring代替信号驱动 IO。信号驱动 IO 更多是历史遗留,新项目不建议使用。
9. 写在最后:一些个人体会
信号处理这块内容,我最大的感受是:文档里写的和实际跑出来的,往往有差距。POSIX 标准留了太多"未指定"的空间,不同平台、不同版本的行为可能不一样。唯一可靠的办法是:用sigaction而不是signal,信号处理函数里只做最安全的事,所有复杂逻辑放到主循环,多线程环境下明确指定信号处理线程。
另外,调试信号相关问题的时候,strace是神器。加上-e trace=signal可以只看信号相关的系统调用,能清楚看到信号什么时候到达、被哪个线程处理、处理函数返回后系统调用是否重启。配合gdb的handle SIGUSR1 nostop noprint pass命令,可以避免调试器拦截信号。
最后分享一个我常用的技巧:在信号处理函数里加一个计数器,主循环定期打印。如果发现计数器增长异常快,说明有地方在疯狂发信号,可能是某个逻辑出了问题。这个简单的监控手段帮我定位过好几次线上问题。