让一路花香满径,让紫日安暖自若。
什么是信号
从生活中角度
在我们大学生活中,上课铃声即便没有响,如果我们有课的话,你也应该知道当上课铃声响了,我们应该怎么去处理这个铃声,也就是我们能 “识别铃声” 。 而当铃声响了,某个老师上课要点名来考查同学们的考勤情况。假设我此时还在宿舍睡大觉,但是我舍友打电话和我说老师要点名了,叫你赶快过去。 在收到舍友的通知时,是由一个时间窗口的,在这段时间里,你并没有去上课,但是你知道你这个时候有课。本质上是你 “记住了上课的时间”,进程收到信号也同样如此,一定要有某种方式记录下来这个信号已经产生,等到合适的时候再去处理。 当你时间合适的时候,你赶忙的跑到教室之后,就要正常上课了。而处理舍友打来的电话叫我们去上课处理的方式一般有三种: 1、执行默认动作(赶忙跑到教室上课)。 2、执行自定义动作(虽然我知道有课,但是我认为一个选修课而已 ,自己学习也应该能看懂,而选择不去教室,自己自学)。 3、忽略电话(我认为这节课是选修课上不上无所谓,而选择不去教室,而忽略舍友给我们打来的电话,继续睡大觉)。
而我们人对应进程 1、进程虽然现在没有收到任何信号,但是进程知道收到信号之后,该如何去做。所有进程内部一定能够识别“信号”。而我们人能设别上课铃等信号是因为训练得来的,而进程是由程序员已经在编写设计进程的时候,已经内置了信号的处理方案。信号是属于进程内部特有的特征。 2、当信号到来的时候,进程可能正在处理更加重要的事情(比如:往显示器中打印,进行文件操作),此时信号可能不会被立即处理,需要等到合适的时候在进行处理该信号。而当信号来了,处理信号前,信号必须被进程暂时保存起来。 3、进程开始处理信号,有三种方式:
- 1 默认行为(终止进程、暂停、继续运行等)
- 2 自定义行为(提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号)
- 3 忽略信号
进程间信号
信号概念
信号是进程之间事件异步通知的一种方式,属于软中断。
信号列表
我们可以使用如下命令来查看信号列表:
kill -l
一共有62中信号。其中1-31为普通信号,34-64为实时信号。 且每个信号前的数字,都是一个宏值,我们可以去到/usr/include/bits/signum.h文件中查看
信号的产生
通过键盘产生信号
比如:ctrl+c、ctrl+\ 等。 在介绍之前得介绍一个函数:signal 。 函数原型:sighandler_t signal(int signum, sighandler_t handler); 函数头文件:#include <signal.h> 函数参数: signum:代表的是要自定义几号信号处理的方式。 handler:其中sighandler_t 是一个返回值为void ,形参为int类型的一个函数指针类型:typedef void (*sighandler_t)(int); ,所以这里要传入一个函数的地址,等到接收到自定义的信号时,去让它执行这个函数。
知道了signal函数后,我们看如下代码:
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
void sigcb(int signo)
{
cout<<"get a signo"<<signo<<endl;
}
int main()
{
signal(2,sigcb);
while(1)
{
cout<<"I am running "<<endl;
sleep(1);
}
return 0;
}
这里我们利用signal函数来进行信号的自定义信号的处理。 我们知道ctrl+c可以终止前台进程,此时我们会发现按ctrl+c会发现,现在终止不了这个进程了,并打印了一句话,接收到的是2号信号(SIGINT),那么我们该怎么去终止这个进程呢? 我们可以换另外一个快捷键来终止进程ctrl+\。
下面我们将所有的信号都自定义试试 结果如下,我们按CTRL+c、CTRL+\以及CTRL+z都会没有了反应 但是9号信号不能被捕捉,就是不能被我们自定义。所以我们还是能杀死这个进程。
Core Dump(为了定位错误)
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误, 事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。 由于我们这里是云服务器,默认是不允许产生core文件的, 因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。
查看系统资源命令 ulimit -a
云服务器默认情况core文件的个数为0个,如下图所示: 例如你要修改Core Dump的资源的数量,我们可以发现在上图中有一堆的小写字母。 如果你要修改那一项的资源,就使用ulimit +这些小写字母选项 +数量 即可
我们首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为10K: $ ulimit -c 10。 下面我们以这段代码为例来演示Core Dump,这里我们写一个NULL指针访问的问题。 运行起来这个程序后,我们会发现在这个段错误后面跟了一句话(core dumped) 此时我们在使用ll命令就能看到当前目录下会多了一个core的文件 这个core文件点后面跟的数字就是该进程的pid。 然后我们再使用gdb来进行调试,看看到底是哪里出现的问题。 如果不想产生了错误而生产core文件,则使用ulimit -c 0即可。
通过系统调用函数向进程发信号
1、kill 函数原型:int kill(pid_t pid, int sig); 函数功能: 向指定的一个进程发送一个指定的信号。 函数头文件: #include <sys/types.h> #include <signal.h> 函数参数: pid:进程的pid。 sig:要发送的信号编号。 函数返回值: 成功返回0,错误返回-1。 下面我们以这段代码为例,我传入参数是该可执行程序本身的pid,所以最终的效果就是,自己杀死了自己。这个函数也可以实现杀死其他的进程,不一定是杀自己。
#include<iostream>
#include<sys/types.h>
#include<signal.h>
#include<unistd.h>
int main()
{
kill(getpid(),3);
return 0;
}
2、raise 函数原型: int raise(int sig); 函数头文件:
#include <signal.h>
函数功能: 可以给当前进程发送指定的信号(自己给自己发信号)。 函数参数: sig:要发送的信号编号 函数返回值: 成功返回0,错误返回-1。
#include<iostream>
#include<sys/types.h>
#include<signal.h>
#include<unistd.h>
int main()
{
raise(9);
return 0;
}
3、abort 函数原型:void abort(void); 函数头文件:#include <stdlib.h> 函数功能: 自己给自己发信号,发SIGABRT信号。
#include<iostream>
#include<sys/types.h>
#include<signal.h>
#include<unistd.h>
#include<cstdlib>
int main()
{
abort();
return 0;
}
由软件条件来产生信号
alarm 函数头文件:#include<unistd.h> 函数功能: 调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动 作是终止当前进程。
#include<iostream>
#include<sys/types.h>
#include<signal.h>
#include<unistd.h>
#include<cstdlib>
int main()
{
alarm(10);
int i=10;
while(i)
{
std::cout <<" 倒计时:" << --i << std::endl;
}
return 0;
}
这个程序的作用是10秒钟之内不停地数数,10秒钟到了就被SIGALRM信号终止。
硬件异常产生信号
硬件异常是指:被硬件以某种方式异常被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
阻塞信号
信号其他相关常见概念
1、实际执行信号的处理动作称为信号递达(Delivery) 。 2、信号从产生到递达之间的状态,称为信号未决(Pending)。 3、进程可以选择阻塞 (Block )某个信号。 4、被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.。 5、注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
在内核中的表示
信号在内核中的表示示意图:
在上图中我们发现每个信号都有两个对应的标志位分别存储在 阻塞(block位图)和未决(pending位图) 中,还有一个 函数指针表(handler) 示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。 SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前 不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。 SIGQUIT 信号未产生过,一旦产生 SIGQUIT 信号将被阻塞,它的处理动作是用户自定义函数 sighandler。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理? POSIX.1中允许系统递送该信号一次或多次。 Linux是这样实现的:常规信号在递达之前产生多次信号,但只统计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。
sigset_t信号集
从上面图我们会知道,每一个信号都有一个固定的bit位,用来表示信号是否未决,非0即1。不记录该信号产生了多少次,只记录是否有该信号产生,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t 信号集中来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。
信号集操作函数
在上面我们知道信号的存储是用位图来存储,那么为什么我们不直接去利用位操作去直接操作信号集的信息,反而给了一些信号集的函数呢? 这是因为每个操作系统的信号存储模式可能或多或少有点区别,比如是拿数组来实现存储的,那么我们直接用位操作来得到就不知道是啥了。
sigemptyset
函数原型: int sigemptyset(sigset_t *set); 函数头文件: #include<signal.h> 函数功能: 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。 函数返回值: 成功返回0,失败返回-1。
sigfillset
函数原型: int sigfillset(sigset_t *set); 函数头文件: #include<signal.h> 函数功能: 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。 函数返回值: 成功返回0,失败返回-1。
sigaddset
函数原型:int sigaddset (sigset_t *set, int signo); 函数头文件:#include<signal.h> 函数功能: 将某一个信号添加到信号集中。 函数返回值: 成功返回0,失败返回-1。
sigdelset
函数原型: int sigdelset(sigset_t *set, int signo); 函数头文件:#include<signal.h> 函数功能: 将某一个信号从信号集中删除 函数返回值: 成功返回0,失败返回-1。
sigismember
函数原型:int sigismember(const sigset_t *set, int signo); 函数头文件:#include<signal.h> 函数功能: 用于判断一个信号集的有效信号中是否包含某种信号。 函数返回值: 成功返回1,失败返回-1。
注意:在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的 状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
sigprocmask(修改block位图)
函数功能: 可以读取或更改进程的信号屏蔽字(阻塞信号集)。 函数头文件:#include<signal.h> 函数原型:int sigprocmask(int how, const sigset_t *set, sigset_t *oldset); 函数参数: how:表示的你要怎么办。 如果oldset是非空指针,则读取进程的当前信号屏蔽字通过oldset参数传出。如果set是非空指针,则更改进程的信 号屏蔽字。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oldset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。 set:代表你要设置进来哪一个信号屏蔽字 oldset:不需要设置成NULL即可,相当于是对当前信号集做了一个保存。 返回值:成功返回0,失败返回-1。
注意:当我们调用sigprocmask系统调用来解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
sigpending(修改pending位图)
函数原型:int sigpending(sigset_t *set); 函数头文件:#include<signal.h> 函数返回值: 成功返回0,失败返回-1。
根据上面这些信号集操作函数我们写一个如下规则的demon程序。 1、我们先将2号信号屏蔽。 2、然后我们在利用kill命令或者键盘来发送2号信号 ,由于我们已经将2号信号屏蔽了,所以2号信号不会被递达,将一直处于阻塞状态。 3、2号信号将会一直被阻塞 ,然后一定一直在信号集pending当中 4、过了10s中后,我们将解除对2号信号的阻塞,此时我们会发现我们的程序被杀死了。这是因为2号进程被递达了,执行的系统默认的操作。
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
void printPending(sigset_t *pending)
{
int i=1;
for(;i<32;++i)
{
if(sigismember(pending,i))
{
printf("1 ");
}
else
{
printf("0 ");
}
}
printf("\n");
}
int main()
{
sigset_t set,oldset;
sigemptyset(&set);
sigemptyset(&oldset);
sigaddset(&set,2);
sigprocmask(SIG_SETMASK,&set,&oldset);
sigset_t pending;
int count = 0;
while(1)
{
sigemptyset(&pending);
sigpending(&pending);
printPending(&pending);
sleep(1);
if(count==10)
{
printf("恢复信号屏蔽字\n");
sigprocmask(SIG_SETMASK,&oldset,NULL);
}
++count;
}
return 0;
}
信号捕捉
在上面我们 一直说信号的处理是在合适的时候进行才处理,那么合适的时候是什么时候呢? 答:信号的递达是从内核态切换回用户态时进行信号信息的相关检测。
内核态: 通常用来执行OS代码,是一种权限非常高的状态。 用户态: 是一种用来执行普通用户代码的状态,是一种受监管的普通状态。
在32位系统下,每个进程的进程地址空间中,低0-3G空间为用户空间,而高3-4G则为内核空间。 我们的内存中,还有一个OS,而操作系统也是软件,既然是软件,那么就是代码和数据构成的, 那我们的进程要切换必须是OS帮它去切换,那么我怎么做到一个进程运行的好好的,突然开始跑OS系统的代码,然后把我放下去,再将别人放上来,让别的进程再运行呢? 我们之前讲过一个页表,但这个页表是用户级的页表,但是系统也有一个全局的页表,这个全局的页表叫做内核页表,这个页表维护的是OS系统的代码和数据以及进程之间的关系,而内核页表是会被映射到进程地址空间3-4G的空间。换而言之,0-3G的空间是属于每一个进程,看到的数据是完全不同的。而3-4G空间我们所有的进程看到的是同一样的内容,也就是OS系统的代码和数据。 有了上面的概念的时候,我们就知道进程无论如何切换,都能看到OS系统。但是都能看到,但是不一定都能去访问,也就是说你要访问3-4G,你的进程状态必须从用户态转换为内核态,而从内核态要访问用户态的数据,必须从内核态切换回用户态。 内核态是用来执行OS系统的代码的状态,是一种权限非常高的状态。 用户态是用来执行普通用户代码的状态,是一种受监管的普通状态。 内核页表用来维护OS所有的代码和数据和进程之间的关系。
切换时机 信号的捕捉流程示意图: 为了方便大家记忆上面的信号捕捉图,我们可以选择来看下面这张图、 我们平时话无穷符号的时候,都是按下图这个箭头的方向开始画的,规定上班部分是用户态,而下半部分自然而然也就是内核态了,然后用一根横线从这个无穷符号的上班部分穿过,这样横线与无穷符号有几个交点就有几次状态的切换,而中间的红色的点则为内核态切换回用户态之前进行信号检测的时机。
sigaction
函数头文件: #include <signal.h> 函数原型:int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact); 函数功能: 用于捕捉信号。 函数返回值: 调用成功则返回0,出错则返回- 1。 sigaction函数可以读取和修改与指定信号相关联的处理动作。signo 是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oldact指针非空,则通过oldact传出该信号原来的处理动作。act和oldact指向sigaction结构体: 将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信 号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。 下面我们再次写一个信号捕捉案例: 代码如下:
#include<iostream>
#include<string.h>
#include<unistd.h>
#include<signal.h>
using namespace std;
void sigcb(int signo)
{
cout<<"get a signo"<<signo<<endl;
}
int main()
{
struct sigaction act;
memset(&act,0,sizeof(act));
act.sa_flags=0;
sigemptyset(&act.sa_mask);
act.sa_handler=sigcb;
sigaction(2,&act,NULL);
while(1)
{
cout<<"I am running"<<endl;
sleep(1);
}
return 0;
}
代码的含义是捕捉2号信号,捕捉成功后发送捕捉到的信号的编号 结果如下: 这个sigaction函数与上面的signal函数都是用来信号捕捉的,只是这个sigaction函数的功能更加的多,平时简单使用signal函数即可。
注意:当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来 的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。
volatile
在我们平时写C/C++时,很少会用到这个关键字,等到我们要处理一些高并发的场景的时候就特别有效。 作业: 保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量 的任何操作,都必须在真实的内存中进行操作。
下面我们写一个代码来阐述: 一开始我们先让flag=0,这样就会让其死循环。直到接收到了2号信号才会正常退出。 我们的信号的捕捉和main函数是两种执行流,这里的while循环是在main函数中的,main函数在编译器编译的时候,它只能检测到main执行流当中对flag的修改。但在main执行流这里并没有对其修改,当编译器优化级别较高的时候,编译器就会将flag优化成寄存器变量,当信号处理修改了flag,不是去修改寄存器中的flag值,而是修改内存中的flag值,然后main函数执行流检测只检测寄存器当中flag值,而不是去检测内存当中的flag值,这就会导致即使收到信号改变flag在内存中改变成了1了,但是还是死循环的情况。
编译器优化后的结果: 如下所示: 而要解决这个问题就是需要用关键字volatile来修饰这个flag变量解决。
|