首页 > 代码库 > 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之信号第二谈