Linux——进程信号

Linux——进程信号



一、信号的概念

什么叫做信号信号是一种向目标进程发送通知消息的一种机制

你在网上买了很多件商品,再等待不同商品快递的到来,但即便快递没有到来,你也知道快递来临时你该怎么处理快递,也就是你能“识别快递”,当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了,也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”
快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话,你并没有一直在等快递,而是在干自己的事情

在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”当你时间合适,顺利拿到快递之后,就要开始处理快递了

而处理快递一般方式有三种:
1. 执行默认动作(幸福的打开快递,使用商品)
2. 执行自定义动作(快递是零食,你要送给你你的女朋友)
3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)

红绿灯,我们从小就知道,红灯停绿灯行,无论我们在何时何地,无论面前是否有这个红绿灯,我们在脑子里都知道当碰见红绿灯时如何正确处理,也就是我们受到了教育,本质是将处理方法记住了,所以在没有信号来临的时候,我们依然要知道信号的处理方法

结论:

1. 信号没有产生的时候,其实我们已经能够知道,怎么处理这个信号了
2. 信号的到来,我们并不清楚具体什么时候,信号到来相对于我正在做的工作,是异步产生的
3. 信号产生了,我们不一定要立即处理它,而是我们在合适的时候处理
4. 我要有—种能力,将已经到来的信号,进行暂时保存

对于一个信号,首先要识别它,并且需要知道信号意味着什么,要做什么

Linux下查看信号的指令kill -l
在这里插入图片描述
通过观察可以发现以上信号大体来讲是依次递增的
但没有0号、32号、33号信号,只有1 ~ 31号, 34 ~ 64信号
【1 ~ 31】叫做普通信号
【34 ~ 64】叫做实时信号

没有0信号,意味着0是正常运行,未收到任何信号

二、信号的产生

2.1 补充——前台进程与后台进程

在之前的学习中,我们知道终止一个进程可以在bash中按下ctrl + c
而其本质就是向进程发送信号——2)SIGINT

ctrl + c其实是向前台进程发送二号信号

何为前台进程?何为后台进程?

前台进程:所谓前台,是指一个进程控制着标准输入和输出,在程序执行时,shell暂时挂起,程序执行完毕后回到shell,前台进程运行时,在同一个控制台上用户不能再执行其他的程序
后台进程:所谓后台进程,是指一个程序不从标准输入接受输入,一般也不将结果输出到标准输出上,一些运行时间较长、运行之后不需要用户干预的程序适合运行在后台

在命令行操作时前台进程只能有一个,而后台进程可以有多个

在链接到云服务器时候,bash进程默认是前台进程,当我们想将自己的程序跑起来的时候,执行./xxxxx,此时OS会将bash放入后台进程,将我们的程序变成前台进程,这时输入命令就没用了,因为我们输入命令本质上是让bash进程执行我们的指令

ctrl+c 终止前台进程
在这里插入图片描述
./xxx执行默认是前台运行进程
在这里插入图片描述
ctrl+c 终止前台进程,值得注意的是此时由于exe为前台进程,bash进程变成了后台进程,我们输入指令是没有任何效果的

./xxxxx & 以后台运行一个进程
在这里插入图片描述
这时我们以后端运行一个程序,会发现此时输入指令是有效果的,这是因为bash此时还是前台进程,这时候我们 ctrl+c 是终止不了我们写的死循环的,因为 ctrl+c 是终止前台进程,而现在前台进程是bash,由于bash的特殊功能性, ctrl+c 对其是无效的,此时如果想将死循环关闭,可以将其从后台放到前台
在这里插入图片描述

jobs 显示当前终端关联的后台任务情况
在这里插入图片描述

fg + num 将后台任务变成前台,num则是jobs中的后台进程中的序号

在这里插入图片描述
ctrl + z 暂停前台进程并放到后台
bg + num 将后台暂停的进程重新开始运行

前台进程不能被暂停(ctrl+z),如果被暂停,该前台进程,必须立即被放到后台
OS会根据需要,自动的把shell提到前台或者后台

2.2 信号产生的四种情况

2.2.1 外设

ctrl + c:是一个组合键,OS将它解释成2号信号(SIGINT)
ctrl + \:是一个组合键,OS将它解释成3号信号(SIGQUIT)
ctrl + z:是一个组合键,OS将它解释成20号信号(SIGTSTP)

当我们在键盘中敲击上述的组合键时,OS会将其视为信号,而不是指令,并且将其对应表达的信号发送给进程

每个信号都有自己默认的处理方式,进程收到信号,正常情况下会被以对应的默认处理方式处理

man 7 signal查看信号的默认处理方式

在这里插入图片描述

OS怎么知道键盘中有数据输入了?

中断

CPU上有很多引脚,与8259中断芯片相连,而芯片与外设相连(用8259芯片可以连更多的外设,CPU上的引脚有限),每一个外设根据引脚的编号,分配到对应的中断号,当外设有输入的时候,如键盘敲击,CPU就会触发中断,OS拿到对应的中断号,根据中断号到中断向量表中拿到对应外设的读取方法,读到对应的信息

而当我们在键盘中输入特定的组合键时,例如ctrl + c,OS将其特殊解析为信号,于是OS就向对应的进程发送对应的信号

在这里插入图片描述

如何将这些信号与默认的处理方式结合起来呢?

每一个进程都有一张自己的函数指针数组,数组的下标就和信号编号强相关

我们只关注1-31号信号,也就是普通信号
对于普通信号来讲,进程收到信号之后,进程要表示自己是否收到了某种信号?

当然要表示,而且每个进程都需要,对于普通信号而言,信号产生了,进程不一定要立即处理它,而是在合适的时候进行处理

如何表示呢?

信号收到的表示无非是有或者没有的问题,所以位图就能很好的解决这个问题

所以每个进程都会有一份自己的信号位图,进程的PCBtask_struct中必然有一个表示信号的位图的位段

位图:0000 0010
比特位的位置(+1),决定信号编号
比特位的内容,决定是否收到信号

所以OS向进程发送信号,本质上是将位图中对应信号编号的位置的内容由0修改为1

为什么必须是OS向目标进程发送信号?

无论信号有多少种产生方式,永远只能让OS向目标进程发送,因为OS是进程的管理者,换其他人来不够安全,也没有这个权力

在这里插入图片描述
信号捕捉函数sighandler_t signal(int signum, sighandler_t handler);

参数说明
signum:指定的信号
handler:设置自定义动作,就是一个回调函数,函数内我们可以自定义我们想要的动作
作用:捕捉指定的信号,将其对应编号下标的函数指针数组内指向的函数修改为用户提供的handler函数,简单来讲就是将原有信号默认执行的操作修改为我们自己定义的

在这里插入图片描述

在这里插入图片描述
当我们捕捉二号信号,二号信号默认将前台进程终止的功能就被我们给换成了handler函数中的操作,所以每次当我们ctrl + c的时候,都会进行一次打印,而不是终止进程

如果我们对所有的信号都进行了信号捕捉,那我们是不是就写了一个不会被异常终止或者用户杀掉的进程呢?
在这里插入图片描述

操作系统的设计者也考虑到了上述的情况,所以就让 9 号信号无法被捕捉,9 号信号是管理员信号

2.2.2 系统调用(指令)

在这里插入图片描述
kill函数向任意进程发送任意信号

参数
pid:目标进程的pid
sig:向目标进程发送的信号

在这里插入图片描述
在这里插入图片描述

使用kill函数使一个进程控制另一个进程退出
在这里插入图片描述
在这里插入图片描述
raise函数:向自己发送任意信号

参数说明:sig:发送信号的编号
返回值:0为成功,非零不成功

在这里插入图片描述

在这里插入图片描述
raise也可以写成:kill(getpid(), 9)

abort函数:向自己发送六号信号(SIGABRT)
在这里插入图片描述

在这里插入图片描述
进程收到的大部分信号,默认处理动作都是终止进程
信号的不同代表了不同的事件,但是它们的处理动作可以一样

2.2.3 异常

2.2.3.1 除零异常

在这里插入图片描述

在这里插入图片描述
系统会报浮点数错误,而报错的本质就是OS向进程发送八号信号——8) SIGFPE

如何验证?
将八号信号捕捉
在这里插入图片描述
可以发现不报浮点数错误了,而是执行了handler函数内的方法,证明浮点数错误的本质就是OS向进程发送了八号信号
在这里插入图片描述
CPU如何知道发生了错误?

10/0会被放进CPU中的寄存器中进行运算,0相当于无穷小的数字,这样就会导致CPU的状态寄存器中的溢出标记由0变为1,这样就发生了CPU的运算异常

为什么一直在打印呢,也就是一直在执行handler函数中的信号处理方法?

原因是CPU在调度执行程序的时候,发现了错误,便会停下来通知OS告诉其对应的进程和错误问题,而OS则对其进程发送对应的信号,使得程序终止,只要程序终止了就不会有问题了,但此处我们将八号信号进行了捕捉,并没有让程序终止,所以进程依旧存在,对应PCB中寄存器的缓存依然是错误信息,而我们知道CPU是基于时钟信号的轮询执行,由于进程依然存在,存在就会被调度,在下一次被调度的时候,CPU从PCB中拿到的值依然是错误信息,CPU依然通知OS,OS则继续向系统发送信号,时间片一过,CPU则去调度其他的进程,该进程由于信号被捕捉,仍然没被终止,寄存器的缓存依然是错误的,并等待下一次CPU调度,如此循环,由于CPU调度的速度很快,所以我们看到的现象是一直在打印

在这里插入图片描述

2.2.3.2 野指针异常

在这里插入图片描述

在这里插入图片描述
系统会报段错误,而报错的本质就是OS向进程发送八号信号——11) SIGSEGV
在这里插入图片描述

在这里插入图片描述
CPU如何知道发生了错误?

指针本质上是个虚拟地址,虚拟地址需要转化成物理地址,通过页表+MMU,MMU是集成在CPU中的硬件,通过访问页表的内容形成物理地址,就可以访问物理地址。而解引用空指针,页表中没有对应的映射关系,MMU就会发生异常,然后被操作系统得知,然后发送信号给进程,而一直打印的原理与除零异常相同,都是因为CPU每次调度的时候,OS都在给进程发送11号信号

在这里插入图片描述

2.2.4 软件条件

软件触发信号在我们之前学习的管道中就有体现,当两个进程正在利用管道进行读写,此时把读端关闭,操作系统就会终止掉写进程(发送SIGPIPE信号),这种情况称为软件条件产生信号

这里主要介绍alarm函数和SIGALRM信号
在这里插入图片描述

调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号,该信号的默认处理动作是终止当前进程

也可以使得闹钟提前响起,比如有可能手动发送SIGALRM信号,alarm函数就会返回剩余多少时间,当我们把seconds设置为0,表示取消闹钟

在这里插入图片描述

在这里插入图片描述
设置一个一秒的闹钟,在一秒后程序收到信号被终止,而在这一秒内进行了6万多次IO输出

我们将代码进行修改

在这里插入图片描述

在这里插入图片描述

修改后,只有在闹钟响了以后,进行一次IO输出,得到cnt的值,可以发现cnt的值达到了五亿多次

从上述实现的对比来看,IO的效率非常低,一旦进行了IO操作,整体的运行效率就会被减慢

拓展:

每个进程都可能通过alarm接口设置闹钟,所以在系统中可能会存在很多闹钟,那么操作系统一定要管理起来它们,管理方式毋庸置疑,先描述,再组织,先用一个结构体描述每个闹钟,其中包含各种属性:闹钟还有多久结束(时间戳)、闹钟是一次性的还是周期性的、闹钟跟哪个进程相关、链接下一个闹钟的指针…… 然后我们可以用数据结构把这些数据连接起来

操作系统会周期性的检查这些闹钟,当前时间戳和结构体中的时间戳进行比较,如果超过了,操作系统就会发送SIGALRM给该进程

而每次都遍历所有的闹钟效率太低,可以用到我们之前学习的堆,将闹钟以小根堆结构维护起来,堆顶放时间最小(最先触发)的闹钟,每次只需要检查堆顶是否需要发送信号即可,如果堆顶都不需要,后面的也都不需要了

2.3 核心转储Core Dump

man 7 signal查看信号的详细信息
在这里插入图片描述

term与core都是代表终止进程,但有一定的区别
ign代表忽略子进程结束时需要的等待
stop表示暂停进程,cont表示将暂停的进程继续运行

Term和Core都表示进程退出,Trem表示正常结束,操作系统不会做额外的工作,如果是Core退出,并且Core Dump

Core Dump

当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump,进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试),一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中),默认是不允许产生core文件的
因为core文件中可能包含用户密码等敏感信息,不安全,在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件

ulimit -a操作系统给用户所设置的资源上限
在这里插入图片描述
可以看到第一行core file size的大小为0,因为云服务器默认关闭了core file这个选项,因为云服务器现在一般都是自动化管理,一旦开启后发生了错误,系统会帮我们将程序信息保存到文件中,随后程序会自动重启,重启后如果依然错误,信息会再一次写入,如此重复,如果没人一直看着的话,磁盘可能会被写满,所以云服务器并不开启这个功能

如果我们想修改我们就可以用后边的参数进行修改(-c,根据后面的资源编号选择)
ulimit -c size
在这里插入图片描述
开启后,我们执行除零异常
在这里插入图片描述

在这里插入图片描述
多了一个core文件,我们把core dumped叫做核心转储,core文件后面的数字就是问题进程的pid

那么为什么要有核心转储?

我们需要知道程序为什么崩溃,在哪崩溃?而核心转储就是为了支持我们进行调试

那么如何调试呢?

第一步先编译的时候带上 -g 选项(debug)
在这里插入图片描述
第二步使用gdb调试
在这里插入图片描述第三步直接输入core-file core.31819
在这里插入图片描述
可以看到,提示我们进程被八号信号终止,终止原因是算数异常,错误的位置也显示出来了

我们把这种处理错误的方法叫做事后调试

2.4 操作系统的执行中断

操作系统中的时间:

1. 有用户的行为,都是以进程的形式在OS中表现的
2. 操作系统只要把进程调度好,就能完成所有的用户任务
3. CMOS,周期性的,高频率的,向CPU发送时钟中断

在这里插入图片描述
在执行进程的时候,操作系统会基于时钟信号把进程等待队列的PCB投喂给CPU,这样就能使我们的进程被CPU执行起来,但操作系统也是进程,操作系统也要被调度,操作系统是怎么被调度的呢?

中断,CPU的中断引脚上连接着CMOS芯片,CMOS会周期性的,高频率的,向CPU发送时钟中断,CPU拿到中断号,就会去中断向量表中拿到操作系统的调度方法,由此不断调度操作系统

三、信号的保存

3.1 信号概念补充

信号递达(Delivery):实际执行信号的处理动作,也就是处理信号
信号未决(Pending):信号从产生到递达之间的状态,即信号被表示在位图中
信号阻塞 (Block ):进程可以选择阻塞 (Block )某个信号,未决之后暂时不递达,直到解除对信号的阻塞


被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作

3.2 信号在内核中的表示

在这里插入图片描述

上文中我们知道,信号需要被管理,并且每个进程都需要维护自己的信号管理,所以在进程的PCB中存在
两张位图——blockpending
一个函数指针数组——handler

block位图:比特位的内容代表信号是否被阻塞
pending位图:比特位的内容代表进程是否收到OS发送的信号
handler表:函数指针数组,数组中的内容指向对应信号的默认处理方法

比特位的位置:表示信号的编号
比特位的内容:是否对特定的信号进行屏蔽(阻塞),或是否收到该信号

信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志
只要阻塞位图对应比特位为1,那么信号永远不能递达
如果一个信号想要递达,那么pending位图对应的比特位为1,block位图对应的比特位为0

四、信号的处理

信号在合适的时候被处理,什么时候?

进程从内核态返回到用户态的时候,进行信号的检测和信号的处理

4.1 用户态与内核态

用户态是一种受控的状态,能够访问的资源是有限的
内核态是一种操作系统的工作状态,能够访问大部分系统资源

系统调用背后,就包含了身份的变化

在代码段CS中有两位比特位,用来表示当前的权限状态
其中1表示内核态,3表示用户态

在这里插入图片描述

在之前的学习中,我们知道虚拟地址空间的大小是4GB,也知道每一个进程的PCB中都有一份自己的虚拟地址空间,也有一份自己的页表,虚拟地址空间通过页表映射到物理内存中,而之前我们学习的虚拟地址空间都是虚拟地址空间中的用户空间,也就是前3G的大小

而最后1GB的是内核空间,内核空间也需要页表映射,未来在调用系统级别的接口的时候,通过内核级别的页表映射,找到对应的内容,只不过内核级的页表只需要一张,因为每个进程在内核空间看到的内容都一样,所以所有的进程都共用一份内核级页表

就如同曾经的库函数调用一样,调用系统调用接口,也是在进程的地址空间中进行的

用户态:只能访问自己的 [0,3GB)
内核态:可以让用户以OS的身份访问 [3,4GB]

用户态是无法访问内核空间的
所以在调用系统接口的时候,就必然需要从用户态改为内核态

由于每一份进程中都有同一份内核空间,存放的是OS的相关代码和数据
所以无论进程如何调度,CPU都可以直接找到OS
我们的进程的所有代码的执行,都可以在自己的地址空间内通过跳转的方式,进行调用和返回

4.2 信号捕捉处理流程

进程从内核态返回到用户态的时候,进行信号的检测和信号的处理

当每次调用系统接口返回的时候,OS会对其进程的信号位图(pending,block)进行检测,如果满足递达条件的话,并且该信号未被捕捉,也就是系统中默认的信号递达方式,则会在内核态完成对应信号的递达

如果满足递达条件,并且该信号被捕捉了,则需要先将内核态切换成用户态,以用户态执行对应的递达方式,完成后返回内核态,继续对信号进行检测,查看是否还有信号满足递达条件,若没有则返回用户态,继续执行用户态的代码

为什么捕捉后执行递达要从内核态切换到用户态?

首先内核态的权限比用户态高,肯定是有能力执行对应的捕捉递达的

切换的原因是信号捕捉后的执行方法是用户自己写的,如果在这个执行方法中用户越级访问内核中的内容,对内核做一些不安全的事,而内核态又有权限执行,此时操作系统的安全保障就受到了威胁,所以用户自己定义的递达方法,只能由用户态的权限去执行

在这里插入图片描述

4.3 信号捕捉操作

经过上面的的学习我们知道了内核中有block和pending位图,为了方便我们操作,操作系统定义了一个类型sigset_t

sigset_t 信号集

我们能看到阻塞和未决都是用一个比特位进行标记(非0即1),所以在用户层采用相同的类型sigset_t进行描述
这个类型表示每个信号有效和无效的状态:
在阻塞信号集就表示是否处于阻塞
在未决信号集就表示是否处于未决
阻塞信号集有一个专业的名词叫做信号屏蔽字

#include <signal.h>
int sigemptyset(sigset_t *set);//将set置0,并初始化
int sigfillset(sigset_t *set);// 按照set将其初始化
int sigaddset (sigset_t *set, int signo);// 比特位由0变为1
int sigdelset(sigset_t *set, int signo);// 比特位由1变为0
int sigismember(const sigset_t *set, int signo);//查看该信号在位图中是否存在

函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号

函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号

在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态

初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号

这四个函数都是成功返回0,出错返回-1,sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1

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参数更改信号屏蔽字,假设当前的信号屏蔽字为mask,下表说明了how参数的可选值

如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达

在这里插入图片描述

how:怎么修改
在这里插入图片描述
set:主要是用来跟how一起使用,用来重置信号
oldset:输出型参数,把老的信号屏蔽字保存,方便恢复

sigpending函数读取当前进程的未决信号集
在这里插入图片描述

#include <signal.h>
int sigpending(sigset_t *set);

RETURN VALUE
sigpending() returns 0 on success and -1 on error.
In the event of an error, errno is set to indicate the cause.

读取当前进程的未决信号集,通过set参数传出set是输出型参数

使用
首先要知道默认情况所有信号都不会被阻塞,获取pending表对应的比特位变成1
而如果被阻塞了,信号永远不会被递达,获取pending表对应的比特位永远为1

static void show_pending(const sigset_t &Pending)
{
    // 信号只有1 ~ 31
    for(int signo = 31; signo >= 1; signo--)
    {
        if(sigismember(&Pending, signo))
        {
            std::cout << "1";
        }
        else std::cout << "0";
    }
    std::cout << std::endl;
}

int main()
{
    sigset_t Block, oBlock, Pending;
    // 初始化全0
    sigemptyset(&Block);
    sigemptyset(&oBlock);
    sigemptyset(&Pending);
    // 在Block集添加阻塞信号
    sigaddset(&Block, 2);
    // 修改block表
    sigprocmask(SIG_SETMASK, &Block, &oBlock);
    // 打印
    while(true)
    {
        // 获取pending
        sigpending(&Pending);
        show_pending(Pending);
        sleep(1);
    }
    return 0;
}

在这里插入图片描述
此时因为对二号信号添加了阻塞,ctrl+c并不起作用,而pending表中对应的信号值为1

注意:前面我们使用signal函数捕捉信号不能自定义捕捉9号信号,这里也是一样不能屏蔽9号信号

当然我们也可以解除阻塞,让信号递达,信号一旦递达,pending就会先由1置0,然后就会处理信号,进程退出

static void show_pending(const sigset_t &Pending)
{
    // 信号只有1 ~ 31
    for(int signo = 31; signo >= 1; signo--)
    {
        if(sigismember(&Pending, signo))
        {
            std::cout << "1";
        }
        else std::cout << "0";
    }
    std::cout << std::endl;
}

int main()
{
    sigset_t Block, oBlock, Pending;
    // 初始化全0
    sigemptyset(&Block);
    sigemptyset(&oBlock);
    sigemptyset(&Pending);
    // 在Block集添加阻塞信号
    sigaddset(&Block, 2);
    // 修改block表
    sigprocmask(SIG_SETMASK, &Block, &oBlock);
    // 打印
    int cnt = 8;
    while(true)
    {
        // 获取pending
        sigpending(&Pending);
        show_pending(Pending);
        sleep(1);
        if(--cnt == 0)
        {
            // 恢复
            sigprocmask(SIG_SETMASK, &oBlock, &Block);
            std::cout << "恢复对信号的屏蔽" << std::endl;
        }
    }
    return 0;
}

在这里插入图片描述

为什么没有打印后面那句话呢?

因为当我们解除阻塞之后,OS就会在内核态执行信号对应的递达方式,而二号信号的递达方式是结束进程,所以进程被结束了,也就不会切换到用户态继续执行后面的打印了

sigaction函数捕捉信号
上文中,我们提到捕捉信号可以用signal来完成对信号递达方式的自定义

sighandler_t signal(int signum, sighandler_t handler);

sigaction也是用来捕捉信号的,但使用起来要比signal复杂
在这里插入图片描述

#include <signal.h>

int sigaction(int signum, const struct sigaction *act,
              struct sigaction *oldact);
              

参数说明

signum:代表指定的信号
act:是一个跟函数名同名的结构体,输入型参数(C语言中运行函数和结构体同名)
oldact:输出型参数,保存过去的数据,方便恢复

在这里插入图片描述

该结构体中主要了解这两个变量,其他不用考虑
第一个是实现的handler方法
第二个是需要屏蔽的信号集

#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
    std::cout << "catch signo: " << signo << std::endl;
}

int main()
{
    struct sigaction act, oact;
    // 初始化
    act.sa_handler = handler;
    act.sa_flags = 0;
    sigemptyset(&act.sa_mask);

    sigaction(SIGINT, &act, &oact);

    while(1) sleep(1);
    return 0;
}

在这里插入图片描述
此处与signal捕捉函数的作用相同,关键是在第二个参数上

OS帮我们执行对应信号递达的过程中,会不会重复收到该信号呢?
如果重复收到该信号,OS会不会重复调用呢?

OS在处理信号,进行递达操作的时候,会将该信号的block位图中对应的值设置为1,并将其pending位图中对应的值修改成0

也就是说,在递达之前pending表中对应的值就被修改成0了,而该信号的block位图中对应的值设置为1,就是为了防止在进行递达操作的时候依然收到对应相同的信号

场景
我们在handler设置等待15秒的倒计时函数,先发送一个SIGINT信号,在自定义处理等待15s的期间再次发送多个SIGINT信号

现象:在递达期间发了许多的二号信号,但是只处理了两个,当我们处理第一个信号的时候,后边的信号不会再次被提交,当处理完后,后续信号就会递达,但是一共就两个信号递达了,后续信号全部丢失了

结论:当我们正在处理一个递达的信号时,同类信号无法被递达,因为当前信号正在被捕捉时,系统会自动把该信号设置进信号屏蔽字中(block),当信号完成捕捉动作,系统又会自动解除对该信号的屏蔽

如果我们想在处理某种信号的时候顺便屏蔽其他信号,就可以添加进sa_mask信号集中

4.4 信号捕捉细节

进程从内核态返回到用户态的时候,进行信号的检测和信号的处理,但如果该进程不调用系统接口,进入不了内核态怎么办?

进程被CPU调度的方法是基于时间片的轮询访问,也就是说一个进程不会一直占用CPU的资源,每过一个时间片,OS就会将之前的进程从CPU中剥离下来,随后填入下一个进程等待队列中的进程,而进程在被OS填入CPU中的时候,就是内核态,需要切换到用户态执行用户的代码,所以这个问题不必担心

如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?

Linux中允许系统递送该信号一次或多次,Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里

如果一个信号满足递达条件,OS是先将其pending位图中对应的值修改成0,然后再切换成用户态执行自定义递达

为什么我们发送了一堆的二号信号,处理完第一次后会处理第二次?也只会是两次?

当一个信号被递达时,pending位图的位置就由1置为0,后边再次发送多个,又由0置为1(只有一个比特位所以只收到一个),当信号被解除屏蔽的时候,OS会去检查pending位图,如果被置1,就再次递达,出现这种情况的原因是在递达时OS会先改pending位图,再执行递达方法于是就有了在递达途中收到信号,pending位图再次置1

利用信号等待子进程
在子进程退出的时候,其实是会向父进程发送信号,以表明自己退出了,如何证明呢?
在这里插入图片描述
在这里插入图片描述
捕捉SIGCHLD信号,看子进程退出是否真的会发送SIGCHLD信号
在这里插入图片描述

在这里插入图片描述

可以看到子进程退出,父进程确实收到了SIGCHLD信号,但父进程收到信号又好像什么都没做,为什么?

这个设计是为了满足借助信号,使得父进程自动回收子进程的需求
捕捉SIGCHLD信号,在进行自定义递达中回收子进程

在这里插入图片描述

但

但如果需要等待很多子进程呢?handler函数该如何实现呢?

当需要等待很多子进程的时候,首先,我们需要知道需要等待子进程的具体数目,借助这个数目在handler中写一个等待循环即可完成,但另一个问题又出现了,如果不是所有的子进程都在相近的时间内退出呢,假如父进程申请了八个子进程,有其中四个提前完成了任务需要退出,但剩余的四个仍然需要执行任务,那么父进程就必须一直等待子进程,而如果以阻塞等待的话父进程就会一直卡住不动,所以这种情况应该用轮询式的等待,这是之前学习的进程相关知识

上述等待方式还是太麻烦了,所以Linux提供了忽略信号

进程可以选择忽略某个特定的信号
如果设置了忽略信号的处理方式,当进程接收到该信号时,不会采取任何操作,信号被丢弃
以宏的形式被调用SIG_IGN(在handler结构中,是将整数1强转得来的)
例如: 设置的闹钟响了后,你选择继续睡觉,忽略这个闹钟

signal(SIGCHLD, SIG_IGN);//此时父进程就不用等待子进程了,子进程会自动被回收
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
//在const struct sigaction结构体中
//将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号
//将sa_handler赋值为常数SIG_DFL表示执行系统默认动作

Linux支持手动忽略SIGCHLD,所有的子进程都不要父进程进行等待了,退出自动回收Z
当子进程退出时也会向父进程发生SIG_CHILD信号以告知父进程自己退出了,假设父进程并不想关心子进程的退出结果,只想执行自己的代码,那么我们可以将SIGCHLD信号设置为忽略,这样一来父进程收到子进程退出的信号后就不会再拿一部分时间或资源来处理子进程了

至此,信号的整个运行流程就学习完了
在这里插入图片描述

五、信号的其他问题补充

5.1 可重入函数

情景:向一个带头单向链表中头插,理论上来讲应该是两步
1. newnode->next = head
2. head = newnode

假设已经执行了第一步,在执行第二步之前,来了个信号,信号的递达方式正好是向该链表中头插一个节点,就会发生以下情况:
在这里插入图片描述
可以发现这时node2节点就内存泄漏了

像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称
重入
insert函数访问一个全局链表,有可能因为重入而造成错乱,所以像这样的函数称为不可重入函数
反之如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数

为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?

在线程之中,线程虽然强调资源共享,但是他们的栈却是独有的,所以访问它的同一个局部变量或参数就不会造成错乱

如果一个函数满足一下条件之一就是不可重入的:

1. 调用了malloc或free,因为malloc也是用全局链表来管理堆的
2. 调用了标准I/O库函数,标准I/O库的实现都以不可重入的方式使用全局数据结构

5.2 volatile关键字

先看代码
在这里插入图片描述

在这里插入图片描述
此时代码执行起来没什么问题但如果我们让编译器进行优化(也就是release版本)

在g++中加入对应的选项,使得编译器进行优化
-O1 提供基础级别的优化
-O2 提供更加高级的代码优化,会占用更长的编译时间
-O3 提供最高级的代码优化

在这里插入图片描述
在这里插入图片描述

此时再次编译运行,发现CTRL+C并不能结束程序,不是将flag改为1了吗,为什么结束不了?

优化后,编译器看到main函数中while(!quit)并没有被修改,所以直接把quit的值放进寄存器中,不用再从物理内存中获取,后边修改flag改的是内存中的flag,而CPU在使用的时候是从保存flag的寄存器中取,寄存器中的值一直是0,所以不会退出循环

在这里插入图片描述
加上volatile关键字就可以避免这种情况
volatile的作用:

保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作

在这里插入图片描述
此时每次用到flag变量,都会从内存中读取其内容,退出就正常了
在这里插入图片描述


  • 15
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值