经过前两部分的介绍,我们现在已经认识了信号是如何产生的,并且知道无
论信号是如何产生的,最终只能由操作系统来对特定进程发送信号,而发送
信号其实也就是写信号,往内核数据结构(pending表)中写。我们也认识了
信号的几种状态,也直到了关于信号的一些操作,但是我们之前说过,当信
号处于信号未决状态时,进程会在合适的时候对信号进行处理,那么这个“合
适”的时候是什么时候呢?这就是我们今天要谈论的主要问题。
1. 信号的处理和捕获
我们现在知道了,信号的处理方式有三种:默认处理方式,忽略和自定义捕捉。那么接着上面的话题,“合适”的时候究竟是什么时候呢?这里直接给出答案:
进程在从内核态到用户态的时候会进行信号的检测并处理。
a. 小铺垫
从这里就出现了两个新的名词:用户态和内核态。它们俩代表了计算机中进程的不同的身份,从名字来看,内核态所能使用的资源一定是多于用户态的,事实也是正如你所想的。
那么什么是内核态,什么是用户态呢?
我们的代码在编写号的时候,难免会使用到系统调用,访问到系统的内核级资源(例如管道资源,task_struct结构体等等),那么对于我们用户来说,内核级资源是我们普通用户能够访问的吗?答案是肯定不能的,所以当我们的进程调用系统调用访问系统内核级资源的时候,进程要进行从用户态到内核态的身份的转换。比如我们的信号:
其中我们使用signal函数进行自定义捕捉然后向进程发送对应信号时,我们的进程一定是经过了从用户到内核的。
我们以前看到地址空间的时候可能看到的是这样的分布:
我们应该都直到用户区的各种分区代表的什么意思,但是内核数据区我们好像不是那么熟悉。
那你有没有想过我们调用系统调用的时候,这个系统调用在哪里呢?系统调用又不是我们写的,系统调用也肯定不是什么库函数那自然也不在共享区中,我们也没有什么系统库这样的说法。
此时地址空间这幅图里面有一个区域:内核数据区,地址空间中的内核数据区有自己独立的页表会跟内存中的操作系统进行映射。内存中的操作系统有一部分就是系统调用,当然除了系统调用外,还有各种管理体系,调度方法,内核数据结构等,这些都会通过内核级页表来跟地址空间中的内核数据区进行映射(要知道操作系统也是一个软件,也是一个进程,它也有代码和数据)。
那么此时就说得通了,调用系统调用就跟调用库函数一样,也是在地址空间中进行的。
在32位机器下地址空间的大小是4GB其中用户区占[0, 3GB],内核占[3GB, 4GB]。
在这种机制下,我们进程的代码所有的执行都是在资金的地址空间进行调用,跳转,和返回。
此外这么做的话,要知道计算机是不论何时都会有进程在运行,系统中又不止一个进程,那么我们要进行我们的文件系统,驱动管理,调度方法等等直接就可以通过当前调度进程的地址空间的内核区去进行找对应的方法然后调用不就行了啊。比如时钟中断到了需要进行进程调度切换的时候,此时我们直接就可以在当前要被剥离的进程上的地址空间中的内核区去调用调度方法,直接就可以实现进程间切换。这样的机制可以让CPU随时都可以找到操作系统。
当我们的进程从用户区到内核区的跳转其实就已经让进程的身份发生变化了。当再次回到用户区执行我们的主代码时,又会从内核态转变位用户态,此时操作系统就会对该进程进行信号的检测以及处理。
那么就有人问了,CPU是怎么知道该进程此时是属于内核态还是用户态啊,该不该让它进入内核啊,其实CPU中有一个寄存器叫做CS,它的低两位会表示当前进程是用户态还是内核态,在Linux操作系统中1表示用户,3表示内核,就算两个位能够表示四种状态,但确实设置的就只有两种用户态和内核态,这样,CPU就能够识别进程的状态了。
这里有个小知识点:我们知道CPU运行我们的代码和运算我们的数据时使用的都是虚拟地址,然后通过MMU转化为物理地址区进行操作的,而页表的地址不是虚拟地址而是物理地址,这个结果也是不令人意外的。
b. 信号的处理和捕捉
经过上面的铺垫之后,我们再来认识“合适”的时候捕获并处理信号。
那么我们发送给进程信号之后,当进程执行完系统调用之后,会从内核态转变为用户态,这个时候,操作系统首先会检测信号(pending表),检测到有信号(该信号没有被阻塞)之后就会进行对信号的处理。
如果被阻塞了,那么操作系统会直接返回,然后继续执行主代码。
在处理的时候默认处理和忽略自不用多说,这些都是内核的事情,它们会做好,它们会在做完之后,直接返回然后继续执行我们的代码。
那假如是用户自定义的呢?这里给出大致的流程图:
当自定义捕捉执行完之后,它该干什么呢?直接回到主代码接着执行吗?它怎么能够找到主代码在哪里呢?我们的函数中可没有提供主代码的地址。所以它会再次回到内核中,至于回到内核的方式则是调用系统调用:sigreturn
这个函数能够将我们重新带回到主代码上。
那么疑问就又来了,它都找不到主代码在哪里,我们又没有调用这个系统调用,它又凭什么能够找到这个函数呢?
要知道当调用函数的时候,是会在栈区建立栈帧的,而当函数有返回值时,还会通过压栈的方式将返回值存入到寄存器中,而操作系统正是利用了这一特点,将该系统调用的地址作为我们自定义捕捉函数的返回值,然后就能调用了。当调用了这个函数之后,我们的进程再次回到内核,然后再返回执行我们的主代码:
此刻信号的处理和捕获就完成了。
现在还有一些小问题:
自定义捕捉的函数是以用户态来执行还是以内核态来执行?
其实通过上面的了解后,它是以用户态进行的。这么做的主要的目的就是怕当以内核态执行自定义捕捉函数时,该函数中会又恶意修改内核数据的代码,这样的后果是不堪设想的。
用户态和内核态之间的切换只能通过系统调用吗?
我们有的代码中可能压根就不会使用系统调用,如果假如里面只进行一些运算呢?向这个进程发送信号就不起作用了吗?这样的现象显然是不合理的。要知道我们的进程调度和进程的切换机制。当时钟中断之后该进程的时间片到了,需要替换掉该进程,而当再次调度到该进程的时候,经过上面的介绍进程的切换调度方法是通过地址空间中的内核区找到的,而当调度完成之后开始运行这个进程,是不是也从内核态到用户态了呢?所以用户态和内核态之间的切换不仅仅是只能通过系统调用的,还有时钟中断。当然还不只有时钟中断,这里不再介绍了。
此时就可以将我们信号的处理和捕获进行大致的抽象了:
那处理方式是自定义捕捉的的话,两种状态的切换就进行了四次,再对这个过程进行抽象:
c. 信号的处理的特点
1) . sigaction
信号在进行递达时,如果此时有相同类型的信号再次未决的话,则这个信号会被阻塞,直到该次信号递达完成之后,在进行下一次的递达,这么做的目的也是防止进程重复进入相同的自定义捕捉中。
我们可以写一段这样的代码来验证:
可以看到当我们发送二号信号,进程开始处理二号信号时,我们再次发送二号信号该进程的二好信号是未决的,但是没有递达,直到第一次信号递达完成之后,立即开始第二次的信号递达,这里也有一个结论:被阻塞的信号当解除阻塞之后,该进程要立即处理。
而此时我们又要介绍一个关于这方面的系统调用:
这个函数可以看到它的参数长得跟我们前面的sigprocmask有点像,但是这里使用参数的是结构体,如果我们只关注这个结构体的第一个参数的话,这个函数跟signal的效果是一样的:
我们要介绍的是这个结构体的另一个成员sa_mask,当看到它的类型和名字其实就可以猜个差不多了,这个成员的作用是,当进行信号的递达时,可以自定义阻塞想阻塞的信号,这个时候我们在使用一下这个接口,同时阻塞三号信号:
可以看到确实是阻塞住了。
2) .多信号的处理
以上谈论信号的捕捉和处理都是单个信号的情况,那么就有一个问题,如果是多信号的情况下的话,究竟是一个一个执行呢(中间会进行用户态和内核态的切换)?还是一次性全执行呢?
我们也可以写代码进行验证:
可以看到是一并处理。但是看到它的处理顺序是无序的,这也说明信号的处理也是有优先级的。
2. 信号的其他补充
a. 可重入函数
我们现在来想这么一个问题:
假如我们有一个链表,现在在主代码中要进行头插一个节点node1,而在进行插入的时候,刚好有一个信号发过来,也刚好立即处理信号,而信号的自定义捕捉中,也调用了头插函数,头插了一个节点node2.那么此时就会出现这样的情况:
就会出现内存泄漏。
你可以想象为主代码和自定义捕捉是两个执行流(虽然这是多进程和多线程的知识),像这样有两个执行流,在一个执行流还没结束的时候另一个执行流也进入了这个函数,这种现象叫做重入,而因为这种重入现象造成混乱(例如内存泄漏)的函数叫做不可重入函数,反之则称为可重入函数。
需要注意的是,不可重入/可重入这只是一个函数的特性,我们无法评判这个函数的好坏,所以不可重入函数它不是不可以使用的函数,它只是在多执行流中会产生混乱,但是单执行流中是正常的。
一般来说,拥有以下任一特点,都是不可重入函数:
调用了malloc或free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
b. volatile关键字
volatile是C语言中的一个关键字,它可以使得变量保持内存可见性。我们可以使用代码来验证这一点:
在使用g++/gcc编译器时默认是对我们形成的可执行没有优化的,使用-O+特定数字选项可以对我们要形成的可执行进行优化:
我们看到这个程序还在运行,下面我来解释其中的原理:
我们的主代码中并没有对这个flag进行算数修改,而我们又对我们的可执行进行了优化,导致在启动我们的程序的时候flag这个变量会被加载到寄存器中成为一个寄存器变量,while循环判断的就是这个寄存器中的变量,而我们发送信号进入自定义捕捉中将flag修改,这是将内存中的flag修改了。所以while还是会进入死循环,而解决方法就是使用volatile关键字:
保证了flag的内存可见性,而保证的对象就是CPU。
c. SIGCHLD
我们在研究父子进程的时候,僵尸进程是非常恐怖的事情,所以回收子进程变得尤为重要。其实子进程再退出的时候会给父进程发送一个信号那就是17号信号SIGCHLD:
通过代码我们可以验证这一点:
既然当子进程退出的时候会向父进程发送信号的话我们可以利用这一特点,在自定义捕捉中进行子进程的回收:
这样我们也可以进行多个子进程的回收:
但其实,我们也可以这样:
将17号信号手动设置成忽略,这样在Linux操作系统下,系统在子进程退出后会自动回收子进程。这样的方式回收子进程确实方便,但是却无法查看子进程的退出码和信号,所以子进程的回收需要依据具体场景使用。