目录
- Linux——信号
- 1.信号的基础了解
- 2.技术应用角度的信号
- 3.产生信号
- 3.1按键组合
- 3.2系统调用产生信号
- 3.2.1 kill()
- 3.2.2 raise()
- 3.2.3 abort()
- 3.3**.** 软件条件产生信号
- 3.4硬件异常产生信号
- 3.4.1 /0异常
- 3.4.2 内存越界异常
- 4.理解信号的存在
- 5.总结一下
- 6.核心转储
- 7.全部信号都可以被自定义捕获吗?
- 8.阻塞信号
- 9.信号在内核中的表示(信号的保存)
- 10.信号的捕捉流程(重要)
- 10.1用户态、内核态
- 10.2 CPU寄存器CR3
- 10.3进程地址空间的内核区
- 10.4用户态和内核态的切换时机
- 10.5总结
- 10.6官方解释
- 11. sigset_t
- 12.信号集操作函数
- 13.sigprocmask && sigpending
- 14.实验
- 让实验更有趣
- 15.sigaction
- OS对于信号的处理原则:
- 16.可重入函数 && 不可重入函数
- 17.volatile
- 18.SIGCHLD信号(与进程等待相关)
Linux——信号
信号是有生命周期的:
预备——信号产生——信号保存——信号处理(递达)
1.信号的基础了解
- 信号的概念
信号是进程之间事件异步通知的一种方式,属于软中断。
- 生活角度的信号
-
你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
-
当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
-
在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”
-
当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
-
快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
- 从进程的角度
- 信号是给进程主动发送的,进程需要先识别信号【认识+动作】
- 进程本身是程序员写的代码数据和逻辑以及属性的集合
- 当进程收到信号的时候,进程本身可能在执行其他任务,因此信号可能不会被立即处理
- 进程本身必须要有对于信号的保存能力。才能延迟处理信号
- 当进程开始处理信号(信号被捕捉)的时候,会有三种动作(默认,自定义,忽略)
那进程将信号保存到哪里了呢?——进程PCB【task_struct】
但是信号一共不是有很多个吗,这里一个unsigned int类型也只能保存32个信号。这是因为信号分为普通信号和实时信号,这里这个信号只保存普通信号[1, 31]
因此,发送信号的本质其实就是修改PCB中的信号标记位的信号位图!!!
而PCB属于内核数据结构,因此只有OS才能对其进行修改,因此不论是什么发送信号的方式,本质都是通过OS修改目标进程的PCB的信号位图【也就是说,OS必须提供信号相关的系统调用】
2.技术应用角度的信号
这里先写一个很简单的代码:
#include<iostream>
#include<unistd.h>using namespace std;int main()
{while(1){sleep(1);cout << "pid: " << getpid() << endl;}return 0;
}
如上图所示,当时遇到一个死循环的时候,输入一个ctrl + c
可以终止一个前台进程的运行。为什么?
这是因为ctrl + c
是一个热键,本质是一个组合键。OS识别到之后将其解释为2号信号
前面说了,当进程接收到一个信号之后,可能会采取3种动作(默认,自定义,忽略)
如果要查询信号的动作可以输入man 7 signal
来验证一下ctrl + c 就是被OS识别成了信号2
这里需要介绍一个接口signal
这个接口本事就是一个函数指针,当碰到了sig信号后,就会直接调用func函数
#include<iostream>
#include<unistd.h>
#include<signal.h>using namespace std;void handler(int signo)
{cout << "进程捕捉到了一个信号:" << signo << endl;//exit(1);
}int main()
{signal(2, handler); // 当进程接收到了2号信号就会执行handler函数while(1){sleep(1);cout << "pid: " << getpid() << endl;}return 0;
}
进程接收到ctrl + c,即2号信号就是自定义处理
此时输入kill -9 21717
就能杀死
如果handler函数自带exit,那么也能退出
注意:
Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
3.产生信号
3.1按键组合
就是类似ctrl + c
这样的组合按键,会被OS识别为特定的信号
这里在介绍一个ctrl + /
,会被OS识别为3号新号SIGQUIT
SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump
3.2系统调用产生信号
3.2.1 kill()
第一个接口:kill
这个接口也很简单,就是给参数pid的这个进程发送一个sig信号
成功返回0,失败返回-1
实验:模拟实现一个kill
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
#include<cstdio>using namespace std;
// 模拟实现系统调用的killint main(int argc, char* argv[])
{if(argc != 3){cout << "\nUsage: " << argv[0] << " pid signo\n" << endl;exit(1);}pid_t pid = atoi(argv[1]);int signo = atoi(argv[2]);int n = kill(pid, signo); //利用系统调用发送信号if(n != 0){perror("kill error");exit(1);}
}
如果此时有一个进程死循环,可以通过这个进程来实现kill的功能,通过命令参数,先传进程pid,在传signo【要发送的信号】
kill()函数可以向任意进程发送任意信号
3.2.2 raise()
raise函数可以给当前进程发送指定的信号(自己给自己发信号)。
int cnt = 0;
while(cnt != 10)
{cout << "cnt: " << cnt << endl;cnt++;if(cnt == 5)raise(3); // 自己给自己发送3号信号
}
执行结果如下:
这个函数很简单,这里不多讲
3.2.3 abort()
给自己发送指定的6号信号SIGABRT
这个函数其实就等价于kill(getpid(), 6);
这个也很简单,不多说
3.3**.** 软件条件产生信号
SIGPIPE是一种由软件条件产生的信号,在“管道”中已经介绍过了。
这里主要介绍alarm函数 和SIGALRM信号
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数
1s之后,os会给当前进程发送一个SIGALRM信号,即14号信号
这个alarm的意义是什么呢?其实就是统计1s内,某种累加行为能够累加到多少
上述代码执行的话最终会累加到4w~10w多次【取决于是本地虚拟机还是云服务器】,但是如果不输出到显示器上,而是一直while循环让cnt++,直至alarm发14号信号给进程的时候就将最终的cnt输出到屏幕上。此时cnt的值会是3亿多。这是因为cout输出到限制器上有一个与外设的IO过程。速度比起cpu来说很慢
拓展:
alarm是一个系统调用,很有可能有很多进程都在调用这个接口来在内核中设置闹钟。而为了更好的管理多个alarm的调用,依然是对alarm这个闹钟先描述后组织。
将闹钟管理起来的数据结构方法有很多,可以通过最小堆来实现。堆顶就是里超时时间最短的那个闹钟
3.4硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
3.4.1 /0异常
比如下面这段代码:当前进程执行了除以0的指令
运行的时候就会崩溃。这是在学c语言的时候就知道的
可以看到进程确实终止了,这是一个现象,那到底为什么会终止呢?其实就是OS给当进程发了一个8号信号SIGFPE!
但是OS凭什么给进程发送信号,它怎么知道是这个进程运算出错了呢?
这就和硬件相关了!
在CPU中,有很多寄存器,有一些寄存器负责计算,而有一个寄存器负责计算状态是否正确——状态寄存器
当CPU的运算单元/0发生计算出来的数是一个无穷大的数,为异常,那么状态寄存器的溢出标志位就会置为1!本次计算状态为溢出状态!
而CPU出现异常计算状态,那OS就会识别到这个异常,并判断CPU的状态寄存器,识别是什么异常,发现是溢出标志位为1之后,就会找到是那个进程正在调度CPU,就会将这个异常解释 为SIGFPE信号发送给进程。
3.4.2 内存越界异常
其实就是数组越界或者野指针问题
[hb@localhost code_test]$ cat sig.c
#include <stdio.h>
#include <signal.h>
void handler(int sig)
{printf("catch a sig : %d\n", sig);
}
int main()
{//signal(SIGSEGV, handler);sleep(1);int *p = NULL;*p = 100;while(1);return 0;
}
[hb@localhost code_test]$ ./sig
Segmentation fault
[hb@localhost code_test]$ ./sig
catch a sig : 11
catch a sig : 11
catch a sig : 11
11号信号就是SIGSEGV
那OS这里又是怎么知道是那个进程内存越界了呢?
一个进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
这个MMU是内存管理单元,它是集成在CPU中的
4.理解信号的存在
我们发现,目标进程对os发送给它的信号会做处理,但是这个默认的处理,多数都是直接终止进程
那设置这么多信号的意义何在呢?
信号的意义:不同的信号,代表着不同的情况/事件。
而对不同的情况/事件的处理动作可以一样,都是进程终止,但是不同的情况代表着不同的终止原因
就像/0异常和内存越界异常一样,一个是8号信号,一个是11号信号。这就说明了不同的信号可以代表不同的情况,从而快速定位出错的原因,来修正代码!
5.总结一下
上面所说的所有信号产生,最终都要有OS来进行执行,为什么?
OS是进程的管理者,只有OS有权力修改位于进程PCB的信号位图
信号的处理是否是立即处理的?
在合适的时候,这个合适的时候在学习信号捕捉之后就明白了
信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
是的,并且要被记录在当前进程的PCB的信号标记位
一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
应该知道
如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
发送信号的本质其实就是修改进程PCB中的信号位图。
6.核心转储
在Linux中,c/c++在发生数组越界访问的时候,不一定会报错。比如下面这个代码:
int main()
{int a[10];//a[10] = 1;//a[100] = 1;//a[1000] = 1a[10000] = 1; // 执行到这里才报错return 0;
}
这是因为在Linux中,向OS申请一个栈帧的时候,虽然a[10],只申请了10个空间,a也确实只能用10个空间,但是这不代表OS实际给你的空间就是10个int类型大小。
这里出现的错误也是段错误,11号信号
注意:在云服务器上,core除了终止进程所做的其他事情,是看不太到的。需要先打开限制
云服务器默认关闭了 这个选项,可以看到是0。
因此要输入ulimit -c 1024
来打开这个选项
此时多了一个core dumped
,意思就是核心转储,并且还会再当前目录生成一个文件
核心转储的概念:当进程因为异常而退出的时候,将进程在对应时刻的位于内存的有效数据转储到磁盘中!
这个文件的内容全都是二进制,直接打开行不通的
那核心转储的意义是什么呢?
**配合gdb来支持更好的调试,找到出错的原因!**在gdb上下文可以直接找到问题出错的地方和原因
7.全部信号都可以被自定义捕获吗?
先说结论:不可以,哪怕手动捕获了31个普通信号,但是OS仍旧不允许9号信号被捕捉。OS至少会保留一个9号信号来杀死异常的信号
代码如下:
#include<iostream>
#include<unistd.h>
#include<signal.h>using namespace std;void catsig(int i)
{cout << "捕获到信号:" << i << endl;
}int main()
{for(int i = 1; i < 32; i++){signal(i, catsig);}int cnt = 0;while(1){sleep(1);cout << "我正在运行: " << cnt++ << endl;}return 0;
}
尽管我们手动捕获了全部31个普通信号,但是9号仍然可以终止进程。
8.阻塞信号
-
实际执行信号的处理动作称为信号递达(Delivery)
-
信号从产生到递达之间的状态,称为信号未决(Pending)。
-
进程可以选择阻塞 (Block )某个信号。
-
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
-
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
9.信号在内核中的表示(信号的保存)
说了这么多,信号未达,信号递达,信号阻塞这些概念,实际上信号在内核中到底是怎么被保存起来的呢?可以看下面这张图:
pending是决定一个进程是否收到信号的位图,是一个32位的位图,可以存储31个普通信号,从右往左,第一个比特位置1就是当前进程接收到信号1
而block是决定一个进程是否阻塞了某个信号的32位的位图
结合pending和block两个位图,此时可以判断一个进程是否递达了某个普通信号,但是递达了之后要处理信号(默认、自定义、忽略)。而handler就提供了OS中对某个信号的处理方法
handler本质上是一个函数指针数组!每个下标都对应一个信号编号,而下标对应的内容,就是对应信号的处理方法!
那当一个2号信号发给一个进程的时候,在内核数据结构的角度来看发生了什么?
首先判断是否存在自定义处理信号2的函数,如果有就将该函数的地址填入到该进程的handler数组中下标为2的位置,然后判断该进程的block位图的从右到左第二个比特位是否为1,也就是判断该进程是否阻塞2号信号,如果不阻塞(比特位为1),就判断在pending位图的第二个是否为1,不为1就置为1,然后调用handler中存储的信号2的处理方法
因此,现在在对第一个图片的例子进行一个总结:
-
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
-
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
-
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
-
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。这里不讨论实时信号
10.信号的捕捉流程(重要)
10.1用户态、内核态
信号产生的时候不会立即被处理,而是在合适的时候!
这个合适的时候就是——从内核态转变为用户态的时候!要弄清楚信号的捕捉流程,就要弄清楚内核态和用户态是什么,内核态如何转变为用户态,什么时候?
很多时候,我们作为用户级,总是有对硬件和内核资源的访问需求,而硬件和内核资源又只能由OS访问,因此当我们的进程调用系统调用的时候,实际上是切换为内核态去执行的系统调用
那是CPU怎么知道,什么时候是用户态,执行用户代码,什么时候是内核态,执行内核级代码,这就要了解CPU的结构了
10.2 CPU寄存器CR3
在CPU中有很多看得见的寄存器也有很多看不见的寄存器,而cpu怎么执行进程的代码呢,这是因为有一个寄存器专门负责记载task_struct,也有一个寄存器专门负责页表的起始地址,因此,也有一个寄存器叫CR3,专门负责当前进程的运行级别
10.3进程地址空间的内核区
但是作为一个进程,我又如何能够切换到内核态,执行OS的方法呢?
这就要回忆之前所学的进程地址空间了。前面学的都是用户层面的进程地址空间**,完整的进程地址空间一共有4G,用户级占3G,内核级占1G**
因此内核级进程地址空间,自然也需要内核级页表来映射物理地址和虚拟地址之间的关系,而每次开机的时候,就会把os的相关数据加载到内存中。
由于所有用户层调用的都是一样的系统调用和内核数据,因此所有内核级页表一个就够了。也就是说,所有进程地址空间的内核区(3~4G)都是同一个内核级页表映射的!即每一个进程地址空间能够找到的OS都是同一个。
这也很合理,OS本来也只有一个,让所有的进程都看到同一个OS才是正常的。
这就意味着:一个进程想要调用系统调用,只需要在自己的进程地址空间,跳转到内核区,然后就可以调用了!
拓展:
进程切换有两种切换方式,一种是时间片策略,一个进程被OS强制切换了,还有一种是进程主动退出【通过系统调用的接口实现】,让给其他进程
10.4用户态和内核态的切换时机
知道了上面三个点之后。我们知道了,用户希望访问内核或者硬件资源,就会在自己进程的地址空间跳转到内核区找到对应的系统调用。但是凭什么用户就能够直接访问内核数据和硬件资源呢?不是说CR3会判断当前进程的执行身份吗?用户的进程肯定是3(用户态)
这是因为,当用户的进程调用系统调用的接口时,在系统调用中,会有代码将当前进程的身份从3(用户态)切换到0(内核态),然后再跳转到进程地址空间的内核区去访问OS的代码和数据。然后CPU才能接着执行
10.5总结
因此,当一个用户进程需要访问OS内核数据和代码的整体过程是怎么样的呢?
首先用户进程执行到系统调用的接口的时候,该接口会先将进程的用户态改为内核态,然后再自己的进程地址空间,从用户区跳转到内核区,然后CPU判断当前进程身份是否是内核态,然后再调用需要的OS的数据或代码。执行完后将内核态切换为用户态,然后回到进程地址空间的用户区,继续执行用户代码
这个时候回到我们一开始的问题,信号的捕捉流程,信号的捕捉其实就是对信号做处理。而信号不会立即被处理,是在内核态转为用户态的时候处理。这个过程是什么样的呢?来看看下图:
在这张图片中,我们可以看到,用户进程在调用系统调用接口之后,切换到了内核态【在切换之前所做的准备工作前面已经学习了】,访问了OS的相关代码和数据,此时执行完了之后,就准备要从内核态切换回用户态
而此时OS就会做一个工作!就是从当前进程的PCB中,找到信号的内核数据结构,判断当前进程是否保存了信号需要处理、如果检测到有一个信号未被block阻塞,也在pending中置1,那就会去handler的对应下标找到处理方法去处理。
而处理又分三种方式【默认,忽略,自定义】。而默认和忽略都很简单。
- 默认:大部分默认方法就是os内部规定的,而此时还处于内核态,因此顺便就将信号处理了,
- 忽略:将pending的对应的比特位置0,然后返回用户态继续执行
- 自定义:就需要在handler这个函数指针数组中,找到用户自定义的函数地址,然后跳转过去执行?不是的,此时进程处于内核态,无法执行用户态的函数!【os实际上从权限上看能执行用户态的代码,但是不可以这样做!】
- 因此自定义是这样处理的:在handler这个函数指针数组中,在对应下标找到用户自定义的函数地址,然后先切换回用户态!然后跳转过去执行自定义的方法,完成对信号的处理(捕获),然后再切换回内核态,最终在返回到用户态!
将上述过程抽象一下就得到下图,方便记忆
10.6官方解释
上述的理解都是比较偏主观的,没有那么官方,下面是一些官方的说法,会带上具体是如何实现的
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂、
举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了
11. sigset_t
每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。他们用的是同一种位图来表示
因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号 的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态
阻塞信号集也叫信号屏蔽字(Signal Mask)
12.信号集操作函数
**sigset_t类型并不是上面我们所讲的一样就是一个32位的位图,只能用来表示每个信号的有效或无效状态,它实际上还封装了一些东西,是一个结构体类型。**因此并不能简单的直接那这个类型的变量去做位运算操作,而是要使用os提供的接口
#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置位,表示 该信号集的有效信号包括系 统支持的所有信号。
-
注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的 状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号
-
signsmember: 可以查看set信号集的第signo个比特位是否有效
前四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含 某种 信号,若包含则返回1,不包含则返回0,出错返回-1
13.sigprocmask && sigpending
- sigprocmask
前面的信号集操作函数都是对一个信号集(sigset_t)类型进行操作,而sigprocmask就是直接对进程当前的阻塞信号集(block)进行操作。调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
how参数是一个选项,表示可以选择的操作,下图都是SIG开头,少截了
set参数是配合how的,如果要添加,就将set参数表示的信号集设置到当前进程的阻塞信号集。如果要解除,也是将其set表示的信号集设置到进程的阻塞信号集
oset参数是一个输出型参数,它就记载修改前的block
- sigpending
这个接口很简单,谁调用它,谁就能获取当前进程的pending信号集
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
14.实验
用前面学的几个系统调用来做一个实验:
#include<iostream>
#include<vector>
#include<signal.h>
#include<unistd.h>using namespace std;#define MAX_SIG 31static vector<int> sig_block = {2, 3}; // 要阻塞的信号都装在这个vector中void Print_Pengding(const sigset_t& pengding)
{//打印for(int i = MAX_SIG; i >= 1; i--){//判断pengding信号集第i位的的比特位是否为1if(sigismember(&pengding, i))cout << "1";else cout << "0";}cout << "\n";
}int main()
{// 1. 初始化信号集sigset_t block, oblock, pending;sigemptyset(&block);sigemptyset(&oblock);sigemptyset(&pending);//2. 添加要阻塞的信号for(auto& sig : sig_block)sigaddset(&block, sig);//3. 将要阻塞的信号添加到当前进程的block中sigprocmask(SIG_SETMASK, &block, &oblock);//4. 不断获取当前进程的pending信号集int cnt = 10;while(true){//4,1 初始化sigemptyset(&pending);//4.2 获取当前进程pengdingsigpending(&pending);//4.3 打印pending信号集Print_Pengding(pending);//慢一点sleep(1);if(cnt-- == 0){cout << "解除对信号的屏蔽\n";//将修改之前的信号集设置到当前进程sigprocmask(SIG_SETMASK, &oblock, &block); //一旦解除对信号的屏蔽,OS至少会立马处理一个信号!cout << "解除完毕\n";}}return 0;
}
执行结果如下:
这里有个问题,为什么cout << "解除完毕\n";
这个代码没有被执行呢?
因为我们这里没有自定义处理信号2,当信号被解除屏蔽之后,会立马处理信号2,而处理方法就是默认,就直接到handler数组中找到默认处理方法,然后处于内核态的情况下就直接处理了信号2,因此不会回到用户态了,自然不会执行用户态的下一步代码。
因此:如果不想要推出,可以用signal自定义捕获2号信号。并且在自定义方法内也不要退出。这样进程就会回到用户态
代码也很简单:
void handler(int signo)
{cout << signo << "号信号被捕获" << endl;
}
//对sig_block内的信号进行自定义捕获for(auto& signo : sig_block)signal(signo, handler);
此时执行效果如下:
但是此时打印出来的pengding信号集,无法显示某个信号被发送到该进程了。既无法看到发送的信号变成1了,如下图所示
这是因为,之前被阻塞的信号不在阻塞之后,每次发送到该进程都能直接递达,并且直接自定义方法处理。
让实验更有趣
因此想重新看到某个信号因为阻塞了,而处于未达状态,就要重新设置阻塞。也就是能重新看到pending信号集出现1
代码如下:
#include<iostream>
#include<vector>
#include<signal.h>
#include<unistd.h>using namespace std;#define MAX_SIG 31static vector<int> sig_block = {2, 3}; // 要阻塞的信号都装在这个vector中void Print_Pengding(const sigset_t& pengding)
{//打印for(int i = MAX_SIG; i >= 1; i--){//判断pengding信号集第i位的的比特位是否为1if(sigismember(&pengding, i))cout << "1";else cout << "0";}cout << "\n";
}void handler(int signo)
{cout << signo << "号信号被捕获" << endl;
}int main()
{//对sig_block内的信号进行自定义捕获for(auto& signo : sig_block)signal(signo, handler);// 1. 初始化信号集sigset_t block, oblock, pending;sigemptyset(&block);sigemptyset(&oblock);sigemptyset(&pending);//2. 添加要阻塞的信号for(auto& sig : sig_block)sigaddset(&block, sig);//3. 将要阻塞的信号添加到当前进程的block中sigprocmask(SIG_SETMASK, &block, &oblock);//4. 不断获取当前进程的pending信号集int cnt = 10;while(true){//4,1 初始化sigemptyset(&pending);//4.2 获取当前进程pengdingsigpending(&pending);//4.3 打印pending信号集Print_Pengding(pending);//慢一点sleep(1);if(cnt-- == 0){cout << "解除对信号的屏蔽\n";//将修改之前的信号集设置到当前进程sigprocmask(SIG_SETMASK, &oblock, &block); //一旦解除对信号的屏蔽,OS至少会立马处理一个信号!cout << "解除完毕\n";//如果想要sig_block内的信号重新被阻塞,这里可以设置cout << "重新设置阻塞\n";sigprocmask(SIG_SETMASK, &block, &oblock); //将之前被解除阻塞的信号重新设置阻塞cnt = 10; //重置,再来10s之后,再次进入该分支}}return 0;
}
此时这个代码会每个10s为一个周期,将阻塞的信号取消阻塞,然后os进行一次集中的递达,然后自定义处理,然后重新设置阻塞信号
执行效果如下:
15.sigaction
除了signal可以对信号进行自定义捕获,还要一个函数sigaction也可以进行捕获。这个函数的功能更加多样
#include <signal.h>int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
-
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo 是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传 出该信号原来的处理动作。act和oact指向sigaction结构体:
-
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动 作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回 值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信 号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用
OS对于信号的处理原则:
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字(block),当信号处理函数返回时自动恢复原来的信号屏蔽字(block),这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。
OS处理信号的原则:只允许串行的处理信号,而不允许递归
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需 要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
sa_flags字段包含一些选项,本章的代码都 把sa_flags设为0,sa_sigaction是实时信号的处理函数,这里不详细解释这两个字段
16.可重入函数 && 不可重入函数
这里不深讲,到线程部分会学习,这里就是引出一个概念,了解一下
一般来说,都认为main函数执行流和信号捕获执行流是两条执行流
就如下图所示:
insert是一个头插函数,本来按照main函数执行流,是应该让node1头插,但是当执行到node1的next指向原来的头节点,即将把head修改到node1的时候,此时发生了信号的捕获(自定义),在自定义处理函数中,再度调用了一次insert,此时头插了另一个节点node2,这就导致了当回到main执行流的时候,head从指向node2再度指向了node1。这造成了内存泄漏,因为node2是无效的
像上例这样**,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数有可能因为重入而造成错乱,像这样的函数称为不可重入函数**
反过来,如果一个函数不会因为重入而出错的话,就称为可重入函数
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
17.volatile
下面来聊一下编译器优化的问题,下面是一个代码,编译的时候不带优化选项
[hb@localhost code_test]$ cat sig.c
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int sig)
{printf("chage flag 0 to 1\n");flag = 1;
}
int main()
{signal(2, handler);while(!flag);printf("process quit normal\n");return 0;
}
[hb@localhost code_test]$ cat Makefile
sig:sig.cgcc -o sig sig.c #-O3
.PHONY:clean
clean:rm -f sig
[hb@localhost code_test]$ ./sig
^Cchage flag 0 to 1
process quit normal
按照本次代码的思路就是:有一个死循环while,如果当前进程递达了一个信号2,那么就会自定义处理,在handler函数中会将flag的值改为1,然后该进程就会退出循环,然后正常退出
但是当我们带上编译器的优化选项之后:会发现一个奇怪的现象——无论我们怎么发信号给当前进程,尽管会不断的递达,不断的自定义捕获2号信号,但是循环无法退出!
这是为什么呢?看下图:
因此,为了解决这个问题,volatile关键字就出现了
volatile:保持内存可见性
它的作用说白话就是告诉CPU,我volatile声明的变量,你不要给我保存在寄存器中,我要你每次取他的时候都要从内存中取
因此上面的代码只需要给flag带上一个volatile声明即可:
volatile int flag = 0;
18.SIGCHLD信号(与进程等待相关)
之前在进程等待和进程状态的时候,我们说过,当一个子进程死亡的时候,会进入僵尸状态,然后等待父进程来回收自己,而父进程也需要以阻塞或者非阻塞的状态去等待子进程。
但是子进程死亡的时候不是直接死的,在死之前会发一个SIGCHLD信号(17号)给父进程
这个信号的默认操作是Ign
在学习了信号后,现在有个想法:我是否能通过递达子进程发给我的信号,然后再自定义处理方法,来对子进程进行资源的回收和退出信息的获取
当然可以,前提是使用正确。【并且这样还有好处,之前是直接使用wait或者waitpid以阻塞的方式或者轮询非阻塞的方式进行等待,现在是一直在干自己的事情,等待子进程自己给我发信号,然后再进行进程等待】
写代码之前要小心两个点:
- 信号的处理原则:串行处理,而非递归。也就是如果同一时间有很多个子进程退出,那么在处理一个子进程发来的17号信号,会直接自动阻塞17号信号,这样就会导致大量进程无法被回收。因此在进程等待的时候,一定要while循环不断地进程等待
- 一定要选择轮询非阻塞等待,因为如果有很多进程,都是隔一段时间结束一个,那么阻塞式等待就会将整个进程都卡在处理17号信号的自定义函数上,父进程无法再做自己的事情。
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig)
{pid_t id;while ((id = waitpid(-1, NULL, WNOHANG)) > 0){printf("wait child success: %d\n", id);}printf("child is quit! %d\n", getpid());
}
int main()
{signal(SIGCHLD, handler);pid_t cid;if ((cid = fork()) == 0){ // childprintf("child : %d\n", getpid());sleep(3);exit(1);}while (1){printf("father proc is doing some thing!\n");sleep(1);}return 0;}
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法**:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。**
此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
也就是将signal(SIGCHLD, handler);
改为signal(SIGCHLD, SIG_IGN);
此时子进程退出就不在需要父进程的回收,而是直接被OS回收
- 注意:
系统默认的忽略动作Ign和用户用sigaction函数自定义的忽略SIG_IGN 是不一样的!
这里的Ign就是按照之前的流程,死之前给父进程发个17号信号,然后等待父进程来回收