Linux信号处理机制(三)——信号捕捉
本文主要针对信号捕捉展开话题,文中可能会提到前两篇博客中的知识点,对此迷惑的可以查看前两篇博客:
内核是如何捕捉信号的?
如果信号的处理动作是用户自定义的函数,在信号递达时就调用这个函数,这称为信号捕捉。由于信号处理函数的代码是在用户空间执行的,处理过程较为复杂,我们画图来进行解释:
上图很好的说明了信号捕捉时用户态和内核态的切换(用户处理信号最好的时机是程序从内核态切换至用户态的时候),下面就上图的一系列操作作以解释说明:
(1)用户程序注册了SIGQUIT信号的处理函数sighandler(自定义信号处理函数)。
(2)当前正在执行main函数,这里发生中断、异常或者系统调用切换至内核态。
(3)在中断处理完毕后要返回用户态的main函数之前,检查到有信号SIGQUIT递达。
(4)内核决定返回用户态后不是恢复main函数的上下文信息继续执行,而是执行sighandler函数,sighandler函数和main函数使用不同的堆栈空间,两者之间不存在调用和被调用的关系,属于两个独立的控制流程。
(5)sighandler函数返回后自动执行特殊的系统调用,调用四个return再次进入内核态。
(6)如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续向下执行。
还用一点需要注意的是,通过上图我们可以看到信号捕捉过程总共发生了4次内核态与用户态之间的切换,其中第3和4次是因为自定义信号的捕捉函数引起的,可有可无。
用户处理信号的时机为上图中红色箭头所示,即内核态切换至用户态之时,那么为什么要选这个时候呢?
原因是:信号不一定会被立即处理,操作系统不会为了处理一个信号而挂起当前正在运行的进程,这样产生的消耗太大(当然紧急信号除外)操作系统选择在内核态切换至用户态的时候去处理信号,不用单独进行进程切换而浪费时间。但是有时候一个正在睡眠的进程突然收到信号,操作系统肯定不愿意切换当前正在运行的进程,预示着就将该信号存在此进程的PCB的信号字段中。
信号处理程序捕捉信号的基本思想
捕捉思想是对上述内核捕捉的一点补充,如果没有理解上图,请看下图信号处理程序捕捉信号的基本过程:
一个进程可以有选择性地阻塞接受某种信号,当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接受,直到进程取消对这种信号的屏蔽。
一个待处理信号最多只能被接受一次。内核为每个进程在pending表中维护着待处理信号的集合,而在block表中维护着被阻塞信号的集合。只要传送一个类型为N的信号,内核就会设置pending表中的第N位。只要接收了类型为N的信号,内核就会清楚pending表的第N位。
可移植的信号处理
POSIX标准定义了sigaction函数,它允许像Linux和Solaris这样与POSIX兼容的系统上的用户,明确地指出它们想要的信号处理语义。
sigaction函数可以读取或者指定信号相关联的处理动作,signal与其功能类似,但signal是标准C的信号接口,对不同的操作系统有不同的行为,所以一般尽量不使用signal,取而代之的是sigaction函数。函数原型如下:
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数signum:指定信号的编号(利用kill -l命令可以查看);
参数*act:若act指针非空,则根据act修改该信号的处理动作;
参数*oldact:若oldact指针非空,则通过oldact传出该信号原来的处理动作;
返回值:成功返回0,失败返回-1;
上述参数中,act与oldact都指向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_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行默认动作,赋值为函数指针表示用户自定义的函数捕捉信号。该函数的返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这是一个回调函数,不是main函数调用,而是被系统调用。
sa_mask:是一个信号集,可以将信号加进进程的信号屏蔽字中。如果在调用信号处理函数时,除当前信号外,还希望屏蔽一些其他信号,就可以使用sa_mask来屏蔽,仅当从信号捕捉函数返回时再将进程的信号屏蔽字复位为原先值。
sa_flags:包含多个选项,一般设置为0。
SA_RESETHAND:当调用信号处理函数时,将信号的处理函数重置为缺省值SIG_DFL
SA_RESTART:如果信号中断了进程的某个系统调用,则系统自动启动该系统调用
SA_NODEFER :一般情况下,当信号处理函数运行时,内核将阻塞该给定信号。但是如果设置了SA_NODEFER标记,那么在该信号处理函数运行时,内核将不会阻塞该信号
sa_restorer:实时处理函数。
pause和alarm函数
#include <unistd.h>
int pause(void);
函数说明:pause()库函数使调用进程(或者线程)处于睡眠状态,直到接收到信号,要么终止,要么导致它调用一个信号捕捉函数。
返回值:只返回-1。
错误代码:EINTR有信号到达最后中断了此函数。
pause函数使进程挂起直到有信号递达。
可能会出现的三种状态如下:
(1)如果信号的处理动作是终止进程,则进程终止,pause函数没有机会返回。
(2)如果信号的处理动作是忽略,则进程继续处于挂起状态,pause不返回。
(3)如果信号的处理动作是自定义捕捉,则调用了信号处理函数之后,pause返回-1,errno设置为EINTR,所以pause只有出错的返回。
alarm函数在这里就不再详细解释了,详情请戳另一篇博客:信号引入
普通版本的mysleep
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
void handler(int sig)
{
//句柄函数,因为sleep函数本身什么事情都不做,所以这里为空语句
}
int mysleep(int seconds)
{
struct sigaction act,oldact;
act.sa_handler = handler;//设置自定义捕捉函数
sigemptyset(&act.sa_mask);//初始化信号集
act.sa_flags = 0;//默认一般为0
sigaction(SIGALRM, &act, &oldact);//注册信号处理函数
alarm(seconds);//设置闹钟
pause();//挂起等待,直到有信号递达
int time = alarm(0);//取消闹钟
sigaction(SIGALRM, &oldact, NULL);//恢复默认信号处理动作
return time;
}
int main()
{
while(1)
{
mysleep(2);
printf("sleeping...\n");
}
return 0;
}
运行结果如下:
现象为每2秒打印一次,模拟了mysleep的概念。
对于上述代码的理解主要有以下几点:
(1)主函数调用mysleep函数,mysleep函数调用sigaction注册了SIGALRM信号的处理函数。
(2)调用alarm(seconds)函数设置闹钟。
(3)调用pause函数挂起等待,内核切换到别的进程执行。
(4)seconds秒后,闹钟超时,内核发送SIGALRM信号给这个进程。
(5)从内核态返回这个进程的用户态之前处理未决信号,发现有SIGALRM信号,处理函数为handler。
(6)切换至用户态执行handler函数,发现SIGALRM信号被自动屏蔽,从handler函数返回时SIGALRM信号自动解除屏蔽,然后自动执行系统调用sigreturn再次进入内核,再返回用户态继续执行进程的主控制流程。
(7)pause函数返回-1,然后调用alarm(0)取消闹钟,调用sigaction恢复SIGALRM信号以前的处理动作。
关于上述问题的几点思考
问题1:信号处理函数handler什么都没有做,为什么还要注册它为SIGALRM的处理函数?不注册可以吗?
答:必须注册。因为pause函数使调用进程挂起直到有信号递达,如果未注册,当有信号SIGALRM产生时会执行默认动作,即终止进程。
问题2:为什么mysleep函数返回时要恢复SIGALRM信号原先的sigaction?
答:mysleep函数在mysleep(seconds)之后不会对SIGALRM信号进行修改,将SIGALRM不恢复会使得alarm()失效。
问题3:mysleep函数的返回值代表什么含义?什么情况下返回非0值?
答:表示信号传来时闹钟还剩余的秒数。当闹钟结束前有其他信号发送给该进程,并对该进程进行了相关处理,alarm(0)表示取消闹钟,且返回值为非0。
遗留的问题
上述的mysleep函数虽然跑完了,但是并没有立即结束。出现这个问题的根本原因是系统运行的时序并不像我们写程序时所设想的那样,虽然alarm(seconds)紧接着下一步就是pause(),但是无法保证pause()一定会在调用alarm(seconds)之后的seconds秒之内被调用。
由于异步事件在任何时候都有可能会发生(异步事件在这里指的是更高优先级的进程),如果我们写程序时考虑不周密,就可能由于时序问题而导致错误,这就是竞态条件。
避免竞态条件
设想如何将解除信号屏蔽与挂起等待信号合并成一个原子操作就可以避免因时序问题导致的错误,因此引入sigsuspend函数。它不仅用于pause函数的挂起等待功能,而且解决了竞态条件产生的时序问题。
sigsuspend()函数的原型如下:
int sigsuspend(const sigset_t *mask);
参数mask:指定进程的信号屏蔽字,可以临时解除对某一个信号的屏蔽,然后挂起等待。当suspend返回时,进程的信号屏蔽字恢复原先的值,如果原先对信号是屏蔽的,返回后仍然屏蔽。
返回值:返回值与pause一致,永远返回-1,errno设置为EINTR。
规避竞态条件的mysleep
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
void myhandler(int sig)
{
}
int mysleep(int seconds)
{
struct sigaction act,oldact;
sigset_t newmask,oldmask;//设置信号集
act.sa_handler = myhandler;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGALRM, &act, &oldact);
sigemptyset(&newmask);
sigaddset(&newmask,SIGALRM);
alarm(seconds);
sigdelset(&oldmask, SIGALRM);
sigsuspend(&oldmask);
int ret = alarm(0);
sigaction(SIGALRM, &oldact, NULL);
return ret;
}
int main()
{
while(1)
{
mysleep(2);
printf("sleeping...\n");
}
return 0;
}
运行结果如下:上一篇: 从 0 开始学习 Linux 系列之「21.信号 Signal」
下一篇: Linux信号捕捉