在学习进程间通信时,我们曾经学过一种方式叫信号量,它就相当于一个计数器,而当时我还在想信号量与信号有什么关系呢?其实信号量与信号是两件截然不同的事物。接下来我们就来学习一下信号,然后就会发现信号和信号量到底有什么不同?
在我们生活中,关于信号的例子特别多,比如红绿灯啊,手机、闹钟铃声啊,十二点的下课铃声啊等等都是一种信号。
1、信号的基本概念
(1)信号的引入
其实,在Linux中信号无处不在,举个栗子:
- 当用户输入一条命令时,在shell下启动一个前台进程;
- 当用户按Ctrl +C组合键时,键盘产生了一个硬件中断;
- 如果CPU当前正在执行这个进程的代码,则该进程的用户空间代码暂停执行,CPU从用户态转至内核态去处理硬件中断;
- 终端驱动程序将Ctrl+C解释成一个SIGINT信号,记在该信号的PCB中(也可以说发了一个信号给该进程);
- 当某个时刻要吃内核态返回到该进程的用户空间代码继续执行之前,首先检查PCB中是否有记录的信号,当发现有一个SIGINT信号待处理,而这个信号的默认处理动作是终止程序,所以直接终止进程而不再返回它的用户空间代码执行。
但是有几点注意:
- Ctrl+C产生的信号只能发给前台进程。一个命令后面加个&就可以让程序在后台进行运行,然后你就可以运行其他的程序而不用担心只能运行一个程序了。
- shell只能在前台运行一个进程,但可以有多个后台进程。
- 前台进程在运行过程中用户可以随时按下Ctrl+C进而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到SIGINT信号使进程终止,所以信号对于进程的控制流程来说是异步的。
(2)信号的种类
在Linux中,信号的种类有很多,我们可以通过一条命令来查看
用kill -l 命令来查看系统定义的信号列表:
如果你细心的话,你回发现其实编号到64的信号表没有32号和33号信号,所以其实一共只有62个信号。每一个信号都有自己的编号和一个宏定义名称,这些宏定义可以在头文件signal.h中找到。其实这些信号我们可以对他们进行分类,1~31号为普通信号,34~64号为实时信号。目前,我们只讨论普通信号。
在此我必须要说明一下,9号信号是不能被捕获的。
如果你要是对每个信号有好奇心,你可以使用命令man 7 signal进行查看每个信号的意义以及产生条件等详情。
(3)信号的产生方式
- 用户在终端按下某些键时,终端驱动程序会发送信号给前台进程。例如Ctrl-C产生SIGINT信号,Ctrl-\产生SIGQUIT信号,Ctrl-Z产生SIGTSTP信号(可使前台进程停止)
- 硬件异常产生信号。这些条件由硬件检测到并通知内核,然后内核想当前进程发送相应信号。例如 当前进程执行力除以零的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号 发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个解释为SIGEGV信号发送给进程。
- 一个进程调用kill(2)函数可以发送信号给另一个进程 。可以用kill(1)命令发送信号给某个进程,kill(1)命令也是调用kill(2)函数实现的,如果不明确指定信号则发送SIGTERM信号,该信号的默认处理动作是终止进程。当内核监测到某种软件条件发生时也可以通过信号通知进程。例如闹钟信号SIGALRM信号,向读端已经关闭的管道写数据时产生SIGPIPE信号。如果奴相按默认动作处理信号,用户程序可以调用sigaction(2)函数告诉内核如何处理某种信号(即用户自定义处理动作)。
- 软件条件产生。
(4)信号的处理方式
信号有产生,就意味着肯定有相应的处理方式,就像人别给你打一个电话你肯定会有所行动,要么接要么拒绝,信号也一样,信号也有三种处理方式;
- 忽略此信号;
- 执行该信号的默认动作;(绝大多数信号的默认动作为终止程序(进程))
- 用户自定义一个函数,当内核在处理该代码时从内核态到用户态切换的时候,进行判断是否有信号的产生,有就执行用户自定义的函数来进行信号处理,这一动作也叫做信号的捕捉。
在我们的生活中,信号随处可见,比如说“红绿灯”、“铃声”等,这些信号的产生都是有规律的,有的是人为设置的,那么在计算机中的信号是如何产生的呢?
计算机中产生信号的方式有四种,接下来我们就来看看信号的具体产生方式;
(1)通过终端按键产生信号
在我们允许程序时,假如我们要观察某个现象或者结果时,我们可能会写个死循环来让程序一直跑起来,然后我们按Ctrl-C时,进程终止,实质上是产生了SIGINT信号,而SIGINT信号的默认动作就是终止进程;也可以按Ctrl-\,不过该组合键产生的是SIGQUIT信号,SIGQUIT信号的默认处理动作是终止进程并且Core Dump。
接下来我们就来验证一下:
#include<unistd.h>
#include<stdlib.h>
#include<stdio.h>
#include<signal.h>
void handler(int sig)
{
printf("get a %d signal\n",sig);
}
int main()
{
signal(SIGINT,handler);
while(1)
{
sleep(2);
printf("hello ,I'm yezi \n");
}
return 0;
}
通过程序运行结果,我们可以看出,Contrl+C 确实是2号信号SIGINT,而当我们对SIGINT信号的默认动作进行重写时,它的默认动作已经变为执行用户自定义的捕捉函数;而我们对Contrl+\没有进行用户自定义改写,所以它任然执行他的默认动作终止进程并且产生Core Dump。
那么Core Dump又是什么呢?我们经常听到大家说到程序core掉了,需要定位解决,这里说的大部分是指对应程序由于各种异常或者bug导致在运行过程中异常退出或者中止,并且在满足一定条件下会产生一个叫做core的文件。 通常情况下,core文件会包含了程序运行时的内存,寄存器状态,堆栈指针,内存管理信息还有各种函数调用堆栈信息等,我们可以理解为是程序工作当前状态存储生成第一个文件,许多的程序出错的时候都会产生一个core文件,通过工具分析这个文件,我们可以定位到程序异常退出的时候对应的堆栈调用等信息,找出问题所在并进行及时解决。
(2)调用系统函数向进程发信号
kill命令是调用kill函数来实现的。kill函数可以给一个指定的进程发送知道的信号。raise函数可以给当前进程(也就是自己给自己)发送知道的信号。
#include <signal.h>
int kill(pid_t pid,int signo); 给任意进程发送指定信号;
int raise(int signo); 自己给自己发送指定信号;
这两个函数的返回值相同,都是成功返回0,错误返回-1;
还有一个abort函数也能够终止进程,不过abort函数是使当前进程收到信号而异常终止;
#include<stdlib.h>
void abort(void); 给自己发送6号信号
该函数跟exit函数相似,abort函数总是会成功的,所有没有返回值。
(3)由软件条件产生信号
由软件条件产生的信号其实也有很多,就像我们以前学管道时遇到的SIGPIPE(13号信
号),不过这次我们来学习另外一个alarm函数和SIGALRM信号。
#include<unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒后给当前进程发送
SIGALRM信号,而该信号的默认动作是终止当前进程;
这个函数的返回值是0或者是以前设定的闹钟时间还剩下的秒数。
接下来我们就来设置一个闹钟,一秒内计算time加加了多少次?
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int time=0;
int main()
{
alarm(1);
while(1)
{
time++;
printf("time = %d\n",time);
}
return 0;
}
(4)由硬件异常产生的信号
由硬件异常产生的信号是由硬件检测到并通知内核,然后内核向当前进程发送适当的信
号。比如当前进程访问了非法内存地址,,MMU()会产生异常,内核将这个异常解释为发送了
SIGSEGV信号(11号信号)给进程,再例如当前进程出现了除零错误,CPU的运算单元会产生
异常,内核将这个异常解释为发送了SIGFPE信号(8号信号)给进程。
3、阻塞信号
(1)信号的相关概念
- 实际执行信号的处理动作称为信号递达(Delivery)。
- 信号从产生到递达的状态,称为信号未决(Pending)。
- 进程可以选择阻塞(Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程接触对此信号的阻塞,才执行递达的处理动作。
- 在这里,要强调的是,忽略和阻塞是不同的,只要信号被阻塞就不会被递达,而忽略是在递达之后的一种处理动作。
(2)信号在内核中的表示
信号的发送实质上就是把进程PCB中的信号位图中的比特位进行修改,当为0时,表示未收到该信号,当把比特位由0改为1 时表示收到了该信号,比特位的位数为信号的标号,第一个比特位代表1号信号,以此类推。信号在内核中的表示如下图:
每个信号都有两个标志物分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才消除该标志
。在上图的栗子中,信号1未阻塞也未产生过,当它递达时执行默认动作。信号2产生过,但正在被阻塞,所以暂时不能递达。虽然他的默认动作是忽略,但在没接触阻塞之前,该信号不能被忽略,因为进程人有机会改变处理动作之后再接触阻塞。信号3未产过,一旦产生它将被阻塞,它的处理动作是用户自定义函数,如果在进程解除对某信号的阻塞之前该信号产生过很多次,将会如何处理呢?POSIX.1允许系统递送该信号一次或多次。而在Linux写,普通信号在递达之前产生多次只能算一次。
(3)信号集操作函数
在上图中未决和阻塞标志可以用相同的数据类型sigset_t来存储.sigset_t实际上是一个长整型数组,数组的每个元素的每个位表示一个信号。这种定义方式和文件描述符集fd_set类似我们用这个类型可以表示每个信号的“有效”或“无效”状态。在阻塞信号集其含义是该信号是否被阻塞;在未决信号集中就代表该信号是否处于未决状态。 阻塞信号集也叫当前进程的信号屏蔽字(Signal Mask)。
sigset_t类型对于妹子信号用一个bit表示“无效”或“有效”状态,至于这个类型内部如何存储这些bit则依赖于系统函数实现,从使用者的角度这些都无从重要,使用者只能调用一下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释。
接下来就来了解一下这些操作函数:
#include <signal.h>
int sigemptyset(sigset_t *set); 清空信号集
int sigfillset(sigset_t *set); 在信号集中初始化所有信号
int sigaddset(sigset_t *set, int signum); 将信号signum添加至信号集set中
int sigdelset(sigset_t *set, int signum); 将信号signum从信号集set中删除
int sigismember(const sigset_t *set, int signum); 测试signum是否在信号集set中
返回值:前四个函数都是成功返回0,失败返回-1;
最后一个函数是判断某个信号是否在信号集中函数,存在返回1,不存在返回0,
出错返回-1
在使用sigset_t类型的变量之前,⼀定要调用sigemptyset或sigfillset做初始化,使信
号集处于确定的状态。 除此之外,系统还提供了两个函数用来读取或更改当前进程的信号屏
蔽字(block表)和未决信号集(pending表)。
Sigprocmask
调用 Sigprocmask可以读取或者更改进程的信号屏蔽字(阻塞信号集);
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数:how:如何更改;set:指定新的信号屏蔽字;oldset:保存原来的信号屏蔽字
返回值:成功返回0,失败返回-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
列个表格说明一下how的取值:sigpending
#include <signal.h>
int sigpending(sigset_t *set); 读取当前的未决信号集
参数:set:保存当前进程的未决信号集;
返回值:成功返回0,失败返回-1下面以实例形式展示:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
//捕捉2号信号,方便我们查看解除阻塞后的信号状态
void handler(int signo)
{
printf("Get a %d signal\n",signo);
}
//显示未决信号表
void printsigset(sigset_t *set)
{
int i=0;
for(i=0;i<32;i++)
{
if(sigismember(set,i))//判断信号是否在信号集中
{
printf("1");
}
else
{
printf("0");
}
printf(" ");
}
printf("\n");
}
int main()
{
sigset_t s,p;
sigemptyset(&s);//清空信号集
sigemptyset(&p);
sigaddset(&s,2);//将2号信号加入信号集中
sigprocmask(SIG_BLOCK,&s,&p);//设置信号屏蔽字,阻塞2号信号
signal(2,handler);//当2号信号解除阻塞后捕捉它
sigset_t pset;
int count=0;
while(1)
{
sigpending(&pset);//读取信号未决表
printsigset(&pset);
sleep(1);
if(count++==5)//count累加到5时,解除阻塞
{
printf("unblock signal\n");
//恢复信号屏蔽字
sigprocmask(SIG_SETMASK,&p,NULL);
}
}
return 0;
}
4、信号的捕捉
信号的处理方式中由用户自定义的信号处理函数,当内核从内核态到用户态切换时,检测是否有收到信号,若有信号的产生,则要先进行对信号的处理,即执行用户自定义的信号处理函数,这就叫做对信号的捕捉。信号的捕捉说明了也就是一种信号处理的方式。
(1)内核实现信号捕捉
接下来我们就用图来了解一下信号的捕捉过程:
由图我们可以看出,信号捕捉过程中,一共从内核态转至用户态,再从用户态转至内核态一共进行了四次转换,其中交点处为信号的检查。
(2)信号捕捉函数
sigaction()函数:
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
参数:signo:要捕捉的信号
act:若非空,表示根据act修改信号signo的处理动作
oact:保存该信号原来的处理动作
返回值:成功返回0,失败返回-1sigaction函数可以读取和修改与指定信号相关联的处理动作。
pause()函数
#include <unistd.h>
int pause(void);
返回值:如果信号的处理动作是终止进程则进程终止。pause函数没有机会返回
如果信号的处理动作是忽略,则进程继续处于挂起状态。pause不返回
如果信号的处理动作是捕捉,则调用了信号处理函数之后pause返回-1
pause函数使调用它的进程挂起,直到有信号递达
接下来我们用alarm和pause函数来实现一个sleep(3)函数,称为mysleep.
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
void sig_alarm(int signo)
{
//捕捉闹钟信号但是不做任何事
}
unsigned int mysleep(unsigned int seconds)
{
struct sigaction new,old;
unsigned int unslept=0;
new.sa_handler=sig_alarm;
sigemptyset(&new.sa_mask);
new.sa_flags=0;
sigaction(SIGALRM,&new,&old); 注册信号处理函数
alarm(seconds); 设置闹钟
pause();
unslept=alarm(0); 清空闹钟
sigaction(SIGALRM,&old,NULL); 恢复信号默认处理动作
return unslept;
}
int main()
{
while(1)
{
mysleep(5);
printf("5 seconds paused \n");
}
return 0;
}
不过,这个代码也有点小问题,你们先自行琢磨。提醒一下,是时序问题哦。
5、可重入函数
什么是可重入函数呢?接下来我们先看一个例子。
上面的图说明一个什么看懂了吗?我来捋一捋吧。
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚把第一步做完,然后因为硬件中断使进程切换到内核,再次回用户态之前检测有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入的两步都做完之后,从sighandler函数返回内核态,再次回到用户态就从刚才没完成的main函数调用的insert函数中继续往下执行,进行insert函数的第二步,结果就是,main函数和sighandler函数前后向向链表中插入两个节点,而最终只有一个节点插入成功。
就如上图中所展示的例子,insert函数被不同的控制流程调用,有可能在第一次调用还没结束返回就再次进入该函数,这就称为重入。insert函数访问一个全局链表,有可能因为重入而造成错乱,这样的函数称为不可重入函数,反之,如果一个函数值访问自己的局部变量或参数,则称为可重入函数。
当一个函数符合以下条件之一就不是可重入函数:
- 调用malloc或free。因为malloc也是用全局链表来实现管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
接下来我们再来学习一个关键字:volatile
相信在学习C语言的你也见过这个关键字,这个关键字有什么作用呢?你可能想都不想就会说保证内存的可见性。那么保证内存的可见性到底是什么呢?就拿上图的例子来说,main函数和sighandler函数都调用insert函数就有可能出现链表的错乱,其根本原因 在于对全局变量的插入操作要分两步完成,不是一个原子操作,假如这两个操作必定一起完成,中间不会被打断,那么就不会出现问题。
在C语言中,一个简单赋值语句只有一句,但若把他写成汇编来看,则就有三句与之对应,由此可见这也不是原子操作。
对于程序中存在多个执行流程访问同一全局变量时,volatile限定符是必要的。此外,虽然程序只有单一的执行流程,但是变量属于以下情况之一的也需有volatile限定符限定:1、变量的内存单元中的数据不需要写操作就可以自己发生变化,每次读上来的值都不一样;2、即使多次向内存单元中写数据,只写不读,也并不是在做无用功,而是有特殊意义的,比如映射到内存地址空间的硬件寄存器。
竞态条件这个概念就是在上面的mysleep函数中提出的,其实也就是由时序问题而导致错误,这就是竞态条件。
6、SIGCHLD
在前面学习进程的时候我们提过通过wait和waitpid这两个函数可以回收清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时的轮询一下,程序实现太复杂。
其实,子进程在终止时会给子进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不用操心子进程的状态了,子进程退出时会通知父进程,然后父进程在循环处理函数中调用wait函数清理子进程即可。