信号保存+信号处理
点赞👍👍收藏🌟🌟关注💖💖
你的支持是对我最大的鼓励,我们一起努力吧!😃😃
上一篇博客,我们已经学过信号预备知识和信号的产生,今天讲讲信号保存+信号处理以及其他补充知识。
1.信号保存
1.1信号其他相关概念
补充一些概念。
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择 阻塞 (Block) 某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
阻塞和未决是两种状态。
1.2信号在内核中的表示
我们在OS内部,在进程的内部,我们要保存信号的周边信息,我们其实是有三种数据结构与我们的信号是强相关的。
第一种数据结构我们称为pending表。其实这个pending表就是一张位图。
之前我们说过,我们的进程可能在任意时刻收到OS给它发送的任意信号,而该信号可能并不会被立即处理,所有它要暂时被保存。之前我们又说过,进程为了保存信号,采用的是位图结构来保存它收到的信号,那么我们曾经所说的那张位图,在今天它就叫做pending位图,而凡是对应的信号被置为pending位图,我们称该信号处于未决状态。
第二种数据结构我们称为block表。block表也是一张位图。
假设OS发送2号信号,但2号信号被阻塞了。
也可以给某个信号预先设置阻塞状态
在说第三种数据结构handler表,我写一个东西,看看大家认不认识这个东西
typedef void(*handler_t)(int signo)
这其实这是一个函数指针。
所以可以在内核当中可以定义一个函数指针数组,来保存信号抵达的所有方法。
handler_t handler[32]={0};
这三种数据结构是内核中,信号的基本数据结构构成。
我们之前写的signal(signo,handler)捕捉方法,本质上就是拿着signo信号在对应的数组中查找对应的位置,将用户层所设置的该信号handler处理方法的函数地址填入到对应信号所对应数组下标里,未来当信号产生时,修改pending表对应信号的比特位由0->1,且该比特位没有被阻塞,OS拿着这个信号,根据信号的位置,反向得到信号编号,进而根据信号编号访问数组得到该信号的处理方法。
结论:
1.如果一个信号没有产生,并不妨碍它可以先被阻塞。
2.进程为何能够识别信号?
在上一篇信号产生,我们是这样说的,进程本身就是程序员编写的属性和逻辑的集合。所以粗略的说是由程序员编码完成的。当然这是没错的。现在我们说的更仔细一点。
程序员在设计信号这一套体系或机制的时候,在内核中给每一个进程设置了对应的三种数据结构,分别是pending位图,block位图,handler表,这三种结构组合起来就能完成识别信号的目的。
但是现在还有一个小小的问题。就是信号现在能产生了我觉得没问题,可是它是用位图来产生的,用位图来保存的,可以用一个比特位来表示是否收到该信号,这些都没有什么问题。但是呢,我是一个用户,我不断ctrl+c,假设我ctrl+c,100次。又或者这个进程因为某些原因而导致自己在某个时间段收到了大量的同一个信号,假如收到的是2号信号,把比特位由0->1,可是你是位图啊,只能记录下一个对应信号产生了,那如果我现在同时来了大量的相同信号,那此时是不是我们只能记录下一个呢,只能记录下一个是不是意味着其他的信号丢失了呢?
答案是的,我们在linux中学习的信号是普通信号,普通信号当我收到多次,并且没有来的及抵达处理的时候,那么对应的信号它只会统计一次,那么在处理一次之后后序剩下的相同信号就相当于被丢失了。
2.信号处理
2.1信号的捕捉流程
之前说过信号产生的时候,不会被立即处理,而是在合适的时候处理。
什么合适的时候?
从内核态返回用户态的时候,进行处理
先来了解什么是用户态和内核态。
以前我们在谈系统编程的时候,我们一直在谈一个概念,用户代码和内核代码这样的概念。其中我们要有两个重要概念,叫做平时我们所写的代码,比如数据结构的代码,算法的代码还有你自己所写的所有代码,在编译运行之后全部都是运行在用户态的。也就是说自己写的代码是属于用户态的代码。但是在自己写代码的时候,难免会访问两种资源。
无论是操作系统自身资源还是硬件资源,都是属于操作系统及其操作系统之下。
用户在自己写的代码访问这些资源,必须直接或间接访问去访问操作系统提供的接口,这批接口我们称为系统调用接口。
除了系统调用接口这件事情,还有一个事情,普通用户无法以自己用户态的身份来执行系统调用接口,必须让自己的状态变成内核态。
就比如说在你还是学生时,学校有些地方你是不能去的。然后当你毕业然后回当初学校当老师,你会发行你以前不能去的地方现在都能去了。你依旧是你,但是你的身份发送了变化,那么你的权限级别也要发送变化。
换句话说,系统调用除了我们在调用时调用这个函数,还有一个,就是我们还会发生身份的变化,从用户态->内核态
其中对我们来讲,因为我们从用户态到内核态身份的转变,还要去调用OS内部的代码,所以一般系统调用比我自己在应用层调自己写的函数方法成本要高(直接调用),所以往往系统系统调用比较费时间一些。
简单来说就是,你现在要执行系统调用,并不是你直接调用OS的代码,首先你要将你的身份做变化(用户态->内核态),然后才能执行系统调用。
所以系统调用一定比你自己写个函数去调用成本要高,因此尽量避免频繁调用系统调用。
那现在就有一个问题了。说起来用户态和内核态还是不太理解啊,我只知道从用户态到内核态就是权限变大了。从内核态到用户态权限变小了。那我怎么知道我当前是在用户态,还是在内核态呢?或者说那怎么知道当前进程在运行什么时候是用户态,什么时候是内核态?
进程在实际执行时,要把上下文数据投递到CPU中。
CPU中不仅有保存数据的寄存器,还有非常多的寄存器在进程中有特定用途。
其实还有一个CR3寄存器,表征当前进程的运行级别。
如何知道当前进程是用户态还是内核态,很简单只要查看CPU对应的CR3寄存器是为0还是3,就可以辨别出当前进程的运行级别。
虽然说了这么多但是还有一个问题
我一直不太理解:我是一个进程,怎么跑到OS中执行方法呢?
当前进程正在占有CPU,比如说当前进程有一个系统调用,系统调用是我现在这个进程要跑过去调用对应这个函数这个方法,说白了就是OS给我提供的方法。那么这个系统调用函数在哪里,又是如何跑到OS中执行这个方法的呢?
以前我们说过的进程加载到内存,进程要通过页表的映射访问物理内存的代码和数据。这些都没有什么问题
可是当时有一个东西没有讲,我们以前谈的进程地址空间都是用户空间(0-3G),图上还有一个内核空间(3-4G)没有说过,这个空间是干什么的呢?今天我们就来说说。
内核空间是用让当前进程来映射OS的。这里我们就不得不在引入一个概念。当时我们就说过进程地址空间和物理内存之间是通过页表来建立映射关系的。而进程的代码和数据是通过用户级页表与物理内存建立映射关系的。
除此之外,OS内部它还维护了一张内核级页表。内核级页表是OS为了维护从虚拟地址到物理地址之间的OS级别的代码所构建的一张内核级映射表。
当你电脑开机,说白了就是将操作系统的代码和数据加载到内存,而操作系统的代码和数据在内存中只会存在一份,不像进程的代码和数据可以有很多份。
操作系统在物理内存中的代码只有独一份,并且将内核级代码和数据映射到当前进程内核空间(3-4G)只需要使用内核级页表就行了,而每个进程都有(3-4G)内核空间要进行这种映射,所以内核级页表只有一份就够了。
也可以理解成CPU内部也有一个寄存器指向内核级页表,以后进程切换时,这个寄存器不变就可以了。
所以每个进程都可以在自己的进程地址空间特定的区域内以内核级页表的方式,经过内核级页表的映射去访问操作系统的代码和数据。
现在我们又知道了每个进程(3-4G)内核空间是属于操作系统的映射,所以我们进程建立映射时,可不仅仅是把用户的代码和数据和我们的进程产生关联,每一个进程还要通过内核级页表和OS产生关联。
现在你的代码调用了系统调用,其实就是在进程的上下文中跳转到内核空间找到对应方法,通过内核页表映射找到OS代码执行完之后返回到你的代码处继续往下执行。
每个进程都有(3-4G)内核空间,都会共享一个内核级页表,无论进程如何切换,都不会更改(3-4G)内核空间,所以每个进程都能去访问OS。
虽然我知道每个进程也都能去访问OS,但是还有问题。
1.进程凭什么能够执行内核的接口和数据呢?(或者跳转到(3-4G)内核空间呢?)
当进程想访问OS代码,OS捕捉到这个行为,先去读取CPU内部CPR寄存器,如果发现当前进程的运行级别是0内核态才允许访问,3就不允许。
2.用户什么时候从用户态就变成了内核态的?
系统调用接口,起始的位置会帮你做的。
3.怎么做的?
linux中有一条中断汇编指令叫做int ,中断编号 80,int 80这个汇编指令---->陷入内核,它就帮我们把用户态改成内核态。
到目前为止,我们把用户态和内核态及其相关概念说完了。但是我还是没有说信号怎么被处理的。接下来我们就开始。
从上面得知,系统调用,进程会进入内核态。
那假设我写的代码,确实没有调用任何的系统调用接口,就比如我就写了一个算法,只是用CPU资源,把代码放上去让CPU去跑。那这样的进程收到了信号,就不会处理任何信号了吗?
其实并不是的。你的代码再跑的时候有用户态的代码在跑,也还有内核态代码也在跑。虽然你的进程没有用过这些资源,但是你的进程要被OS调度。当时间片到达时,即使你的进程没有任何调用接口或者其他原因导致你陷入内核,但你一定要被调用,只要被调度,一定要把你这个进程从CPU上剥离下来,在放上其他进程或线程,谁拿的你呢?—>OS,所以无论是主观还是客观上或多或少都会涉及内核态的切换过程。
信号捕捉的过程。
假设你的进程执行到系统调用,经过一系列工作,在采用内核级页表访问内核函数。执行完函数之后,正常情况下是不是返回到系统调用处然后继续往下执行代码。但是一般不会这样干,好不容易来一趟不干点其他事情怎么能好意思呢,陷入内核本来就需要时间。所以OS在你返回的时候会做一件事情,当前我们处于内核态返回之前也还是处于内核态,是一个权限状态比较高的状态。所以OS在在对应的返回之前,找到task_struct,也就找到了三张表。
先查block表,如果没有被阻塞,然后再查pending表,(循环往下查找block表和pending表)如果没有信号。再往下面找,如果对应信号位置block表被阻塞了,直接往下走。对应信号位置block表没有被阻塞,并且pending表有信号,就读handler表,三种处理动作(默认,忽略,自定义)。
默认大部分都是终止进程,因为我现在是内核态啊,所以直接终止进程。
忽略,也处理这个信号,但是什么都不做,把比特位由1->0
自定义是最难的,handler方法是我们自己写的,这个方法是在用户态自己写的。只能跳转去执行。那没办法就跳转把。
那这里就有一个问题了,我们能不能以内核态的身份执行用户态代码呢?
答案是不能,OS不相信任何人,万一你在handler方法进行非法损坏OS呢?那OS就没有办法做出处理。所以即便你现在身份是内核态你能去执行handler方法,OS也不能让你这样干。
当执行自定义的handler方法时,有特定的调用,将自己的身份重新更改为用户态
并且调完handler方法(信号抵达)后,也不能直接从用户态返回到曾经调用系统调用的位置往下继续执行代码。因为不能确定从哪里跳转的。
必须要在经过特定的系统调用返回内核态
再经过特定的系统调用,从内核态返回调用的位置往下执行代码
到目前我们信号捕捉的所有流程才全部完成。关于几个特定的调用先不管。
现在这个流程有点复杂啊,我们来根据刚才的思路画一下简化这个流程。
当我正在执行代码时,经过一些特定的方式进入了内核。处理完内核工作后,检测当前进程的三张表,检测之后发现有信号要处理, 然后我就去捕捉这个信号,捕捉完成之后我在回到内核里,回到内核里我在进行返回,返回到当时的地方在往下执行代码。
这个像不像倒写的8
2.2sigset_t
内核中有两张位图,分别是pending位图和block位图,可这两张位图是内核的数据结构,用户是没办法直接修改的,而且如果你想改2个,改10个呢?OS没有办法设置十几个参数的函数,调起来太麻烦,OS为了能够让我们能够更好的使用信号,所以给统一定义了一个 sigset_t类型,
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
所以这个sigset_t类型,它是OS为了用户更好设置当前进程的pending表和block表而设置的的用户级数据结构,它是位图结构。我们一般称sigset_t为信号集,而我们的信号集分为两种,pending信号集,block信号集,block信号集我们一般称为信号屏蔽字。
sigset是什么东西,我们已经知道了,那怎么改呢?先介绍下面的接口然后再演示。
2.3信号集操作函数
函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位为1,表示 该信号集的有效信号包括系统支持的所有信号。
注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。
sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
sigprocmask函数
那个进程调用这个函数就可以修改那个进程的block表。
how:你想怎么修改屏蔽信号。
下表是how参数的可选值
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号(在原来的信号屏蔽字基础上做追加新增) |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽子中解除阻塞的信号(在当前信号屏蔽字中解除指定的信号) |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值(重置当前信号屏蔽字) |
set:做修改用的信号集
oset:如果oset是非空指针,则读取进程读取信号屏蔽字通过oset参数传出,也就是说,你想修改信号屏蔽字,oset是将修改之前的信号屏蔽字返回
sigpending函数
获取当前进程的pending信号集
set:通过用户定义的sigset_t类型对象,获取该进程的pending位图。
2.4实操
函数介绍完了,那现在来实操一下。
接下来我想实现这样的代码。
1.默认情况下,我们的所有信号都不是被阻塞的
2.默认情况下,如果一个信号屏蔽了,该信号不会被抵达
现在以2号信号为例,在刚开始的时候还没有收到2号信号,pengding位图全是0,然后我们阻塞了2号信号,那2号信号永远不能被抵达,那2号信号在那呢?这个信号必须一直被保存在pending位图中,我想一直打印出pending位图能看到这样这样的效果。没收到2号信号,pending位图全是0,收到但被屏蔽,2号信号所在位置一直是1。
#define BLOCK_SIGNAL 2
int main()
{
//1.先尝试屏蔽指定信号
sigset_t block,oblock;
//1.1初始化
sigemptyset(&block);
sigemptyset(&oblock);
//1.2添加要屏蔽的信号
sigaddset(&block,BLOCK_SIGNAL);
return 0;
}
到1.2,我给当前进程添加了2号屏蔽信号了吗?
其实并没有,我只是给当前block信号集添加了2号信号。
int main()
{
//1.先尝试屏蔽指定信号
sigset_t block,oblock;
//1.1初始化
sigemptyset(&block);
sigemptyset(&oblock);
//1.2添加要屏蔽的信号
sigaddset(&block,BLOCK_SIGNAL);
//1.3开始屏蔽,,设置进内核(进程)
sigprocmask(SIG_SETMASK,&block,&oblock);
return 0;
}
1.3才是把包含2号信号的信号集设置进当前进程。
第一个参数选SIG_BLOCK也可以。
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
#define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31
void show_pending(const sigset_t& pending)
{
for(int signo=MAX_SIGNUM;signo>=1;signo--)
{
if(sigismember(&pending,signo))
{
cout<<"1";
}
else
{
cout<<"0";
}
}
cout<<endl;
}
int main()
{
//1.先尝试屏蔽指定信号
sigset_t block,oblock;
//1.1初始化
sigemptyset(&block);
sigemptyset(&oblock);
//1.2添加要屏蔽的信号
sigaddset(&block,BLOCK_SIGNAL);
//1.3开始屏蔽,,设置进内核(进程)
sigprocmask(SIG_SETMASK,&block,&oblock);
//2.遍历打印pending信号集
sigset_t pending;
while(true)
{
//2.1初始化
sigemptyset(&pending);
//2.2获取pending信号集
sigpending(&pending);
//打印
show_pending(pending);
sleep(1);
}
return 0;
}
运行结果是我想要的。
现在我还想看到当我解除对2号信号屏蔽,信号被抵达之后,pending位图就没有2号有效信号了。
//2.遍历打印pending信号集
int cnt=5;
sigset_t pending;
while(true)
{
//2.1初始化
sigemptyset(&pending);
//2.2获取pending信号集
sigpending(&pending);
//打印
show_pending(pending);
sleep(1);
if(cnt-- == 0)
{
sigprocmask(SIG_SETMASK,&oblock,&block);
cout<<"解除对信号的屏蔽,不屏蔽任何信号"<<endl;
}
}
不对啊,运行结果与我预期不符,并且进程怎么直接终止了,连这句话也没给我打印。
修改一下代码看一下
//2.遍历打印pending信号集
int cnt=5;
sigset_t pending;
while(true)
{
//2.1初始化
sigemptyset(&pending);
//2.2获取pending信号集
sigpending(&pending);
//打印
show_pending(pending);
sleep(1);
if(cnt-- == 0)
{
cout<<"解除对信号的屏蔽,不屏蔽任何信号"<<endl;
sigprocmask(SIG_SETMASK,&oblock,&block);
}
}
这句话现在执行了,那为什么后面进程直接终止了呢?
原因很简单,因为ctrl+c的时候,对2号信号的处理是默认的。默认动作是终止这个进程。所有当sigprocmask解除对2号信号的屏蔽,那么此时这个进程就退出了。
那为什么这样写代码,看不到刚刚的打印呢?
因为,一旦对特定的信号进行解除屏蔽,一般OS要至少立马抵达一个信号!
也就是说,当你系统调用解除对2号信号的屏蔽,解除屏蔽了,然后从内核态返回用户态的时,OS顺手就帮你把2号信号抵达了,只不过抵达的时候默认处理动作是终止进程,终止进程不需要返回到用户态了,所有你看到这个进程就直接退出了,不会再执行后面的语句。
但我就是想看见2号信号由0->1,在由1->0怎么办,很简单把2号信号进行自定义捕捉,不让收到2号信号进程就退出就好了。
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
#define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31
void show_pending(const sigset_t& pending)
{
for(int signo=MAX_SIGNUM;signo>=1;signo--)
{
if(sigismember(&pending,signo))
{
cout<<"1";
}
else
{
cout<<"0";
}
}
cout<<endl;
}
void handler(int signo)
{
cout<<signo<<"号信号已被抵达!!"<<endl;
}
int main()
{
signal(2,handler);
//1.先尝试屏蔽指定信号
sigset_t block,oblock;
//1.1初始化
sigemptyset(&block);
sigemptyset(&oblock);
//1.2添加要屏蔽的信号
sigaddset(&block,BLOCK_SIGNAL);
//1.3开始屏蔽,,设置进内核(进程)
sigprocmask(SIG_SETMASK,&block,&oblock);
//2.遍历打印pending信号集
int cnt=5;
sigset_t pending;
while(true)
{
//2.1初始化
sigemptyset(&pending);
//2.2获取pending信号集
sigpending(&pending);
//打印
show_pending(pending);
sleep(1);
if(cnt-- == 0)
{
cout<<"解除对信号的屏蔽,不屏蔽任何信号"<<endl;
sigprocmask(SIG_SETMASK,&oblock,&block);
}
}
return 0;
}
如果想一次屏蔽多次信号,可以使用vector
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<vector>
using namespace std;
#define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31
vector<int> sigarr={2,3};
void show_pending(const sigset_t& pending)
{
for(int signo=MAX_SIGNUM;signo>=1;signo--)
{
if(sigismember(&pending,signo))
{
cout<<"1";
}
else
{
cout<<"0";
}
}
cout<<endl;
}
void handler(int signo)
{
cout<<signo<<"号信号已被抵达!!"<<endl;
}
int main()
{
for(auto& sig:sigarr) signal(sig,handler);
//1.先尝试屏蔽指定信号
sigset_t block,oblock;
//1.1初始化
sigemptyset(&block);
sigemptyset(&oblock);
//1.2添加要屏蔽的信号
for(auto& sig:sigarr) sigaddset(&block,sig);
//1.3开始屏蔽,,设置进内核(进程)
sigprocmask(SIG_SETMASK,&block,&oblock);
//2.遍历打印pending信号集
int cnt=5;
sigset_t pending;
while(true)
{
//2.1初始化
sigemptyset(&pending);
//2.2获取pending信号集
sigpending(&pending);
//打印
show_pending(pending);
sleep(1);
if(cnt-- == 0)
{
cout<<"解除对信号的屏蔽,不屏蔽任何信号"<<endl;
sigprocmask(SIG_SETMASK,&oblock,&block);
}
}
return 0;
}
想一想能不能屏蔽9号信号呢?
9号信号屏蔽不了,也不能自定义。可以自己试试看。
2.5捕捉信号的方法
目前我们就学了一种捕捉信号的方法:signal函数,使用比较简单,当然非常推荐。
接下来我们在学一个函数。
sigaction
和signal作用一模一样,对特定信号设置特定的回调方法,当触发信号时执行对应的捕捉动作。
不过它多了一个结构体sigaction,你没有看错它结构体的名称和函数体的名称是一样的。以前再写C的时候不会出现类型名和函数名一样的情况,但事实上它确实可以。
signum:特定的信号对象
act:你要设置特定的捕捉方法就要设置一个结构体对象进去(输入型参数)
这个结构体第一个参数
这就是我们曾经讲过signal函数要设置的函数指针。
实际要捕捉一个信号时,你要先定义一个结构体对象,然后把自定义捕捉方法设置进结构体对象中,进行相关对应的操作。
暂时不用管,设置为nullptr。
同样不用管,设置为0。
sa_mask的类型是sigset_t,它是一个信号集,OS为了方便用户更好设置当前进程的pending表和block表而设置的用户级数据结构。它的具体作用是什么呢,我们在写代码的时候再说。
这个sigaction结构体,只需要关心sa_handler,sa_mask就可以了。
oldact:是一个输出型参数,它是为了能够获取对于特定信号旧的处理方法。
接下来用用这个函数。
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
void handler(int signo)
{
cout<<"get a signo: "<<signo<<endl;
}
int main()
{
struct sigaction act,oact;
act.sa_handler=handler;
act.sa_flags=0;
act.sa_mask;//是干什么的先不说,但是它是信号集,先初始化一下
sigemptyset(&act.sa_mask);
sigaction(2,&act,&oact);
while(true) sleep(1);
return 0;
}
也确实能够捕捉2号信号。和signal作用一样,那这个函数到底和signal函数有什么差别呢?
先来做一个小实验。
void handler(int signo)
{
cout<<"get a signo"<<signo<<"正在处理中..."<<endl;
sleep(20);
}
当我们正在处理2号信号时,如果再来一个2号信号呢?如果系统允许我对信号再抵达,那我们是不是系统正在处理2号信号内部它又反过头在递归似的在调handler。换句话说,一个进程再未来时可能收到大量同类型的信号,如果收到同类型的信号,但我当前正在处理某一种信号时,那么接下来会有什么问题?OS允不允许我进行频繁的信号提交呢?
修改一下代码看的更清楚一些
void count(int cnt)
{
while(cnt)
{
printf("cnt :%2d\r",cnt);
fflush(stdout);
cnt--;
sleep(1);
}
}
void handler(int signo)
{
cout<<"get a signo: "<<signo<<"正在处理中..."<<endl;
count(20);
}
由上图可以看到,当正在执行2号信号时,后序同类型信号来了没有被递归似处理,并且发了这么多2号信号,只保留了前两个后面的都丢弃了。这是现象,基于这个现象我们来阐述一下结论。
当我们进行正在抵达某一个信号时,同类型信号无法抵达!
因为当前信号正在被捕捉,系统会自动将当前信号加入到进程的信号屏蔽字(block表)中。 当信号完成捕捉动作,系统又会自动解除对信号的屏蔽。
那为什么我们发送一堆2号信号,只执行了两次捕捉呢?不是系统又自动解除对信号的屏蔽了吗?既然只执行2次,那后序信号去哪里了?
当信号要被抵达时,OS会先将2号信号pengding位图位置由1->0,然后再抵达,当正在抵达的时候,这时你在发送2号信号就可以再把pending位图由0->1,你当然可以一直发送2号信号,但只有一张pending位图你只可以改一次,后序的这些2号信号根本没有意义,所有当我们把对应信号处理完之后,看到pengding表还有一个2号信号就又处理了一次。
一般一个信号被解除屏蔽的时候,会自动抵达当前屏蔽的信号,如果该信号已经被pending的话(也就是该信号在pending位图中的位置又由0->1),没有就不做任何动作。
我们进程处理信号的原则是串行的处理同类型的信号,不允许递归。
接下来我们这个sa_mask的作用。
sa_mask ,当我们正在处理某一种信号的时候,我们也想顺便屏蔽其他信号,就可以添加到这个sa_mask中。
就如上图,正在处理2号信号,但是3号信号照样可以干掉它,因为你只会屏蔽2号信号,处理那个屏蔽那个这是OS自动做的。
如果今天你也想把其他信号也屏蔽了,那么你就可以通过设置sa_mask来完成。
void count(int cnt)
{
while(cnt)
{
printf("cnt :%2d\r",cnt);
fflush(stdout);
cnt--;
sleep(1);
}
}
void handler(int signo)
{
cout<<"get a signo: "<<signo<<"正在处理中..."<<endl;
count(20);
}
int main()
{
struct sigaction act,oact;
act.sa_handler=handler;
act.sa_flags=0;
act.sa_mask;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask,3);//将3号信号也屏蔽掉
sigaction(2,&act,&oact);
while(true) sleep(1);
return 0;
}
刚才我发3号信号,可以把进程终止,现在发3号信号进程不会终止,证明确实把3号信号屏蔽了。
但我2号信号发了这么多,怎么进程最后退出了?
当信号抵达完,OS会自动解除被抵达的信号的屏蔽,以及你刚才顺便屏蔽的其他信号。
所以第一次2号信号抵达的时候,3号信号也被屏蔽了,所以你发3号信号没有用,当2号信号抵达完成之后,解除屏蔽,3号信号也被解除屏蔽,可是你别忘了你还有一个2号信号,这个2号信号被抵达时,3号信号又被屏蔽了来不及处理,最后2号信号再次抵达完成,解除2号屏蔽和3号信号的屏蔽(刚才说了发送一堆就执行两次2号信号的原因),现在3号信号就可以抵达了。
至此我们的信号终于全部讲完。内容比较干,值得好好学习。
接下来我们再说说与信号相关的一些知识。
3.可重入函数
上面是不带头单向链表的头插。我们看到的main函数中正在执行头插函数,头插node1完成了第1步,正在准备往下走到第4步的时候,因为一些原因触发了信号捕捉的一些动作(比如进程的时间片到了,进程被挂起,零点几秒又被唤醒,唤醒之后从内核态返回用户态做信号检测,发现有信号然后就跑过去执行handler方法了),可是handler方法很不幸,它的内部也做了insert方法,把node2头插入链表。插入之后
返回,handler方法执行完之后也返回了,然后进行第4步。 head就不再指向node2,而指向node1了最终导致内存泄漏。我们代码也没有写错,就因为正在执行主执行流(main执行流),突然主执行流在某些特定时间段内而导致信号捕捉,而又插入node2,最终导致内存泄漏。
一般而言,我们认为main执行流和信号捕捉执行流只两个信号流。而在这两套执行流中, main执行流进入insert函数正在访问这个函数期间,另一个执行流也进入到这个函数,并且因为这两个执行流都在重复进入insert函数,导致代码出现出错情况,我们将insert称为不可重入函数。
1.一般而言,我们认为main执行流和信号捕捉执行流只两个信号流。
虽然在当前我们的代码中执行是串行的,main执行流要停下来才能执行handler,handler在执行时,main就没执行了, 当我正在执行main函数对应的方法时,handler方法一定没有调用,因为我们只有一个进程,所以理论上其实我们只有一个执行流。
但是我们可以发现,main执行流它和handler方法并不是正常调过去的,而是信号到来它回调过去的直接执行自定义方法,最典型的代表,我设置了一个捕捉方法,如果这个信号永远没有发生,那么只有main执行流在跑,但如果有这个信号产生了,对应的信号捕捉方法也被执行了,这意味着我们正在执行main方法,可能跑过去执行其他方法了,所以我们认为它们时两个信号流。
2.如果在main中,和在handler中,该函数被重复进入,出问题-----称该函数(insert)为不可重入函数。
如果在main中,和在handler中,该函数被重复进入,没有出问题-----称该函数(insert)为可重入函数。
那这个可重入函数和不可重入函数是问题吗?
并不是,我们目前大部分情况下使用的接口有个75%,全部都是不可重入。
这个可重入函数和不可重入函数是特性,是一个中性词。
如果一个函数符合以下条件之一则是不可重入的:
调用了malloc或free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
4.volatile
C语言有32个关键字,其中大部分都见过使用过,但是volatile很少使用,今天我们站在信号的角度重新理解一下。
看下面一段代码,捕捉到2号信号,让quit变成1,然后循环条件不符合,就终止循环。往下执行代码。
#include<stdio.h>
int quit=0;
void handler(int signo)
{
printf("%d 号信号,正在被捕捉!\n",signo);
printf("quit: %d",quit);
quit=1;
printf("-> %d\n",quit);
}
int main()
{
signal(2,handler);
while(!quit);
printf("注意,我是正常退出的\n");
return 0;
}
得到这个结果不是很正常嘛,就和没讲一样。
下面我们做一个工作,在gcc或g++在进行编译的时候,不知道有没有听说一个概念,编译器优化。我们刚才的代码有没有编译器优化呢?是有的,不过优化的不是 那么过分。现在呢,我想把我们的代码优化等级提高一下,
像我们一般的优化级别有下面这么多。我们默认的优化是O1,O2这样的级别。
现在我们把优化级别改成O3。
我发了2号信号,quit由0->1了,为什么优化后进程不退出了?
多发几次,quit就是1,while循环条件不满足进程应该退出的。但是进程还是没有退出。为什么?
既然说了优化,这肯定和优化有关,循环没退出,证明条件不满足,但是我quit已经变成1了,这优化把我的quit优化到哪里去了。
申请quit全局变量肯定是要在物理内存给我把对应的空间开辟出来。CPU在运算的时候永远要做3件事情。
前3条指令跟CPU本身有关,而最后一个属于我们的固定流程,数据处理完了如果用户需要就给用户写回去。
如何理解while呢?
while循环对quit做检测,逻辑上它应该重复着做取对应的quit内容,然后在里面做判断。换句话说它就不断的从内存中取数据。
可是呢,我们刚刚讲过main执行流和信号捕捉指令流是两个执行流,当编译器在一般优化的时候,它默认遵守,取quit,在CPU里判断,判断为真,然后执行后序代码,只不过后序代码为空。然后不断从内存中取数据执行上述操作。所以当你不做优化时,你进行信号捕捉,执行信号捕捉方法,把quit改1,改1之后回到main执行流,main执行流还依旧在做读取quit值,读到CPU里,做逻辑运算结果为false,循环条件不满足,就执行打印。没有优化时它是这样的。
可是我们后来带来一个优化选项,所谓的优化就是编译器觉得在main执行流中,发现while循环中quit只被做检测,没被做修改,它就觉得既然没修改,就可以直接在编译器编译时建议它,当然quit是全局变量空间还要开辟,只不过在正常运行时,默认优先把quit的0值直接放到了寄存器,从此往后在检测,再也不去做从内存中load数据的行为了,while循环检测这条语句执行时,只是再看寄存器的值,因为这个值是0,所以条件一直被满足,后来做信号捕捉时把quit改1了,修改的是内存中的quit的值由0->1,和我当前在CPU内保存的预加载的优化到寄存器的quit没有任何关心,所以呢你再怎么改内存中的值,寄存器中的值不变,while循环一直处于检测值为0逻辑变为真,所以即使你确实是把quit值改了,程序一直不退出。
所以就可能会存在代码没有问题,因为编译器优化级别和策略(不同编译器优化级别不一样),而导致我们的程序没有按照我们的预期来进行工作。
所以为了解决这样的问题我们就需要使用volatile关键字。
在C/C++的范畴里只谈一个常见作用,叫做,保持内存可见性
大白话就是,while循环在这里做检测,虽然quit不会再main执行流中做修改,但以后在检测quit值时,请不要给我给我优化到寄存器里,我要时时刻刻每一次检测都要从内存里读,我要保持内存的可见性,而不是每次读都通过寄存器来覆盖我们物理内存当中的某个变量。这就叫做保持内存可见性。
同样的代码我们给quit带一个volatile再看一下效果。
#include<stdio.h>
volatile int quit=0;
void handler(int signo)
{
printf("%d 号信号,正在被捕捉!\n",signo);
printf("quit: %d",quit);
quit=1;
printf("-> %d\n",quit);
}
int main()
{
signal(2,handler);
while(!quit);
printf("注意,我是正常退出的\n");
return 0;
}
和最开始的一样,可以正常退出。
所以以后写全局变量,万一代码不通过或编译通过,但结果不符合预期时,代码怎么查问题都找不到,要想到这个场景。
5.SIGCHLD信号
这个信号和进程等待有关。
在以前讲进程等待时说过一句话,如果一个子进程退出了那么它就会变成僵尸状态,然后呢让我们的父进程去读取它,父进程在等待子进程时,如果子进程并没有退出,那么父进程就要阻塞或非阻塞去等待子进程,直到子进程退出了,父进程wait就会成功返回,获取子进程的退出码,退出信号。这些都是之前说过的。
可是呢有一个事实,是以前刻意回避的,这个事实就是当子进程在退出时它会僵尸,但它不是默默无闻的进入僵尸状态,而是它在死的时候告诉父进程我死掉了,也就是父进程在进行某种工作的时候,子进程在它死亡时会主动告诉父进程我死了。 它是通过向父进程发送SIGCHLD信号来告知父进程自身的死亡。
子进程在死亡的时候,会向父进程直接发送SIGCHLD信号,通过这个信号告知父进程我死了。只不过对于SIGCHLD(17号)这个信号父进程默认处理动作是Ign(忽略),这个忽略是内核级的忽略,和我们等下讲的忽略有一点点差别。
怎么证明呢?子进程在死亡的时候,会向父进程直接发送SIGCHLD信号。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<signal.h>
void count(int cnt)
{
while(cnt)
{
printf("cnt :%2d\r",cnt);
fflush(stdout);
cnt--;
sleep(1);
}
printf("\n");
}
void handler(int signo)
{
printf("pid :%d, %d号信号,正在被捕捉!\n",getpid(),signo);
}
int main()
{
signal(SIGCHLD,handler);
printf("我是父进程,pid: %d, ppid :%d\n",getpid(),getppid());
pid_t id=fork();
if(id == 0)
{
printf("我是子进程,pid: %d, ppid: %d,我要退出啦\n",getpid(),getppid());
count(5);
exit(1);
}
while(1) sleep(1);
return 0;
}
子进程退出确实向父进程发送SIGCHLD信号。
那有什么意义呢,给我们带来什么好处呢?
好处就是以前我们并不知道子进程什么时候退出,所以只能主动去调用waitpid和wait这样的函数,如果子进程还没有退,父进程还得拉着老脸一直在等,并且不管是阻塞等还是非阻塞等,父进程得一直问,要不然就不知道子进程退了。
但今天就不一样了,我捕捉一下SIGCHLD信号就意味着,我以后再也不关心子进程了,你退的时候告诉我,我来执行对应的回收方法就可以了。所以我们就可以在handler方法里来对子进程进行回收。
可是如果就按照刚才的思路写代码,这个代码并不是一个非常健壮的代码。为什么呢?
1.有没有一种可能父进程有非常多的子进程,在同一个时刻退出了。那此时有什么后果?
void handler(int signo)
{
//伪代码
//1.父进程有非常多的子进程,在同一个时刻退出了
waitpid()
//printf("pid :%d, %d号信号,正在被捕捉!\n",getpid(),signo);
}
是不是同时都在向你发送SIGCHLD,前面刚讲,当你正在抵达SIGCHLD的时候,其他SIGCHLD都会被屏蔽,更重要的是,保存这个信号的位图只有一个,如果同时
来了SIGCHLD信号要被设置,那位图只能设置一个其他的SIGCHLD信号都被丢失。换句话说,如果你的handler方法里只调用一次waitpid()是不对的,因为你会遗漏掉那些已经退出的子进程发送的SIGCHLD信号。waitpid()只会调用一次是不合理的。那怎么办呢?这就决定了我们在等的时候,必须循环去等。
void handler(int signo)
{
//伪代码
//1.父进程有非常多的子进程,在同一个时刻退出了
waitpid()---->while(1)
//printf("pid :%d, %d号信号,正在被捕捉!\n",getpid(),signo);
}
while循环去等的意思是,虽然我收到SIGCHLD信号子进程退出了,但是我不确定有几个子进程退出,那我就while循环调用waitpid(),waitpid()第一个参数是要等待进程的pid,我们可以设置成-1。
void handler(int signo)
{
//伪代码
//1.父进程有非常多的子进程,在同一个时刻退出了
waitpid(-1)---->while(1)
//printf("pid :%d, %d号信号,正在被捕捉!\n",getpid(),signo);
}
这个意思是,我可以等待任意一个子进程退出。所以呢我只要把waitpid()第一个参数设置为-1,然后while循环一直回收,直到waitpid()函数出错的时候,出错就对应我把退出的子进程全都回收了。
2.有没有一种可能父进程有非常多的子进程,在同一个时刻只有一部分退出了。假设有10个子进程,只有5个退出了,即使只有1个退出,也得循环。循环把5个退出的子进程回收了,那第6次还调不调waitpid()?
void handler(int signo)
{
//伪代码
//1.父进程有非常多的子进程,在同一个时刻退出了
//waitpid(-1)---->while(1)
//2.父进程有非常多的子进程,在同一个时刻退出一部分
while(1)
{
pid_t id=waitpid(-1,NULL,0);//阻塞等待
}
//printf("pid :%d, %d号信号,正在被捕捉!\n",getpid(),signo);
}
举个栗子,你父母关系非常好,你爸会把工资给你妈,你妈每个月给你爸500零花钱。具体你妈给你爸多少钱你也不知道,有一天你找你爸要100块钱,你是知道你妈每个月都给你爸零花钱的。你爸二话没说就给你了,第二天你找你爸再要100你爸又给你了,第三天同样如此,请问第四天你还会找你爸要钱吗?肯定会的啊。因为你要他就给你要他就给,证明他还有钱。因为你没站在上帝视角,不知道你爸还有多少钱。
同样道理,10个子进程5退出了,也回收了,问你还要不要第6次,你怎么知道5个进程退出了?答案是你根本不知道,因为刚才那句话是站在上帝视角,才知道有5个退了。
站在进程的视角,它只知道自己创建了10个进程,根本不知道有几个子进程退出了。就和你一样根本不知道你爸有多少零花钱一样。所以把5个子进程回收了,还要再进行waitpid()。注意waitpid()刚才可是阻塞等待。如果第6个,第7个子进程没退那不就尴尬了,你还在while循环中,出什么问题了?
当前你的进程在handler方法里就出现了阻塞式调用。阻塞式调用的时候,你就无法返回了。
所以正常写代码,你必须非阻塞式等待所有子进程,
void handler(int signo)
{
//伪代码
//1.父进程有非常多的子进程,在同一个时刻退出了
//waitpid(-1)---->while(1)
//2.父进程有非常多的子进程,在同一个时刻退出一部分
while(1)
{
//pid_t id=waitpid(-1,NULL,0);//阻塞等待
pid_t id=waitpid(-1,NULL,WNOHANG);//非阻塞等待
if(id == 0) break;
}
//printf("pid :%d, %d号信号,正在被捕捉!\n",getpid(),signo);
}
当有子进程退出的就直接回收,当没有子进程退出也不会阻塞,并且我在返回,证明这轮的子进程全部回收完了。
下面是回收子进程的代码
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig)
{
pid_t id;
//>0就回收,==0或<0出错就不回收了
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 id=fork();
if(id == 0)
{
printf("child : %d\n", getpid());
sleep(3);
exit(1);
}
while(1)
{
printf("father proc is doing some thing!\n");
sleep(1);
}
return 0;
}
虽然说了这么多,但在这里想说的还不是这个。
最重要的知识是,其实在处理子进程退出的时候我们也可以选择不waitpid()它。
事实上,由于UNIX的历史原因,Linux是脱胎于UNIX的,要想不产生僵尸进程除了以前讲的阻塞和非阻塞式等待,以及信号式的等待,还有一种就是,你可以调用sigaction将SIGCHLD的处理动作显示设置为SIG_IGN(忽略),那么从此往后父进程可以不用在等子进程了,子进程退出它会自动的变成僵尸,然后自动的被OS自动的回收。不会再通知父进程了。
int main()
{
//signal(SIGCHLD,handler);
//显示的设置对SIGCHLD进行忽略
signal(SIGCHLD,SIG_IGN);
printf("我是父进程,pid: %d, ppid :%d\n",getpid(),getppid());
pid_t id=fork();
if(id == 0)
{
printf("我是子进程,pid: %d, ppid: %d,我要退出啦\n",getpid(),getppid());
count(5);
exit(1);
}
while(1) sleep(1);
return 0;
}
刚开始父子进程都在,最后只剩下父进程了,子进程自动被回收了。
如果你创建出一个子进程,你再也不想等待它,每次等待太恶心了,那你就可以直接对SIGCHLD进行手动忽略。这种方法只再linux下有效,其他不做保证。
这里还有最后一个问题。查信号手册的时候,好像看到SIGCHLD,对17号信号默认处理动作就是Ign(忽略)啊。
这里还显示设置SIGCHLD,为SIG_IGN(忽略)。
它本来就是还设置干什么?
默认处理动作是Ign和我们手动设置SIG_IGN表示出来的特性是不一样的,当你使用默认的Ign时它还是我们之前那个流程,收到信号就处理你,处理你我就捕捉这个信号,然后该等还是要等,子进程退出了它会僵尸。但是如果我们设置了SIG_IGN,OS内部它就会修改,你可以理解成它直接修改的是未来创建子进程的时候,因为你肯定是先设置signal函数, 后面才调用fork,当你fork的时候OS它会直接去识别对于子进程的处理动作是什么样子,如果手动的用户设置了SIG_IGN,系统就设置让子进程退出自动回收,OS回收的。这个SIG_IGN和系统的Ign在数值和区分度一定是不一样的。
还有一种就是显示对信号设置为默认处理
关于信号的内容终于全部搞定!!!内容较干,值得好好品尝!!!