被爱情困住的是傻子
文章目录
一、信号的预备知识
1.通过生活例子来理解信号
- 关于信号这个话题我们其实并不陌生,早在以前的时候,我们想要杀死某个后台进程的时候,无法通过ctrl+c热键终止进程时,我们就会通过kill -9的命令来杀死信号。
查看信号也比较简单,通过kill -l命令就可以查看信号的种类,虽然最大的信号编号是64,但实际上所有信号只有62个信号,1-31是普通信号,34-64是实时信号,这篇博文不对实时信号做讨论,只讨论普通信号,感兴趣的老铁可以自己下去研究一下。
2.
在生活中不乏关于信号的场景,比如红绿灯,闹钟,手机消息提示音,上课的铃声,田径场起跑的枪声等等,那么信号从产生到被处理的具体过程是怎么样的呢?
我们拿红绿灯来举例子,首先我们能够认识红绿灯其实是因为有人教育过我们,我们的大脑中有红灯停绿灯行的意识,其次如果我们站在马路对面,现在已经绿灯亮起了,我们可以选择忽略这件事,也可以选择先不管他,我现在正打王者呢,绿灯亮不亮和我没关系,我都被推到高地了,此时由于我们有着更重要的事情要去做,所以我们先把处理红绿灯信号这件事放在脑后了,也就是他的优先级比较低,等会儿再说红绿灯的事情,但我们的脑海中是有红绿灯这回事的,这个信号被保存在了我们的脑海里,等我们王者打完之后,我们想起来原来红绿灯亮了啊,此时我们忙完别的事之后,我们要进行过马路了,也就是处理红绿灯信号,处理时我们也可以分为三种处理行为,一般情况下的默认行为就是大家都绿灯过马路了,那我也跟着过马路吧,另一种行为就是忽略,我们处理红绿灯信号这件事了吗?处理了,我们选择忽略这件事,继续开下一把排位赛,这也是我们的处理行为。最后一种就是自定义行为,假设你的妈妈从小告诉你在绿灯亮的时候,你要先在马路边跳一段舞,然后在过马路,所以当别人绿灯亮的时候,其他人的默认行为就是直接过马路,你先来旁边跳起来了,这就是自定义行为。
所以在信号产生和信号被递达处理之间,还有一个时间窗口,这个时间窗口其实就是用来保存信号,因为我当前正在做别的事情呢,处理红绿灯什么的等会儿再说,等我忙完的。忙完之后,在进行信号处理时,我们的行为可以选择默认或忽略或自定义等行为。
从我们暂时不处理信号,把这件事先排到后面来看,其实可以看出,信号的产生和我们当前做的事情是异步的,也就是说,两者相当于两个执行流,互相是不影响的,信号该发送就发送,我该做啥事就做啥事,等我做完之后再去处理你这个信号。
2.迁移到进程上来理解信号
如果将这样的生活例子迁移到进程上呢?其实道理是类似的。
进程为什么能够认识信号呢?其实是由于编写系统代码的程序员所规定的,程序员让进程能够对不同的信号产生不同的响应。进程本质上就是程序员所写的属性和逻辑的集合,信号的含义都是程序员所赋予的,而且进程这样的数据结构也是程序员所建立出来的,所以进程能够认识信号,本质上就是程序员告诉他的。
信号是发送给进程的,那么进程能不能先不处理这个信号呢?比如当前进程正在处理别的信号,或者进程此时被挂起了并未处于运行状态,那么如果这个时候操作系统给进程发送信号呢?进程都不运行了,还处理啥信号啊?诸如以上这样的情况,进程都不会立即处理该信号,那么在到信号被递达处理之前这段时间窗口,信号就会被保存起来,等到进程在合适的时候去递达处理该信号。所以,进程当前正在做的事情和处理信号这件事依旧是异步的。
当进程已经到了合适的时候,进程会去处理这个信号,处理的行为也是三种,默认,忽略,自定义,大概有60%的信号的默认处理动作都是Term终止进程。进程处理信号这个动作,有专业的名词叫信号捕捉或者是信号递达。在有了上面的认识之后,我们可以用现有的知识推导出一些结论。
我们知道信号是发送给进程的,如果进程当前并不处理这个信号,那么信号就需要被保存,以便于将来在合适的时候处理该信号,那么这个信号应该被保存在哪里呢?其实应该被保存在PCB struct task_struct{}里面,进程收到了哪些信号,进程要对信号做怎样的处理,这些信息都属于进程的信息,那么这些信息就理应被保存在PCB里面。
话又说回来,既然信号需要被保存,那么信号应该被保存在哪里呢?其实在PCB里面有对应的信号位图,操作系统用信号位图来保存信号的,31个普通信号,我们可以选择用32个比特位的unsigned int signal整数来进行保存。比特位的编号代表信号的编号,比特位的0或1代表进程是否接收到该信号。那么信号发送其实就好理解了,所谓的信号发送实质上就是修改PCB种对应的信号位图结构,将对应的比特位编号由0置1,这样就完成了进程对于信号的接收了。
另一方面,PCB是内核数据结构,修改位图其实就是修改内核数据结构,想要访问硬件或内核系统资源,则一定绕不开操作系统,因为操作系统是软硬件资源的管理者,那么修改位图这件事也一定绕不开操作系统,而操作系统为了保证自身和他管理的成员的安全性,所以他必须提供系统调用接口,让用户按照操作系统的意愿来访问内核资源或硬件,不能随意的想怎么访问就怎么访问。所以如果我们作为用户想要向进程发送信号,那么就一定得通过系统调用接口来完成这样的工作,所以我们以前所用到的kill指令,其底层一定需要调用系统调用接口。
二、信号的发送(修改PCB的信号位图)
1.通过键盘发送信号(kill指令 和 热键)
- 最常用的发送信号方式就是一个热键ctrl+c,这个组合键其实会被操作系统解释成2号信号SIGINT,通过man 7 signal就可以查看到对应的信号和其默认处理行为等等信息。
我们并未对2号信号做任何特殊处理,所以进程处理2号信号的默认动作就是Term,也就是终止进程。平常在我们终止前台进程的时候,大家的第一感受就是只要我们按下组合键ctrl+c,进程就会被立马终止,所以我们感觉进程应该是立马处理了我们发送的信号啊,怎么能是待会儿处理这个信号呢?值得注意的是,我们的感官灵敏度和CPU的灵敏度是不在同一个level的,我们直觉感受到进程是立马处理该信号的,但其实很大可能进程等待了几十毫秒或几百毫秒,而这个过程我们是无法感受到的,但事实就是如此,进程需要保存信号等待合适的时候再去处理信号。
- 下面介绍一个接口叫做signal,它可以用来捕捉对应的信号,让进程在递达处理信号时不再遵循默认动作,而是按照我们所设定的方法函数进行递达处理,这个自定义的方法函数就是handler,signal的第二个参数其实就是接收返回值为void参数为int的函数的函数指针,所以在使用handler时我们需要传信号编号和处理该信号编号时所遵循的自定义方法的函数名即可。
signal函数的返回值我们一般不关注,signal函数调用成功时返回handler方法的函数指针,调用失败则返回SIG_ERR宏。
SIG_ERR宏其实就是-1整型被强转成函数指针类型,其余的两个宏可以作为参数传到signal的第二个参数,分别代表当进程收到对应的signo信号时的处理行为,SIG_DFL是默认行为,比如进程的默认行为是终止进程,但我们将其处理行为改为了忽略,但此时又想将行为改回默认行为,此时就可以用SIG_DFL这个宏。SIG_IGN是忽略行为,如果此时进程对于signo的处理行为是终止,那我们可以手动将其处理行为改成SIG_IGN忽略,也就是什么都不做,惰性的将对应的信号位图中的比特位再由1置为0,然后什么都不干,这就是忽略行为。
- 通过代码运行结果可以看出,当我们向进程发送2号信号时,进程此时不会再被终止了,而是打印出了一条信息"进程捕捉到了一个信号编号是2的信号",此时进程处理2号信号的行为就变成了自定义行为,去执行我们自己设定的handler方法。
那我们是不是就无法通过向进程发送2号信号来杀死进程了呢?答案是 是的,但我们还有其他的手段,通过kill -9指令可以杀死进程。假设我们把所有的信号都捕捉了,并且捕捉后的处理行为也不终止这个进程,那么是不是这个进程就金刚不坏,哪个信号都没有办法杀死他呢?答案并不是这样的,9号信号是管理员信号,是操作系统给自己留的底牌,这个信号被规定为无法捕捉,所以即使你使用signal捕捉这个信号也是没有用的,操作系统必须保证自己有能够杀死终止任意一个进程的能力,这个能力就是通过9号信号来达到的。
- 实际上除热键ctrl+c外,还有一个热键是ctrl+\,这个组合键会被操作系统解析为3号信号SIGQUIT,这个信号的默认处理行为是Core,除终止进程外还会进行核心转储,Core于Term有什么不同?这个话题放到信号的递达处理部分进行讲解。
5.
有很多人误以为只要显示写了signal函数,这个函数在main执行流里面就会被调用。这样的想法完全是错误的,我们显示写signal函数其实相当于注册了一个信号处理时的自定义行为,然后这个自定义行为handler不会平白无故被调用的,只有当对应信号发送给进程时,这个handler才会被调用,否则这个函数是永远不会被调用的。
你可以把main和handler看作两个执行流,没有信号时,只有main一个执行流在执行代码,接收到对应的信号时,会从main执行流转移到handler执行流,等到handler执行流执行结束后,再回到main中刚刚执行到的那一行代码继续向下执行剩余代码。
- 另外补充一个知识点,linux规定,当用户在和shell交互时,默认只能有一个前台进程,所以当我们自己编写的程序运行时,bash进程就会自动由前台进程转换为后台进程。
除上面的情况外,如果某一个进程由于被发送19号信号SIGSTOP停止后,再被发送18号信号SIGCONT重新继续运行时,这个进程也会由原来的前台进程转换为后台进程。
对于前台进程我们可以用2号信号SIGINT进行进程终止,但后台进程无法用SIGINT进行进程终止,我们可以选择9号信号终止进程。
2.通过系统调用发送信号(kill系统调用 和 raise、abort库函数)
其实除上面那种用组合键或者是手动的通过kill指令加信号编号的方式给进程发送信号外,我们还可以通过系统调用的方式给进程发送信号。操作系统有向进程发送信号的能力,但是他并没有这个权力,操作系统的能力是为用户提供的,用户才有发送信号的权力,操作系统通过给用户提供系统调用赋予用户让OS向进程发送信号的权力。就像我们将来可能都会变成程序员,我们有写代码的能力,我们的能力是服务于公司或老板的,让我们写代码的权力来自于老板。
注意你没看错,kill不仅是指令,他还是一个系统调用,这个接口用起来非常简单,参数分别为进程id和信号编号,通过kill系统调用和命令行参数的知识,我们也可以实现一个kill指令,我们规定运行mysignal时,命令行参数形式必须为./mysignal pid signo的形式,通过命令行输入的信号编号和进程id,在mysignal可执行程序中向id进程发送对应的信号,这样的功能不就是kill指令的功能吗?
- 有一个库函数是raise,它可以用来给自己所在进程发送信号,其实他底层调用的还是系统调用kill(pid_t pid, int sig),这接口没啥意思,说白了就是变相的用kill系统调用给自己的进程pid发送指定信号而已,换汤不换药。
- 还有一个接口是abort,这个接口就是什么参数都不用传,它会自动给异常进程发送信号SIGABRT,默认处理动作就是终止该进程,abort有中止的意思。这个接口说白了也是变相的使用kill系统调用给自己进程发送6号SIGABRT信号而已,换汤不换药。
5.
我们上面所说的raise和abort都在man 3号手册,这代表他们都是库函数,而kill在2号手册,是纯正的系统调用。但3号手册的库函数可以分为两类,底层封装了系统调用的库函数和没有封装系统调用的库函数,很明显,raise和abort库函数就是底层封装了kill系统调用的库函数。就连kill指令底层其实也是封装的kill系统调用来实现的。
由此可以看出,想要修改PCB中的信号位图,也就是修改内核资源,必须通过操作系统来完成,而操作系统会给用户提供对应的系统调用接口,让用户按照内核意愿来修改内核资源。
- 在上面的内容中我们已经见到了许多的信号,比如SIGINT, SIGQUIT, SIGABRT, SGIKILL等,他们在递达处理时的默认动作都是终止进程,那搞出来那么多信号还有什么意义呢?他们的默认处理动作都是一样的呀!
信号的意义并不在于其进程递达处理信号的结果上,而是在于是由于什么原因而产生的信号,不同的事件会产生不同的信号,通过信号的不同我们能够定位出进程是由于什么异常而退出的,这能帮助我们快速定位代码错误所在。
就像C++的异常一样,那么多的异常种类,在捕获异常之后,进程不都终止了吗?那还要那么多的异常干什么啊?道理不就和信号类似吗,异常的意义也不在于异常的处理结果上,而是程序员能够通过异常的种类代表产生错误的不同事件来判定出程序的错误所在。
3.硬件异常 通知内核 向进程发送信号
3.1 除0错误(OS怎么会知道给当前进程发8号信号?进程只除0一次为什么handler疯狂被调用递达处理8号信号呢?)
- 除我们主动调用系统调用或通过键盘发送指令外,软件本身其实也可以自发的发送信号,比如这个部分所讲的硬件异常导致软件自发的发送信号。
从下面代码运行结果可以看出,当发生除0错误之后,代码运行之后,打印出了一条错误信息Floating point exception然后进程就退出了,在通过kill -l指令查找后,不难确定进程其实是收到了8号信号SIGFPE而退出的。
如果想要证明确实是8号进程导致的进程退出,我们可以用signal捕捉一下8号信号,然后进行自定义处理,看看进程在运行时是否会调用我们自定义的handler方法。
2.
可以看到,第一次在死循环里面我们除0一次,然后当程序运行的时候,signal疯狂捕捉8号信号SIGFPE,那我们可以将其理解成是由于除0代码放在死循环里面导致的,因为在死循环里面,不断进行除0错误,那么OS就不断的给进程发送8号信号,signal就会不断的被捕捉,handler方法就会不断的被执行,从而导致显示器上疯狂打印handler里面的输出信息,进程捕捉到了一个信号,信号编号是8号。
上面确实可以这么理解,没有丝毫问题。那我们就赶快把除0代码放到死循环外面啊,放到外面8号信号SIGFPE就不会一直发送了,那signal就只会捕捉一次8号信号,handler也就只会被执行一次,打印一行输出信息即可,但!结果和我们所想的一样吗?当然不一样!程序依旧还是疯狂捕捉SIGFPE信号,handler中的输出信息还是像鞭尸一样疯狂的输出,这是怎么回事捏?
3.
经过你上面的两个代码运行结果来看,此时我有两个问题,一个是操作系统怎么知道要给我这个进程发送8号信号呢?另一个问题,我都已经把除0代码放到死循环外面了,就除0一次而已啊,你signal怎么还给我疯狂捕捉8号信号呢,这是怎么回事啊?
问题1:CPU中有很多很多的寄存器,这些寄存器就相当于CPU的工作台,其中有些寄存器位状态寄存器,用于标识这次CPU的计算结果是否正确,状态寄存器标识每次CPU的计算结果是否正确其实也是通过状态位图来解决的,如果计算结果正常那么对应的标志位就是0,如果计算出现错误对应的比特位就会由0置1。除0其实就相当于除无穷小,那么CPU计算出来的结果就会很大很大,可能已经超出INT_MAX了,此时状态寄存器中的溢出标志位就会由0置为1,那么这是不是代表CPU计算出错了呢?当然是啊!
那么操作系统要不要知道CPU计算出错了呢?当然要知道!因为操作系统是软硬件资源的管理者,你硬件计算都出异常了,我操作系统能不知道吗?所以操作系统就会知道当前在CPU上运行的进程导致CPU出现计算错误了,并且CPU计算错误是由于溢出,那么此时操作系统就会给对应进程发送8号信号SIGFPE,进程收到该信号后,在合适的时候会处理这个信号,处理时默认的行为就是终止该进程,这就能解释为什么操作系统知道要给具体哪个进程发送8号信号了。因为进程在CPU上运行的时候,进程相关的上下文数据都被临时加载到CPU的寄存器上了,操作系统一读取寄存器内容,进程的相关数据还不是轻轻松松都拿到了吗?根据CPU的计算异常种类,向进程发送个8号信号对于操作系统还不简单吗?
所以总结成一句话就是,CPU计算发生异常,操作系统知晓CPU发生的计算异常种类后,向当前在CPU上正在运行的进程发送对应的8号信号,进程在合适的时候处理该信号,默认处理行为就是终止退出进程。
操作系统作为软硬件资源的管理者,什么都知道!
问题2:问题1是基于进程递达处理信号时是默认处理行为,也就是终止退出进程,我们想知道为什么OS会给进程发送8号信号。问题2是基于我们通过signal捕捉8号信号,自己定义handler方法,想要验证进程的确就是由于收到8号信号而退出的,但发现除0即使就除了一次,但handler依旧被疯狂的调用,我们想知道这是为什么。所以问题1和2基于的场景是不同的,老铁们注意一下。
进程收到信号后,在合适的时候进行递达处理后,一定会终止退出吗?这是不一定的!那如果进程没有退出的话,他是不是还有可能被CPU进行调度呢?当然有可能被重新调度,这也是我们常说的进程切换。我们知道寄存器中的数据是临时数据,当进程被切换时,CPU中这一套寄存器的内容又会被重新加载为新的在CPU上运行的进程的数据(CPU的寄存器中的内容只属于当前正在执行的进程的上下文数据,进程切换时会进行进程的上下文数据保护,下次调度时会进行上下文数据恢复,下面的图描绘的很详细,这里不赘述)所以当除0的进程被重新调度到CPU上运行的时候,对应的状态寄存器里面的溢出标志位又会由0置为1,此时CPU又会出现计算异常,操作系统知晓后又会给进程发送8号信号,那么signal又会捕捉到8号信号,handler方法又会被再一次调用,所以这就是为什么我们只除0一次,但8号信号依旧多次被捕捉,handler依旧被多次调用的原因,本质上就是因为我们自定义8号信号递达处理的行为,我们并没有让进程退出,那么进程就有可能被CPU重新调度,此时相同的问题就会重复多次的发生,况且CPU的运行速度那么快,就算是进程切换,我们的除0进程可能在1s内还是会被重复调度很多很多次,所以CPU的速度很快很快!不要用我们的感知去衡量。
那么对于这样的问题,我们能否修正这个错误呢?比如将状态寄存器的溢出标志位重新再置为0?答案是不能,因为状态寄存器是由CPU自己维护的,并且CPU也要被操作系统管理,而用户是没有权力访问和修改CPU上寄存器的数据的。
这一点也不难理解,用户能做的工作从权限角度来讲是比较有限的,当程序已经在CPU上跑起来的时候,此时用户是什么都无法做的,他只能在一旁看着CPU取程序的指令并执行指令,至于用户想要修改或维护此时CPU计算异常这样的事情,是无法做到的,我们唯一能做的就是看到进程的运行结果或中断运行进行报错,一旦程序开始运行,如果出错我们也只能进行事后调试。从除0错误这个例子我们就能够对语言级别产生的除0错误有一个新的认识了,实际上语言级别我们进行除0时,也是由于硬件CPU计算溢出导致操作系统给进程发送SIGFPE信号,信号的默认处理动作就是终止进程,下面代码就是在VS上跑的,可以看到进程退出。
3.2 访问空指针指向的空间(OS怎么会知道给当前进程发送11号信号呢?)
- 另一个常见的问题就是空指针访问,这个问题本质其实也是由于硬件异常导致的软件自发向进程发送信号。与除0相同,为什么进程会在报错一条信息Segmentation fault之后会退出呢?我们通过kill -l的命名推测是由于操作系统给进程发送了11号信号SIGSEGV从而导致进程退出,从11号信号的默认处理动作我们也知道Core也是会终止进程的。
2.
那问题又来了,操作系统怎么知道要给当前这个进程发送11号信号呢?
(首先我们需要了解一下页表和MMU)
页表是操作系统维护的一种内核数据结构,用于存储虚拟地址到物理地址之间的映射关系,当进程运行时,他的地址空间mm_struct会被划分为许多固定大小(一般是4KB)的块,这个块我们称之为页(Page),每个页在虚拟地址和物理地址中都有唯一性的标识,页表就是用来维护两个部分标识之间的映射关系的。页表是由操作系统来维护和进行管理,操作系统会给每个进程都分配一个独立的完全属于该进程的页表,实际上这个页表就是用户级页表(信号被捕捉的完整流程部分会讲到这个知识内容)。
而MMU是内存管理单元,是集成在CPU内部的一个硬件部件。当CPU访问内存时,CPU其实访问的是虚拟地址,MMU此时就会通过查找内核数据结构页表来完成CPU访问的虚拟地址到物理地址的转换,物理地址就是实际硬件上的地址,是内存芯片或其他物理设备上的物理位置,最终CPU访问的地址就是经过MMU转换后的物理地址,MMU转换虚拟地址这一步骤是实现虚拟内存机制的关键所在。而页表则负责存储虚拟地址和物理地址之间的映射关系,方便MMU在进行虚拟地址转换时通过页表来进行快速查找虚拟地址对应的物理地址。
在大多数操作系统中,内核将0号虚拟地址保留给操作系统本身,而不允许应用程序进行访问,并且页表内部也没有存储0号虚拟地址到物理地址之间的映射关系,操作系统没有将0号虚拟地址映射到物理内存的任何一个页帧上,所以在MMU尝试将0号虚拟地址转换为物理地址时,查询内核数据结构页表时,此时MMU就会发生错误,无法将0号虚拟地址进行转换。MMU会检测到这个错误并触发空指针异常,操作系统作为软硬件资源的管理者,知晓空指针异常之后,就会给当前正在CPU上运行的进程发送11号信号SIGSEGV,在进程收到信号之后,合适的时候会去处理这个信号,默认处理动作就是Core,会终止当前进程。
4.由软件条件产生信号
4.1 管道:读端关闭,写端一直写。
- 在进程间通信IPC部分我们谈到过匿名管道和命名管道的读写四大特征,其中的一个特征其实就隐含了软件异常所产生的信号,当读端关闭时,操作系统会给写端发送13号信号SIGPIPE,13号信号的默认处理行为就是Term终止当前进程,也就是终止写端进程。
所以读端关闭这一软件条件,触发了操作系统向进程发送信号,这就是由软件条件所产生的信号。
4.2 alarm定时器
- 通过alarm闹钟,我们可以计算出1s内CPU能够累加数据多少次,下面测试的代码中其实分了两种情况进行测试,一种是每次将累加数据之后的结果打印到显示器上,一种是在1s内只进行数据的累加,等到1s到了的时候,我们捕捉信号在handler里面进行累加后数据的值的打印。
声明:cnt是一个静态全局变量,我想让cnt只具有内部链接属性,handler和main当中都能用cnt,cnt的初始值为0
当我们采用每次将信息输出到显示器上时,cnt累加达到的数据仅仅是53820,其实主要是因为我们多次的访问了显示器硬件,也就是进行了IO,向显示器文件进行output,另外由于我用的是云服务器,所以还需要将数据通过网络传输到我的本地电脑,所以1s内的时间大部分都消耗在等待显示器就绪和网络传输资源上了,CPU计算的时间却占比很小。所以打印出来的cnt大小仅仅为5w多。
当我们将1s的时间全部放到CPU计算上来,等到1s过后定时器alarm响了,会给进程发送13号信号SIGALRM,此时用signal捕捉信号,在handler方法里面输出cnt的值,输出过后exit退出子进程即可。从打印结果可以看到,如果将时间全部用来进行CPU的计算,CPU还是非常快的,1s计算了大概5亿多次,和上面的5w次差了大概1w多倍数,可以看到一旦访问外设CPU的执行速度就会慢下来,因为等待硬件就绪很慢,硬件就绪的时间和CPU计算的时间根本不在一个量级。
- 话又说回来,那为什么alarm闹钟是软件条件异常呢?
闹钟实际就是软件,他不就是数据结构和属性的集合吗?所以闹钟本身就是软件,当前进程可以设定闹钟,那么其他进程也可以设定闹钟,所以操作系统内部一定会存在很多的闹钟,那么操作系统要不要对这些闹钟进行管理呢?当然要,管理的方式就是先描述,再组织。所以闹钟在操作系统中实际就是内核数据结构,此内核数据结构用于描述闹钟,组织的最常见方式就是通过链表,但闹钟的组织方式也可以通过堆,也就是优先级队列来实现。
下面是闹钟内核数据结构的伪代码,其内部有一个闹钟响铃的时间,表示在当前进程的时间戳下,经过所传参数second秒后,闹钟就会响铃,这个响铃时间即为当前进程时间戳+second参数大小。
另外闹钟还需要一个PCB结构体指针,用于和设置闹钟的进程进行关联,在闹钟响了之后,便于操作系统向对应进程发送14号信号SIGALRM,此信号默认处理动作也是终止当前进程。
OS会周期性的检查这些闹钟,也就是通过遍历链表的方式,检查当前时间戳超过了哪个闹钟数据结构中的when时间,一旦超过,说明此闹钟到达设定时间,那么这个时候操作系统就该给闹钟对应的进程发送14号信号,如何找到这个进程呢?通过alarm类型的结构体指针便可以拿到alarm结构体的内容,其结构体中有一个字段便是PCB指针,通过PCB指针就可以找到闹钟对应的进程了。
除链表这样经典的组织方式之外,另一种组织方式就是优先级队列,priority_queue,实际就是堆结构,按照闹钟结构体中的when的大小建大堆,如果堆顶闹钟的时间小于当前进程时间戳,则说明整个堆中所有的闹钟均为达到响铃的条件。如果堆顶闹钟的时间大于当前进程时间戳,那就要给堆顶闹钟对应进程发送14号信号了,检查过后再pop堆顶元素,重新看下一个堆顶闹钟是否超时,大概就是这么一个逻辑。
5.总结一下
上面我们谈到了四种产生信号的方式,有通过键盘产生信号,通过系统调用产生信号,由于硬件异常导致软件自发的产生信号,由于某些软件条件产生信号等等,老铁们不难发现,这四种产生信号的方式最终都落到了操作系统本身身上,键盘的kill或组合热键不是通过kill系统调用吗?系统调用不就是操作系统提供的接口吗?硬件异常不还是操作系统知晓后给进程发送信号吗?由于软件条件而产生的信号,最终不还是通过操作系统来向进程发送信号吗?
那为什么所有发送信号最终都要落到操作系统上呢?因为进程接收信号的本质就是修改PCB中的信号位图,而修改PCB这样的能力只有操作系统才具有,所以只要发送信号最终都绕不开操作系统,因为操作系统是进程的管理者。只要进程收到信号,那么信号就一定被处理吗?并不是这样的,进程会在合适的时候处理该信号。那在合适处理和收到信号之间有一个时间窗口,这个时间窗口内信号被保存在哪里呢?信号会被保存到PCB的信号位图里面。
如何理解OS向进程发送信号呢?发送信号的本质就是OS修改进程PCB结构体中的信号位图,将对应比特位由0置1即为进程接收到信号。
一个进程在未收到信号的时候,能否知道自己要对合法信号做什么处理呢?当然可以知道,这个工作早被编写系统的程序员完成了,他们让进程能够知道自己对不同的信号该做什么样的处理。
三、信号的保存(PCB内部的两张位图和一个函数指针数组)
1.未决 阻塞 递达概念的抛出
信号会在合适的时候被进程处理,执行信号处理的动作,称为信号递达,信号递达前的动作被称为信号捕捉,我们一般通过signal()或sigaction()进行信号的捕捉,然后对应的handler方法会进行信号的递达处理。当然如果你不自定义handler方法的话,那递达处理的动作就不会由handler执行,操作系统自己会根据默认或忽略行为对信号进行递达处理。
信号被保存,但并未被递达处理叫做信号未决!意思就是此时进程已经收到信号了,但信号尚未被进程递达,此时称之为信号未决。
还有一种状态是信号阻塞,此状态下即使信号已经被收到,但永远不会被递达,只有信号解除阻塞之后,该信号才会被递达。
信号是否产生和信号阻塞是无关的, 就算一个信号没有被产生,没有被发送给进程,但进程依旧可以选择阻塞该信号,意味着将来如果进程收到了该信号,那该信号也不会被递达,只有解除阻塞之后才可以被递达。注意阻塞和忽略是两种完全不同的概念,阻塞指的是信号被阻塞,无论进程是否收到该信号,进程永远都不会递达这个信号。而忽略是进程收到该信号后,对信号进行递达时的一种处理行为,进程在递达时可以选择忽略该信号,也就是直接将信号位图(实际是pending位图)中对应的比特位由1置0之后不再做任何处理。
2.通过内核数据结构和伪代码理解概念
在内核中操作系统为了维护信号,为其创建了三个内核数据结构,也就是三张表,分别为pending表,block表,handler表,前两个表有专业的称呼叫做pending信号集和block信号集,当进程收到信号时,对应pending位图中的比特位就会由0置1,当某个进程被阻塞时,对应block位图中的比特位就会由0置1。
当调用signal捕捉函数时,如果处理行为采取自定义,则用户层定义的handler函数的函数名就会被加载到对应的内核数据结构handler表里面,内核调用handler进行自定义处理时,就会去handler表里面进行查找。指针数组的下标代表不同的信号编号,指针数组的内容代表对应信号被递达时调用的handler方法。
如果一个信号想要被递达,最多需要进行两次检测,第一次判断其是否为阻塞信号,如果是则判断结束,该信号一定不会被递达。如果不是则进行第二次判断,pending信号集中比特位是否为1 ,如果为1说明该进程确实收到了对应的信号,那就进行递达即可,如果为0说明该进程没有收到对应信号,则不进行递达。下面是PCB源码中的部分字段,正好对应我们所说的三个内核数据结构,我上面所画的图是为了帮助大家理解信号在内核中是怎么被操作系统维护的,原理和源码中是相似的,但具体源码的实现肯定要比我们上面所画的复杂很多,如果有老铁感兴趣,可以自己下去研究一下源码是如何实现的。
3.
a.即使一个信号没有被产生,但这并不妨碍进程阻塞该信号。
b.由于pending位图中比特位只能被置1一次,所以如果某一个进程多次收到同一类型的普通信号,这就意味着除第一个普通信号外,剩余的普通信号都将被丢失(信号丢失也不是什么坏事,他是个中义词)。实时信号有所不同,实时信号产生多次时会被操作系统放到队列里面,以这样的方式来保存信号,防止信号丢失。
四、信号的递达处理(捕捉信号:忽略 默认 自定义)
1.信号默认处理动作Core和Term的区别(核心转储话题 + 越界访问检查不出来)
这个问题其实已经在上面的文章中产生不少次了,那么多的信号默认处理动作都是终止进程,那他们有什么区别呢?实际上Term的处理动作只是单纯的终止进程,而Core除终止进程外,还会多做一件事,就是核心转储core dump。
在介绍核心转储话题之前,先来谈一下以前在语言阶段我们常见到的越界访问问题,有时候越界访问能检查出来,有时候却检查不出来,其实是由于访问的位置不同而导致的,当访问的位置可能已经超过了数组的有效空间,但没有超出数组所在函数栈帧的有效空间,OS对于正在运行的程序是有可能检查不出来越界访问的,同时g++编译器在编译阶段也没有查找出来越界访问问题,这就有可能导致数据已经被修改,但用户还有可能不知情的情况产生。
此时可以通过编译器选项或其他检查工具或插件进行越界访问的检查,同时我们在编写代码的时候也要注意一些,不要写出越界访问的代码。
编译器负责编译代码时进行越界访问的检查,OS负责在程序运行时对越界访问进行检测。
我自己在测试的时候,100,1000的数组index位置,g++都没有检查出来越界访问,index到10000的时候检查出来了。
2.
云服务器默认关闭了core file的选项,所以当发生越界访问也就是段错误时,不会触发核心转储,核心转储实际上是将出现异常的进程的二进制数据转移存储到磁盘上,此时就会生成一个名为core.xxxxx的普通文件,这个文件的后缀是当前异常进程的pid。
当我们利用ulimit -c选项设置core file的大小的时候,就可以产生对应的文件了,否则云服务器默认是关闭core file选项的,也就是不给用户生成对应的核心转储文件。
- 那么这个核心转储文件有什么用呢?
他主要用来帮助我们进行事后调试,当gdb进程之后,我们通过core-file 指令 再加对应的异常进程的核心转储文件,回车之后立马就可以帮助我们快速定位问题出错的位置,直接告诉我们是在main函数的第46行出现了段错误。
所以通过核心转储文件快速定位程序问题所在,是一种不错的调试策略。
2.信号被捕捉的完整流程(进程在 合适的时候 处理信号)
2.1 内核态和用户态(调用系统调用触发软中断,处理器由用户态切换到内核态)
我们上面老是说进程会在合适的时候处理信号,那么什么时候是合适的时候呢?答案是,从内核态返回用户态的时候,进程会在这个时候处理信号。
需要知道的是,我们所写的代码在编译后运行时,其实是以用户态的身份去跑的,但用户态的代码难免会访问内核资源或硬件资源,而这些资源都是由操作系统管理的,所以想要访问这些资源则一定绕不开操作系统,那么操作系统就需要提供系统调用接口,让用户以操作系统的意愿去访问内核或硬件资源,因为操作系统不相信任何用户,所以操作系统必须自己实现系统调用,这个实现代码也就是我们常说的内核代码,然后把代码的接口提供给用户,用户只能通过这些系统调用接口来访问,不能自己随意访问内核或硬件资源。所以例如printf() write() read() getpid() waitpid()等等接口,前部分需要访问显示器或键盘等硬件,后部分需要访问内核资源PCB,这些接口的底层一定是离不开系统调用接口的,因为他们都直接或间接的访问了内核或硬件资源。
再比如stl容器的各个接口,这些接口中有没有某些接口底层一定调用的也是系统调用呢?当然是有的!所有的stl容器都需要扩容,仅凭这一点就可以确定他们底层要调用系统调用了,因为扩容实际上就是在访问物理内存这一硬件资源,实际是先访问mm_struct,然后再通过MMU去访问内存硬件资源,那这些接口也一定绕不开操作系统,因为操作系统是软硬件资源的管理者,那这些接口底层也一定封装了系统调用。
(实际上按照我个人理解来看,访问硬件资源本质还是访问内核资源,因为所有的硬件都需要被管理,操作系统会在内核里面创建对应硬件的内核数据结构,对其进行描述和组织,所以你访问硬件说到底还是访问内核资源)
当代码运行到系统调用接口时,要执行对应的内核代码了,程序能否以用户态的身份去执行系统调用的内核代码呢?这当然是不可以的!因为在用户态下,进程只能访问受操作系统授权的用户空间的代码,用户态的进程运行级别太低,内核并不相信用户,所以如果想要执行内核代码,则进程的运行级别必须由用户态切为内核态,内核态下,进程可以访问内核代码或其他内核资源,等到系统调用结束之后,当然也不能以内核态的身份去执行用户态的代码,因为用户态的代码有可能被恶意利用去攻击操作系统,而内核态的执行权限大,所以在系统调用结束后,为防止发生意外,进程的运行级别还需要由内核态切换为用户态,此时如果某些代码想要攻击操作系统,用户态的执行权限是不够的,他无法访问任何内核资源或硬件,自然就保证了系统的安全性。
当调用系统调用接口,也就是执行内核代码时,我们称进程陷入了内核态,由于执行系统调用时和执行之后各需要进行一次身份的切换,所以系统调用往往要费时间一些,所以应尽量避免频繁调用系统调用接口,因为这会降低程序运行的效率。
所以stl的空间配置器在实际开空间的时候,往往要给用户多扩容一些,因为他怕你稍微还需要多用一些空间时再次调用系统调用,而这样会降低程序运行的效率。
在linux系统中,当用户进程调用系统调用时,会提前执行一个int 0x80汇编指令(也称为中断指令),此指令会触发一个软中断(也称为陷阱),这个指令会让处理器从用户态切换为内核态,便于内核能够访问进程的上下文数据(这个上下文数据就是内核资源),其实内核访问进程的上下文数据还是通过处理器来实现的,不过此时处理器已经切换为内核态,能够取到相应的进程上下文数据
2.2 CPU工作原理(与其说是进程级别的切换,不如说是处理器级别的切换)
- 我们知道CPU中有一套寄存器,寄存器中保存的永远是临时数据,寄存器就是CPU的工作台,凡是和当前进程强相关的寄存器,寄存器内部数据称为当前进程的上下文数据,在进程切换时要进行上下文数据的保护,也就是将被轮换下去的进程的上下文数据暂时存到操作系统的某一块特定空间区域中,便于下次进程被轮换上来的时候能够进行上下文数据的恢复。
寄存器大致可以分为可见寄存器和不可见寄存器,其中有一个特殊的寄存器叫做CR3寄存器,他便为不可见寄存器,用户是无法对其进行修改的。还有一些其他的寄存器比如EBX EDI ESI等(我们这里方便叙述用cur寄存器来替代),保存的是指向当前运行进程的PCB指针。
2.
实际上这个CR3寄存器内部存储的是页表的地址,当进程运行级别是用户态时,这个CR3寄存器内部存储的是用户级页表的物理地址,当进程运行级别是内核态时,这个CR3寄存器内部存储的是内核级页表的物理地址。
通过这个CR3寄存器存储内容的变化,就可以实现进程运行级别的切换。
这个页表地址有那么牛吗?变一变CR3存储的页表地址就能实现进程运行级别的切换?页表能有这么厉害呢?没毛病!页表确实挺牛的!你想要访问内核资源,这些内核数据结构或代码可能位于物理地址空间的不同位置上,所以想要找到他们就必须通过内核级页表,那么MMU进行地址转换时,会去CR3寄存器内部取内核级页表的地址,通过这个内核级页表才能实现内核资源的访问,因为内核级页表存储了内核资源从虚拟地址到物理地址转换的映射关系。
在进程切换时,操作系统会将新的进程的页目录表的物理地址加载到CR3寄存器中,MMU会根据新的页目录表地址进行虚拟到物理地址的转换。
实际上进程运行级别的切换,说到底还是处理器由用户态切换为内核态,或由内核态切换为用户态,你可以这么理解,进程在CPU上运行,如果此时处理器是用户态级别,那么处理器的寄存器存储的内容什么的是不包括任何进程的内核资源的,处理器无法取到进程中PCB,mm_struct,页表,文件描述符表,block信号集……等等信息,只有当处理器为内核态级别的时候,他就可以取到进程的内核资源了,并将这些资源加载到寄存器里面,那么内核就可以通过CPU的寄存器读取到进程的内核资源,进程如果想要执行内核代码,CPU也可以通过进程内部的内核空间找到对应的内核代码并执行。
所以与其说成是进程的运行级别的切换,不如说成是处理器级别的切换,不过处理器级别的切换底层还是通过CR3寄存器存储内容发送变换来实现的。
2.3 再谈进程地址空间
- 进程该如何找到操作系统的代码并执行呢?其实是通过进程地址空间中的内核空间来完成的。在内核中实际除了用户级页表之外,还有一张内核级页表,这个页表可以将物理内存中的操作系统代码映射到每一个进程的地址空间中的内核空间,这个内核级页表专门用于进程访问内核资源时进行内核数据结构或代码的虚拟地址到物理地址之间的转换。
与用户级页表不同的是,内核级页表只需要存在一份就够了,因为所有的进程访问的内核代码都是同一份的,而每个进程都有自己独立的用户级页表是因为每个进程的代码是不同的,需要经过各自独立的页表进行映射才能找到物理内存上对应的进程的代码。
那怎么执行内核代码呢?也很简单,在进程地址空间的上下文进行跳转即可,进程运行时其进程的上下文数据都会被加载到CPU的寄存器里面,进行地址的跳转即可找到内核代码的虚拟地址,经过MMU映射后便可执行内核代码。
2.4 信号被捕捉递达的完整流程(内核如何实现信号的捕捉?→ vital)
信号会在内核态切换到用户态的时候被进程处理,那么进程是由于什么原因进入的内核态呢?
常见的进入内核态有两种情况。当进程调用系统调用时,由于处理器要执行内核代码,则进程运行级别一定需要切换为内核态,因为用户态权限太低,等到系统调用执行完毕,进程又会由内核态切换为用户态。另一种情况是进程切换,这种情况较为常见,当进程被轮换下去的时候,进程的上下文要进行保存,内核如果想要访问进程的上下文数据,那么进程的运行级别也必须切换为内核态,否则处理器无法拿到进程的上下文数据,进程的上下文数据也就无法保存。当进程陷入内核后,执行完系统调用或者某些任务后,内核会顺便检查进程的三张表,只要信号未被阻塞并且pending信号集中对应的比特位是1,那么就可以递达该信号,递达时的处理行为如果是默认,则当前进程运行级别为内核态,操作系统正好向进程发送信号,终止杀死该进程,如果是忽略直接惰性递达即可,将pending位图的比特位由1置0后什么处理都不做。上面所说的这两种处理行为直接以内核态的身份执行即可,递达后直接返回用户态执行剩余代码即可。
但自定义行为的递达就没有那么轻松了,首先进程以内核态的身份去执行用户层的代码是万万不可以的,因为这不安全,如果用户层的代码恶意攻击操作系统呢?此时内核态的身份还正好能执行这样的恶意攻击访问内核资源的代码,这不完蛋了吗?所以递达想要执行自定义行为,则进程运行级别必须由内核态切换为用户态,通过iret汇编指令可以切回到用户态,在执行完handler后,是不能直接回到代码的下一条运行语句处的,因为跳转是需要地址的,这个过程必须有内核参与才行,你现在处于用户态,进程的地址空间等内核信息的访问都需要内核态运行级别的进程,所以此时还需要再回到内核态,在内核态中通过进程虚拟地址的跳转找到用户态运行到哪行代码处了,然后再通过iret指令将进程运行级别由内核态转为用户态。最后进程以用户态身份继续向下执行剩余代码即可。
- 在上面叙述的过程中,进程执行handler方法后为什么不能直接回到main执行流?而是需要先回到内核态,然后再通过某些汇编指令(iret)回到用户态,恢复main函数的上下文继续执行。
我上面的解释其实是有问题的,我从进程地址空间的角度解释了进程执行完handler方法后要回到内核态,这个角度是错误的,因为进程地址空间中的0-3G用户空间不属于内核资源.
其实真正的原因是因为,handler执行流和main执行流使用不同的堆栈空间,他们之间不存在调用和被调用的关系,是两个独立的控制流程。所以进程是无法做到从用户层的handler执行流直接跳转到main执行流的,而是需要通过sigreturn再次进入内核态,如果此时没有信号被递达,则这次返回用户态就是恢复main函数的上下文继续执行剩余代码了。
- 下面画了一张图,帮助大家理解信号捕捉递达(处理行为是自定义行为)的完整流程,从左上角开始 到 再回到左上角的一个过程。红色圈圈代表进程的运行级别要发生切换,中间的绿圈代表信号检测。(如果信号递达的行为是默认或忽略,则信号检测过后直接返回到用户态即可,无须执行handler方法)
5.
最后再总结一下信号被捕捉递达的完整流程(很详细)。
递达像是一个过程,而捕捉更像是一个动作,当信号的处理行为是自定义行为,那么在信号递达的时候会调用对应的handler方法,此时我们称调用handler方法为捕捉信号。
假设用户已经了注册某个信号(9号信号除外)的处理函数sighandler()。当前正在执行main函数,由于中断或异常切换到内核态。在中断或异常处理完毕之后,即将返回到用户态的main函数之前,内核发现用户注册的某个信号需要被递达,那此时内核决定:返回用户态不是恢复main函数的上下文继续执行,而是去执行用户注册的信号处理函数sighandler()。sighandler()执行完毕之后,由于sighandler()和main()是两个独立的控制流程,各自使用不同的堆栈空间(具体我后面在多线程部分会讲解),两者之间并不存在调用和被调用的关系。所以在sighandler()函数执行完毕,进行返回时,会自动调用特殊的系统调用sigreturn()(sys_sigreturn()是内核中该系统调用的具体实现)再次进入内核态,如果此时没有新的信号需要被递达,那么进程将会返回为用户态,内核回恢复main函数的上下文继续执行main的剩余代码。
上面的叙述过程抛出了中断和异常,以及堆栈空间等概念。
1.堆栈空间其实就是栈区,一种后进先出的数据结构,而每个函数都会有自己独立的函数栈帧空间,这些堆栈空间会被依次压入堆栈空间中,进行后进先出的处理。有很多人喜欢把栈叫做堆栈空间,堆栈空间大小是有限制的,如果函数调用层数过多,比如递归,此时堆栈空间是有可能发生stack overflow堆栈空间溢出的,所以在调用函数时要注意递归的写法,递归展开太多的话,栈溢出在所难免。
STL中的stack容器和这里的堆栈空间要区分开来,动态分配内存的容器空间一般都开辟在堆上,容器对象本身一般都是在堆栈空间上,对象中含有指向堆空间的指针。所以我们在使用stack容器时,一般是不用担心溢出的,因为堆空间很大很大有好几个G。(强调这里,是怕大家把堆栈空间和某些数据结构stack搞混掉,stack的空间一般都在堆上进行开辟,和堆栈空间是不同的)
2.时钟中断就是我们常说的进程时间片到了,进程要被轮转下去,此时需要执行内核中的中断处理程序,进程就需要被切换为内核态。软件中断,譬如在信号产生部分谈到的管道读端关闭,管道写端进程被终止。或者是alarm定时器,时间戳超过alarm定时器设定时间时,就会触发软件中断,此时进程回切换为内核态,执行内核中对应的中断处理程序。
下面是一个硬件中断的例子,其实可以看到,进程运行级别的切换还是非常频繁的。因为程序的运行难免要访问到内核资源,程序不是单蹦的,他也需要和操作系统进行交互,访问内核资源,所以进程运行级别切换非常频繁我们也能够理解,毕竟事物的产生和运转往往不是独立凭空出现的,而是需要配合其他事物来共同完成。
五、通过代码编写 理解 信号的保存和递达
1.信号集操作的库函数
- sigset_t类型对于所有的信号都用一个bit位来表示当前进程是否受到该信号,至于这个类型内部如何存储这些bit位,用户是不需要关心的,用户只能通过以下的库函数操作接口来操作sigset_t变量,而不应该主观的对其内部数据做任何解释。(比如你想打印printf输出sigset_t变量的值,等等操作都是不被允许的。)
前4个函数都是成功返回0,出错返回-1.sigismember是布尔函数,信号集若包含signo则返回1,不包含返回0,出错返回-1。
在使用sigset_t类型的变量之前,一定要使用sigemptyset()或sigfillset()函数对变量进行初始化,使变量内部的数据处于一个确定稳定的状态,在初始化sigset_t变量之后就可以调用剩余三个函数进行信号的添加,删除,判断是否存在等。
- 实际上sigset_t类型是一个结构体类型的重定义,这个结构体中包含了一个类型为unsigned long int的数组,每个元素大小是8字节。至于信号是如何添加,如何删除等操作我们不关心,感兴趣的老铁可以看下源码。
2.系统调用: sigprocmask 和 sigpending
- 我们之前所说的block位图,其实还有一些其他的称呼:信号屏蔽字,阻塞信号集。
sigprocmask是一个可以读取或修改进程信号屏蔽字的函数,set和oset均为输出型参数,函数内部会对set和oldset指针指向的sigset_t类型变量做修改。如果oset为非空指针,则读取当前进程的信号屏蔽字通过oset指针变量传出。如果set为非空指针,则更改当前进程的信号屏蔽字,how通过传递宏的方式实现sigprocmask的不同功能,SIG_BLOCK用于添加某些信号到信号屏蔽字当中,SIG_UNBLOCK用于移除信号屏蔽字的某些信号,SIG_SETMASK用于通过set参数将函数外sigset_t类型的信号集 设置到 内核中PCB里面的信号屏蔽字。如果set和oset同时为非空指针,则先将原来的信号屏蔽字(set指向的信号集)备份到oset指向的信号集里面,然后再通过how和set参数对内核中PCB的信号屏蔽字做修改。
下面便是how参数的选项,其实就是宏。
2.
sigpending用于将内核PCB中的pending位图掩码返回到set参数,进行传出。
我们可以通过这个函数取到内核中pending信号集的内容,将其放到用户层set所指向的sigset_t类型的变量里面,用户层就可以输出sigset_t信号集变量的内容,进行观察等一系列操作。
3.上面所学接口的代码实现
在了解上面与信号有关的库函数接口以及系统调用接口之后,我们可以来实现一段代码,我们想屏蔽一下2,3号信号,此时向进程发送对应信号,信号一定是不被递达的,但是pending位图中的第2和第3个比特位一定被置为1了,我也想看看pending位图的变化。以上现象我们通过代码运行结果来观察。
这段代码在理解上有一个关键点就是用户层和内核层的分辨,在开始屏蔽数组sigarr内部的信号之前所做的工作,其实都是在用户层准备的工作,对内核中的block信号集,pending信号集未产生任何影响,第一行的signal会陷入内核,因为他要把myhandler的函数地址设置进信号处理函数的方法表里面,所以进程会陷入内核。而其他我们定义的block oblock pending等sigset_t类型的变量实际都是为使用系统调用接口做的准备工作,用一些库函数sigemptyset() sigaddset() 进行变量的初始化,做完这些准备工作之后,我们才调用系统调用接口,比如sigprocmask将用户层定义的block信号集设置进内核的信号屏蔽字当中,让进程对2和3信号进行阻塞,我们想看看在阻塞过程中,如果我们向进程发送信号,进程是否会递达呢?并且还想看到pending信号集的变化,所以需要调用sigpending系统调用接口,将内核中的pending信号集不断的加载到用户层的pending对象里面来,然后我们多次打印这个pending对象的内容即可。我们当然无法通过调用某个函数输出pending对象内容,但可以利用一下sigismember来判断所有的信号是否在pending位图中,如果是就输出1,不是就输出0,这样打印出的一行结果正好就相当于32个比特位。在10s之后,我们对信号解除阻塞,解除的方式也很简单,调用sigprocmask,将oblock的内容设置到内核即可,oblock中的比特位全部都是0,则相当于解除对所有信号的屏蔽,解除屏蔽之后,此时进程刚好处于内核态(因为调用了sigprocmask系统调用),检测到有信号需要被递达,那么直接递达该信号即可
下面来看一下代码的运行结果,在代码跑起来的前10s,我利用热键向进程发送2号和3号信号,可以看到的现象是pending位图的第二个比特位和第三个比特位都被置为了1,但是在这10s内进程不会递达信号,等到10s过后,进程解除所有被屏蔽的信号,此时信号会被递达,pending位图的所有比特位又全部变成了0.
4.sigaction和signal的区别(代码验证)
sigaction和signal的作用很相似,都可以用来进行信号的捕捉,signal使用起来较为简单,只需要传信号编号和handler函数指针即可。而sigaction从参数的命名上来看,有点像sigprocmask,两者都有当前的 和 原来的,分别通过带old和不带old进行命名。
与signal相同,signum为需要处理的信号的编号,所以当调用signal或sigaction时就代表我们不想按照信号本身的默认行为进行信号递达,而是想要通过自己定义的处理行为进行信号的递达。
若act为非空指针,则根据act修改对应信号的处理行为。若oldact为非空指针,则通过oldact传出内核中对于该信号的原本的处理动作,这个就有点像sigprocmask取出内核中信号屏蔽字的过程。若oldact和act均为非空,则还是将act的处理行为备份到oldact里面,再根据act修改内核中对应信号的处理行为。
结构体struct sigaction{}的定义如下图所示(这里有点特殊哈,结构体的命名和系统调用的命名均为sigaction,老铁们不要混在一块儿),其中的三个结构体成员与普通信号无关,我们也就不用这几个成员了,只用sa_handler和sa_mask即可,前者代表信号自定义处理行为的执行方法,后者其实代表进程在处理信号时,顺便屏蔽的信号有哪些,将要屏蔽的信号添加到sa_mask即可。
sa_handler也可设置为宏SIG_DFL和SIG_TGN,这两个宏其实就是整型数字强转为函数指针类型了,设置后内核对于对应信号的处理行为则分别为默认和忽略。
3.
sigaction实际上是要比signal更为安全可靠的,signal具有不可靠性,比如当前正在执行信号处理函数,如果此时相同信号被递达,则当前信号处理函数会被中断,转而执行新的信号处理函数,此时会新创建信号处理函数的函数栈帧,在新的信号处理函数执行完后,会恢复执行旧的信号处理函数,这个过程被称为信号处理函数的嵌套执行。如果多个相同类型信号被递达,则他们的处理顺序是不一定的,这无法确定。
而sigaction注册信号处理函数时,可以通过设置SA_RESTART标志来支持信号处理函数的可靠性。当正在执行信号处理函数时,如果相同信号被递达,系统会自动等待当前信号处理函数执行完毕后再重新调用该信号处理函数,而不是选择重新建立函数栈帧,这就保证了信号处理的可靠性。
下面代码可以帮助我们验证signal信号处理的不可靠性,但是我们其实无法通过显示器输出的数据看到这个信号处理的不可靠性,因为第二次执行handler的时候,第二个handler()函数的执行环境与第一个handler()函数的执行环境是不同的,包括函数的局部变量、参数、返回地址等信息都是不同的。所以第二个handler()函数输出的消息不会重复打印,我们也就无法通过输出信息看到内核重新开辟函数栈帧的现象了。
- 下面代码中,我们通过sigaction对2号信号进行捕捉,但同时又向结构体act的sa_mask里面设置了3号信号,这意味着在2号信号递达处理期间,如果向进程发送3号信号,信号也是会被阻塞的,无法被递达。
在信号被递达处理期间,同类型的信号会被OS自动添加到信号屏蔽字当中,当信号完成递达后,OS会自动解除对该信号的屏蔽。所以进程处理同类型信号的原则是串行的处理同类型信号,不能递归式的进行处理。
当信号处理函数调用结束后进行返回时,操作系统会自动解除对sa_mask中所有被阻塞的信号的阻塞状态。
下面是代码运行结果,在信号处理期间,我们发送2号或3号信号,他们是不会被递达的,只有递达完当前信号后,OS解除对于3号的阻塞,此时3号被递达,进程执行3号的默认行为,终止退出进程。
六、补充知识内容
1.可重入函数
假设现在有一个全局链表,main函数调用了insert头插函数,但是当函数执行一半的时候,还没有执行完剩余代码时,此时由于硬件中断使进程陷入内核,此时恰好有信号需要被递达,进程返回用户态执行handler方法,结果handler方法内部也调用了insert头插函数,恰好链表还是全局的,那么在handler内部完成了结点的头插,此时再返回内核态,若无信号递达,将返回用户态恢复main函数的上下文,正好main的上下文执行到头插的第二行代码,我们又调整了一下head指针的指向。此时就会出现问题,我们明明调用了两次头插函数,但链表只头插了一个结点。
像上面这样的例子,insert被不同的执行流调用,有可能在第一次调用还未结束时就被进行第二次调用,我们称这样的现象为重入。
insert函数访问全局链表,链表有可能因为发生重入而导致结果出现错误,我们称这样的函数为不可重入函数。
反之,如果一个函数仅仅访问局部的变量或数据,则此函数为可重入函数,因为这样的函数即使发生了重入也不会出现问题,所以我们称其为可重入函数。
其实访问局部变量不会产生问题的原因还是因为,main和handler两个执行流各自处于不同的堆栈空间,insert函数是两份,你handler内部想怎么调insert就怎么调,对我main执行流又没什么影响,你爱咋调咋调,反正你访问的是局部数据或变量,这都是属于你的,而全局的数据和变量是不太一样的,因为我们两个执行流是共享这部分内容的。
- 如果一个函数满足以下条件也是不可重入函数:
a.调用了malloc或free,因为malloc也是用全局链表来管理堆的。
b.调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构
2.volatile关键字(保持内存的可见性)
- 以下代码中,正常情况下,进程收到2号信号时被handler方法捕捉,在handler方法里将quit置为1,当handler执行完毕返回的时候,while循环判断为假,进程代码执行结束,自动退出。以上叙述情况确实正常,但当gcc编译时如果开了-O3级别的优化,并且quit全局变量没有volatile修饰时,此时进程的运行结果就不尽如人意了。
2.
当无volatile修饰quit时,即使quit已经被改为1了,但进程依旧没有退出,执行着main控制流里的while死循环代码。当有volatile修饰quit的时候,quit被改为1后,main控制流的while判断为假,代码执行完毕,进程退出。
- 那到底是什么原因呢?其实本质上和CPU的工作原理有关。
CPU的寄存器存储的其实是临时数据,当执行完handler后,CPU会将quit=0这一数据内容写回物理内存中,因为将计算的结果写入寄存器是没有意义的,寄存器只保存临时数据,所以此时物理内存中的quit为1,寄存器中的quit为0。
当编译器编译时不开优化选项,在执行while判断部分代码时,编译器就会去物理内存找quit的值,此时进程就会正常退出。
而当开优化选项时,CPU检测到你的while循环代码上下文并未对quit做任何修改,此时为了编译代码的效率,CPU就不会去物理内存中查找quit的数据,而是直接在当前寄存器内部查找quit的数据,而寄存器保存的是当进程代码加载进来的时候的quit的刚开始的数据,这个quit恰好是0,所以while判断就一直为真,所以进程就无法退出。
加volatile修饰代表的意思就是,无论你编译器开多高级别的优化,在取数据时不要去寄存器里面拿数据,而是去内存里面拿,这样就不会出现数据二义性的问题。我们称volatile的这种作用为保持内存的可见性。
3.SIGCHLD信号
- 以前我们在处理子进程退出问题时,采用的方法就是waitpid,采用waitpid比较麻烦,很影响main控制流,比如你进行阻塞式等待,那main控制流就得停下来等你子进程退出,如果你非阻塞式等待,main中还需要进行轮询的方式一遍一遍的去检测你子进程是否退出。
但今天在我们学习信号之后,回收子进程就不用那么麻烦了!实际子进程在退出时,是会给父进程发送17号信号SIGCHLD的,那父进程注册一个SIGCHLD信号的handler不就可以了吗?这样我父进程就完全不用主动的再去等子进程了,而是当子进程退出的时候,直接给我父进程发17号信号,此时代码转到handler控制流执行方法即可,在handler里面进行子进程的等待回收。
这样就不用让父进程去主动的等子进程,而是我父进程该干嘛干嘛,等你子进程退出的时候,给父进程打个电话,告诉父进程“我死了”,你赶快来回收我吧!
- 在handler里面进行子进程等待的时候,其实要分情况的。
假如父进程fork了大量的子进程,子进程在同一时刻都退出了,父进程收到了大量的17号信号,然后进入handler方法内部,此时单纯只进行一次的waitpid当然是不行的,因为这么多进程都退出了,你就回收一个啊?其他全变僵尸进程了你不管啊?所以在handler内部要进行while循环式的回收子进程,我们将waitpid的第一个参数设置为-1,表示等待任意的子进程退出。
那如果子进程是分批退出的呢?在这种情况下,如果将waitpid设置为阻塞式等待(第三个参数传0),就会出问题,比如handler此时正在阻塞式等待某一子进程退出,但其他子进程过了一会儿又退出了,但你父进程此时正在阻塞啊,就无法回收其他子进程,所以waitpid一定要设置为非阻塞式等待(第三个参数传WNOHANG),这样的话如果为等待到子进程退出,那么waitpid函数返回值就为0,此时handler内部的循环结束,重新回到main执行流,如果再有一批子进程想要退出,那就再进入handler即可。设置为非阻塞式等待就可以把任意时刻退出的任意子进程都能够回收了。
- 实际上,除上面间接通过waitpid的方式回收僵尸进程外,还可以通过父进程调用sigaction()或者是signal()将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会向父进程发送信号。
但其实SIGCHLD的默认行为就是忽略,一般情况下,系统默认的忽略和我们手动设置的忽略,通常是没有区别的,但这里是一个例外。操作系统对我们手动设置的SIG_IGN做了特殊处理,帮我们做了回收子进程的工作。
注意:此方法对于Linux系统可用,但不保证在其他UNIX系统上也可用,比如MAC OS 或 直接本身就是UNIX操作系统。
版权归原作者 rygttm 所有, 如有侵权,请联系我们删除。