本节目标:
1. 掌握Linux信号的基本概念
2. 掌握信号产生的一般方式
3. 理解信号递达和阻塞的概念,原理。
4. 掌握信号捕捉的一般方式。
5. 重新了解可重入函数的概念。
6. 了解竞态条件的情景和处理方式
7. 了解SIGCHLD信号, 重新编写信号处理函数的一般处理机制
首先声明:这里所讲的信号与上文的信号量毫无关联。
目录
1. 信号是什么?
2. 信号的处理过程
注意:
3. 信号的准备知识
3.1 信号的种类
3.2 信号的行为
core dump(核心转储)
3.3 常见信号处理方式
4. 信号的一生
4.1 信号的产生
方法1--kill命令编辑
方法2--键盘键入
方法3-- 系统调用
1. kill 向任意进程发送任意信号
2. raise 向自己发送任意信号
3. abort 向自己发送六号信号
方法四--由软件条件产生信号
方法五--硬件异常产生信号
4.2 信号的保存
4.2.1 pending、block与handler
4.2.2 sigset_t
4.2.3 信号集操作函数
4.2.4 sigprocmask与sigpending
sigprocmask
sigpending
4.2.5 实验
实验1
代码
结果
实验2
代码
结果
1. 信号是什么?
什么是信号呢?
我们从生活引入。当你在房间里苦学c++的时候,妈妈喊你吃饭,你有两个选择--立即去吃饭、看完再吃饭,这个例子就可以完美的解释什么是信号。
这就是我们日常生活中的信号以及对信号的处理过程。
那么我们要讲的信号是什么呢?
我们所将的信号同上例本质是一样的,不过是由某人发给进程,然后由进程进行一系列处理过程罢了。为什么用某人呢?因为这个某人并不确定,有可能是OS,有可能是用户,也有可能是其他进程乃至进程自己(这个就涉及信号的产生了)。
那么从上面的例子我们可以知道信号与进程之间有哪些关系呢?
1. 信号既然是由其他人发送的,会中断我们(进程),且这一信号是我们无法预料的,那么信号本身就是异步的,即信号是OS提供给用户(进程)向其他进程发送异步信息的一种方式,这一过程是并发的。
2. 信号发来我们就需要能够进行处理,这就说明我们(进程)具备认识信号的能力,并知道如何对该信号进行处理。因此进程不仅认识信号,而且还储存有对信号的处理方式。
3. 当我们(进程)接收到信号,如果我们在做更重要的事,我们可以先对信号进行保存,等到合适的时候再进行处理。因此进程应当具备保存信号,以及在合适时机处理信号的能力
2. 信号的处理过程
下图是信号的处理过程时间轴示意图,我们后续的讲解线就是根据这个时间轴。
注意:
1. Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
2. Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
3. 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
3. 信号的准备知识
3.1 信号的种类
我们先来看看都有哪些信号(kill -l命令)
这些信号大部分的行为都是终止进程,还有一部分是忽略,暂停等等。
3.2 信号的行为
下图信号的行为,三十一个信号的行为都囊括其中。
core dump(核心转储)
其他的信号默认处理动作都很好理解,但core动作是怎么回事,好像有点看不懂。
首先解释什么是Core Dump(核心转储)。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K: $ ulimit -c 1024
也就是说,我们的云服务器默认关闭核心转储功能,在Linux中,我们的g++/gcc默认是release版本,因此如果要生成core文件,除却上述的打开core dump功能外,还需要在编译时加-g选项。
有这样一种场景,倘若某种大型服务器出现异常,但由于大型服务器出错的第一件事不是找错,而是重新启动,因此我们有自启服务器的程序。如果服务器在无人发觉的情况下疯狂终止又疯狂重启,经过一段时间后,会生成无数core文件,这会导致空间爆炸的问题。因此在部分系统里,core文件的名字就叫做core,一个进程即便不断终止与重启,也只会有一个core文件。
此前我们在讲进程返回码时有一个标志位略过没有讲,现在看他刚刚好。
3.3 常见信号处理方式
可选的处理动作有以下三种:
1. 忽略此信号。(即接收到该信号的进程对此信号进行忽略)
2. 执行该信号的默认处理动作。(每一个信号有自己的默认处理动作,如果用户没有对信号的处理动作进行自定义,那么就执行该默认处理动作)
3. 对信号进行捕捉。提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号(即用户对信号的处理动作进行自定义化)
比如:
SIGINT的默认处理动作是终止进程,我们现在对SIGINT信号进行捕捉,自定义其处理动作为打印一串字符。使用signal函数。
4. 信号的一生
4.1 信号的产生
信号要想发给进程,首先当然要先产生,那么信号的产生方式有哪些呢?
我们先写一个正常情况下不会终止的进程。
#include<iostream>
#include<unistd.h>
int main()
{
while(true)
{
sleep(1);
pid_t pid=getpid();
std::cout<<"process pid :"<<pid<<std::endl;
}
return 0;
}
方法1--kill命令
方法2--键盘键入
记得我们之前使用的ctrl+c吗,它可以终止进程,但键盘可以输入的信号可不只有他
ctrl+\后的core dumped是什么呢?
方法3-- 系统调用
这里的系统调用一般有三种,我们挨个来看看。
1. kill 向任意进程发送任意信号
kill可以向任意进程发送任意信号,我们来试试吧。
我们看到实验成功了,不过这一实验有一些丑陋,大家可以使用父进程发送信号杀死子进程,同时记录当前进程状况。
2. raise 向自己发送任意信号
3. abort 向自己发送六号信号
相当于kill(getpid(),9)
方法四--由软件条件产生信号
首先这一方式我们熟知的有SIGPIPE,即管道读端关闭而写端还在写时,OS会向写端进程发送SIGPIPE强制杀死该进程。
还有我们并未接触过的SIGALARM,我们来看看。
我们来验证一下。
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时还余下的秒数。
闹钟是由OS发送的,而OS中的进程何其多,所以OS就需要对闹钟进行管理,先描述在组织。
方法五--硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
因此我们平时写的程序崩溃了,就是硬件异常发送给我们进程信号了。
4.2 信号的保存
欸你可能会疑惑,为什么信号的一生时间线中没有发送信号的过程呢?别急,我们对信号的一生填充一下。
我们学习了上面的内容,应该已经明白了向进程发送信号的过程是由OS来做的,因为产生信号的方式全部是系统调用。那么OS是如何发送的呢?
我们知道,进程是承担系统资源的实体,那么信号既然要保存,自然也是存储在进程中的某个区域。那么存在哪呢?进程地址空间吗?
不是的,信号的保存是在pcb中的。信号本质并不属于进程,而是属于系统,但由于进程需要能够对信号及时响应,因此进程需要保存信号,且进程要可以及时察觉到信号的变化,因此将信号保存在pcb中。
4.2.1 pending、block与handler
那么问题来了,信号在pcb中要怎么保存呢?
由上图我们可知,信号在pcb中的存储是三张表, pending(未决信号)、block(阻塞信号)、handler(信号处理函数)。
注意,信号的屏蔽与忽略是截然不同的,信号的屏蔽是指信号始终处于未决,不对其进行处理;信号的忽略本身就是对信号的处理,即信号已然递达。
这里要注意,我们一开始就说,只谈1-31个信号,因此这里的位图都是三十二个比特位,1-31位标识信号。
我们来看看三张表的作用
我们之前有一个案例代码,其中有对信号进行捕捉,其实就是让我们的捕捉函数覆盖了该信号的原处理函数。
看到这里,有没有明白信号是如何发送给进程的呢?
没错,就是OS对pending表进行写入,进程会时刻监视这三张表,并做相应处理。
我们之前所讲信号的产生,无论是命令行输入命令,还是程序代码调用系统调用,都是让OS帮我们向进程内写入信号。那么系统调用究竟是什么呢?系统调用其实就是写在系统内的函数,只有系统有权限使用。OS内会有一个函数指针数组,其内放的全部都是系统方法。
4.2.2 sigset_t
每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
4.2.3 信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置为1,表示 该信号集的有效信号包括系统支持的所有信号。
注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
注意:这里的几个函数仅仅是对创建出的对象进行操作,要设置入进程内需要其他的函数,
4.2.4 sigprocmask与sigpending
sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。(即set为要设置入进程block位图的信号集,oset为输出型参数,将会记录进程原block位图信号集)假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
如果调用sigprocmask解除了若干个对未决信号的阻塞,那么在sigprocmask返回前,OS会立即将其中一个信号递达。
sigpending
#include <signal.h>
sigpending
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
4.2.5 实验
接下来我们将使用上面的函数做一个实验
实验1
将2,3信号屏蔽(sigprocmask)
向进程发送信号,打印pending位图。
代码
#include<iostream>
#include<unistd.h>
#include<signal.h>
//打印当前pending信号集
void printsig(sigset_t p)
{
std::cout<<getpid()<<" ";
for(int i=31;i>0;i--)
{
if(sigismember(&p,i))
std::cout<<"1";
else
std::cout<<"0";
}
std::cout<<std::endl;
}
int main()
{
sigset_t s,p;//创建信号集
sigemptyset(&s);//清空信号集
sigaddset(&s,2);//向信号集内添加有效信号2
sigaddset(&s,3);//添加3
sigprocmask(SIG_BLOCK,&s,nullptr);//这里我们不需要记录原信号屏蔽字
while(true)
{
sleep(1);
sigpending(&p);//获取当前进程pending信号集
printsig(p);//打印pending信号集
}
return 0;
}
结果
实验2
将所有信号屏蔽(sigprocmask)
向进程发送信号,打印pending位图。
代码
#include<iostream>
#include<unistd.h>
#include<signal.h>
//打印当前pending信号集
void printsig(sigset_t p)
{
std::cout<<getpid()<<" ";
for(int i=31;i>0;i--)
{
if(sigismember(&p,i))
std::cout<<"1";
else
std::cout<<"0";
}
std::cout<<std::endl;
}
int main()
{
sigset_t s,p;//创建信号集
sigemptyset(&s);//清空信号集
for(int i=1;i<32;i++)
{
sigaddset(&s,i);//向信号集内添加有效信号
}
sigprocmask(SIG_BLOCK,&s,nullptr);//这里我们不需要记录原信号屏蔽字
while(true)
{
sleep(1);
sigpending(&p);//获取当前进程pending信号集
printsig(p);//打印pending信号集
}
return 0;
}
结果
下篇我们来看信号的处理。