【每天一点linux】进程间通信--信号

来源:互联网 发布:安卓与php服务器端 编辑:程序博客网 时间:2024/06/08 14:29

信号是linux进程间通信的一种机制,本质是在软件层次上对对硬件中断机制的一种模拟。和其他进程间通信方式(例如管道、共享内存等)相比,信号所传递的信息是比较少的。正因为传递的信息比较少,信号更方便管理和使用,例如进程终止、挂起等。

每一种信号都是用常量宏定义的,以SIG开头,在linux中可以在signal.h中找到,当然我们也可以选择通过“kill -l”来查看系统中所有信号。

这里写图片描述

我们平时见到最常见的信号有:

  1. SIGUP – 1 当控制它的终端被关闭的时候给进程发送该信号
  2. SIGINT –2 当用户使用ctrl + C中断进程,控制它的终端会向进程发送该信号
  3. SIGQUIT –3 当用户发送退出信号ctrl + D 时向进程发送该信号
  4. SIGKILL –9 这个信号会立刻杀死进程,并且不会进行任何清理工作。
  5. SIGTERM –15 程序终止信号,kill 发送这份信号
  6. SIGSTP –20 它的控制终端发送这个信号给进程要求它终止,用户通过ctrl + Z触发

1.信号的产生

了解了信号的基本概念后,我们需要说说信号产生条件有哪些。在linux中,信号的产生有以下3种方式:

  • 通过终端按键产生信号
  • 调用系统函数向进程发送信号
  • 由软件条件产生信号

接下来详细说这三种产生方式。

<1>通过终端按键产生信号

在了解了系统中的信号后,我们需要解释一个概念,叫Core Dump。core dump叫做核心转储,也叫做核心文件(core file)。当一个进程要异常终止时,可以选择把用户的空间内存数据全部保存在磁盘上,文件名通常时core,这就叫做Core Dump。在调试的过程中,我们可以用调试器检查core文件以查清楚错误原因。但由于这样也会牵扯一些敏感的信息,所以一般默认是不允许产生这个文件的。在我们调试的时候,可以通过命令行改变这个限制,允许产生。

这里写图片描述
这里写图片描述

这里我们写了一个有问题的代码来用core测试。测试结果如下。

这里写图片描述
这里写图片描述

<2>调用系统函数向进程发送信号

经常在linux下写代码我们都会遇到写了个死循环,程序无法终止,最后选择用kill+进程的id直接杀死程序。这就是最常见的调用系统函数向进程发送信号。

在这里我们介绍一下几个函数:

int kill (pid_t pid ,int signo);
kill命令实际上是调用kill函数实现的,kill函可以给指定进程发送指定的信号。

int raise (int signo);
raise函数可以给当前进程发送指定的信号,也可以自己给自己发送信号。

PS:以上两个函数都是成功返回0,失败返回-1。

void abort (void);
abort函数使当前进程收到信号而异常终止。

像exit函数一样,abort函数总是会成功执行的,所以没有返回值。**

<3>由软件条件产生信号

在这里我们必须要说的函数alarm,它就像是一个闹钟一样可以提示内核在seconds秒后给当前进程发送SIGALRM信号,这个信号的默认处理动作就是终止当前的进程。

#include< unistd.h>
unsigned int alarm (unsigned int seconds);

值得注意的是:这个函数的返回值是0 或者以前设定的闹钟还剩余的秒数。在这里我们写一个例子来使用alarm。

#include<stdio.h>int main(){    alarm(1);    int count=0;    while(1)    {        printf("count : %d\n",count++);    }    return 0;}

可以看出这个程序是在计算一秒中count可以累加多少次,当一秒结束时,整个程序就会立刻结束。运行结果如下。

这里写图片描述

2.阻塞信号

在上面我们一直说得都是信号是如何产生的,那产生信号后呢?我们会怎么处理信号呢?在linux中,把实际执行信号的处理动作成为信号递达。信号从产生到递达之间的状态称为信号未决。在产生一个信号,我们有三种处理方式:系统默认处理、忽略、用户自定义处理

<1>信号在内核中的表示

从内核的角度来看,信号的过程是如下图这样的。

这里写图片描述

对这个图做一个解释,在上图中的SIGHUP信号没有阻塞也没有递达,当它递达时执行默认的处理动作;SIGINT信号产生过,但从图中可以看出它正在被阻塞,所以暂时也不会被递达,虽然处理动作是忽略,但在没有解除阻塞之前也不能忽略这个信号;SIGQUIT信号并没有产生,一旦产生也将被阻塞,它的信号处理动作是用户自定义的行为。

讲到这里,我们应该思考一下:如何把这些信号保存在一个数据结构了,让我们知道这个信号是否存在?前32个普通信号如何刚好存在一个数据结构中便于我们判断信号是否存在呢。这个时候就想起我们之前学得一个东西—位图!一个整型恰好32个比特位,一个信号对应一个比特位,刚好便于我们存储与判断。

中间的pending表,也就是信号未决表,表示信号是否产生,block阻塞表,表示当前进程与信号屏蔽相关内容。我们也把阻塞信号集叫做当前进程的信号屏蔽字。另外值得注意的一点是,在信号的处理中,忽略和阻塞是两种情况。信号阻塞是无法递达的,而忽略是递达后做出的一种处理。

<2>有关信号集的操作函数

上面说道每种信号的存储,但信号在内部的存储是依赖系统实现的,从我们使用者的角度自然是不用考虑的。但如果我们想去查看这些信号,用简单的按位与或是无法查看的。我们只能调用信号集操作sigset_t 变量。

#include< signal.h>
int signemptyset(sigset_t *set)
初始化set所指向的信号集,使其中所有信号对应的bit清零,表示该信号集不包含任何有效信号。

int sigfillset(sigset_t *set)
初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号

int sigaddset(sigset_t *set,int signo)
在信号集中添加某种有效信号

int sigdelset(sigset_t *set,int signo)
在信号集中删除某种有效信号

int sigismember(const sigset_t *set,int signo)
一个布尔函数,用来判断某种信号集中是否包含某种信号。若包含返回1,不包含返回0。

注意:在使用sigset_t 变量前,必须调用signemptyset和sigfillset初始化,使信号处在确定的状态。

上面的几个函数只能对信号做查看或添加删除等操作,如果我们想读取或更改进程的信号屏蔽字(即阻塞信号集),这个时候就需要另外一个函数了。

#include< signal.h>
int sigprocmask(int how, const sigset_t * set , sigset_t *oset)

返回值:成功返回0 ,失败返回-1。
在上面的参数中,如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set和oset都是非空指针,那么将原先的信号屏蔽字保存在oset中,根据set和how参数更改信号屏蔽字。

假设当前信号屏蔽字为mask,那么how参数的含义如下:

SIG_BLOCK set包含了我们希望添加到当前信号屏蔽字的信号,mask =mask | set SIG_UNBLOCK set包含了我们希望从当前信号屏蔽字中解除的信号,mask =mask & ~ set SIG_SETMASK 设置当前信号屏蔽字为set所指向的值,mask =set

在第二个大点中,我们提到了pending表,那么如何查看当前进程的pending表呢?在linux中有这么一个函数,sigpending读取当前进程的未决信号集,通过参数set传出。

#include< signal.h>
int sigpending(sigset_t *set)

调用成功返回0,失败返回-1。

下面我们写一个程序使用上面的几个函数,并且打印出pending表在我们ctrl + c 后pending表的变化。

#include<stdio.h>#include<unistd.h>#include<signal.h>void printSigset(sigset_t *pending){    int i=0;    for(i=0;i<32;i++)    {        if(sigismember(set,i))        {            printf("1");        }        else        {            printf("0");        }    }    printf("\n");}int main(){    sigset_t set,oset,pending;    sigemptyset(&set);    sigemptyset(&oset);    sigaddset(&set,SIGINT);    sigprocmask(SIG_SETMASK,&set,&oset);    while(1)    {        sigpending(&pending);        printSigset(&pending);        sleep(1);    }    return 0;}

这里写图片描述

在图中可以看到当我们ctrl + c后,2号信号从0变成了1 ,也验证了我们上面说得理论。

3.捕捉信号

<1>内核如何实现信号的捕捉

在上面说来那么多关于信号的产生、查看、阻塞,那么接下来我们终于可以说说捕获信号了。

这里写图片描述

对着一张信号的捕获图我们来讲讲这个过程是怎么做的。我们都知道程序在运行的时候需要在用户态和内核态中切换。那么当我们在主函数中以用户态身份开始执行一条命令时,可能因为中断或者异常、系统调用进入在内核态中。假设我们现在因为异常进入了内核态,当内核态处理完异常准备切回用户态之前,会处理当前进程中已经递达的信号。如果这个信号的处理方式是用户自定义的,那么为了安全我们会切回到用户态执行信号处理函数。当在用户态处理完信号后,这个时候会先回到内核态,然后会接着我们之前的动作返回主函数中从上次中断的地方继续向下执行。所以这个过程,我们不断地从用户态切换到内核态,又从内核态切换回用户态。用一张图形象说明如下。

这里写图片描述

说完过程,我们就要盘点捕捉信号的操作函数了。

#include< signal.h>
int sigaction(int signo,const struct sigaction *act,struct sigaction *oact)

sigaction 函数可以读取和修改与信号相关联的处理动作。sigaction的结构体如下。

struct sigaction
{
void (*sa_handler)(int);
sigset_t sa_mask;
int sa_flags;
void (* sa_sigaction)(int ,siginfo_t * ,void *);
}

<2>pause

pause函数是我们在捕捉信号要说得第二个函数,这个函数可以调用进程挂起直到信号递达。如果信号的处理动作是终止进程,则进程终止,pause没有机会返回;如果信号的处理动作是忽略,则进程继续处于挂起状态,pause也不返回;如果信号的处理动作是捕捉,那么调用了信号处理函数之后,pause返回-1,errno设置为EINTR,所以pause只有出错的返回。

函数原型:

#include< unistd.h>
int pause(void)

接下来我们要使用pause和alarm模拟实现一个sleep。

#include<stdio.h>#include<signal.h>#include<unistd.h>void handler(int signo){}int mysleep(int timeout){    struct sigaction set,oset;    set.sa_handler=handler;    sigemptyset(&set.sa_mask);    set.sa_flags=0;    sigaction(SIGALRM,&set,&oset);  //注册信号处理函数    alarm(timeout);   //设置闹钟    pause();   //timeout 秒后,发送信号,然后处理信号    int ret=alarm(0);//清空闹钟    sigaction(SIGALRM,&oset,NULL);  //恢复默认信号处理动作    return ret}int main(){    while(1)    {        mysleep(2);        printf("2 seconds\n");    }    return 0;}

可以看出这个代码就是每sleep 2 秒,打印一句 “2 seconds”。实验结果如下。

这里写图片描述

4.竞态条件

竞态条件(race condition)是指设备或系统出现不恰当的执行时序,而得到不正确的结果。从多进程间通信的角度来讲,是指两个或多个进程对共享的数据进行读或写的操作时,最终的结果取决于这些进程的执行顺序。

由于异步事件在程序运行的任何时候都有可能发生,如果我们在写程序时没有考虑这点,就可能由于时序问题而导致错误。

在上面编写的mysleep函数中,如果是这样的时序:

  1. 注册信号处理函数
  2. 调用alarm设定闹钟
  3. 内核中优先级更高的进程取代了当前进程在执行,每个都要执行很长时间
  4. 然后闹钟响了,内核发送信号给进程,处于未决状态
  5. 优先级更高的进程执行完了,内核切回这个进程继续执行。SIGALRM信号递达,执行信号处理函数之后再次切回内核。
  6. 返回进程的主控制流程,alarm返回,pause等待挂起。

    但是这个时候,SIGALRM信号已经处理完了,还等待什么呢?所以在程序运行时,我们无法保证系统运行的时序是否是我们写程序这样如期进行。当出现这种情况导致错误,就叫作竞态条件。由此就会引出sigsuspend函数。sigsuspend包含了pause的挂起功能,同时也会解决竞态条件的问题。在一些时序要求严格的场合下都应该调用sigsuspend。

#include< signal.h>
int sigsuspend(const sigset_t * sigmask)

sigsuspend 没有成功返回值,只有执行了一个信号处理函数之后,sigsuspend才返回,返回值为-1,errno设置为EINTR。进程的屏蔽字由sigmask参数指定。

5.可重入函数

之前在写链表的时候,我们可能会在链表中插入一个节点,插入时需要先开辟新节点,然后这个时候因为硬件中断使得进程切换到内核,再次回到用户态之前检查有信号需要处理,切换到信号处理函数。如果信号处理函数也是调用插入函数插入一个节点。等这个插入操作做完后,返回内核态,在接着之前的插入函数向下执行。其实这个时候,我们已经先后插入两个节点,而事实上只有一个节点被插入进链表。

这里写图片描述

像这种情况,插入函数被不同的控制流程调用,有可能在第一次调用还没有结束时,就再次进入该函数,造成重入。有时候因为一个重入,可能会引起整个程序出现错乱,这个时候这种函数就成为不可重入函数。与此对应的是可重入函数—如果一个函数只访问自己的局部变量或者参数,则称为可重入函数。当我们在访问局部变量或者参数时,这个参数只作用于这一个代码块,出了作用域就被销毁,所以不会引起错乱。

由此可见,可重入函数还会和线程安全有一定的联系:

  1. 线程安全是在多线程情况下引发的,而可重入函数可以在只有一个线程的情况下发生。
  2. 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  3. 如果一个函数有全局变量,则这个函数既不是线程安全也不是可重入的。
  4. 如果一个函数当中的数据全身自身栈空间的,则这个函数即使线程安全也是可重入的。
  5. 如果将对临界资源的访问加锁,则这个函数是线程安全的;但如果重入函数的话加锁还未释放,则会产生死锁,因此不能重入。
  6. 线程安全函数能够使不同的线程访问同一块地址空间,而可重入函数要求不同的执行流对数据的操作不影响结果,使结果是相同的。

    满足下列条件的函数是不可重入的:
    1) 函数体内使用了静态的数据结构;
    2) 函数体内调用了malloc()或者free()函数;
    3) 函数体内调用了标准I/O函数。

因此当我们编写代码的时候,需要就可能编写可重入的函数。一个函数想要成为可重入的函数,必须满足下列要求:

  • 不能使用静态或者全局的非常量数据
  • 不能够返回地址给静态或者全局的非常量数据
  • 函数使用的数据由调用者提供
  • 不能够依赖于单一资源的锁
  • 不能够调用非可重入的函数