首先区分一下Linux信号跟进程间通信中的信号量,它们的关系就犹如老婆跟老婆饼一样,没有一毛钱的关系。
信号的概念
信号的概念:信号是进程之间事件异步通知的一种方式,属于软中断。比如:红绿灯是一种信号,早上的时候妈妈催我起床是一种信号、下课铃声也是一种信号等等。我们需要有一个共识,那就是信号是给进程发的。
学习Linux进程信号,我们学习的是信号的预备知识+信号是如何产生的+信号是如何保存的+如何处理信号,即信号的整个生命周期。
系统定义的信号
使用kill -l命令,可以查看到Linux中的系统定义的信号。我们可以看到,在这些信号当中,分有[1,31]和[34,64]两个连续区间的信号编号。其中[1,31]的信号称为普通信号,[34,64]的信号称为实时信号。
除此之外,当我们在Shell下启动了一个前台进程后,使用Ctrl+c命令中断这个进程。其中, Ctrl+c便是一个信号!因为操作系统把Ctrl+c解释成kill中的2号信号:SIGINT。
信号的预备知识
红绿灯信号例子:
我们拿红绿灯举例子。红绿灯是一个信号,那么人是能够识别红绿灯的,于是就会产生识别+行为这两个过程,比如说识别到这个是人行道上的绿灯,意味着行人可以过了。
那么就有以下四个关于信号的性质:
①人是如何识别红绿灯的?是因为受过相关教育,当我们的大脑记住了红绿灯的属性以及引导我们判断接下来的行为。---------识别。
②当我们接受到了红绿灯的信号后,我们不一定马上去处理这个信号,立即去执行相应的行为,或许我还得回头跟朋友告别了再去处理这个行为。-------行为。
③跟朋友告别后,我们就会去处理来自红绿灯的信号。在此之前,我们会将这个信号进行保存。--------信号的保存
④对于处理这个红绿灯的信号,我们一般会有三种处理方式:第一种是默认动作,即马上过马路。第二种是自定义动作,如果我们从小就被教育,过马路前要看一看两边再过马路。这个看一看马路两边的行为就是自定义动作。第三种是忽略动作,就是看到了红绿灯,但是我忽视它,因为我不打算过马路。-------信号的处理
接下来我们把红绿灯信号转化成进程信号:
①进程能够识别信号,是通过程序员编程写出来的。因为进程本身就是被程序员编写的属性+逻辑的集合。
②进程在接受信号后,有可能在执行着更重要的的代码,所以信号不一定能够被立即处理。
③进程本身就要有保存信号的能力。
④进程在处理信号的时候,有三种处理方式:默认动作、自定义动作和忽视动作。进程处理信号称为信号被捕捉。
一句话总结:进程能够识别信号通过程序员编码完成的,接受到信号不一定会马上处理,因此就需要有保存信号的能力,当处理这个信号的时候有三种处理方式。
保存信号
保存的地方
信号是给进程发送的,那么进程就应该具备保存信号的能力。而进程保存信号的地方,就是进程PCB中。
保存的方法
我们学习的是kill中的普通信号,即[1,31]区间的信号。因此,有32个信号,在PCB中,使用信号位图的比特位来表示信号的编号和判断是否接受到信号。通过比特位的位置来表示信号的编号,通过比特位的内容来代表是否接受到信号,0代表没有,1代表有。
理解什么是发送信号
发送信号不能理解为从A处发送到B处。发送信号的本质就是对PCB中的信号位图的修改!因此,我们看到发送信号的时候,不要往谁向谁发送了一个信号方向想,而是应该意识到是进程的PCB中的信号位图被修改了!
OS在其中扮演的角色
OS是PCB的管理者,也只有OS有资格对信号位图进行修改!因此,我们学习到的发送信号的方式,本质上都是通过OS提供的系统调用去向目标进程发送信号,由OS去修改位图的比特位。比如说kill命令,其底层就是调用了系统接口。
产生信号
通过终端按键产生信号
一个死循环的进程,我们可以通过按键Ctrl+c,或者Ctrl+\来进行终止进程。也可以通过kill -2 pid 或kill -3 pid终止进程。这些都是终端按键产生信号。
通过系统调用产生信号
系统调用接口
⭐1.kill接口
函数原型:int kill(pid_t pid, int sig); 头文件:#include <sys/types.h> #include<signal.h> 参数:第一个参数pid是接受参数的进程的pid 第二个参数是传入的是几号信号 返回值:返回0代表成功,否则返回-1
测试代码:
①用于调用kill产生信号的源代码:
#include <iostream> #include <signal.h> #include <unistd.h> #include <cstdio> #include <sys/types.h> static void Usage(const std::string &proc) { std::cout<<"\nUsage: "<<proc<<"pid signo\n"<<std::endl; } //在命令行上输入: ./mysignal pid(进程的pid) signo(信号的编号) 此时argc==3 int main(int argc,char* argv[]) { //当argc等于3的时候,跳过下面这条执行语句 if(argc != 3) { Usage(argv[0]); exit(1); } //将pid和signo转化成整型 pid_t pid = atoi(argv[1]); int signo = atoi(argv[2]); //通过kill产生信号 int n = kill(pid,signo); //如果失败,就输出kill if(n!=0) { perror("kill"); } return 0; }
②用于测试的源代码:
#include <iostream> #include <sys/types.h> #include <unistd.h> int main() { while(true) { std::cout<<"我是一个正在运行的进程,pid: "<<getpid()<<std::endl; sleep(1); } return 0; }
结果如下:
结论:信号是由用户通过系统调用发起,由操作系统执行的。
⭐2.raise接口
函数原型:int raise(int sig); 函数功能:将信号发送给调用者,即给自己发送信号 头文件:#include<signal.h> 参数:参数是传入的是几号信号 返回值:返回0代表成功
测试代码:
#include <iostream> #include <signal.h> #include <unistd.h> #include <cstdio> #include <sys/types.h> int main() { //raise()给自己发送任意信号 int cnt = 0; while(cnt<=10) { printf("cnt: %d\n",cnt++); sleep(1); //当到达5的时候,调用raise,发送3号信号SIGQUIT,终止进程 if(cnt>=5) { raise(3); } } //kill()可以给任意进程发送任意信号 return 0; }
结果:
其实raise()将信号发送给自己,kill也可以做到。
kill(getpid(),signal);
⭐3.abort()接口
函数原型:void abort(void); 函数功能:给自己发送指定的信号:SIGABRT,6号信号 头文件:#include<stdlib.h>
测试代码:将raise()替换成abort()
#include <iostream> #include <signal.h> #include <unistd.h> #include <cstdio> #include <stdlib.h> #include <sys/types.h> int main() { //raise()给自己发送任意信号 int cnt = 0; while(cnt<=10) { printf("cnt: %d\n",cnt++); sleep(1); //当到达5的时候,调用raise,发送3号信号SIGQUIT,终止进程 if(cnt>=5) { abort(); //raise(3); } } return 0; }
结果:
三种系统调用总结:
①kill是给任意进程发送任意信号
kill(pid,signo);
②raise是给自己发送任意信号
kill(getpid(),signo);
③abort是给自己发送指定的信号:SIGABRT
kill(getpid(),SIGABRT);
对于信号的处理,在很多情况下,进程接受到的大部分信号,默认动作都是终止进程。虽然信号有很多种,信号的不同,是代表着事件的不同的,但对事件发生之后的处理动作是一样的。就跟程序抛异常一样,抛异常的原因有很多种,但是最终结果都是让程序终止。因此,我们加下了分析信号产生的另一种情况:硬件异常产生信号。
硬件异常产生信号
除0造成的异常
先来看一个小小的测试代码:
#include <iostream> #include <unistd.h> int main() { while(true) { std::cout<<"我在运行中..."<<std::endl; sleep(1); int a = 10; a/=0; } return 0; }
结果如下:
这种错误我们都是知道的,那是因为除0了。那为什么除0就会使进程终止了呢?
当除0的时候,进程会收到来自操作系统的信号,这个信号是8号信号,SIGFPE。下面使用代码来证明这一点:
使用signal接口来捕捉SIGFPE信号并对其自定义,当这个信号被发送给进程,进程处理这个信号的时候,不再是默认终止进程,而是执行自定义动作。
#include <iostream> #include <signal.h> #include <unistd.h> #include <cstdio> #include <stdlib.h> #include <sys/types.h> void catachSig(int signo) { std::cout<<"获取到一个信号,信号编号是: "<<signo<<std::endl; } int main(int argc,char* argv[]) { //自定义信号,将SIGFPE捕捉 signal(SIGFPE,catachSig); // int a = 10; //放外面 // a/=0; while(true) { std::cout<<"我在运行中..."<<std::endl; sleep(1); int a = 10; //放里面 a/=0; } return 0; }
结果如下:会一直打印以下语句。没有终止进程。后续将代码拿出while循环,只执行一次除0操作,但结果依旧如下。
通过上面的测试,有以下两个问题:
①为什么只执行一次除0操作跟不断执行除0操作的结果是一样的?即我只执行了一次除0操作,为什么进程不断处理SIGFPE信号?
②操作系统怎么知道我除0了?
这一块就跟硬件有关系了。接下来,我们通过硬件来分析除0操作。
在CPU中,进程中的数据运算在其中计算,计算出来的结果存放在寄存器中,此时会判断结果是否合理,即有没有溢出等等。而在CPU内部,有一个叫做状态寄存器的寄存器,当状态寄存器中的溢出标志位从0变为1,说明数据溢出。
在除0的例子中,10除0,是可以被计算的,0被看成无穷小,溢出结果会溢出,结果会非常非常大,寄存器无法保存,于是就随便保存一点或者不保存,然后状态寄存器将其标记溢出。此时就是CPU运算异常了,此时操作系统自然知道了这件事。
状态寄存器中的溢出标记为从0置为1,操作系统就会马上识别到CPU内部出错了,然后操作系统会看看是谁导致CPU出错的,噢,是这个进程,因为正是这个进程正在调度这个CPU,于是,操作系统就知道了:①CPU运算异常。②是除0的进程导致的。于是向这个进程发送8号信号去终止这个进程!
解决了上面的第二个问题,再来看看第一个问题,为什么只执行一次除0操作,信号却一直被自定义处理。
从上面的测试代码的事实看到,收到信号后,进程不一定是终止的,一个没有终止的进程,就可能还会一直调度CPU,一直调度CPU,CPU中的状态寄存器的溢出标志位就会一直从0被置为1!此时操作系统就会不断地向进程发送8号信息。
野指针造成的异常
对空指针进行解引用,即野指针问题,也会使硬件异常产生信号。
测试代码如下:
#include <iostream> #include <signal.h> #include <unistd.h> #include <cstdio> int main() { while(true) { std::cout<<"我在运行中..."<<std::endl; sleep(1); int *p = nullptr; *p = 100; } return 0; }
结果如下:
为什么野指针使进程会崩溃?
野指针的问题,会使进程收到11号信号:SIGSEGV。11号信号的作用也是终止进程,事件为非法的内存引用。我们来证明一下:
#include <iostream> #include <signal.h> #include <unistd.h> #include <cstdio> void catachSig(int signo) { std::cout<<"获取到一个信号,信号编号是: "<<signo<<std::endl; } int main() { //自定义信号,将SIGFPE捕捉 signal(11,catachSig); while(true) { std::cout<<"我在运行中..."<<std::endl; sleep(1); int *p = nullptr; *p = 100; } return 0; }
结果如下:
操作系统是如何知道发生了野指针的问题的呢?
因为*p指向的地址是虚拟地址,当需要映射到物理地址空间的时候,是通过页表+CPU中的MMU寄存器去映射访问物理地址空间的。
当访问0号地址的时候,就是越界访问的时候,MMU就会发生异常,此时操作系统就会立马将这个异常接受,并且发送11号信号给进程,使得进程终止!
总结硬件异常从而产生信号:硬件异常是因为进程的不恰当操作,导致进程调度的CPU异常,操作系统通过这个异常情况给调度CPU的进程发送终止信号。这种信号的产生即没有通过终端按键产生,也不是使用系统调用产生的。
软件条件产生信号
软件条件产生信号的场景比如说:在使用管道进行进程间通信的时候,如果将读端关闭,而写端一直在写,操作系统就不允许这样的行为,此时就会发送SIGPIPE信号去终止写端的进程!这种即没有通过终端按键发送信号,也没有通过用户系统调用发送信号,也没有通过硬件的异常发送信号的场景,就是软件条件产生信号的情况。这个是管道的情况。那么接下来,我们使用定时器软件条件来感受一下软件条件产生信号的情况。
我们使用alarm函数来设定一个闹钟,当闹钟响铃后,进程会收到SIGALRM信号,进而终止进程。
函数原型:unsigned int alarm(unsigned int seconds); 函数功能:设置一个时钟来发送信号,发送的信号为SIGALRM 头文件:#include<unistd.h> 参数:参数就一个,按秒为单位 返回值:这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。
测试代码:
#include <iostream> #include <unistd.h> int main(int argc,char* argv[]) { //设立闹钟 //执行到alarm函数的时候,并没有立刻发送信号 //而是1秒之后再发送信号 alarm(1); int cnt = 0; while(true) { std::cout<<"cnt: "<<cnt++<<std::endl; } return 0; }
结果如下:执行程序一秒钟后便发送SIGALRM信号终止进程。
这份程序的意义是统计我们的计算机在一秒钟的时间里能够将数据累加多少次。可以看到,可以累加到6万多。
接下来改变一下这个代码,改变的是先不执行输出,而是先让它不断累积起来,然后捕捉SIGALRM信号,一秒钟后再将结果打印出来。
测试代码:
#include <iostream> #include <signal.h> #include <unistd.h> #include <cstdio> #include <stdlib.h> #include <sys/types.h> int cnt = 0; void catachSig(int signo) { std::cout<<"获取到一个信号,信号编号是: "<<signo<<"累加结果为: "<<cnt<<std::endl; //exit(1);没有退出 } int main(int argc,char* argv[]) { //设立闹钟 //执行到alarm函数的时候,并没有立刻发送信号 //而是1秒之后再发送信号 //捕捉信号 signal(SIGALRM,catachSig); alarm(1); while(true) { cnt++; } return 0; }
结果如下:这次统计出来的数量达到5亿!
这两种情况的计算机速度差了一万倍左右!原因是第一种情况不断地打印,即不断地访问了外设,外设的速度是很慢的!第二个例子是借助了alarm函数来感受了IO的慢。
alarm函数设立的闹钟只会响一次,也就是说只会发送一次信号,即使没有终止进程。
对于闹钟来说,任意一个进程都可以通过alarm系统调用在内核中设置闹钟,因此操作系统中会存在许多闹钟,操作系统就会把这些闹钟管理起来。如图:
进程退出时的核心转储问题
在说核心转储问题前,我先认识到,我是在云服务器上使用的Linux系统。然后我们再去看看信号,终止进程的信号的动作有两种:Term和Core。
Term和Core都是终止进程的意思,不同的是Term将进程终止了就没后续动作了,而Core将进程终止后,会进行核心转储。但是这个我们看不了,因为云服务器默认关闭了core file选项。
使用 ulimie -a查看:
因此,我们可以自己动手设置一下:
我们通过一个小小的测试来看看:
int a[10]; a[10000]= 123;
core dumped就是核心转储。核心转储的意思是当进程出现异常的时候,会在进程对应异常的时刻将内存中有效数据转储到磁盘中。
我们可以看到上面的结果中,出现了一个core.17358。其中,core就是核心转储,17358是对应进程的pid。
core.17358的作用是支持调试。在gdb中,可以直接找到出现异常的代码:
core-file core.XXX
信号的保存
阻塞信号
一些概念:
实际执行信号的处理动作称为信号递达(Delivery)。
信号从产生到递达之间的状态,称为信号未决(Pending)。
进程可以选择阻塞 (Block )某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号在内核中的表示
在内核数据结构中的信号,每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作handler。
pending位图中,比特位的位置代表着信号编号,比特位的内容,即0或1,代表着是否接受到信号。
block位图,比特位的位置也代表着信号编号,比特位的内容则是代表着是否阻塞信号。比如,在block位图中,4号位置的比特位为1,说明4号信号被阻塞了,因此4号信号无法递达,除非解除阻塞。也就是说,阻塞是提前阻塞的,提前将未来不想接受的信号阻塞。
handler数组中,其下标表示信号的编号,下标对应的内容存放的是信号的处理方法。
这就是信号在内核中的基本数据结构构成。
当我们使用signal函数自定义信号的时候,比如signale(signo,handler);就是通过signo找到这个下标,然后把handler的地址填入数组中。
当需要处理信号的时候,操作系统会根据pending位图结构,找到信号的编号,然后根据这个编号去数组中找到这个编号对应的处理方法。
结论:①如果一个信号没有产生,并不妨碍它可以先被阻塞。②进程识别信号,是由程序员在设计信号机制的时候,为进程设计了pending位图、block位图和handler表。这三个结构组合起来,就能去识别信号。
捕捉信号
上文说到,信号产生的时候,是在合适的时候才会去处理信号。那么这个适合的时候,就是内核态返回用户态的时候。
用户态:以用户的身份去使用操作系统自身的资源和硬件资源。
说明:用户要使用这些资源(访问内核或硬件资源),就必须通过系统调用。
内核态:以内核的身份去访问这些资源,但实际上执行这些系统调用的“人”是进程本身。
说明:系统调用往往会比较费时间,因此尽量避免调用系统调用。
进程怎么知道自己是内核态还是用户态呢?
进程的数据代码都会存放在CPU中,CPU中有许多的寄存器,其中一个叫CR3的寄存器就是表征当前进程的允许级别,对应的数字如果为0,那么就是内核态,如果为1那么就是用户态。
进程如何跑到操作系统中执行方法?
内核级页表和用户级页表
在虚拟地址空间中,我们一直所说的栈堆、常量区等等都是在用户空间中的。在虚拟地址空间中,还存在着内核空间。
每一个进程PCB可以通过页表,让虚拟地址空间跟物理空间建立映射关系,其中,用户空间使用的页表称为用户级页表。同样的,内核空间使用的页表称为内核级页表。
对于用户级页表来说,每一个进程都有自己独立的用户级页表,这样就能让每一个进程都能够通过自己的页表访问内存空间。但是内核级页表是让虚拟地址空间与物理地址空间中存放操作系统数据和代码的建立映射关系的,在计算机启动的时候,操作系统作被加载到了内存中,只有一份,是独一无二的。因此,内核级页表只有一份,每一个进程共享这一份。
访问步骤
因此,跟加载动态库,使用动态库的接口一样,当进程要访问OS的接口的时候,只需要在自己的进程空间的用户空间上跳转到内核空间,然后通过内核页表映射到内存中即可,让执行操作之后,返回到原本的空间即可,此时需要把CR3中对应的数字由0改为3。
那么用户能够去访问内核的接口或数据,是因为CPU中的CR3中对应的数字是0.而由用户态转成内核态,从3到0的操作,在调用系统调用的时候自动完成。
于是,我们了解了进程是如何从用户态转化成内核态了。进入内核态后,接下来就看看是如何进行信号捕捉的。
捕捉信号
当进程从用户态转到内核态后,并且执行完系统调用,此时并没有马上返回,本着来了都来了,不能就这么回去,于是会去检查block、pending和handler表。
首先检查block位图,从起始位置开始,如果是1,那么往下找,找到为0的时候,就转到pending位图去找,如果是0,那么直接返回block位图中继续找,如果是1,那么就转到handler表中找相应的信号的处理方法。
处理方法有三种:默认动作、忽略动作和自定义动作。
默认动作就是直接终止进程,忽略动作就什么也不干,就返回回去。若是自定义动作,则会转到这个方法中执行代码。
具体流程图
简化:
找到的图:
使用信号集操作函数实现信号保存的测试代码
接下来将使用信号集操作函数,将上面关于信号保存的理论测试一下,达到知行合一。
代码功能:在开始的时候没有在终端按键产生信号,此时会将pending位图中的比特位打印出来,此时打印的应该是全0。当按下Ctrl+c(或者别的信号)的时候,位图对应的比特位的位置的内容由0变1,接着通过自定义动作,不让进程终止。接着取消对信号的屏蔽,此时再次打印全0。
信号集操作函数:
类型:sigset_t。
sigset_t: 从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
#include <signal.h> int sigemptyset(sigset_t *set); 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有 效信号。 int sigfillset(sigset_t *set); 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系 统支持的所有信号 int sigaddset (sigset_t *set, int signo); int sigdelset(sigset_t *set, int signo); sigaddset和sigdelset在该信号集中添加或删除某种有效信号。 这四个函数都是成功返回0,出错返回-1。 int sigismember(const sigset_t *set, int signo); sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。 int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。 返回值:若成功则为0,若出错则为-1 如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。 #include <signal.h> int sigpending(sigset_t *set); 读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
sigprocmask()函数的屏蔽字。
测试代码:
#include <iostream> #include <signal.h> #include <unistd.h> #define BLOCK_SIGMAL 2 #define MAX_SIGNUM 31 static void show_pending(const sigset_t &pending) { for(int signo = MAX_SIGNUM;signo >= 1;signo--) { if(sigismember(&pending,signo)) { std::cout<<"1"; } else { std::cout<<"0"; } } std::cout<<std::endl; } static void myhandler(int signo) { std::cout<<signo<<" 号信号已经被递达!"<<std::endl; } int main() { //捕捉信号,自定义动作 signal(BLOCK_SIGMAL,myhandler); //1.先尝试屏蔽指定的信号 sigset_t block,oblock,pending; //1.1初始化 sigemptyset(&block); sigemptyset(&oblock); sigemptyset(&pending); //1.2添加要屏蔽的信号 sigaddset(&block,BLOCK_SIGMAL); //1.3开始屏蔽,设置进内核(进程PCB) sigprocmask(SIG_SETMASK,&block,&oblock); //2.遍历打印pending信号集 int cnt = 10;//计数 while(true) { //2.1初始化 sigemptyset(&pending); //2.2获取 sigpending(&pending); //2.3 打印 show_pending(pending); sleep(1); if(cnt--==0)//十秒后,消除对信号的屏蔽 { sigprocmask(SIG_SETMASK,&oblock,&block); } } return 0; }
结果:
sigaction
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传出该信号原来的处理动作。
#include <signal.h> int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
act结构体:
它与signal功能类似,通过修改act.handler表来自定义动作。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
测试代码如下:
#include <stdio.h> #include <string.h> #include <signal.h> #include <unistd.h> void handler(int signo) { //循环测试 while(1){ printf("get a signo: %d\n", signo); sleep(1); } } int main() { struct sigaction act; memset(&act, 0, sizeof(act)); //修改handler表 act.sa_handler = handler; sigemptyset(&act.sa_mask); sigaddset(&act.sa_mask, 3); //本质是修改当前进程的的handler函数指针数组特定内容 sigaction(2, &act, NULL); while(1){ printf("hello bit!\n"); sleep(1); } return 0; }
可重入函数概念
拿链表的插入操作举例子。当我们进入main函数,进入insert方法对链表进行节点头插,但当执行到head=p,即将新节点的地址交给头节点的这一步之前,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了,从而导致内存泄漏。
因此,对于像insert这样的函数一旦重入,就可能会导致问题的,表示该函数不可重入。而如果某个函数重入后,没有问题发生,表示该函数可重入。
不可重入的情况:
①调用了malloc或free,因为malloc也是用全局链表来管理堆的。
②调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile
我们站在信号的角度上理解volatile关键字吧。
测试代码:
#include <iostream> #include <signal.h> //全局变量 int flag = 0; void handler(int signo) { //修改值 flag = 1; std::cout<<"change flag 0 to 1"<<std::endl; } int main() { signal(2,handler); while(!flag); std::cout<<"这个进程是正常退出的"<<std::endl; return 0; }
分析一下代码:进入main函数后,捕捉2号信号,自定义动作为handler。然后继续往下执行到while,当这个while循环不断循环的时候,此时我们按下Ctrl+c,就会处理信号2,进入handler方法,修改flag值以及打印输出语句。返回来的时候,while循环条件不满足从而结束循环。
OK,基于这个代码,我们让编译器优化一下代码:
在自动化构建工具makefile中,加上-O3,表示优化一下代码;
test_volatile:test_volatile.cc g++ -o $@ $^ -std=c++11 -O3 .PHONY:clean clean: rm -f test_volatile
此时,我们在执行代码,然后按下Ctrl+c,发现,循环没有退出。我们可以试着猜测一下,循环没退出,那就是flag没有从0置为1。
原因是优化后,flag值直接被放到CPU的寄存器中,不需要再从内存中加载到CPU了,目的是提高效率。但是这样的话,因为flag一开始的值是0,0放到CPU中,即使我们后来的flag被置为1,但这是在内存中的,flag还是在CPU中的那个0.因此,while循环没有退出。
我们用volatile修饰全局变量flag后,最后可以退出循环了。
volatile int flag = 0;
因此,volatile的作用是保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
SIGCHLD信号
子进程退出,而父进程没有退出,导致僵尸进程的情况,其实子进程在终止的时候会给父进程发送SIGCHLD信号。我们可以捕捉这个信号并自定义动作,让其忽略这个信号,此时就可以让子进程退出后,自动释放僵尸进程。
测试代码:
#include <iostream> #include <signal.h> #include <unistd.h> #include <stdlib.h> int main() { //显示设置忽略17号信号,当进程退出后,自动释放僵尸进程 //只在Linux下有效 signal(SIGCHLD, SIG_IGN); pid_t id = fork(); if(id == 0){ //child int cnt = 5; while(cnt){ printf("我是子进程: %d\n", getpid()); sleep(1); cnt--; } exit(0); } while(1); return 0; }