引入信号概念
在 Linux 中,信号是一个非常重要的概念。我们平时写程序、调试程序、管理进程时,经常会在不知不觉中用到它。
比如按下:
Ctrl + C正在运行的程序会退出。
再比如执行:
kill-9pid某个进程会被强制杀死。
还有程序访问空指针时出现的:
Segmentation fault这些现象背后,其实都和 Linux 的信号机制有关。
信号这个东西刚开始看起来有点抽象,但如果用一句话来理解,它其实就是:
信号是 Linux 提供的一种异步通知机制,用来告诉进程:某件事情发生了,你需要处理一下。
这篇文章就从最熟悉的Ctrl+C开始,一步一步讲清楚信号是什么、怎么产生、怎么保存、怎么处理,以及它在实际编程中的使用。
一、从 Ctrl+C 开始理解信号
先看一个最简单的程序:
#include<stdio.h>#include<unistd.h>intmain(){while(1){printf("I am running...\n");sleep(1);}return0;}编译运行:
gcc test.c-otest./test程序会一直打印:
I am running... I am running... I am running...这时候如果我们按下Ctrl+C,程序就会退出。
表面上看,好像是键盘直接让程序停止了。实际上并不是这样。
当我们按下Ctrl+C时,终端驱动会向当前前台进程发送一个信号:
SIGINTSIGINT的默认动作就是终止进程,所以程序退出了。
也就是说,Ctrl+C本身并不是“魔法按键”,它只是触发了一个信号。
信号可以来自很多地方,比如用户按键、命令发送、程序异常、定时器到期、子进程退出等。进程收到信号以后,可以执行默认动作,也可以选择忽略,或者执行自己写好的处理函数。
二、常见信号先有个印象
Linux 中的信号有很多,可以通过下面的命令查看:
kill-l常见信号如下:
| 信号 | 编号 | 常见来源 | 默认动作 |
|---|---|---|---|
SIGINT | 2 | 按下Ctrl+C | 终止进程 |
SIGQUIT | 3 | 按下Ctrl+\ | 终止进程并产生 core dump |
SIGKILL | 9 | kill -9 pid | 强制终止进程 |
SIGSEGV | 11 | 访问非法内存 | 终止进程并产生 core dump |
SIGPIPE | 13 | 管道读端关闭后继续写 | 终止进程 |
SIGALRM | 14 | 定时器到期 | 终止进程 |
SIGTERM | 15 | 默认kill pid | 终止进程 |
SIGCHLD | 17 | 子进程退出 | 默认忽略 |
SIGSTOP | 19 | 暂停进程 | 暂停进程 |
SIGCONT | 18 | 继续运行暂停的进程 | 继续进程 |
这里有几个信号需要重点记一下。
SIGINT是按下Ctrl+C时产生的信号,它的默认动作是终止进程。但是这个信号可以被捕捉,所以程序可以选择不退出,而是执行自己定义的处理函数。
SIGKILL是kill -9发送的信号,它非常特殊。这个信号不能被捕捉、不能被忽略、也不能被阻塞。系统必须保留这样一种强制终止进程的手段,否则某些进程可能永远杀不掉。
SIGSEGV就是常见的段错误。比如:
int*p=NULL;*p=10;这段代码访问了非法内存,程序一般就会收到SIGSEGV信号,然后异常退出。
SIGCHLD在多进程编程中非常重要。子进程退出时,操作系统会给父进程发送SIGCHLD,父进程可以借助它回收子进程,避免僵尸进程。
刚开始学习时,不需要把所有信号编号都背下来。先记住几个高频的:SIGINT、SIGKILL、SIGTERM、SIGSEGV、SIGCHLD就够了。
三、信号从产生到处理,中间发生了什么
信号不是一产生就一定马上被处理。
一个信号从产生到真正执行处理动作,中间大致会经历这样的流程:
这里面有几个关键概念需要连在一起理解。
首先是信号产生。信号可以由键盘产生,比如Ctrl+C;可以由命令产生,比如kill pid;也可以由程序异常产生,比如空指针访问;还可以由程序自己给自己发送,比如调用raise。
信号产生之后,会被记录到进程中。如果这个信号已经产生,但是还没有被处理,就叫做未决信号,也就是 pending signal。
但是进程还可以选择暂时屏蔽某些信号。被屏蔽的信号即使产生了,也不会马上被处理,而是继续保持未决状态。这个过程叫做信号阻塞。
这里要特别注意:
阻塞不是忽略。
阻塞的意思是:
信号来了,但是我现在先不处理,等解除阻塞之后再说。忽略的意思是:
信号来了,但是我直接丢掉,不处理。这两个概念一定不要混淆。
从内核角度看,每个进程都有自己的进程控制块,也就是 PCB。信号相关的信息也会保存在 PCB 中。
可以简单理解成:进程内部有几张“表”。
pending信号集记录哪些信号已经来了;block信号集记录哪些信号现在被屏蔽;handler表记录每个信号应该怎么处理。
比如SIGINT已经产生了,并且它又被阻塞了,那么它就会同时出现在 pending 和 block 中。
这时候它的状态就是:
SIGINT 已经来了,但是暂时不能被处理。
等以后解除阻塞时,这个信号才有机会被递达。
四、发送信号与捕捉信号
理解完信号的基本流程之后,再来看相关命令和函数就清晰很多了。
很多人看到kill命令,第一反应是“杀进程”。其实kill的本质不是杀进程,而是向指定进程发送信号。
语法如下:
kill-信号 pid比如:
kill-2pid表示向指定进程发送SIGINT。
也可以写成:
kill-SIGINTpid如果不指定信号,默认发送的是SIGTERM:
killpidSIGTERM可以理解成一种比较礼貌的退出请求。它是在告诉进程:“你该退出了。”
而:
kill-9pid发送的是SIGKILL,属于强制终止。进程没有机会执行清理逻辑。
所以实际使用时,一般建议先用:
killpid如果进程确实无法正常退出,再考虑:
kill-9pid程序中也可以捕捉信号,最简单的方式是使用signal函数:
#include<stdio.h>#include<signal.h>#include<unistd.h>voidhandler(intsigno){printf("捕捉到信号:%d\n",signo);}intmain(){signal(SIGINT,handler);while(1){printf("程序正在运行,按 Ctrl+C 试试...\n");sleep(1);}return0;}运行程序后,按下Ctrl+C,程序不会直接退出,而是执行handler函数。
原本SIGINT的默认动作是终止进程,现在我们把它改成了执行自己的处理函数。
信号递达后,大体有三种处理方式:
不过需要注意,signal虽然简单,但是在实际工程中更推荐使用sigaction,因为它的行为更加明确,也能设置更多选项。
#include<stdio.h>#include<signal.h>#include<unistd.h>voidhandler(intsigno){printf("catch signal: %d\n",signo);}intmain(){structsigactionact;act.sa_handler=handler;sigemptyset(&act.sa_mask);act.sa_flags=0;sigaction(SIGINT,&act,NULL);while(1){printf("running...\n");sleep(1);}return0;}sigaction中常见的几个成员如下:
structsigaction{void(*sa_handler)(int);sigset_tsa_mask;intsa_flags;};其中,sa_handler表示信号处理函数;sa_mask表示执行处理函数期间临时屏蔽哪些信号;sa_flags用来设置额外选项。
初学阶段不需要把所有选项都背下来,先掌握这种基本写法就够了。
还要注意一点,信号处理函数不是普通函数。
普通函数是我们主动调用的,而信号处理函数是信号到来后,由操作系统安排执行的。它可能在程序执行的任意位置被调用,所以处理函数里面不要写太复杂的逻辑。
教学代码里经常用printf,但严格来说,printf并不是异步信号安全函数。更稳妥的做法是只修改一个标志位,然后让主流程去处理真正的逻辑。
例如:
#include<stdio.h>#include<signal.h>#include<unistd.h>volatilesig_atomic_tquit=0;voidhandler(intsigno){quit=1;}intmain(){signal(SIGINT,handler);while(!quit){printf("running...\n");sleep(1);}printf("收到退出信号,开始清理资源...\n");return0;}这种写法更接近实际开发习惯。
五、信号阻塞:让信号先等一等
信号可以被阻塞。所谓阻塞,就是信号已经来了,但是进程暂时不处理它。
阻塞信号需要用到信号集,常见函数有:
sigemptyset();sigaddset();sigdelset();sigismember();sigprocmask();sigpending();这些函数名字看起来比较多,但思路很简单:
先定义一个信号集 把要阻塞的信号加入信号集 调用 sigprocmask 修改进程的阻塞信号集 用 sigpending 查看哪些信号处于未决状态下面这个程序会阻塞SIGINT,也就是Ctrl+C对应的信号:
#include<stdio.h>#include<signal.h>#include<unistd.h>voidprint_pending(){sigset_tpending;sigpending(&pending);for(inti=1;i<=31;i++){if(sigismember(&pending,i))printf("1");elseprintf("0");}printf("\n");}intmain(){sigset_tset;sigemptyset(&set);sigaddset(&set,SIGINT);sigprocmask(SIG_BLOCK,&set,NULL);while(1){print_pending();sleep(1);}return0;}运行程序后,按下Ctrl+C,你会发现程序没有退出。
但是 pending 信号集中对应的位置会变成 1,大概类似这样:
0000000000000000000000000000000 0000000000000000000000000000000 ^C0100000000000000000000000000000 0100000000000000000000000000000这说明SIGINT已经来了,但是因为它被阻塞了,所以暂时没有被处理。
再看一个解除阻塞的例子:
#include<stdio.h>#include<signal.h>#include<unistd.h>voidhandler(intsigno){printf("处理信号:%d\n",signo);}intmain(){signal(SIGINT,handler);sigset_tset;sigemptyset(&set);sigaddset(&set,SIGINT);sigprocmask(SIG_BLOCK,&set,NULL);printf("SIGINT 已被阻塞,10 秒内按 Ctrl+C 不会立即处理\n");sleep(10);printf("解除 SIGINT 阻塞\n");sigprocmask(SIG_UNBLOCK,&set,NULL);while(1){sleep(1);}return0;}程序运行后,在前 10 秒内按下Ctrl+C,不会马上执行处理函数。等到 10 秒之后解除阻塞,之前处于 pending 状态的SIGINT就会被递达。
这就能看出阻塞和忽略的区别:
阻塞:信号来了,先放着,之后可能处理。 忽略:信号来了,直接丢掉,不再处理。还有一个细节也很重要:
普通信号在未决状态下不会重复排队。
也就是说,如果SIGINT被阻塞了,你连续按很多次Ctrl+C,内核一般只记录“SIGINT 来过”,而不会记录“SIGINT 来了 10 次”。
普通信号更像是一个位图:
0 表示没来 1 表示来了至于来了几次,普通信号并不关心。
六、信号在实际编程中的几个典型场景
信号不是只存在于课本里的概念,在实际 Linux 编程中经常会遇到。
一个很常见的场景是定时器。
alarm函数可以设置一个闹钟:
#include<unistd.h>unsignedintalarm(unsignedintseconds);它的意思是:seconds秒后,系统向当前进程发送SIGALRM信号。
#include<stdio.h>#include<unistd.h>intmain(){alarm(3);while(1){printf("running...\n");sleep(1);}return0;}这个程序运行 3 秒后会被终止。原因是alarm(3)到期后,系统发送SIGALRM,而SIGALRM的默认动作就是终止进程。
如果我们捕捉SIGALRM,就可以实现一个简单的周期性任务:
#include<stdio.h>#include<signal.h>#include<unistd.h>voidhandler(intsigno){printf("闹钟响了\n");alarm(1);}intmain(){signal(SIGALRM,handler);alarm(1);while(1){pause();}return0;}这里的pause函数表示让进程暂停,直到有信号到来。每次SIGALRM到来后,执行处理函数,然后在处理函数里重新设置下一次闹钟。
另一个很常见的场景是回收子进程。
当子进程退出时,操作系统会给父进程发送SIGCHLD。如果父进程不回收子进程,子进程就可能变成僵尸进程。
#include<stdio.h>#include<stdlib.h>#include<signal.h>#include<unistd.h>#include<sys/wait.h>voidhandler(intsigno){while(waitpid(-1,NULL,WNOHANG)>0){printf("回收一个子进程\n");}}intmain(){structsigactionact;act.sa_handler=handler;sigemptyset(&act.sa_mask);act.sa_flags=0;sigaction(SIGCHLD,&act,NULL);for(inti=0;i<3;i++){pid_tpid=fork();if(pid==0){printf("child %d exit\n",getpid());exit(0);}}while(1){sleep(1);}return0;}这里处理函数中用了:
while(waitpid(-1,NULL,WNOHANG)>0)为什么是while?
因为多个子进程可能几乎同时退出,而普通信号不会排队。父进程可能只收到一次SIGCHLD,但实际上有多个子进程需要回收。所以需要循环调用waitpid,把已经退出的子进程都回收干净。
还有一个常见场景是SIGPIPE。
比如管道中,读端已经关闭,写端还继续写,就可能触发SIGPIPE。
例如:
catbigfile|head-n1head读完一行后就退出了,管道读端关闭。如果cat继续向管道写数据,就可能收到SIGPIPE。
网络编程中也会遇到类似情况。如果对端连接已经关闭,本端还继续写数据,也可能因为相关问题导致程序异常退出。因此一些网络程序会选择忽略SIGPIPE:
signal(SIGPIPE,SIG_IGN);这样程序不会因为一次写操作直接被信号终止,而是可以通过返回值和错误码自己处理异常。
七、程序崩溃和 core dump 也和信号有关
平时调试程序时,经常会看到:
Segmentation fault它背后对应的信号就是SIGSEGV。
有些信号的默认动作不只是终止进程,还会产生 core dump。core dump 可以理解成程序崩溃时留下的一份“现场快照”。
常见会产生 core dump 的信号有:
SIGSEGV SIGQUIT SIGABRT可以通过下面的命令开启 core dump:
ulimit-cunlimited然后写一个故意崩溃的程序:
#include<stdio.h>intmain(){int*p=NULL;*p=10;return0;}运行后,如果生成了 core 文件,就可以使用 gdb 调试:
gdb ./a.out core这样就能看到程序崩溃的位置。
所以信号不仅能控制进程退出,也能帮助我们定位程序异常。
八、最后把信号机制串起来
学信号时,不建议一开始死记函数。更好的方式是抓住这条主线:
信号为什么产生 | v 信号产生后保存在哪里 | v 信号是否被阻塞 | v 信号什么时候递达 | v 递达后执行默认动作、忽略,还是自定义处理函数再用一句话总结:
信号是 Linux 中的一种异步事件通知机制。
它不是普通函数调用,而是操作系统在某些事件发生后通知进程的一种方式。信号产生后可能暂时处于未决状态,如果没有被阻塞,就会在合适的时机递达给进程。进程可以选择默认处理、忽略信号,或者执行自定义处理函数。
刚开始学信号时,最重要的不是记住所有信号编号,而是理解这几个核心问题:
信号从哪里来? 信号来了之后保存在哪里? 为什么信号来了不一定马上处理? 阻塞和忽略有什么区别? 自定义信号处理函数什么时候执行?把这几个问题想明白之后,再去看signal、sigaction、sigprocmask、sigpending、waitpid这些函数,就不会觉得零散了。
Linux 信号机制看起来复杂,但本质上就是一句话:
操作系统通过信号告诉进程:有事情发生了,接下来怎么处理,由系统默认规则和进程自身设置共同决定。