写这文章之前我想先问一句:你写Linux程序的时候,有没有遇到过这种情况——程序跑得好好的,按下Ctrl+C没反应,或者进程莫名其妙就没了,连个core dump都没留下?又或者你明明在代码里写了signal(SIGCHLD, handler),子进程退出时回调就是不触发?
如果你点头了,那说明你对Linux信号的理解还停留在"会用几个API"的层面。这篇文章我会从内核视角把信号的发送、捕获、阻塞、递达这些机制讲透,最后给一个完整的优雅退出实战代码。系统编程这行,信号这块属于那种"看起来简单、用起来到处是坑"的知识点,值得花点时间把它啃下来。
1. 信号到底是什么:从内核视角看异步通知机制
先别急着翻手册,咱们把概念先理清楚。信号(Signal)在Linux里是进程间通信(IPC)的一种方式,但它和管道、共享内存、消息队列完全不是一类东西。管道那种是"数据交换",信号本质上是一种异步事件通知机制——内核告诉某个进程:你家里出事了,你自己看着办。
1.1 信号不是"中断",它是内核发给进程的异步通知
很多初学者会把信号和硬件中断搞混,这个误会得解开。硬件中断是CPU级别的机制,由中断控制器发给处理器,处理器硬件级别响应;而信号是内核在软件层面维护的一套"待处理事件"标记,发给进程而不是CPU。
整个信号的生存周期大致是这样:
- 产生:某个事件发生,内核或用户调用kill()等接口,把一个信号登记到目标进程的PCB里。
- 注册:内核在目标进程的task_struct里,把对应的信号位图置位。注意这里只是"置位",不是立刻执行。
- 递达:在进程从内核态返回用户态的前夕,内核检查这个进程的未决信号集合,如果发现有待处理的信号,就修改用户态的指令指针,让进程先跑去执行信号处理函数(或者执行默认动作),完了再回到原来的指令位置继续跑。
也就是说,信号的执行时机是进程从内核态返回用户态的那一刻。这个过程每时每刻都在发生——你写的每次read()、每次系统调用返回、每次时钟中断,都可能触发一次信号检查。
1.2 信号家族图谱:你迟早会碰到的十几个常见信号
Linux标准信号一共31个(1-31,没有0),下面这些是你写程序时一定会遇到的,列个表记下来:
| 信号 | 编号 | 默认动作 | 典型触发场景 |
|---|---|---|---|
| SIGHUP | 1 | 终止进程 | 终端挂断、控制终端关闭 |
| SIGINT | 2 | 终止进程 | 终端Ctrl+C |
| SIGQUIT | 3 | 终止并产生core | 终端Ctrl+\ |
| SIGILL | 4 | 终止并产生core | 非法指令 |
| SIGTRAP | 5 | 终止并产生core | 断点陷阱 |
| SIGABRT | 6 | 终止并产生core | abort()调用 |
| SIGBUS | 7 | 终止并产生core | 总线错误 |
| SIGFPE | 8 | 终止并产生core | 除零等算术异常 |
| SIGKILL | 9 | 终止进程(不可捕获) | kill -9 |
| SIGUSR1 | 10 | 终止进程 | 用户自定义 |
| SIGSEGV | 11 | 终止并产生core | 段错误、非法内存访问 |
| SIGUSR2 | 12 | 终止进程 | 用户自定义 |
| SIGPIPE | 13 | 终止进程 | 写管道但读端已关闭 |
| SIGALRM | 14 | 终止进程 | alarm()定时器到期 |
| SIGTERM | 15 | 终止进程 | kill命令默认信号 |
| SIGCHLD | 17 | 忽略 | 子进程停止或退出 |
| SIGCONT | 18 | 继续进程 | 继续已停止的进程 |
| SIGSTOP | 19 | 停止进程(不可捕获) | 暂停进程 |
| SIGTSTP | 20 | 停止进程 | 终端Ctrl+Z |
| SIGWINCH | 28 | 忽略 | 终端窗口大小变化 |
我特意把SIGPIPE列出来了,这个信号是新手最容易翻车的地方:你往一个已经关闭读端的管道或者socket写数据,第一次写可能触发SIGPIPE,默认动作是"终止进程"。很多网络服务程序莫名其妙挂掉,查了半天发现是对端断开后自己还在疯狂write。
2. 信号的发送:kill、raise、alarm及其他
信号产生之后,你得知道怎么精准地把它送出去。这一节我把常用的发送手段掰开揉碎了说。
2.1 kill系统调用:不只是Shell里的那条命令
Shell里的kill -9 12345大家都用过,但那个命令底层只是包了一层kill()系统调用。系统调用原型是:
#include <sys/types.h> #include <signal.h> int kill(pid_t pid, int sig);这里有个细节值得展开:pid参数不是只能传一个具体进程ID。它有一套约定:
pid > 0:信号发给指定进程pid == 0:信号发给当前进程组内的所有进程pid == -1:信号发给调用者有权发送的所有进程(除init和自身)pid < -1:信号发给进程组ID等于abs(pid)的所有进程
实战里用到pid == -1的极少,但pid == 0这个很实用——我经常用它来给同一个进程组里的多个协作进程广播信号,比如让一组worker同时退出。
还有权限问题。不是你随便向哪个进程都能发信号:你要么是root,要么是目标进程的"owner"(UID相同),否则kill()会返回EPERM。另外还有个冷知识:kill(pid, 0)不发送任何信号,但会做完整的权限校验和存在性检查。你完全可以用它来判断一个进程是否还活着、自己有没有权限管它。
2.2 alarm定时器与SIGALRM:廉价但粗糙的计时工具
alarm是很多程序员最早接触信号的入口:
#include <unistd.h> unsigned int alarm(unsigned int seconds);它做的事情很简单:让内核在seconds秒后给当前进程发送SIGALRM。返回值为之前尚未到期的定时器剩余秒数,如果没有之前的定时器则返回0。
为什么说它"廉价但粗糙"?因为一个进程同一时刻只能有一个alarm定时器。你再调一次alarm(),之前的就被取消了。而且要计时到微秒、纳秒级别,得换成setitimer()或者POSIX定时器timer_create()。alarm适合什么场景?比如超时保护——你的程序要等一个外部操作完成,但不能无限等下去,就设个alarm,超时直接SIGALRM干进去。
注意SIGALRM的默认动作是终止进程。如果你只是想要"超时提醒"而不是"超时杀死",记得在进程启动时就注册SIGALRM的处理函数,否则alarm到期的那一下你的进程直接就没了。
2.3 给自己的进程发信号:raise与实际身份问题
有时候你不需要发给别人,就想在当前进程内部触发某个信号。两个选择:
#include <signal.h> int raise(int sig); // 给当前进程发信号 int kill(getpid(), sig); // 等价操作raise()在多线程环境下有点讲究:它发送信号的目标是调用线程,而kill(getpid(), sig)发送的是给整个进程。对于进程级信号处理来说,绝大多数情况下效果一样,但如果你在用线程级别的信号管理(比如pthread_sigmask),这个区别就会暴露出来。
2.4 一套更现代的接口:sigqueue与实时信号
标准信号的问题在于不携带数据——就是一个数字,int值。你SIGUSR1和SIGUSR1没什么区别,完全不知道谁发的、为什么发。如果跨进程需要传递简单信息,可以考虑用sigqueue:
#include <signal.h> int sigqueue(pid_t pid, int sig, const union sigval value);union sigval里可以塞一个int或者一个指针,接收方在信号处理函数的siginfo_t结构里能取到这份数据。这一下子就把信号从"一个干巴巴的通知"变成了"带附件的通知"。不过要说明的是:只有实时信号(SIGRTMIN到SIGRTMAX,编号32-64)吃这套排队携带数据机制,标准信号那个队列是内核里的一个位图,根本不存数据。
3. 捕获与处理:signal是坑,sigaction是答案
信号发出来了,进程要接住它。接住的方式有两种:默认动作(可能终止进程、可能忽略、可能产生core),或者你自己注册一个处理器函数。注册API有两个:老掉牙的signal()和推荐的sigaction()。我强烈建议你写新代码一律用sigaction。
3.1 信号处理函数为什么会"打断"你的代码
理解这个问题之前,先看一个很反直觉的事实:你正在main()里读一个文件、算一个表达式,突然SIGINT来了,内核把处理函数拉起来执行,处理函数里又做了一堆事,跑完才回到你刚才的代码继续执行。你的程序没有任何感知,仿佛什么都没发生过。
这就意味着信号处理函数是一个异步插入的并发执行流。它和你主线程的正常代码共享同一份全局数据,但它可能在任意指令位置插入。如果在处理函数里访问了主逻辑正在修改的同一个非原子变量,数据竞争就来了。这是信号编程里最隐蔽也最危险的坑。
3.2 signal函数的旧时代缺陷:语义差异与回调重置
signal()的经典用法是:
#include <signal.h> typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);这个函数有两个知名缺陷:
第一,行为在历史上有分歧。在System V的Unix上,信号处理完之后,处置方式会被重置为默认行为,下次再收到相同信号就按默认动作走;而BSD的实现里,处理完之后会保持用户注册的handler。Linux的glibc默认跟着BSD走,但你没法保证所有平台行为一致。
第二,处理函数执行期间,相同信号会被自动阻塞。这看起来是好事对吧?但问题是行为同样因平台而异,有人觉得应该设计成"处理期间别来烦我",有人觉得应该允许重入。
在实际编程中,这些歧义会直接导致:你的程序在A发行版上跑得好好的,换到B发行版上信号处理函数执行到一半又来一个信号,进程直接崩了。
3.3 sigaction的完整打开方式:字段与flags详解
sigaction()是POSIX提供的、语义明确的接口:
#include <signal.h> struct sigaction { void (*sa_handler)(int); void (*sa_sigaction)(int, siginfo_t *, void *); sigset_t sa_mask; int sa_flags; void (*sa_restorer)(void); }; int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);逐个字段说:
sa_handler:普通处理函数,只接收信号编号。sa_sigaction:如果sa_flags里有SA_SIGINFO,用这个函数指针,能拿到siginfo_t(谁发的、为什么发、附加数据)。两个字段是共享内存的联合体,只能选一个用。sa_mask:在处理函数执行期间,额外阻塞的信号集。注意这里是在原有基础上追加阻塞,不是替换。默认情况下,触发当前处理的那个信号在执行期间会被自动阻塞防重入。sa_flags:一堆开关,常用的有几个——SA_RESTART:让被信号打断的系统调用自动重启(比如read()刚读到一半来信号了,处理完了自动重新发起read)。SA_SIGINFO:使用sa_sigaction回调。SA_ONSTACK:在备选信号栈上执行处理函数(配合sigaltstack用,对付栈溢出场景)。SA_NOCLDWAIT:针对SIGCHLD,让子进程退出后直接不进入僵尸态。
下面是一个标准的注册代码,我写项目基本都是从这段开始抄的:
#include <stdio.h> #include <signal.h> #include <string.h> #include <stdlib.h> #include <unistd.h> static volatile sig_atomic_t g_flag = 0; static void handle_term(int signo) { g_flag = 1; } int main(void) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = handle_term; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; if (sigaction(SIGTERM, &sa, NULL) == -1) { perror("sigaction"); exit(EXIT_FAILURE); } while (!g_flag) { pause(); } printf("received SIGTERM, exiting gracefully\n"); return 0; }3.4 volatile sig_atomic_t到底在防什么
你看上面的代码,我用了volatile sig_atomic_t。这两个修饰词拆开来说:
volatile:告诉编译器,这个变量可能在它"看不到"的地方被修改,对它的读写不能优化掉(不能缓存在寄存器里)。从编译器视角看,main()里的while循环和信号处理函数是两个"独立"的代码块,它根本不知道处理函数会改g_flag,如果不加volatile,编译器可能把g_flag优化到一个寄存器里,死循环永远跳不出来。sig_atomic_t:这是一个保证原子读写的整数类型,在几乎所有平台上就是int。信号处理函数里只能用保证原子操作的变量,用普通int或者更复杂的结构体,存在读到半截状态的风险。
注意:
volatile sig_atomic_t只保证"读写是原子的",不保证"先改标志再改数据"的顺序。如果你的处理函数要置多个标志、传递多个值,请老老实实用sigprocmask(后面会讲)或者sigwait,别在异步上下文里搞复杂逻辑。
4. 阻塞、未决与可靠信号:被误解的排队问题
"信号会丢"这事,很多人第一次遇到都很懵。我教书和带人的时候,至少被问了二十次:SIGCHLD明明注册了处理函数,为什么子进程退出了,函数没跑?十个里有八个,最后排查下来都跟信号屏蔽字或者未决信号的合并有关。
4.1 阻塞不是丢弃:pending位图与信号屏蔽字
每个进程有信号屏蔽字(signal mask),说明当前阻塞哪些信号。被阻塞的信号不是没了,而是进了未决集合(pending set)——就是之前说的那个位图。
流程是这样的:
- 信号产生,内核检查当前屏蔽字,如果没被阻塞,就立刻递达(执行处理函数或默认动作)。
- 如果被阻塞了,就把信号登记在pending集合里,一直等着。
- 等到进程解除对该信号的阻塞(比如调
sigprocmask把它从屏蔽字里去掉),信号马上递达。
这里有个关键点:pending是个位图,一个信号最多占一位。如果信号$x$已经pending了,又有新的信号$x$到达,后面的信号就直接被丢弃了。这跟"快递柜满了放不下"不同,更像是"门牌号重复了,后面来的那件快递直接不要了"。
4.2 为什么标准信号会丢,实时信号不会
这就是标准信号和实时信号的本质区别:
- 标准信号(1-31):每个信号对应一个位,不排队。相同信号累积了多个也只算一个。
- 实时信号(32-64):内核为每个进程维护一个信号队列,每个实时信号都单独入队,带的数据也会按到达顺序排列。
| 对比维度 | 标准信号 | 实时信号 |
|---|---|---|
| 编号范围 | 1-31 | 32-64 |
| 排队机制 | 位图,不排队 | 队列,按FIFO排队 |
| 携带数据 | 不能带 | 通过sigqueue携带 |
| 递达顺序 | 未定义 | 编号从小到大 |
| 同样信号多次产生 | 合并为一个 | 各算一个 |
所以如果你需要"每个事件都不丢",必须用实时信号配合sigqueue。用标准信号做高频率事件通知,本来就是设计上的错配。
4.3 sigprocmask:手动控制临界区,挡住信号的侵入
sigprocmask让你能在关键代码段里临时屏蔽信号,防止异步打断,干完了再解开:
#include <signal.h> int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);how有三种取值:
SIG_BLOCK:把set里的信号追加到当前屏蔽字里(简单的并集)。SIG_UNBLOCK:把set里的信号从当前屏蔽字里移除。SIG_SETMASK:直接把当前屏蔽字设置为set。
典型场景是更新共享数据结构时,不希望信号处理函数插进来看到半成品:
sigset_t block_set, old_set; sigemptyset(&block_set); sigaddset(&block_set, SIGUSR1); sigprocmask(SIG_BLOCK, &block_set, &old_set); /* 这里放心地修改全局链表、写文件,SIGUSR1不会打断 */ sigprocmask(SIG_SETMASK, &old_set, NULL);最后那句SIG_SETMASK恢复很重要——不能野着把屏蔽字留在那里,否则SIGUSR1就一直阻塞了。
这里再补充一个小技巧:解除阻塞之后,pending的SIGUSR1会立刻递达,如果你的处理函数还没准备好(比如全局资源还没初始化完),这个时序可能踩雷。稳妥的做法是:初始化阶段保持屏蔽,等一切就绪后再统一解锁。这个模式在写服务端程序时非常实用。
5. 完整实战:实现一个可优雅退出的信号驱动C服务
讲了一堆原理,不动手等于没学。下面我用一个真实场景把前面的知识点全部串起来:写一个监听socket的服务进程,收到SIGTERM时优雅退出——关闭监听、释放资源、保存状态,而不是裸死。这个需求在几乎所有后台服务里都会出现。
5.1 需求拆解:为什么Naive的写法会出事
先看一个反面教材。很多人会这么写:
static void on_term(int signo) { printf("got signal, exit now\n"); exit(0); }这代码的问题在哪儿?exit(0)会让进程直接走C运行时的收尾流程,但此时你可能正握着锁、正写到一半文件、正有网络连接没关。强制exit并不会帮你做这些清理。尤其是多线程程序,在信号处理函数里直接exit,极易碰到"线程A持锁等待,线程B在exit里等待线程A退出"的死锁。
而我们期望的流程是:
- 收到SIGTERM后,不再接受新的连接。
- 关闭监听socket。
- 通知正在工作的线程/子进程,让它们处理完当前任务。
- 等所有人退出后,保存必要状态,再整个进程exit。
要实现这个流程,信号处理函数里就不能做太重的活,它应该只负责"置一个标志位",真正的清理逻辑放主循环里做。
5.2 代码实现:标志变量、屏蔽字与主循环协作
下面这段代码,是基于Linux单进程多路复用场景的完整示例:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <signal.h> #include <unistd.h> #include <errno.h> static volatile sig_atomic_t g_running = 1; static volatile sig_atomic_t g_stop_requested = 0; static void signal_handler(int signo) { if (signo == SIGTERM || signo == SIGINT) { g_stop_requested = 1; } } static int setup_signals(void) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sa.sa_handler = signal_handler; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; if (sigaction(SIGTERM, &sa, NULL) == -1) return -1; if (sigaction(SIGINT, &sa, NULL) == -1) return -1; /* 忽略SIGPIPE:避免写socket时被信号杀死 */ signal(SIGPIPE, SIG_IGN); return 0; } int main(void) { if (setup_signals() == -1) { perror("setup_signals"); return 1; } while (g_running) { /* 模拟主循环:poll / epoll / select 都在这 */ struct timespec ts = { .tv_sec = 1, .tv_nsec = 0 }; nanosleep(&ts, NULL); if (g_stop_requested) { printf("stop requested, draining remaining tasks...\n"); /* 这里可以做真正的清理:关闭监听fd、保存状态、通知worker */ g_running = 0; } } printf("service exited cleanly\n"); return 0; }这段代码你可以直接编译跑。Ctrl+C发的是SIGINT,kill发的是SIGTERM,两个信号都会把g_stop_requested置1。主循环每次醒来检查标志,一旦发现就进入清理流程。
5.3 进程通信场景延伸:父子进程的SIGCHLD与waitpid
信号实战里另一个高频场景是管理子进程。父进程需要知道子进程什么时候退出——这就要处理SIGCHLD信号。常见写法:
static void handle_sigchld(int signo) { int saved_errno = errno; while (waitpid(-1, NULL, WNOHANG) > 0) ; errno = saved_errno; }这里有两个细节:
- 循环waitpid:SIGCHLD可能因为多个子进程同时退出而"合并",如果一个waitpid只回收一个子进程,可能漏掉别的僵尸进程,所以要用while循环配合WNOHANG回收所有可回收的。
- 保存errno:信号处理函数会破坏主流程的errno。处理函数里如果调了系统调用(像waitpid、read这些),会改写errno,回到主代码后你的错误码就错了。标准做法:进入函数先保存errno,退出前恢复。
5.4 进阶思考:sigwait与signalfd,比异步处理函数更稳的路径
上面说的都是异步信号处理函数——处理代码和主逻辑并发执行,限制多、容易出错。还有一个更可控的思路:同步信号处理。
思路很简单:把信号屏蔽掉,然后用专门的线程/主循环去sigwait()拿信号。信号不打断你的任何代码路径,它就像消息队列里的一条消息,你主动去取。这就不存在"处理函数插到一半主逻辑"的问题了,所有共享数据都不用考虑异步竞争。
sigset_t set; int signo; sigemptyset(&set); sigaddset(&set, SIGTERM); sigaddset(&set, SIGINT); sigprocmask(SIG_BLOCK, &set, NULL); /* 先把信号都挡住 */ while (1) { sigwait(&set, &signo); /* 阻塞等待信号 */ if (signo == SIGTERM || signo == SIGINT) { /* 这里是"正常代码"上下文,想干什么干什么 */ cleanup_and_exit(); } }Linux下还有个更现代的选择:signalfd()。它能创建一个fd,把信号变成"文件描述符上可读的事件",直接塞进你的epoll/select循环里。这个方案在单线程事件驱动架构里尤其好用——你不用开专门的信号线程,信号处理和网络事件统一走同一个事件循环,业务代码完全同步化。
比如我们团队在生产环境跑的一个网关程序,就是signalfd + epoll的组合,把所有信号、网络IO、定时器统一管理,代码清晰度提升了一个量级。如果项目允许依赖Linux特有接口,我强烈推荐这个路线。
6. 我这些年踩过的信号坑,写下来给你避雷
前面章节把机制讲得差不多了,最后聊一点真正来自工程现场的教训,每条都是真金白银堆出来的。
第一,信号处理函数里禁止调用printf、malloc、fprintf这些函数。printf内部涉及缓冲区操作和加锁,malloc涉及堆管理,这些函数不保证可重入。如果信号在主线程执行printf的时候插进来,处理函数里再去printf,可能直接死锁。我见过一个线上事故,现象就是"按下Ctrl+C程序卡死不动",最后排查出来就是信号处理函数里的printf和主流程的printf抢同一个FILE锁。
第二,把SIGKILL和SIGSTOP不可捕获这件事记牢。SIGKILL连接到内核的强制终止路径,SIGSTOP是强制暂停路径,它们不经过用户态的处理函数,任何捕获代码对它们都是无效的。kill -9杀不掉的进程只有一个可能——它处于不可中断的内核态等待(D状态),比如等磁盘IO,这时候你只能等内核返回。
第三,多线程程序的信号管理,跟你想象的不一样。默认情况下,进程级信号会投递给任意一个未阻塞该信号的线程。你想让"某个专门线程"处理信号,就得在其他所有线程里把这个信号屏蔽掉。用sigwait模式的同志,请务必在创建其他线程之前就设好屏蔽字,否则竞态会让你偶尔漏掉信号。
第四,EINTR问题虽然老生常谈,但真的会坑你。传统系统调用(read、write、accept)在等待期间被信号打断,可能返回-1并置errno为EINTR。SA_RESTART标志能解决大部分问题,但注意:某些系统调用(比如poll、epoll_wait、nanosleep)即使设置了SA_RESTART也可能不自动重启。所以代码里对这类调用,看到EINTR就重试是个好习惯。
第五,alarm和settimer在信号处理里的重入问题。你没细看的话,alarm到期触发处理函数,处理函数里又调了alarm,时间设置可能被自己打乱。定时器相关的信号逻辑,先想清楚"当前处理的是哪一次到期,下一次到期是什么时候"。有复杂定时需求的,直接用POSIX timer,别在alarm上硬扛。
整套信号机制虽然从Unix诞生之初就存在,但它的设计其实很精巧,用好了能写出非常优雅的进程协同逻辑。我现在的习惯是能不用异步处理函数就不用,优先sigwait或signalfd,走同步的逻辑处理信号,程序出问题的概率低很多。至于系统编程里其他难啃的骨头——文件IO、进程管理、线程并发——那是另一个长篇了,有机会我们接着聊。