一、信号的发送
什么是信号的发送??
与其说是给进程发送信号,倒不如说是给进程的PCB结构体发信号
1、比特位为0或者为1,表明是否收到。
2、比特位的位置是第几个,表明的是信号的编号。
3、所谓的"发信号",本质就是OS去修改task_struct的信号位图对应的比特位。所以其实是"写信号"!!
------>这也就是为什么信号必须由OS发送,因为OS是进程的管理者!!只有它才有资格修改task_struct内的属性!!
问题:PCB内部采用位图来接受普通信号,可是如果我发送了很多次相同的信号呢??你的位图是能保存一次怎么办??
------> 本来这种设计方案就只能保存1次,假设你一直没能处理该信号,而又接受了很多次该信号,那么其实也只能算一次,其他的就会丢失掉。这就好比你妈喊你吃饭,不管他喊了你多少次你没有下去,但是最终你其实都是会下去吃饭的, 而如果是实时信号,就必须得立即处理了,发了几次就得执行几次,不能丢失!!
二、信号的保存
为什么需要有信号保存??
------>因为进程收到信号后,可能不会立即处理这个信号,所以就需要有一个时间窗口
2.1 信号的一些相关概念
1、实际执行信号的处理动作称为 信号递达 (handler表)
2、信号从产生到递达之间的状态,叫做 信号未决 (pending表)
3、进程可以选择阻塞 某个信号 (block表)
4、被阻塞的信号产生时将保存在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
5、阻塞和忽略是不同的(未读和已读不回),只要信号被阻塞就不会递达,而忽略是递达之后可以选择的一种处理动作!!
2.2 内核中的表示
进程内部有关信号部分维护了3张表,block表(被阻塞的信号)和pending表(接受但未处理的信号)是位图结构,而handler表是函数指针数组表示处理动作。
SIG_DFL : 默认动作
SIG_IGN:忽略
而如果我们用户捕获信号设置了自定义方法,就可以将该方法的函数指针填到handler表中!
2.3 sigset_t
三张表都是OS内部的内核数据结构,所以你用户想读或者是想改就必须由系统调用接口!! 关键是获取这些表也存在位图,所以这也就意味着我们需要在用户层和内核层之间进行数据拷贝(参数设计上需要有输入型参数和输出型参数)。然后他就设计出了一个信号集数据类型sigset_t ,本质上就是被封装起来的位图结构。
问题:为什么要这样设计呢??sigset_t本质上部就是一个unsigned int类型吗??
------>(1)这样设计后期更具有拓展性 (2)防止你随意进行位操作 (3)跨平台保证可移植性
2.4 信号集操作函数
2.5 sigprocmask和sigpending
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
sigpending:读取当前进程的未决信号集(pending),通过set参数传出。
尝试屏蔽2号信号 打印pending表
尝试先屏蔽2号信号 然后再解除
问题:那我们如果将所有的信号都进行屏蔽,信号不就不会被处理了么??
------>OS在忽略的时候对9和19号信号防了一手,那么自然就也会在屏蔽信号这里防止9和19号被屏蔽!!
三、信号处理
信号是什么时候被处理的??
------>当我们的进程从内核态返回到用户态的时候,进行信号的检测和处理!!
3.1 重谈进程地址空间
解析:
(1)我们之前所谈到的进程地址空间大多数谈的都是用户区,但是从3GB------4GB的位置是内核区,内核区也有一张自己的内核级页表。映射到OS的代码和数据(打开OS的时间就预先加载起来了)
(2)调用系统调用的本质就是OS自动做"身份切换",从用户态进入内核态,转化为汇编叫做int 80 (陷入内核)
问题1:我怎么知道当前访问的是用户层还是内核层??
------>CR3寄存器存储的是页表的地址,而ecs寄存器中有两个bit位表示当前处于内核态还是出于用户态(00表示内核态 11表示用户态) 用来帮助OS判断当前访问了什么态,如果当前是用户态但是却访问了操作系统的代码和数据,就会拦截你!
问题2:用户页表有几份?内核页表有几份??
------>用户页表,有几个进程就有几份,因为进程具有独立性,而内核级页表只有一份!!
------>说明每一个进程看到的3-4GB的东西都是一样的!!整个系统中进程再怎么切换,3-4GB映射的内容是不变的!!
问题3:进程和OS的视角是怎样的?
------>(1)进程视角:我们调用系统重的方法,就是在我自己的地址空间中进行的!
(2)OS视角:任何时刻都有进程执行,我们想执行OS代码就可以随时调度去执行!!
问题4:OS调度进程的执行,可OS也是一个进程啊,那谁来调度他呢??他的本质是什么??
------>操作系统的本质是基于时间中断的一个死循环!!
------>在计算机硬件中,有一个时钟芯片,每隔很短的时候,向计算机发送时钟中断。由该硬件来督促OS的执行
问题5:时间芯片是如何督促OS的??
------>首先OS在被启动的时候,必然会先初始化一些必须的资源。在那以后他就会在那不断调用pause函数在那等芯片每隔一段时间来驱动自己!!
------>每当时钟响了以后(芯片发送时间中断信号),OS就会执行一些被规定好的检查工作,比如说看看当前正在被调度的进程的时间片是否到了,如果到了就把他从cpu上剥离下来!!
------>所以外部的设备的中断不一定会出现(跟外设做数据的交互),但是时钟中断必然每隔一段时间就会来一次(进行进程调度或者是其他检查工作)
3.2 信号的处理全过程示意图
存在2种情况
情况1(没有设置自定义方法):用户态(执行常规指令)------>内核态(遇到系统调用或者中断、异常之后陷入)------>用户态(返回用户态之前,处理一下当前的信号,因为是内置的方法 所以顺便处理完了正好出来 从中断的地方继续执行 )
情况2(没有设置自定义方法):用户态------>内核态------>用户态(和之前不同的是,这次自定义的方法在用户态,所以必须要先出去)------>内核态(自定义方法调用完后会自动通过sigreturn返回内核 响应信号)------>用户态(返回上次中断的地方继续执行)
问题: 通过系统调用、中断、异常进入内核态我可以理解,可如果我就是一个while循环里面也没有任何系统调用,那我是不是就不会进入内核态了??
------> 不是!! 千万要记住不光光只有系统调用、中断、异常会进入内核态,还有时钟芯片定期驱动OS!!进程是会被调度的!! 当你时间片到了被检测到的时候,你这个进程就会从cpu上被剥离下来,然后该进程的PCB会被暂时链入到等待队列中,上下文信息也会暂时被保存起来,而当你二次调度的时候必然需要将一些上下文信息恢复到cpu上,所以这个过程必然会进入到内核态中!!
------>所以我们会有无数次机会从用户态到内核态!!不用担心处理不了信号!!
3.3 信号的处理方法
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。
sa_handler是方法
问题1:pending位图是什么时候从1->0的??
------>先清0,再调用
验证方法: 捕捉信号后,然后在自定义的方法里打印pending表
问题2:信号被处理时,对应的信号也会被添加到block表中,防止信号捕捉被嵌套使用
------>正在处理2信号的时候,会将2信号的block表屏蔽掉,这样保证在处理2信号的时候,新的2信号不可被递达, 意思就是必须要等到这个2号处理完,才能处理新的2号!!
------>这是为了防止OS一直忙于某种信号的处理,从而引发的嵌套调用(因为自定义函数里可能会再次发送该信号)
------>比方说我们再准备捕捉2号信号之前,我们是先把pending由1->0,然后当我们进入自定义函数的时候还是有可能接受到新的2信号,pending由0->1,而此时如果再进入内核态,那么从内核态出去用户态的时候,当前的2都还没处理完呢,又检测到pending表的1然后接着处理新的2号,此时就会陷入处理2号信号的死循环!!
验证方法:故意在handler方法里写个死循环(意思就是捕获之后就不返回了),这样当我们第二次发送2号信号的时候,那么该信号就会被阻塞到pending表中,我们再打印出来看即可!
问题3:as_mask是什么??
------>as_mask是存放需要手动屏蔽的信号!!
------>比如当前我们处理2号信号的时候,他会顺便把所有sa_mask里面bit位为1的信号也顺带屏蔽了!
四、信号所引发的其他子问题
信号具有从当前执行流直接跳转到别的执行流的能力,因此可能会引发以下的一些子问题
4.1 可重入函数
下图出问题的原因就是:本来insert应该是main函数的执行流里调用的,但**(1)insert还没调用完呢就跳转过去执行信号捕捉sighandle了,此时sighandle这个执行流又再次调用了insert** ,此时insert这个函数被重复进入了!! 加上**(2)访问的链表是全局的链表(如果是局部的不会错乱)** 所以就会因为重入引发错乱!!造成了节点丢失,内存泄漏
问题1: 如果一个函数符合以下条件之一则是不可重入的
------>(1)调用了malloc或free,因为malloc也是用全局链表来管理堆的。
(2)调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
问题2: 什么情况下一个函数会被多次进入??
------>(1)以前学cpp的时候都是单执行流,如果是多执行流的话就有可能一个函数被多个执行流进入(多进程)
(2)如上图一样,虽然只有一个进程,但是main函数和sighanle函数其实并没有调用和被调用的关系,他们是并列的关系,属于不同的执行流,前者是必然执行的,而后者是否执行取决于是否收到信号,如果收到了就会暂时停止main函数,等sighanle函数执行完了才会回来执行main ------>所以重入发生在当main恰好在调用一个函数还没返回的时候,突然停下来去捕获信号,而该信号恰好也调用了这个函数。
问题3:为什么目前大部分的函数都是不可重入函数??
------>因为我们大部分使用的函数都可能会涉及到一些容器, 比如扩容,就会相互影响!!
4.2 解决过度优化的volatile
我们来看看下面的代码
这个代码按道理是个死循环不会退出,但如果flag是全局变量,我们就可以在中途通过捕捉信号来把他的值修改一下,那么就会退出了!!
但是这种情况是有可能会被做优化的:因为main和handler是两个执行流,所以当cpu发现main函数内部不对flag做修改而只是单纯读取并做逻辑运算的时候,综合以上两个条件他就会直接把flag变量优化到CPU内寄存器中方便后续的操作(通过减少数据的拷贝来提高效率),这样的话如果你再去修改flag,那你修改的就是内存里面的flag而不是进程里面的flag!!
Linux的优化方案设计:
我们会发现优化方案为O1的时候,此时flag就被优化了!!
因为优化导致我们的内存不可见了!!
解决方案就是用:
volatile关键字: 保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量 的任何操作,都必须在真实的内存中进行操作!!
与他相反的关键字:
register建议型关键字:当前修饰的变量能放在寄存器中就放在寄存器中!!
4.3 基于信号的异步等待方案
子进程在退出的时候,他并不会悄悄退出,而是在退出的时候会主动向父进程发送SIGCHLD(17)信号
验证:
既然子进程退出的时候会收到信号,那我不如等收到信号再回收,这样不就避免傻傻等待了吗?
所以我们可以把等待子进程的函数写到信号捕捉的函数里!!
问题1:可是假如有10个进程同时退出呢??你在处理1个进程的时候信号可是会被阻塞的,那就会导致8个进程的信号丢失了!!这要怎么办??
------>所以我们可以尝试在收到1个信号的时候,就开始一直尝试回收。
问题2:可是如果我们当前10个进程,只是退出一半,而另一半还得继续运行呢??那么父进程在收到信号时发现进程没有全部退出,他就会卡在信号捕捉函数里阻塞起来了,该怎么办??
------>解决方法就是采用非阻塞轮询!!
因此,将回收子进程的过程放在信号捕捉函数里,并采用非阻塞轮询,可以大大提高等待的效率!
问题3: 那么以前我们并不知道有这种方案的时候,子进程向父进程发送信号,那父进程的默认动作究竟是什么??
------>SIGCHLD信号的默认动作是忽略,所以就是相当于什么都没做!
问题4:子进程会变成僵尸进程因为父进程想关心父进程的退出状态,所以他才会在那等待父进程回收,可是如果我压根就不想关心子进程的退出状态呢??我可不可以让OS直接帮我回收呢??
------>事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作 置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证 在其它UNIX系统上都可 用。请编写程序验证这样做不会产生僵尸进程。父进程就不需要wait了!
问题5: 以前的默认动作就是忽略,那为什么我们把他捕获后再设成忽略就没有僵尸了??这到底是怎么区分的??
------>其实看起来都是忽略,但根本不一样!!其实原本是SIG_DFL,只不过他的方法恰好就是忽略而已,而我们捕获后把他改成SIG_IGN就可以区分开了!! 这样就是一种特殊的方式告诉OS你直接把子进程给回收吧,我不打算关心子进程的状态!!