1. 从一次Ctrl+C说起:信号到底是什么
先抛一个所有Linux工程师都经历过的场景:你在终端里敲下ping baidu.com,跑了一会儿觉得没意思,随手按下Ctrl+C,进程立刻终止,shell提示符回来了。整个过程行云流水,好像什么都没发生。但如果你往深一层想——是谁把ping这个进程停掉的?ping进程自己在运行循环里有没有检查“用户是否按了Ctrl+C”?如果没有,那内核凭什么能强制打断它?
答案是:信号(Signal)。
信号是Linux/Unix系统里最古老也最基础的进程间通信机制之一,本质上是内核给进程发的一个“异步通知”,告诉进程“某件事件发生了”。它不像管道和消息队列那样传输数据,而是纯粹传递一个整数编号——这个编号代表不同的事件类型。比如SIGINT(编号2)代表键盘中断,SIGKILL(编号9)代表强制杀死,SIGTERM(编号15)代表请求终止。这也是Linux面试题里出现频率极高的考点,尤其是“进程间通信方式有哪些”这种问题,信号基本必答,却很少有人真正讲清楚。
我开始接触信号时吃了不少亏。第一次写网络服务程序,子进程莫名其妙退出,日志里没有任何错误输出,排查了半天发现是父进程退出时,子进程收到了SIGHUP挂断信号。还有一次用nohup启动程序,以为它真的“不挂断”,结果终端关闭后进程还是没了——因为我对SIGHUP的默认行为理解错了。这些都是信号的“默认处理动作”在背后起作用,而很多人只记了API,没理解这套事件模型。
这篇文章是进程信号的上篇,重点讲清楚信号的诞生、传递、处理整个生命周期,以及最常用的一组API——signal、kill、raise、abort、alarm、pause。学完之后,你至少能回答这几个问题:为什么Ctrl+C能杀掉前台进程?为什么kill -9杀不掉的进程是真的没救了?为什么程序崩溃会产生core文件?这些都是信号在背后起作用。
2. 信号的生命周期:从产生到处理的完整链路
了解信号,不能只背函数签名。信号有一套完整的生命周期,分四个阶段:产生(Generation)、注册(Pending)、注销(Deletion)和处理(Delivery)。搞清楚每个阶段内核做了什么,才能真正理解行为差异。
2.1 信号是怎么产生的:硬件、软件与用户操作
信号的产生来源有三大类。
第一类是硬件异常。CPU执行指令时遇到除零、访问非法内存地址、非法指令等情况,硬件会触发异常,操作系统捕获异常后转换成信号发给对应进程。比如SIGFPE(浮点异常)、SIGSEGV(段错误)、SIGILL(非法指令)。前端开发同学看到浏览器页面崩溃,本质也是渲染进程收到这类信号后无法恢复,只能终止。
第二类是软件条件。进程调用kill系统调用主动给另一个进程发信号;alarm定时器到期会产生SIGALRM;终端输入Ctrl+C、Ctrl+\时,终端驱动会向前台进程组发送SIGINT、SIGQUIT;子进程退出时,内核自动给父进程发送SIGCHLD;作业控制相关的Ctrl+Z会产生SIGTSTP。这些都属于软件层面产生的信号。
第三类是用户通过命令直接操作。kill -9 <pid>、killall <name>、pkill这些命令本质是kill系统调用的封装。在系统运维场景里,kill和killall是最常用的两个工具,面试时面试官也喜欢问“kill、killall、pkill有什么区别”,其中pkill是按进程名匹配,killall也是按名字匹配但精确度更高,而kill接收的是PID。
2.2 信号的注册与注销:不是排队,是“记账”
进程收到信号后,内核并不是直接去执行处理函数,而是在目标进程的task_struct里做一个标记,对应一个位图(pending信号集合)。注意这里有一个特别容易误解的点:信号不是队列。如果你连续给同一个进程发100次SIGUSR1,内核不会把100个信号都存下来,它只是把位图上SIGUSR1这一位置1——最终进程只处理一次。
标准信号本来就是“丢失”设计,多个相同信号只算一次。如果要排队,必须用实时信号(SIGRTMIN到SIGRTMAX,即34~64号)。实时信号是POSIX.1b引入的,支持排队,发送多少个就处理多少个,还支持伴随数据(通过sigqueue发送)。这个概念在入门阶段先留个印象,后面讲sigaction时再展开。
信号被“注销”发生在处理之前。如果进程决定处理这个信号(而不是忽略),内核会在处理前把pending位图对应的位清除。如果信号是阻塞的(后面讲信号屏蔽时会说),则一直停留在pending状态,直到解除阻塞才被处理。
2.3 信号的处理时机:不是立刻,而是“回到用户态时”
信号处理并不是“收到就立马执行”,而是等进程从内核态切换回用户态时,内核检查pending位图,发现有信号要处理,让进程先去执行对应的信号处理函数,处理完后再恢复原来的执行现场。
这有点像你在公司上班(用户态运行),偶尔要进会议室开会(内核态执行系统调用),开完会出来发现手机上多了条通知(pending信号),你得先处理掉这条通知再继续干活(返回用户态时执行信号处理函数)。
所以信号处理的时机有几个关键节点:
- 当前进程正在用户态执行普通代码时,信号不会立即打断,要么等下一个系统调用返回时处理,要么等时钟中断触发调度时处理。
- 当前进程阻塞在慢系统调用中(比如
read等待终端输入),信号到达后会唤醒进程,并优先执行信号处理函数。
这里有个经典坑:默认情况下,进程被信号打断后,read、write这类系统调用会返回EINTR错误,errno被设置为Interrupted system call。程序员如果没处理这个情况,程序可能误判为读失败而退出。很多网络服务框架里都会看到对EINTR的处理——要么重启系统调用,要么用sigaction的SA_RESTART标志让内核自动重启。
3. Linux信号全清单:哪些信号必须知道
Linux系统支持的标准信号虽然不到30个常规编号,但每个都值得了解。尤其是信号编号和你系统架构相关(x86、ARM等),但标准编号基本一致,下面是整理出来的最常用标准信号表。
| 信号名 | 编号 | 默认动作 | 典型触发场景 |
|---|---|---|---|
| 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 |
| SIGTTIN | 21 | 停止进程 | 后台进程读取终端输入 |
| SIGTTOU | 22 | 停止进程 | 后台进程写终端输出 |
| SIGWINCH | 28 | 忽略 | 终端窗口大小变化 |
| SIGSYS | 31 | 终止进程并生成core | 非法系统调用 |
这张表里有几个必须死记硬背的点:
SIGKILL和SIGSTOP是“管理员特权信号”,任何进程都无法捕获、阻塞或忽略它们。所以面试题里经常出现的“有没有杀不掉的进程”,正确答案是:杀不掉的意思是程序没有机会做清理工作——比如数据库进程来不及落盘就没了,但不代表信号发不进去。kill -9总会生效,除非进程处于不可中断的D状态(比如等待磁盘IO),或者进程已经是僵尸进程(Z状态)没有执行体。
SIGTERM是kill命令不带参数默认发送的信号,它给了进程一个优雅退出的机会,可以在handler里做清理工作。生产环境里服务下线,正确姿势是先发SIGTERM,等几秒,没退再SIGKILL。
SIGHUP这个信号有点意思。历史上它是因为终端线路挂断而产生的,但现在大量服务器程序把它当作“重新加载配置文件”的指令。比如Nginx、sshd、supervisor都支持用kill -HUP <pid>来重载配置,不用重启进程。我踩过一次坑:第一次用kill -HUP重启Nginx时,以为能保持所有连接不中断,结果某些老旧连接还是断了——因为SIGHUP对Nginx来说是在主进程里重新解析配置并fork新的worker,这对正在占用旧配置的worker会有迁就机制,但如果你用了nginx -s reload以外的参数,行为可能不同。
4. 最简单的信号处理:signal函数与回调机制
了解了信号长什么样,接下来是第一个API:signal函数。它是入门级接口,功能是“为某个信号注册处理函数”。
#include <signal.h> typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);第二个参数handler有三种取值:
SIG_IGN:忽略该信号。比如有的后台程序不希望终端关闭时退出,可以忽略SIGHUP。SIG_DFL:恢复默认动作。比如先忽略再恢复。- 自定义函数指针:信号到达时自动调用这个函数,函数参数是信号编号。
下面这段代码演示了捕获SIGINT后的行为:
#include <stdio.h> #include <signal.h> #include <unistd.h> void handler(int sig) { printf("收到信号: %d\n", sig); } int main() { signal(SIGINT, handler); while(1) { printf("运行中...\n"); sleep(1); } return 0; }跑一下,按Ctrl+C,不会退出,而是打印“收到信号: 2”。再按一次,再次打印。要退出只能开另一个终端kill -9它。
这个示例很简单,但它揭示了一个核心机制:信号处理函数是“异步回调”。你不能知道它什么时候会被调用,它可能在主程序执行的任意两条指令之间插入。这带来了很多隐患,比如信号处理函数里如果调用了非异步信号安全的函数(像printf、malloc),可能造成死锁或数据损坏。POSIX标准定义了一批异步信号安全函数,write是其中之一,所以严格意义上上面的printf用法是有风险的,只是示例跑起来问题不大,后面我会再说这个坑。
signal函数还有一个容易被忽视的返回值:它返回上一次设置的信号处理函数指针。如果之前没设置过,返回SIG_ERR。这个返回值有个实用场景——临时改变信号行为后再恢复。
sighandler_t old = signal(SIGINT, handler); // 做一些需要忽略中断的临界区操作?不对,这样太粗暴 signal(SIGINT, old);不过说实话,在实际工程里我建议尽量避开signal函数,用sigaction替代。原因有几个:
signal在不同Unix版本上行为不一致,有的版本处理完信号后会自动重置回默认动作,导致需要反复设置。- Linux上
signal内部等价于带SA_RESTART标志的sigaction,这意味着被打断的系统调用会被自动重启,这个行为在不同平台也不一样。 signal无法设置信号屏蔽集,也无法获取更多信息(比如siginfo_t)。
sigaction相关内容比较多,留在下篇详细讲。这里先把signal作为一种快速上手的方式理解,理解“注册→触发→回调”这个过程就行。
5. 信号的发送者:kill、raise与abort
信号处理函数是“收”的一侧,接下来看“发”的一侧。kill系列函数是发送信号的核心,虽然名字叫kill,但它不只是用来终止进程,更准确的理解是“向进程发送信号”。
5.1 kill函数:指定PID发送信号
#include <sys/types.h> #include <signal.h> int kill(pid_t pid, int sig);kill的pid参数有几个特殊取值,很多人只用了常规PID,不知道这里还有一层路由逻辑:
pid > 0:发给指定PID的那个进程。pid == 0:发给当前进程所在进程组的所有进程。pid == -1:发给当前进程有权限发送的所有进程(不包括init和当前进程自己)。pid < -1:发给进程组ID等于-pid的整个进程组里的所有进程。
这四种路由方式在运维脚本里很有用。比如你想停掉某个进程组下所有进程,可以kill -- -<pgid>直接用负号指定进程组。而kill(0, SIGTERM)这种技巧,可以用来让同组所有进程都收到终止信号——不过要小心,如果你自己是组内成员,也在接收范围内。
kill的返回值也很重要:成功返回0,失败返回-1并设置errno。常见的有ESRCH(进程不存在)、EPERM(没有权限给目标进程发信号)。注意,如果目标进程不存在,kill会返回ESRCH;如果目标进程是僵尸进程,信号可以发送成功(因为进程表项还在),只是信号不会被处理。
我调试过一个很隐蔽的问题:程序定期用kill(pid, 0)探测进程是否存活。这个用法很常见,因为sig=0时kill不会真的发送信号,只做权限检查和存活检查。但问题在于,如果那个PID刚好被系统复用了,kill(pid, 0)返回成功,程序误以为还是原来那个进程——这就是所谓的“PID复用竞态”。后来我改用pidfd_open和poll来跟踪进程生命周期,彻底绕开了这个竞态。
5.2 raise函数:自杀式的信号发送
#include <signal.h> int raise(int sig);raise就是“自己给自己发信号”。在单线程程序里,raise(sig)等价于kill(getpid(), sig);在多线程程序里等价于pthread_kill(pthread_self(), sig)——意思是发给当前线程而不是整个进程。
raise最常见的调用场景是程序自我检测到异常状态时发信号,比如自定义断言失败时raise(SIGTRAP),让调试器能断下来。
但这里有个非常容易混淆的地方:raise(SIGSEGV)和真正发生SIGSEGV是有区别的。前者是“主动发信号给自己”,是软件行为;后者是硬件检测到非法内存访问后的内核行为。前者如果被捕获,处理完还能继续跑;后者即使是捕获了,处理完也可能继续踩同一个非法地址,陷入死循环。很多崩溃处理库会同时处理这两种来源,但逻辑上要区分。
5.3 abort函数:终止 + 产生核心转储
#include <stdlib.h> void abort(void);abort函数的作用是“异常终止当前进程”,并且必定产生core文件(前提是core dump没被禁用,且权限允许)。它的本质是:先解除SIGABRT的阻塞,然后给当前进程发送SIGABRT信号。如果SIGABRT被捕获,且处理函数返回了(没有退出),abort还会做一些额外动作——比如再次解除阻塞并再次发送信号,保证进程最终死掉。
它和exit之间最本质的区别是:exit是正常的进程退出流程,会执行atexit注册的清理函数、刷新stdio缓冲区;abort是异常终止,不执行这些清理。这导致一个经典问题——很多程序里如果abort被调用,之前printf到标准输出的内容可能因为缓冲区没有刷新而丢失。
我遇到过业务代码故意调用abort来触发core dump,然后配合gdb分析崩溃现场。这类用法在嵌入式开发和游戏客户端崩溃上报系统里很常见。崩溃上报的完整链路是:注册SIGSEGV、SIGABRT等信号的handler,在handler里不依赖malloc等不安全函数,把当前调用栈信息写入预先分配的环形缓冲区,再通过write写入文件或发送网络请求——整个过程在“信号处理函数的限制”下运行。
6. 定时器信号:alarm与pause的实用组合
6.1 alarm函数:一次性闹钟
#include <unistd.h> unsigned int alarm(unsigned int seconds);alarm的作用是让内核在指定秒数后向当前进程发送SIGALRM信号。默认动作是终止进程,所以要配合信号处理函数才有意义。
它的返回值是“上一次未到期的闹钟剩余秒数”。如果你调用alarm(10)之后2秒又调用alarm(5),这次调用会返回8,并且重置闹钟时间为5秒。也就是说,每个进程同一时间只有一个闹钟定时器,新的设置会覆盖旧的。
一个简单的用途是实现超时控制:
#include <stdio.h> #include <signal.h> #include <unistd.h> void timeout_handler(int sig) { // 超时处理,这里不能做太多工作量 } int main() { signal(SIGALRM, timeout_handler); alarm(3); // 模拟一个可能阻塞的操作 char buf[10]; ssize_t n = read(0, buf, sizeof(buf)); // 如果被打断,read返回-1,errno=EINTR if (n < 0) { printf("读取超时或被信号打断\n"); alarm(0); // 取消闹钟 } return 0; }这个例子里,如果3秒内没有输入,SIGALRM会中断read,导致read返回EINTR。注意,如果注册的handler里没有调用exit或_exit,read返回后程序会继续执行,然后通过判断返回值发现“被打断了”。
实际工程中拿alarm做超时控制其实很多坑,因为alarm是进程级的,如果程序里多个模块都在用alarm,会互相覆盖。而且SIGALRM和read的交互有一个历史遗留问题:read被信号打断后行为在不同版本上不一致,POSIX规定返回EINTR,但有的系统可能自动重启。更现代的替代方案是select/poll/epoll设置超时时间,或者使用timer_create和timercmp,或者POSIX定时器。一句话结论:入门阶段用alarm理解定时器信号没问题,生产代码建议用带超时的IO复用接口。
6.2 pause函数:让进程挂起等待
#include <unistd.h> int pause(void);pause让调用进程挂起,直到捕获到一个信号,并且信号处理函数执行完毕。它总是返回-1,errno设置为EINTR。如果信号没有注册handler,默认动作是终止进程,那就没有“返回”这一回了。
pause和alarm搭配使用可以做一个“至少等N秒”的效果:先alarm(5),再pause(),当SIGALRM到达时进程被唤醒。这个写法在早期Unix里常用来制造延时,但现在大家更倾向于用sleep/nanosleep,因为pause的精确性和可预测性都不好。
不过pause有一个很重要的变体——sigsuspend。它可以在挂起的同时修改信号屏蔽字,原子地“解除阻塞+等待信号”。为什么需要这个原子性?因为如果先用sigprocmask解除阻塞,再用pause,中间有一个窗口期,信号可能已经到达并被默认动作处理了,pause还没来得及执行。sigsuspend从设计上解决了这个竞态。这段内容在信号屏蔽相关章节里会有更详细的展开,这里先知道有这么个东西。
7. 信号与进程生命周期:僵尸进程、core dump与SIGCHLD
信号和进程状态之间的联动紧密得超乎想象。我整理了几个经常被业务代码踩到的场景。
7.1 子进程退出后,父进程凭什么知道?
子进程退出时,内核会向父进程发送SIGCHLD信号。默认动作是忽略——注意,是“忽略”而不是“删除”,这意味着父进程如果注册了SIGCHLD处理函数,就能知道子进程的退出事件。
这个机制对服务端程序非常重要。写过网络服务的人都知道,如果父进程不调用wait/waitpid回收子进程,子进程退出后就会变成僵尸进程(Zombie)。僵尸进程不占用CPU和内存,但会占用PID和进程表项,积累多了会导致系统无法创建新进程。一个常见的解法是在父进程里注册SIGCHLDhandler,在handler里调用waitpid(-1, &status, WNOHANG)循环回收所有已退出的子进程。
这里有个面试蝉联考点:为什么waitpid要加WNOHANG参数?
因为SIGCHLD是“遗失式”信号,可能多个子进程同时退出,但信号只触发一次。如果在handler里只用一次waitpid,只能回收一个子进程,其他子进程仍然变成僵尸。所以要在handler里循环调用waitpid(-1, &status, WNOHANG)直到返回0或-1,把所有僵尸全部收走。加WNOHANG是为了不阻塞handler——如果子进程还没退出,waitpid会立即返回而不是挂起,避免handler卡死。
7.2 孤儿进程与SIGHUP的恩怨
这个坑我在前面提过:父进程退出后,子进程变成孤儿进程,被init或systemd收养,这本身不会产生信号。但终端会话的退出会产生SIGHUP,它发给会话首进程所在的整个进程组。如果你在一个终端里启动了一个程序,然后直接关闭终端,SIGHUP会发到进程组,程序默认动作是终止。
所以nohup的no hang up真正的含义是:让程序忽略SIGHUP信号。setsid则是让进程创建一个新的会话并成为会话首进程,从而脱离控制终端。经典的一条启动命令是nohup ./app > app.log 2>&1 &,它同时做了忽略SIGHUP和后台运行两件事。
我还见过一种用到SIGHUP的场景:嵌入式设备上,主监控进程fork了一个子进程,子进程通过prctl(PR_SET_PDEATHSIG, SIGHUP)在父进程死亡时自动收到信号,这样主进程挂了子进程能快速感知并自杀。这个技巧用起来要慎重,如果父进程是先退出后fork,pdeathsig会立即生效,需要配合实际场景评估。
7.3 core dump:崩了也要留证据
程序遇到SIGSEGV、SIGABRT、SIGFPE等信号时,默认动作除了终止进程,还会生成core文件。core文件是进程的内存映像,配合gdb可以完整还原出问题现场——函数调用栈、变量值、内存内容。
刚学Linux时很多人会抱怨“程序崩了没有core文件”,通常有几个排查点:
- 检查
ulimit -c。如果输出0,说明core文件大小限制为0,需要ulimit -c unlimited放开限制。注意ulimit是shell内建命令,对子进程生效,改完后要重新启动程序才能带上新限制。 - 检查工作目录的写权限。core文件的生成路径默认为当前工作目录,文件名通常是
core或core.<pid>。如果目录不可写,就生成不出来。 - 检查
/proc/sys/kernel/core_pattern。这个文件控制core文件命名格式和路径,有些发行版配置成|管道交给apport等崩溃处理程序,可能不会生成传统意义上的core文件。 - 检查
/proc/sys/fs/suid_dumpable。如果程序设置了setuid等特殊权限,出于安全考虑系统可能禁止生成core。
生产上我会把core_pattern设置为带PID和时间戳的路径,方便多进程崩溃时对照定位。调试完再恢复默认,避免core文件塞满磁盘。
# 查看当前core pattern cat /proc/sys/kernel/core_pattern # 临时设置 echo "/opt/cores/core.%e.%p.%t" > /proc/sys/kernel/core_pattern # 放开大小限制 ulimit -c unlimited8. 常见问题排查与信号实战避坑
这部分把实际工作中最容易踩的信号相关坑集中列出来,每个都是真实教训。
8.1 问题1:kill -9杀不掉进程
排查顺序:
- 看进程状态。
ps -o pid,stat,cmd输出里的D状态表示不可中断睡眠(通常是磁盘IO或内核驱动阻塞),信号要等进程离开内核态才能处理,这时确实杀不掉。但进程也不会占CPU,所以不是死循环,而是卡在IO上了。 - 看进程是否已经是僵尸进程。
Z状态进程没有执行体,无法接受信号,需要杀父进程才能被回收。 - 看日志或
/proc/<pid>/stack。D状态有时候可以通过读/proc/<pid>/stack看到卡在哪个内核函数里,找出底层原因(比如NFS挂载不可达、设备驱动bug)。
8.2 问题2:进程突然被SIGPIPE杀死
写管道或socket时,如果对端已经关闭连接,继续write会触发SIGPIPE,默认动作直接终止进程。这个信号不像EPIPE错误码那样可以优雅处理,很多网络服务刚开始部署时频繁崩溃就是这个原因。
解法一般有两个:要么在代码里忽略SIGPIPE(signal(SIGPIPE, SIG_IGN)),然后靠send/write的返回值和EPIPE错误来做优雅关闭;要么为SIGPIPE注册handler,记录日志后退出。大多数游戏服务器、Nginx等成熟项目都选择忽略SIGPIPE并用EPIPE处理。
8.3 问题3:信号处理函数里用了printf导致崩溃
这是新手最隐蔽的坑。信号处理函数执行时,进程处于一个微妙的临时状态,主程序可能正在执行printf,内部持有stdio的锁;信号来了,handler里又调printf,尝试获取同一把锁,如果锁是不可重入的,就直接死锁或者数据错乱。更危险的是malloc——主程序可能在malloc内部维护堆链表,信号打断后handler再调malloc,堆结构被破坏,程序随机崩溃。
POSIX异步信号安全函数列表里包含了write、read、open、close、getpid、sigaction等,但不包含printf、malloc、free。所以信号handler里要输出日志,正确做法是直接用write往文件描述符里写,或者把信号编号写入一个volatile sig_atomic_t全局变量,让主循环去检查。
static volatile sig_atomic_t g_signal_received = 0; void handler(int sig) { g_signal_received = sig; // 只保存,不处理 } int main() { signal(SIGINT, handler); while (!g_signal_received) { // 主循环正常工作 } // 主循环里再处理 printf("捕获到信号 %d,正在退出\n", (int)g_signal_received); }注意,volatile sig_atomic_t是信号处理函数能安全读写的唯一保证类型。这个模式在真实服务里是标准写法,把“接收到信号”和“处理信号”剥离开,handler里只做最小的事。
8.4 问题4:重复设置signal导致行为不可预期
signal函数在不同编译选项和库版本下行为略有差异,尤其是“处理完后是否重置handler”这个问题。旧SysV上是重置的,BSD上是不重置的,Linux上遵循BSD语义。但如果你在主程序里多次设置同一个信号的处理函数,期间有信号到达,可能存在瞬间重置窗口。工程上直接换sigaction是最稳妥的,sigaction的语义是明确的,而且可以精确控制SA_RESETHAND、SA_RESTART等标志。
8.5 信号常用命令速查
| 命令 | 作用 | 示例 |
|---|---|---|
kill -l | 列出所有信号名称和编号 | kill -l |
kill -15 <pid> | 发SIGTERM优雅终止 | kill 1234(默认15) |
kill -9 <pid> | 发SIGKILL强制终止 | kill -9 1234 |
kill -HUP <pid> | 发SIGHUP重载配置(对服务进程) | kill -HUP $(cat nginx.pid) |
killall <name> | 按进程名发信号 | killall -9 httpd |
pkill -f <pattern> | 按命令行匹配发信号 | pkill -f "python app.py" |
timeout 5 <cmd> | 超时自动终止(内部用signal/alarm) | timeout 5 ssh xxx |
说一个timeout命令的冷知识:它默认发SIGTERM,但如果超时后进程还没退出,可以加-k参数指定等待时间,之后再用SIGKILL强制杀。这个用法在批量跑测试脚本、防止个别脚本卡死时特别实用。
9. 一个综合小实验:自己实现“5分钟内学会的mini守护进程”
光看理论和坑,不如动手写一遍。下面这个小程序把本文主要内容串起来:注册SIGINT、SIGTERM做优雅退出,忽略SIGPIPE,利用alarm实现心跳日志,再看看SIGCHLD回收子进程。
#include <stdio.h> #include <stdlib.h> #include <signal.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <string.h> #include <errno.h> static volatile sig_atomic_t g_running = 1; void handle_term(int sig) { g_running = 0; } void handle_alarm(int sig) { // 注意:write是异步信号安全的,printf在这里有风险 write(1, "heartbeat\n", 10); alarm(2); } void handle_chld(int sig) { int status; pid_t pid; while ((pid = waitpid(-1, &status, WNOHANG)) > 0) { // 回收一个子进程,循环回收所有僵尸 } } int main() { // 注册信号 signal(SIGINT, handle_term); signal(SIGTERM, handle_term); signal(SIGPIPE, SIG_IGN); signal(SIGALRM, handle_alarm); signal(SIGCHLD, handle_chld); alarm(2); while (g_running) { // 模拟服务逻辑 pause(); } printf("优雅退出完成\n"); return 0; }编译后运行,观察输出。你可以尝试:
- 给主进程发
SIGTERM:kill -15 <pid>,程序打印“优雅退出完成”。 - 给主进程发多次
SIGINT:Ctrl+C或kill -2 <pid>,主循环退出逻辑只走一次,信号不会让它直接崩溃。 kill -9 <pid>:程序没有任何机会打印退出日志,直接消失。- 在主循环里fork几个子进程,然后把子进程杀死,看父进程会不会积累僵尸进程——会因为注册了
SIGCHLD并回收,ps里看不到僵尸。
这个实验如果你一个个试过来,对信号的体会会比单纯读文章深不少。我当年带实习生,就是让他把这个程序跑起来,然后故意把handle_chld里的waitpid循环改成单次调用,再连续创建几个子进程,观察僵尸进程出现——这个体验比任何PPT都直观。
写在最后:信号的哲学与工程边界
我个人使用信号这么多年,最大的体会是:信号是个“事件通知”机制,不是“数据传输”机制,也不是“精确流控”机制。它适合告诉进程“该停一停了”“配置变了”“子进程走了”,但不适合在多线程环境里做复杂的业务通信。现代服务器框架里信号的使用越来越克制,多线程程序里信号大多只用于“进程退出通知”,业务逻辑全交给事件循环(epoll等)处理。
尽管如此,信号仍然是Linux进程模型的地基。没有信号,Ctrl+C不会生效,kill命令不会起作用,进程崩溃时也不会有core文件,系统就失去了一层最基本的事件机制。而且面试题里进程间通信、僵尸进程、守护进程、nohup与setsid的区别,随便一拉都跟信号有关——理解信号就是理解这些知识的钥匙。
这篇文章是上篇,把信号的生命周期、常用函数signal/kill/raise/abort/alarm/pause和实战坑位讲完了。下篇会深入sigaction的完整能力、信号屏蔽集sigprocmask与sigsuspend的原子等待、实时信号与sigqueue、以及多线程程序里信号到底发给谁这些进阶话题。到时候再看,你会理解为什么“看起来能用就行”的signal函数,在实际工程里常常不够用。