首页 > 代码库 > Linux之信号第二谈
Linux之信号第二谈
重提信号概念
前一篇中提到了信号的概念,并且对信号的产生及一些属性有了一定的认识,这一次,要从更加深入的角度看待信号。
之前提到过,当我的进程在接收到信号之前,就已经知道了,当我接收到某种信号之后就要发生某一项动作,换句话说,在进程内部,一定存在这某种结构,将这些信息都记录了下来,很明显,对于进程而言,这些信息都会保存在它的PCB当中。
首先我们来认识这样几个概念:
信号递达(Delivery):执行信号的处理动作;
信号未决(Pending):信号从产生到递达之间的状态;
阻塞(Block):被阻塞的信号被保存在未决状态,直到解除阻塞之后,才会执行递达动作。只要信号阻塞就永远不会递达;
忽略(Ignore):忽略完全不同于阻塞,忽略是在递达之后可选的一种动作;
这样的几个概念显得有点太过笼统,这里截取了一张信号在PCB中的示意图,如下:
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决状态,直到信号递达清除该标志位。
Linux为了节省内存空间,在设计的时候,使用了类似位图的结构,只留出一个bit的大小,分别用0和1表示阻塞或者未决状态,那么对于上图,我们就可以看到三张表:阻塞表,未决表,handler表。
对于阻塞表,该位为0,表示进程对该信号不阻塞,为1表示对该信号阻塞;
对于未决表,该位为0,表示该信号没有产生,为1表示该信号已经发生;
handler表就类似我们之前提到过的信号处理函数signal(),用来表示对于某一信号的处理方式。
了解了基本结构之后,有几点我们需要说明:
1、pending表和block表之间没有任何关系。信号的产生是异步的,对于进程而言完全随机,而阻塞状态是该进程对某一信号所做的限制;
2、信号的发生,对于进程而言,只是将该进程PCB中的pending表中的对应位置1,其他的操作和信号就不再有任何直接关系,这就解释了在信号来临之前进程就已知了某个信号对应的动作;
3、在Linux下,由于这里只是通过一个bit位来存储信息,所以在信号递达之前,信号发生多次只记一次。当然,更严格意义上说,常规信号是这样的,对于实时信号(34~64号信号),在递达之前,多次产生的信号会保存到某个队列当中,实时信号暂时不在我们的讨论范围之内。
4、任何一个信号,都不会是被立刻递达,这个后面解释。
由于阻塞标志和未决标志都是用一个bit位来表示,因此对于Linux,引入了一个用户类型sigset_t,两种标志都可以使用sigset_t数据类型来存储,sigset_t称为信号集。因此就有了阻塞信号集和未决信号集。阻塞信号集又叫做信号屏蔽字(有没有很熟悉的感觉)。
信号集操作函数
信号集操作函数,顾名思义,就是对上面的几种信号进行操作,之前我们提到的信号操作函数,实际上就是在更改这里的pending表,因此,我们这里提到的信号集操作函数,可以查询和修改阻塞信号集中的数据,对于pending表中的数据,这里只提供了查看的函数接口。具体函数声明如下:
// 信号集操作函数
#include <signal.h>
int sigemptyset(sigset_t *set);
# 初始化,清零所有信号对应的bit位
int sigfillset(sigset_t *set);
# 对所有信号的bit位置1
int sigaddset(sigset_t *set, int signum);
# 将指定信号bit位置1
int sigdelset(sigset_t *set, int signum);
# 将指定信号bit位清零
以上四个函数,成功返回0,失败返回-1
int sigismember(const sigset_t *set, int signum);
# 判断一个信号集的有效信号中,是否包含某个信号
# 包含返回1, 不包含返回-1
// 屏蔽信号集操作函数(写)
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
# 读取或更改进程中的信号屏蔽字(阻塞信号集)
# 成功返回0, 失败返回-1
# 参数1,how有三种定义
SIG_BLOCK:添加对应位,mask = mask| set
SIG_UNBLOCK:清零对应位mask&~set
SIG_SETMASK:设置对于位mask=set
# 参数2,设置的SIG值
# 参数3, 输出型参数,用来获取修改之前的信号屏蔽字
当我们调用sigprocmask对某些信号解除屏蔽之后,在该函数返回之前,至少有一个信号被递达
// 未决信号集操作函数(读)
#include <signal.h>
int sigpending(sigset_t *set);
# 输出型参数,将pending列表通过set传回
# 成功返回0,失败返回-1
说了这么多,下面通过代码做一简单验证。(以SIGINT信号为例)
#include <stdio.h>
#include <signal.h>
void printfPending(sigset_t *pending)
{
int i = 1;
for(;i <= 31; i++)
{
if(sigismember(pending, i)){
printf("1");
}
else{
printf("0");
}
}
printf("\n");
}
int main()
{
sigset_t block, oblock, pending;
sigemptyset(&block);
sigaddset(&block, SIGINT); // 设置block值
sigprocmask(SIG_SETMASK, &block, &oblock); // 设置信号屏蔽字
while(1){
sleep(1);
sigpending(&pending);
printfPending(&pending); // 获取pending值
}
printf("hello world\n");
return 0;
}
因为SIGINT信号对应的操作是ctrl+c,但上面将SIGINT信号设置为屏蔽状态,因此,当我们输入ctrl+c之后并没有立即终止该进程,我们看到的第二为pending值由0变为1。如下图:
接下来将代码做一简单调整,我们设置10秒之后,信号屏蔽字被自动清零,为了防止ctrl+c将信号终止,所以这里SIGINT信号执行自定义行为,代码如下:
#include <stdio.h>
#include <signal.h>
void printfPending(sigset_t *pending)
{
int i = 1;
for(;i <= 31; i++){
if(sigismember(pending, i)){
printf("1");
}
else{
printf("0");
}
}
printf("\n");
}
void runSig(int i)
{
printf("run SIGINT\n");
}
int main()
{
signal(SIGINT, runSig);
sigset_t block, oblock, pending;
sigemptyset(&block);
sigaddset(&block, SIGINT);
sigprocmask(SIG_SETMASK, &block, &oblock);
int count = 0;
while(1)
{
if(count == 10)
{
sigdelset(&block, SIGINT);
sigprocmask(SIG_SETMASK, &block, &oblock);
}
sleep(1);
sigpending(&pending);
printfPending(&pending);
count++;
}
printf("hello world\n");
return 0;
}
运行行结果如下:
由于这里已经设置了自定义SIGINT的动作,因此,即使10秒之后,ctrl+c也不会终止进程
信号捕捉
信号捕捉的过程
关于信号捕捉,其实前面一直在说,我们把对信号的自定义行为称为信号捕捉。对信号的处理有三种,忽略,默认,捕捉。
前两种算是比较简单的。站在操作系统的角度,忽略信号其实要做的就是将pending中的1改为0即可,不需要其他操作;对于默认动作,大部分的默认动作的最终结果都是终止进程,先有个简单简单认识,接下来看捕捉状态下的情况,看下面这张图:
①:发生了外部终端,或者遇到了陷阱、异常,这个时候,会由用户态切换到内核态处理该异常;
②:内核处理完成异常之后,在返回用户态执行原代码之前,会检查该进程的PCB中有无未处理的信号(内核会在内核态切换到用户态的过程中检查有无未处理的信号);
③:这时发现了存在未处理的信号,不受阻塞,而且该信号的处理动作是捕捉的,就会切换到用户态去执行自定义的函数(因为这个函数是用户定义的,如果不切换用户,由内核态直接去执行,是不安全的);
④:在执行完自定义的信号处理函数之后,会受到系统调用再次切换到内核态;
⑤:再次进行检查,然后返回到用户态,从上次被中断的地方继续向下执行。
这就是捕捉的整个过程,一共发生了四次用户态到内核态之间的转化,这时候再看我们的忽略动作,当执行的第三步之后,发现该动作是忽略,于是在内核态直接将pending中的对应位清零,直接返回用户态终端的地方继续执行。对于默认动作,由于通常会终止进程,所以在内核态将对应位的pending值改0之后,同时销毁PCB,直接结束进程。(这个过程还是挺重要的)
sigaction()函数
sigaction函数可以设置和读取与指定信号相关联的动作,与signal函数功能类似,函数声明与注释如下:
#include <signal.h> int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact); # 成功返回0, 失败返回-1 # 参数1,信号编号 # 参数2,若act非空,按照结构体中的信息修改处理动作 # 参数3,输出型参数,若非空,获取原来的struct结构。 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有三种,SIG_DFL表示默认动作;SIG_IGN表示忽略信号;为函数指针,表示执行捕捉动作 # sa_mask表示当正在对该信号动作时,除了当前信号被屏蔽之外,还需要屏蔽的其他信号 # sa_flags这里直接设置为0即可,暂不关心 # 其他两个参数这里也暂不关心
这里给出测试代码:
#include <stdio.h> #include <signal.h> void IntRun(int i) { printf("my sigaction is running\n"); } int main() { struct sigaction act, oact; act.sa_handler = IntRun; sigemptyset(&act.sa_mask); act.sa_flags = 0; sigaction(SIGINT, &act, &oact); while(1){ sleep(1); printf("hello world\n"); } return 0; }
输出结果如图:
sigaction与signal函数功能类似,这里只介绍用法,不在多说。
pause()函数
首先给出pause函数的定义
#include <unistd.h> int pause(void);
函数定义特别简单,pause函数的功能是将调用进程挂起,直到有信号递达。
如果到达的信号是将进程终止,那么进程直接结束,来不及返回;
如果到达信号被忽略,则继续挂起,无返回值;
如果调用动作是捕捉,那么调用信号处理函数之后,pause返回-1,同时设置errno为EINTR(被信号中断)。
可见,pause函数,只有当出错的情况下才会有返回值,这点和exec函数类似。
接下来,让我们写一段小代码,使用alarm函数和pause函数写一个自己的sleeep函数,函数名为mysleep。
实现原理:利用了pause函数的特性,会将进程挂起,直到有捕获(catch)的行为,才会将pause函数终止。利用alarm函数定时,闹钟时间到达之后,会调用自定义函数,发生捕获行为,导致pause函数终止,从而实现了sleep的功能。
这里给出了signal函数和sigaction函数版本的,两者基本一致,不同之处在于sigaction需要设置的参数较多。代码如下:
#include <stdio.h> #include <unistd.h> #include <signal.h> void run_alarm(int i) {} /* // signal版本 size_t mysleep(size_t second) { signal(SIGALRM, run_alarm); alarm(second); pause(); int ret = alarm(0); return ret; } */ // sigaction版本 size_t mysleep(size_t second) { struct sigaction act, oact; act.sa_handler = run_alarm; sigemptyset(&act.sa_mask); act.sa_flags = 0; sigaction(SIGALRM, &act, &oact); alarm(second); pause(); int ret = alarm(0); sigaction(SIGALRM, &oact, NULL); return ret; } int main() { while(1){ mysleep(2); printf("this is mysleep\n"); } return 0; }
可重入函数
可重入函数的概念其实很好理解。有些函数,如果重入不会导致出错或不安全的话,我们把这些函数叫做可重入函数,反之,叫做不可重入函数。
举个例子,当我们对一个链表进行插入的时候,中途收到一个信号,该信号执行自定义动作,该动作也是在该结点处插入一个新节点,就会造成下图所示的情况,最终的2号结点并没有被插入,这就是所说的不可重入函数
问题来了,很容易可以发现,这个和线程安全有着很大的相似之处,都是由于重入导致的问题,这里做以简单区分。
区别:
1、前提不同:线程安全是在多线程情况下产生的,可重入函数可以是在单线程下由信号的捕获产生的的重入
2、范围不同:线程安全不一定可重入,可重入函数一定满足线程安全
3、对临界资源加锁可以实现线程安全,但依旧是不可重入的,因为加锁只能防止多线程的情况,单一线程的情况不一定安全。
4、线程安全要求不同线程访问同一块地址空间,而可重入函数要求不同的执行流对数据的操作互不影响。
可重入函数的几点必要条件
1、不在函数内部使用静态或全局数据 ;
2、不返回静态或全局数据,所有数据都由函数的调用者提供;
3、使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据;
4、不调用不可重入函数;
------muhuizz整理
本文出自 “暮回” 博客,请务必保留此出处http://muhuizz.blog.51cto.com/11321490/1900724
Linux之信号第二谈