我做了这么多年Linux开发,经常看到有人被信号折磨得死去活来。进程突然死了、程序卡住不动、服务莫名其妙退出,很多时候都是信号机制没搞明白。信号这个东西,说简单是一回事,真要玩透又是另一回事——它是操作系统通知进程"有事发生"的异步机制,也是进程间通信的最原始手段。这篇东西我会从信号的本质、核心API、阻塞与未决、并发陷阱到实战封装,一条线完整过一遍,适合刚接触Linux编程的初学者,也适合写了几年代码但对信号细节一直模棱两可的人。
1. 先弄明白信号到底是什么,以及它凭什么能"打断"进程
很多教材把信号解释成"软件中断",这个说法其实非常精准。中断的本质是CPU暂停当前任务、跳去执行中断处理程序、再回来继续原来的任务;信号干的也是类似的事,只不过它不是一个硬件管脚拉低了电平,而是内核在软件层面给进程制造的一次"打断"。
1.1 信号从产生到被处理的完整路程
我习惯把一条信号的完整生命周期拆成三个阶段:信号产生、信号挂起、信号递达。信号产生就是某个事件触发了信号,比如你按了Ctrl+C,终端驱动会向前台进程组发送SIGINT(编号2);比如你执行了kill命令,实际上是调用kill()系统调用向目标进程发送信号;再比如进程访问了非法内存,内核检测到段错误后产生SIGSEGV(编号11)。
信号产生之后并不会马上执行处理函数,而是先进入"挂起"状态,放在进程的pending信号队列里。进程运行在用户态还是内核态在这个阶段区别很大——如果进程正在内核态执行系统调用,信号通常要等系统调用返回、进程切换回用户态之前才有机会被递达处理。你有时候会感觉到信号处理好像有延迟,往往就是这个原因。
信号真正被递达处理有三种选择:默认动作、忽略、捕获。默认动作对大多数信号就是"终止进程",所以很多面试题问"哪些信号可以被捕获",实际上是在问你有没有背过那两张表:不可捕获的SIGKILL和SIGSTOP是例外,这俩直接由内核处理,你注册任何handler都拦不住。忽略也好理解,就是告诉内核"这个信号我不在乎"。而捕获就是我们这篇文章的重头戏——用户注册一个回调函数,信号到来时内核会打断进程当前的执行流,跳到回调函数里跑,跑完再回到原来的断点继续执行。
1.2 一个最容易忽略的底层规则:信号处理跑在用户态栈上
很多人不理解为什么信号处理函数里不能随便调用非异步信号安全的函数,原因就在于信号处理函数被调用时,进程并没有切换到另一个"线程",它就是在当前进程的同一个执行流里、同一个栈上、甚至同一个函数调用链的中间位置被硬生生插了一段代码。
内核在递达信号时,会在用户态栈帧上压入一个特殊帧,把当前上下文(寄存器、PC指针等)保存好,然后修改用户态PC指向你的handler入口。handler返回时执行特殊的sigreturn系统调用,内核恢复之前保存的上下文,进程接着原来的断点继续跑。整个过程就像你在写一篇文章时手机响了,你接电话说两句挂掉,回来接着写——但你根本不知道电话会在哪句话中间响,所以你在电话里说出的任何话都不能依赖"当前写到第几个字"这种状态。
被信号打断的这段代码如果是不可重入的,比如malloc内部的链表还没操作完、printf的缓冲区锁还攥在手里,handler里再去调用malloc或printf,就会造成双重加锁、链表错乱甚至死锁。这就是为什么信号处理函数里最安全的做法是只设置标志变量、或者使用write()这类异步信号安全的系统调用,一切复杂操作统统扔给主循环去做。
2. signal()和sigaction(),一个是被时代淘汰的坑,一个是正经该用的API
初学信号时大家基本都是从signal()函数入手的,代码短、看着简单。但我要说的是,如果你的新代码还在用signal()注册handler,趁早改掉。它至少有两大硬伤:行为在不同Unix版本间不一致,以及信号处理期间会重置handler且无法阻塞其他信号。
2.1 signal()到底哪里不靠谱
先看两种历史语义。在System V里,signal()注册的handler执行完一次之后会被重置为默认行为,这意味着如果同一个信号连续来两次,第二次进程就直接按默认动作退出,好多线上程序的诡异崩溃就是这么来的。而在BSD里,signal()的语义又改成了持久注册、执行期间阻塞当前信号。Linux的signal()实现的是BSD语义,但GLIBC的man手册里明确建议:不要用它实现可移植的信号语义。
再就是这个"执行期间自动阻塞当前信号"的特性,signal()要么不支持,要么不可控。设想你的handler正在执行,这时同一个信号又来了一次,第二次信号如果没被阻塞,就会嵌套打断当前handler,造成栈上递归。这是非常危险的行为,光凭signal()你是没法精细控制的。
2.2 sigaction()的关键参数逐个拆解
sigaction()是POSIX标准推荐的信号注册接口,它的核心结构体长这样:
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是增强版回调,能拿到siginfo_t结构体,里面有发送者PID、信号发送时的用户态寄存器上下文、导致信号的内存地址等丰富信息。这俩是union,同一个时刻只能设置一个,用sa_flags里的SA_SIGINFO标志来区分。
sa_mask是信号处理期间的额外阻塞集。比如handler执行时你想屏蔽SIGUSR1和SIGALRM,在handler执行期间这两个信号就会被挂起,等handler返回后再递达。注意,当前正在处理的信号本身默认是会被阻塞的,除非你显式设置SA_NODEFER标志。
sa_flags里几个实用标志我列一下:
- SA_RESTART:让被信号打断的慢速系统调用(read、write、wait等)自动重启,而不是返回EINTR错误。
- SA_SIGINFO:启用sa_sigaction回调,需要搭配siginfo_t使用。
- SA_NOCLDWAIT:配合SIGCHLD使用,子进程退出时自动回收,不产生僵尸进程。
- SA_ONSTACK:使用sigaltstack设置的备用信号栈,适合handler需要较大栈空间的场景。
2.3 完整演示:用sigaction接收SIGUSR1并打印发送方信息
看一个实际可编译的例子。这个程序注册了SIGUSR1的handler,通过siginfo_t拿到发送者和触发时的系统调用上下文,同时测试信号不会因为handler执行期间被再次触发而丢失:
#include <stdio.h> #include <signal.h> #include <string.h> #include <unistd.h> #include <stdlib.h> static void handler(int sig, siginfo_t *si, void *ctx) { // 这里只能用异步信号安全函数 char msg[128]; int len = snprintf(msg, sizeof(msg), "received signal %d from pid %d, uid %d\n", sig, si->si_pid, si->si_uid); write(STDOUT_FILENO, msg, len); } int main(int argc, char *argv[]) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_sigaction = handler; sa.sa_flags = SA_SIGINFO; sigemptyset(&sa.sa_mask); if (sigaction(SIGUSR1, &sa, NULL) == -1) { perror("sigaction"); exit(EXIT_FAILURE); } printf("pid=%d, waiting for SIGUSR1...\n", getpid()); for (;;) { pause(); // 挂起进程直到任意信号递达 } return 0; }用一个测试进程发信号过去:
kill -USR1 12345输出会是:
received signal 10 from pid 4567, uid 1000这里我用snprintf拼字符串然后write输出,而不是直接printf,就是因为printf内部有锁、缓冲和更复杂的调用链,在handler里禁用。snprintf本身是否全路径绝对安全我不敢打包票,但在glibc下常用场景足够稳,如果想完全严格可以用手写数字转换。
3. 信号的发送、阻塞和未决,处理"信号风暴"的正确姿势
信号机制真正容易出问题的地方在"量"的控制上——单个信号好处理,但几十上百个信号同时涌过来,标准信号会直接丢给你看。这里面有一个绝大多数教程讲不透的关键点:标准信号不支持排队。如果你连续给一个进程发送10个SIGUSR1,进程可能只会收到一次,其余9次会合并成一次递达,这就是所谓的"信号丢失"。
3.1 阻塞集与未决集:信号不会消失,只是"排队"等待
进程里有两张关键的位图:blocked(阻塞集)和pending(未决集)。你用sigprocmask()把某个信号加进阻塞集之后,这个信号来了不会消失,而是被置于pending状态;当你想通了,把这个信号从阻塞集里去掉,它立刻就会递达。
这里有一个经典陷阱需要特别注意:如果一个信号被阻塞期间,多次产生,pending集里只保留一位。也就是说,阻塞期间来了3次SIGINT,解除阻塞后进程只会收到1次SIGINT。对于标准信号,内核不会帮你排队计数。
上代码,我用阻塞+未决演示一个"信号合并"的现象:
#include <stdio.h> #include <signal.h> #include <unistd.h> #include <stdlib.h> static volatile sig_atomic_t count = 0; static void handler(int sig) { count++; } int main(void) { struct sigaction sa = { .sa_handler = handler }; sigemptyset(&sa.sa_mask); sigaction(SIGINT, &sa, NULL); sigset_t block_set, old_set; sigemptyset(&block_set); sigaddset(&block_set, SIGINT); sigprocmask(SIG_BLOCK, &block_set, &old_set); printf("SIGINT blocked, send 5 SIGINTs…\n"); raise(SIGINT); raise(SIGINT); raise(SIGINT); raise(SIGINT); raise(SIGINT); sigset_t pending_set; sigpending(&pending_set); printf("pending SIGINT=%d\n", sigismember(&pending_set, SIGINT)); sigprocmask(SIG_SETMASK, &old_set, NULL); printf("unblocked, sleep 1s...\n"); sleep(1); printf("handler executed count = %d\n", (int)count); return 0; }运行结果会让你印象深刻:pending标志位为1,解除阻塞后handler只执行了1次,count=1。5个信号被压缩成了1个。这就是为什么在做"优雅退出"这类场景时,千万不要依赖"收到了几次SIGTERM"来统计业务量。
3.2 想排队?用实时信号
标准信号不排队,但Linux还提供了一组实时信号(范围是SIGRTMIN到SIGRTMAX,默认是34到64),它们支持排队,每个实时信号都附带一个整数数据,由sigqueue()发送。实时信号在pending队列里不是位图,而是链表,每个信号都有独立节点,发送100次就会递达100次,且按编号从小到大递达。需要精确计数或者传递数据时,实时信号是标准信号的补位选手。
3.3 kill、raise、sigqueue,几个发送接口的区分
发信号的接口各有各的适用场景:
- kill(pid, sig):最常用,向任意进程或进程组发信号。pid为0表示发给同组所有进程;pid为-1表示发给有权限发送的所有进程;pid为负值表示发给对应进程组。
- raise(sig):进程向自己发信号,等价于kill(getpid(), sig),在多线程程序里等价于pthread_kill(pthread_self(), sig)。
- sigqueue(pid, sig, value):发送实时信号并附带union sigval里的整数值或指针值。
这里提醒一个权限问题:普通用户只能向uid相同的进程发信号,向root进程或别的用户进程发送会被EPERM拒绝。所以脚本里kill不到某个进程时,先看一眼uid对不对。
3.4 sigprocmask使用要点
sigprocmask有SIG_BLOCK、SIG_UNBLOCK、SIG_SETMASK三种操作,最推荐的前置姿势是:先sigemptyset初始化一个空集合,再sigaddset逐个添加信号。
多线程环境下,sigprocmask是进程级的,但要谨慎使用——信号递达的线程是不确定的。如果某个线程专门负责处理信号,其他线程就要各自调用pthread_sigmask把信号屏蔽掉,只留处理线程不屏蔽。这块展开是一整篇独立话题,这里先记住一个原则:多线程程序不要在主线程里随意sigprocmask,除非你清楚所有线程的信号屏蔽关系。
4. 从SIGCHLD到优雅退出:进程生命周期里的三个高频实战场景
光会调API还不够,真正考验功力的是信号在真实进程管理场景下的运用。下面这三个场景是我在服务端程序中反复用到的,代码量不大,但每一个都藏着不少细节。
4.1 子进程退出与SIGCHLD:处理僵尸进程的终极方案
父进程fork出子进程后,子进程退出时内核并不会立刻清除它的task_struct,而是把它变成僵尸进程,等待父进程调用wait/waitpid来"收尸"。如果父进程一直不等待,子进程的PID就永远占着坑,积累多了,系统能创建的进程数会耗尽(进程号上限一般可以通过ulimit -u查到)。最优雅的收尸方式就是利用SIGCHLD信号。
关键代码框架:
static void child_handler(int sig) { int status; pid_t pid; while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { // 回收一个子进程 } } int main() { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = child_handler; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART | SA_NOCLDWAIT; // 注意SA_NOCLDWAIT sigaction(SIGCHLD, &sa, NULL); ... }这里必须解释两个关键点。一是while循环而不是if,是因为信号会合并,一次SIGCHLD递达可能对应多个子进程退出,只wait一次会漏掉其余僵尸。二是WNOHANG标志,保证当前没有子进程可回收时waitpid立即返回0,不让handler阻塞。至于SA_NOCLDWAIT,加了这个标志后,子进程退出时内核直接自动回收,不会产生僵尸进程,你的waitpid会返回-1且errno为ECHILD——也就是说,用了SA_NOCLDWAIT之后,其实不需要再写waitpid了,整个收尸逻辑都省了。不过要注意,如果你还有其他逻辑依赖"子进程退出后获得退出码",SA_NOCLDWAIT会让你拿不到status,需要根据业务做权衡。
4.2 优雅退出:用SIGTERM做热清理,而不是被kill -9猝死
线上发布新版本时,我们总希望服务进程收到"请退出"信号后有缓冲时间做清理——关掉监听、刷盘、释放锁、通知注册中心摘除节点,然后再真正退出。这个机制几乎是所有服务端框架的标配。
经典写法是:设置一个全局的volatile sig_atomic_t标志,handler里只置位,主循环里检查。注意这个类型,sig_atomic_t保证读写是原子的,不会出现在handler写完主线程还没看到旧值这种半更新状态——当然,还要配合编译器不要过度优化这个变量,所以前面加volatile。为什么不用更宽的类型?C标准只保证sig_atomic_t在信号上下文中的原子访问,别自作聪明用int64或结构体。
static volatile sig_atomic_t g_running = 1; static volatile sig_atomic_t g_reload = 0; static void term_handler(int sig) { g_running = 0; } static void hup_handler(int sig) { g_reload = 1; } int main(void) { signal(SIGTERM, term_handler); signal(SIGHUP, hup_handler); while (g_running) { if (g_reload) { g_reload = 0; reload_config(); // 主线程中执行 } do_work(); } cleanup_and_exit(); return 0; }SIGTERM是kill命令不带-9时发出的信号,也是systemd等服务管理器通知服务退出的标准信号,所以业务程序一般只捕获SIGTERM和SIGINT,让SIGKILL留在最后兜底。SIGINT就是Ctrl+C,开发调试时打断程序也会走这条路,所以测试优雅退出可以直接Ctrl+C验证。
4.3 定时器信号SIGALRM:setitimer与alarm的机制差异
定时器信号在异步I/O、超时控制、看门狗等场景出场频率很高。alarm()只能定秒级的一次性定时器,setitimer()可以定到微秒级,还支持ITIMER_REAL(真实时间)、ITIMER_VIRTUAL(进程用户态CPU时间)、ITIMER_PROF(用户态加内核态CPU时间)三种计时器,到期分别发SIGALRM、SIGVTALRM、SIGPROF。
典型用法是配合sigaction设置SIGALRM的handler做超时中断:
struct itimerval timer; timer.it_value.tv_sec = 1; timer.it_value.tv_usec = 0; timer.it_interval.tv_sec = 1; timer.it_interval.tv_usec = 0; setitimer(ITIMER_REAL, &timer, NULL);这套配置实现每秒触发一次SIGALRM。我在看门狗程序里常用这个机制:主线程每收到一次SIGALRM就检查一次某个业务进程的心跳文件更新时间,超过阈值就判定异常并拉起新的工作进程。注意handler里只是置标志或write一个字节到管道通知select/poll醒过来,不要在handler里直接做逻辑。
5. 信号处理与并发:EINTR、重入和数据竞争的三座大山
这一节是区分"会写信号"和"玩明白信号"的分水岭。表面看信号处理函数就像普通函数一样跑,但它在多线程、多信号并发环境下对全局状态的访问,有一系列暗坑。
5.1 EINTR:被信号打断的系统调用
当进程阻塞在read()等慢速系统调用上时,一个信号递达并执行完handler后,如果sa_flags没设SA_RESTART,read()会返回-1,且errno被置为EINTR。很多人第一次在这个errno上翻车,以为是网络错误,其实是信号打断。
处理EINTR的通用代码范式:
ssize_t n; do { n = read(fd, buf, sizeof(buf)); } while (n < 0 && errno == EINTR);这种做法本质上是"重新发起一次调用"。另一个做法就是开头提到的SA_RESTART标志,让内核帮你自动重启大部分系统调用,省掉手动重试。但注意:SA_RESTART并非对所有系统调用生效,比如nanosleep、poll、epoll_wait、sigwait等在某些内核版本上仍然会返回EINTR,所以写成"底层循环重试"是更稳妥的通用保障。
5.2 什么是异步信号安全函数,什么是绝对不能在handler里调的
理解重入,要先弄明白"函数被重入"是什么概念。简单说,就是同一个函数在执行到一半时,又被另一条执行路径再次调进来。如果这个函数内部用了静态变量、全局锁、或者malloc的堆管理结构等共享可变状态,两次调用就会互相踩踏。信号处理函数插入的位置是完全随机的,所以它只能调用那些保证可以重入的函数。
POSIX列了一个async-signal-safe函数清单,里面有write、read、open、close、fcntl、wait、waitpid、sigaction、sigprocmask、_exit等。常见但绝对不安全的有:malloc/free(绝大多数真实实现都加锁)、printf/fprintf/sprintf(内部缓冲和锁)、syslog、getpwnam等库函数。还有一个容易踩的坑,就是handler里调用了gethostbyname这类看似无害的库函数,实际上内部用了静态缓冲区,整个都不安全。
所以我在生产代码里约定了一条硬规则:信号处理函数里只做一件事——向一个pipe或者eventfd写入一个字节(write本身是异步安全函数),通知主事件循环"有信号来了,去处理"。所有读取状态、修改全局数据、清理资源的逻辑全放到主循环里执行。这套模式,业界叫self-pipe trick,是最稳妥的实用方案。
5.3 volatile sig_atomic_t是下限,不是万能药
全局标志位用volatile sig_atomic_t,只是保证读写原子性和编译器不缓存寄存器副本,但它不能保证多个信号处理函数之间的复合操作是安全的。比如"先判断、再修改",两个不同信号并发递达时,仍有可能出现交错。需要复合操作时,应该用sigprocmask先阻塞相关信号,或者干脆把复杂操作移到主循环。
另外提一下C11的原子类型:_Atomic int这类变量在信号场景下是否能完全替代volatile sig_atomic_t,标准里并没有把信号递达和C11原子模型完全对齐,常规实践中我用得更保守——直接遵循POSIX的guidance,不折腾。
6. 搭建一个可复用的信号处理框架:self-pipe与统一事件循环
理论到这儿已经够多了,这一部分我给出一套完整的、可以直接抄走的信号处理框架。很多服务端框架里都能看到它的影子:信号处理函数里什么逻辑都不做,只往管道里丢一个字节;主事件循环select/poll/epoll监听这个管道read端,读到字节后解码信号编号,再分发到对应用户回调。这样信号处理和事件循环共用一套模型,彻底规避了并发和重入问题。
6.1 框架的骨架代码
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <signal.h> #include <errno.h> #include <fcntl.h> #include <poll.h> static int sig_pipe[2]; static void sig_handler(int sig) { // 在handler内,write是异步信号安全函数 if (write(sig_pipe[1], &sig, sizeof(sig)) != sizeof(sig)) { // 忽略写失败,一般不可能发生 } } int setup_signal_handlers(void) { if (pipe(sig_pipe) == -1) { perror("pipe"); return -1; } // 把读端设为非阻塞,防止管道里残留字节时无限阻塞 int flags = fcntl(sig_pipe[0], F_GETFL, 0); fcntl(sig_pipe[0], F_SETFL, flags | O_NONBLOCK); fcntl(sig_pipe[1], F_SETFL, fcntl(sig_pipe[1], F_GETFL, 0) | O_NONBLOCK); struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = sig_handler; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; int signals[] = { SIGINT, SIGTERM, SIGHUP, SIGUSR1, SIGCHLD }; for (size_t i = 0; i < sizeof(signals)/sizeof(signals[0]); i++) { if (sigaction(signals[i], &sa, NULL) == -1) { perror("sigaction"); return -1; } } return 0; } void process_signal_byte(void) { int sig; while (read(sig_pipe[0], &sig, sizeof(sig)) == sizeof(sig)) { switch (sig) { case SIGHUP: reload_config(); break; case SIGINT: case SIGTERM: g_running = 0; break; case SIGCHLD: reap_children(); break; case SIGUSR1: dump_status(); break; default: break; } } } int main(void) { if (setup_signal_handlers() == -1) { exit(EXIT_FAILURE); } while (g_running) { struct pollfd fds[2]; fds[0].fd = sig_pipe[0]; fds[0].events = POLLIN; // fds[1]可以放你的监听socket等 int ret = poll(fds, 1, 1000); if (ret > 0 && (fds[0].revents & POLLIN)) { process_signal_byte(); } do_work(); } cleanup_and_exit(); return 0; }这个框架最大的优势是:所有业务逻辑都在单线程主循环里顺序执行,信号处理函数和主循环不再共享可变状态,自然就没有锁竞争和数据竞争。配置文件重载也好、优雅退出也好、子进程回收也好,统统变成事件循环里的一个case分支,排查问题时思路特别清晰。
6.2 EINTR在这个框架里的处理原则
虽然上面sigaction设置了SA_RESTART,poll/select这类调用依然有可能被信号打断返回EINTR。在这个框架里,poll被信号打断根本无所谓——因为信号已经写进管道,下一次poll立刻又能读到。所以我在这里不会对EINTR做重试,反而会让poll超时短一点(比如800ms或1000ms),用轮询做兜底,逻辑更简单。这里也体现出框架的统一性:你不必为某个信号单独熬夜排查系统调用是谁打断的,因为所有信号都汇聚到了同一个入口。
6.3 调试信号问题常用的套路
最后聊点实际的排错手段。进程收不到信号,第一步用strace挂上去跑一段,看有没有sigreturn、rt_sigaction这些系统调用;第二步检查进程是不是变成僵尸了(状态列是Z表示还没被父进程收尸,信号根本递达不了);第三步检查是不是被另一个进程先吞了信号,看全局的handler是不是被谁篡改过。还有一个实用工具:/proc/ /status里的SigBlk、SigCgt、SigIgn三行,分别显示该进程阻塞了哪些信号、捕获了哪些信号、忽略了哪些信号,直接cat出来一看就知道配置对不对。比如SigCgt里没有对应信号的掩码位,说明handler压根没注册上,别再查业务代码了。
grep -E "^Sig(Blk|Cgt|Ign)" /proc/12345/status输出会是一个十六进制掩码,用python算一下第几号信号有没有置位就行,比靠猜快得多。另外,如果要给一个已经跑起来的进程动态调整信号策略,可以用gdb attach上去调用sigaction,但这需要ptrace权限,线上一般不建议这么搞,本地调试时很管用。
我从第一次被SIGPIPE搞到进程静默退出、到后来把self-pipe框架沉淀成团队共用组件,中间踩的坑基本都写在上面了。信号这块的学习路径,我的建议是先跑小实验验证"信号合并""EINTR"这些底层行为,再把框架代码自己敲一遍,最后到线上服务里把默认的暴力kill换成优雅退出,趟完这几个阶段,你基本就能跟人聊明白"进程是怎么被信号干掉的"这一整件事了。