目录
🏆一、信号的保存
①信号的捕捉
②sigset_t
③sigaction
🏆二、不可重入函数
🏆三、volatile
🏆四、SIGCHLD
🏆一、信号的保存
在聊信号保存之前,我们不妨想一个问题,如果把所有信号都自定义设置行为,是否进程就无法杀死了呢?
为了避免这种情况出现,OS中kill -9 可以强制杀死进程,也就是说无法对9号信号进行自定义动作的!
信号的几个专业术语:
1、实际执行信号的处理动作称为信号递达(Delivery)
2、信号从产生到递达之间的状态,称为信号未决(Pending)
3、进程可以选择阻塞(Block)某个信号,被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。阻塞和忽略是不同的,只要不被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
那么信号在进程pcb中是如何存储的呢?
🖊 在task_struct结构中,有pending位图和block位图,其中pengding位图表示是否收到了某些信号,而block位图则是表示是否阻塞了那些信号。而handler数组则是一个指针数组,它其中存储的是函数指针,它对应的是每个信号对应的处理方法(默认,忽略,自定义)。
🖊pengding位图每个比特位的位置表示信号编号,而比特位的内容表示是否收到了某些信号(0表示没有收到,1表示收到)。
🖊block位图每个比特位的位置表示信号编号,而比特位的内容表示是否阻塞了某些信号(0表示没有阻塞,1表示阻塞)。
🗡理解阻塞不是阻止
比如2号信号,如果我们阻塞了它,那么OS发送2号信号给进程是会修改pending位图2号信号对应的比特位由0变为1,但是因为是block,所以不做处理,当解除阻塞时还是会处理的。
信号的未决是一种状态,指的是从信号的产生到被处理前的这一段时间;信号的"阻塞"是一个开关动作,指的是阻止信号被处理,而不是阻止信号产生。
①信号的捕捉
信号在产生的时候,不会被立即处理,而是在合适的时候(从内核态返回到用户态的时候,进行处理!)。
这里简单说一下为什么要有内核态和用户态,因为我们的task_struct本身是由OS维护的,所以说要对pending位图进行修改,是需要内核去修改的,我们普通用户是没有权限的!
🎄内核态和用户态
细说内核态和用户态。
我们平时编译的代码都是用户态的,比如说编写一些代码在编译器上。而我们难免会访问两种资源:
1、操作系统自身的资源(比如getpid(),waitpid()).
2、硬件资源(printf,write,read接口)
用户为了访问内核或者硬件资源,必须通过系统调用来完成访问,那么在调用这些OS接口还有访问硬件接口,就需要从用户态切换到内核态。需要注意的是这种行为本身是影响效率的,多次由用户态转变为内核态,是很花费时间的---尽量避免频繁调用系统调用。
这些例子很多了,简单来说STL接口在设计时扩容时1.5倍或者2倍扩容就是为了减少系统调用的频次。
那么怎么知道自己是处于用户态还是内核态呢?
🗡CPU表征状态
还得看CPU,我们知道进程会把自己的上下文信息寄存在CPU中,CPU中有大量的寄存器:画个简图:
其中有一个CR3寄存器用来表征当前进程的运行级别:
0表示内核态,而3表示用户态。
那么知道表征处于什么状态后,怎么由用户态跳转到内核态呢?
在32位系统下,4G的虚拟地址,其中1-3G是用户级,而3-4GB则是内核级,无论进程如何切换都不会更改这一区域。 所以每个进程都可以随意访问OS,只需在虚拟地址上进行跳转即可!
所以我们只需更改CR3寄存器状态由用户态变为内核态,由3变为0,再在虚拟地址上跳转。
这些操作我们用户不需要做,当调用系统接口时,会帮我们由用户态转为内核态,然后才能跳转到OS空间,结束的时候再切换回用户态。
这里这个图表更能表现信号是如何在task_struct中存储的。那么在从内核态回到用户态时要查看block阻塞位图和pending位图,依次遍历二进制信号编号,如果block位图上信号编号对应的二进制为1,就不做处理,也就是未决状态。如果为0,再查看pending位图,如果为0不做处理,如果为1,调用对应的handler方法(默认,忽略,自定义)。
用户态不能执行内核态代码,因为权限不够,而内核态虽然理论上可以执行用户态代码,实际是不行的:因为OS不相信任何进程,以防出现篡改系统数据等非法行为。所以内核态要经过特定的调用,将自己的身份重新更改为用户态,然后再执行用户态代码!
系统调用-->内核态--->检测信号--->调用捕捉方法--->返回内核态--->返回用户态
画个∞符号,这个图比较贴切。
②sigset_t
之前都是纸上谈兵,真正实操层面还得上代码。说到代码就得介绍一批OS提供的接口。
首先我们要介绍信号集操作函数。
sigemptyset():初始化一个自定义信号集,将其所有信号都清空,也就是将信号集中的所有的标志位置为0,使得这个集合不包含任何信号,也就是不阻塞任何信号 。
sigfillset ():用来将参数set信号集初始化,然后把所有的信号加入到此信号集里即将所有的信号标志位置为1,屏蔽所有的信号。
sigaddset ()用来将参数signum 代表的信号加入至参数set 信号集里。
sigdelset() 允许您从一个自定义信号集中删除一个指定的信号,也就是将该信号的标准位设为0,不阻塞这个信号。
sigismember ()用来测试参数signum 代表的信号是否已加入至参数set信号集里。 如果信号集里已有该信号则返回1,否则返回0。 如果有错误则返回-1。
要理解上面这些函数,必须要先理解sigset_t类型。
因为每个信号只有一个bit的未决标识,非0即1.不记录该信号产生了多少次,阻塞标识也是这样表示的。因此未决和阻塞标识可以用相同的数据类型sigset_t来存储。sigset_t称为信号集。这个类型可以表示每个信号的"有效"或"无效"状态,在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态。
sigpending()
这个函数用来检查pending信号集,获取当前进程的pending信号集,通过用户设置的sigset_t类型的set,哪一个进程调用的sigpending,就获得哪一个进程的pending位图。
sigprocmask()
how:SIG_BLOCK:设置阻塞某个信号。SIG_UNBLOCK:取消阻塞某个信号。SIG_SETMASK:阻塞信号集设置为我们设置的set。
set:要设置的信号掩码
oldset:之前设置的信号掩码
上面这段代码,我们把2号信号阻塞了,需要观察验证的现象是:2号信号被block无法递达,可以看到被pending,但是阻塞不执行。
再来一段2号信号先被阻塞,然后再被恢复的过程:
通过演示,发现10s后解除对2号信号的阻塞后进程就直接退出了。因为阻塞的2号信号的默认动作是终止进程,当不再阻塞2号信号时,进程就直接退出了,看不到后序打印了。
想看到后序执行用户态代码,需要对2号信号进行自定义捕捉!
给2号和3号设置自定义动作,然后将2号和3号添加进阻塞。10s后解除阻塞,就看到了自定义动作。
③sigaction
sigaction()是不同于signal()的捕捉信号的方法。
对特定信号设置特定的回调方法,当触发信号时,执行对应的捕捉动作。
signum参数指出要捕获的信号类型,act参数指定新的信号处理方式,oldact参数输出先前信号的处理方式。
需要重点关注的是sigaction结构体中sa_handler,sa_mask以及sa_flags.
sa_handler就是我们的自定义动作,它是一个回调函数。
sa_mask就是阻塞信号集
sa_flags则是 指定信号处理的行为,这里我们设置为0.
上面只是简单的对于sigaction()函数的使用。
对于2号信号进行了自定义行为重写。这里其实要引出和解决一个疑问的。先看动图:
如果我们在捕捉2号信号期间,多次发送2号信号,会发生什么呢?通过动图可以看到,只保留了前两次相同信号。为什么?
1、当我们进行正在递达某一个信号期间,同类型信号无法被递达--当当前信号正在被捕捉,OS会自动将当前信号加入到进程的信号屏蔽字,所以正在处理2号信号时,后序2号信号不会被递达!
2、当信号完成捕捉动作,系统又会自动解除对该信号的屏蔽。
一般一个信号被解除阻塞的时候,如果他被pending,会自动进行递达当前阻塞信号。
通过gif可以看到在捕捉2号信号期间,3号被屏蔽,但是我们发送3号信号,会修改pending位图,在结束屏蔽时会捕捉3号信号!
🏆二、不可重入函数
上面这个图展示了这样一种场景:
上图是链表插入的一种情况,当主函数流调用了插入链表,而这时发送信号,信号捕捉执行自定义动作也是插入链表,那么这时是插入node1,还是插入node2呢?如果插入node1会导致node2丢失,同理插入node2会导致node1丢失,导致内存泄漏。
这种主函数和自定义行为中的函数相同的,称为不可重入函数。
一般而言,我们认为:main执行流和信号捕捉执行流是两个执行流。
如果在main函数中,和在handler函数中,该函数被重复进入,出问题--该函数是不可重入函数。如果在main函数中,和在handler函数中,该函数被重复进入,没有出问题---该函数是可重入函数。
目前大部分情况下的接口,都是不可重入的!这是一种特性而不是缺陷。
🏆三、volatile
🖊优化级别
通过man gcc 往下翻阅,可以查到当前Linux编译器的优化级别。
我们都知道vs上编写的代码有release版本和debug版本,release版本对debug版本进行了优化,那么具体是怎么优化的,优化了之后又和debug有什么区别呢?今天来讨论一下。
一般默认优化级别是-O0。没有 优化
上面是没有优化的。我们看到正如我们所料,当调用自定义行为时,将quit修改,循环终止进程退出。那么我们加上优化呢?
在编译时加上-O3优化级别
通过演示,可以看到,调整优化级别后,为什么不会退出了呢?
注意,这里只是为了方便演示问题,所以加上-O3优化,一般不建议。
还得回归到硬件CPU的角度来解答。
一般级别:
当while循环执行的时候,CPU不断从物理内存读取quit值到寄存器做判断。但是我们的main执行流和handler执行流是两个执行流。当我们一般优化的时候,默认从物理内存取到quit到CPU中判断。当quit被改为1的时候,再从物理内存中读取,quit为1,不满足循环条件,循环终止。
优化:
代码本身没有问题,但是因为优化策略导致出现问题。为了解决这个问题,引入了volatile关键字。这个关键字是为了保持内存可见性。
🏆四、SIGCHLD
子进程在死亡或停止的时候,会向父进程直接发送sigchld信号来告诉自身的死亡或者停止。
怎么验证呢?还是用到自定义行为!
验证子进程退出时确实向父进程发送了SIGCHLD信号。我们不再需要轮询子进程,而是子进程退出时告诉了父进程!
基于这个特性,那么要想不产生僵尸进程,还有一个办法:
父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。
我们看到不需要父进程等待,子进程自动被OS回收。但是需要注意这种方式在Linux下是有效的,其他的操作系统就不一定了。
还有最后一个问题
既然默认是忽略,还进行设置是否多此一举呢?
并不是,默认设置忽略和手动设置忽略动作是不一样的。当我们使用默认的忽略动作就是之前的父进程需要等待子进程回收,而手动设置的忽略,OS会自动回收子进程!