本篇博客由 CSDN@先搞面包再谈爱 原创,转载请标注清楚,请勿抄袭。
前言
我前一篇博客进程间通信中提到了信号量,这里说一嘴,信号量和本篇要讲的信号是两个东西,就像老婆和老婆饼一样,没啥关系。
正式讲信号前先来说点生活中的例子。
我们日常生活中能看到哪些信号呢?
红绿灯的颜色提示我们是否能够通行,闹钟提示我们该起床了,打王者的时候队友狂点请求集合的信号告诉我们要开团了,前面的车打了左转向灯提示你要注意他要进行左转/超车等操作了,古代边防的烽火台上点狼烟提示诸侯们要打仗了……等等。这些都算是信号。只要产生了信号就要进行后续的行动。
为啥我们能够认识这些信号呢(好二的问题)?
因为我们从小就耳濡目染这些东西,早已成为习惯了,我们能够记住不同的的信号和对应要做的后续动作。当信号产生时,我们的大脑能够识别这些信号。
说一些信号的特点,并用生活中的例子来验证一下:
.
.
- 即便特定的信号没有产生,但是我们依旧知道应该如何处理这个信号。
拿游戏来说,打团的时候你一个刺客就是要切对面的ad,干掉就算完成任务了,队友可以为你标记一下,但是如果你是老手就算队友没有提醒你,你也会自动那样做。
.
.
- 我们在收到了某个信号时,可能不会立即处理这个信号。
还是游戏,大中午了,你饿了,点了份外卖,但是外卖还要二三十分钟才能到,这时候你觉得等待很漫长,于是开了一把lol,正好外卖到了,外卖员给你打电话了, 但这时候正好是中后期正打团的时候,你觉得不能随便离开电脑,于是给外卖员说能不能等两三分钟,后面的就不再多说了。。。这就是外卖来了这个信号到了,但是你正在忙别的事情,不能立刻停止当前正在执行的任务,也就是无法立即处理信号。
.
.
3. 信号在我们无法立即处理时,也一定要先被我们记住。接着上面的来说,假如说外卖员说他还有别的单要送,然后你说先放到门口就接着打游戏了,这时候你的脑子里是要记住门口有你的外卖的,游戏打完了要记得去取。不能说前一秒刚说后一秒就忘了, 打完了游戏直接去睡觉了,这可不行,你还饿着呢。
ok,前面的部分我都是拿生活中的例子来说的。下面就正式开始讲解系统中的信号。
正式开始
什么是信号
什么是Linux信号?
其实和我们生活中的这些是相通的,艺术来源于生活嘛。。。
那么系统中的信号,本质是一种通知机制,用户或者操作系统通过发送一定的信号来通知进程某些事件已经发生,需要进程来做后续的处理。
结合我们生活中的例子,把打游戏的你当做进程,我们可以总结出一下结论:
- 进程要处理信号,必须具备“识别”信号的能力(这一点我们不需要管,程序员已经将“识别”信号的代码写好了),也就是看到信号 + 处理动作。
- 信号产生是随机的,比如说狼烟,我们无法确定敌人来犯的时间,或者等外卖,我们不能确定一个外卖到达的准确时间(提示的只是大概时间)。信号来的时候,进程可能在忙着自己的事情,所以信号可能是后续处理的,无法立即处理。
- 进程会临时的记录下对应的信号,方便后续处理。
- 这里不能说什么时候处理,情况有点复杂,等会再详谈。
- 一般而言,信号的产生相对于进程而言是异步的。
这里说一下异步的意思,比如说上课时老师正讲课时让一个同学去取一下某样东西,这位同学走后,可以分两种情况,一种是老师等到这位同学回来后再继续讲;一种是不等这位同学,老师接着讲。后者各干各的,互不影响,这就是异步。而进程在接收到信号前,用户/os 不会和 进程有影响的地方,都是各干各的。
信号是如何产生的
我们来写一个简单的程序:
运行,循环打印:
这是个前台的进程,我们此时命令行是不起作用的,无法输入命令。
但是想要关闭这个进程的话直接ctrl + c就行了:
其实这里的ctrl + c就是一个信号,本质上就是操作系统向该进程发送了kill -2信号。
前面我的博客中也提到过kill相关的信号,但是没细讲过,那么下面就要讲讲有关kill的那些信号。
kill
首先,查看系统中有哪些信号,可以用kill -l:
乍一看,有64个,但是细看的话,32和33是没有的。也就是总共62个。
1~31号信号为普通信号,我这里就只讲普通信号,后面的那些可以说非常少见了。而且我们平常用的也不会有这么多,所以各位不必担心,我会挑着重要的讲的。
其实这些大写的字母在signum.h库文件中都是宏定义。定义的值就是对应前面的标号。比如说SIGINT对应的就是2:
信号处理方式
进程对于信号处理的常见方式有三种:
- 默认处理方式,也就是进程自带的,程序原本身写好的逻辑,当我们发送2号信号的时候就是关闭进程。
- 忽略。这个是当进程接收到信号后,处理方式就是忽略掉这个信号,就当没看见。
- 自定义动作。
这里的自定义动作是指,当进程接收到某一个信号后不再按照原先默认的处理方式或忽略的方式来执行后续处理。而是用我们自己定义的方法来处理。举个栗子,我们定闹钟是为了起床,闹钟一响就要起床,这应该是我们默认情况下的处理方式,假如说你决定闹钟响了之后直接蹦起来跳一段舞,这就算是自定义的方式,闹钟一响->跳舞,显然是不太正常的处理闹钟信号的方式。也就是我们的自定义方式。
那么我们来继续看着刚刚的2号信号:
kill -2
刚刚ctrl + c演示的就是kill -2信号。各位如果不太相信ctrl+c是让系统给进程发送2号信号的话,这里我就来用命令行演示一下,还是同样的代码:
然后我向mySignal进程发送2号信号:
进程没了,但是这样也没有什么说服力。
来个更有说服力的:
库中有一个函数能够让进程获得指定的信号并执行你指定的后续处理动作。
signal函数捕捉信号
就是红色框起来的,参数signum就是指你要接收哪个信号,第二个参数类型就是上面typedef的函数指针,意思就是第二个参数是函数指针。这里涉及到了回调函数的概念。当某一进程执行了这个函数之后就会为对应的signum号信号做准备,接收到signum号信号的时候就会去调用handler函数来做后续的处理。返回值先不说。
我们可以自行定义handler所指向的函数,也就是说我们可以决定进程在接收到某个信号的时候该做什么。只要一个返回值为void,参数为int的函数就行。内部的细节由我们自己来定。
我先来写份代码看看:
注意signal函数仅仅是修改进程对特定信号的后续处理动作,而不是直接去调用对应的处理动作。是在信号产生的时候才会去调用这个方法,调用signal与处理方法handler在时间上有一定的延后性。
来个例子,比如说你在工作中犯了一个低级错误,你的老板说这次没事,下次不要犯了,但是下一次你又犯了,这时候老板承诺说:你要是再犯这个错误我就开了你,这里的signal就相当于是这个承诺,老板还没有开你,当你再次犯这个错误的时候就会开了你,而不是像第一次一样原谅你。对应到这里的话,就是signal还没调用的时候,接收到了SIGINT信号,就执行第一次犯错的处理方式,原谅你,也就是直接退出;而signal设定了之后就是老板给你承诺了,犯了错就开了你,也就是进程接收到SIGINT后就执行catchSignal中的方法。
这里是承诺,而不是直接去执行开除这个任务,所以类比一下,signal函数不会去直接调用,而是承诺一下,进程知道了之后就去干别的事情了。
运行:
如果我ctrl+c的话,不会直接退出,而是去执行catchSignal中的方法了。echo $?就是打印上一个进程的退出码,退出码为1,也就是catchSignal中的exit(1)。我觉的这里足以说明ctrl+c就是2号信号了。
如果我手动的发送2号信号:
可以看到,当我向进程发送了2号信号的时候,进程就去执行了catchSignal中的方法,打印了一句话后就退出了。echo $?可以看到就是1,
那么为什么进程不像刚刚没有用signal接收信号的那样直接退出呢。而是去执行了我定义的方法。
前面说了,有三种处理信号的方式,我们没有用signal改变进程处理信号的方式时,走的就是默认处理那条路,也就是终止进程。但是我们用signal改变处理方式时就变成了自定义的处理方式了。所以当发送二号信号就不会走原来默认的处理方式,而是我这里重新写的signalCatch这个方式。
特定信号的处理动作一般只有一个,捕捉到信号后就不再执行原方法了,而是新方法(默认、忽略、自定义三选一)。
信号默认处理方式
二号信号有其对应的默认处理方式,那么其他信号也是有的。我们可以用man -7 signal来查看:
其中就有上面这个表格。我来解释解释:
可以看到,大部分情况下的信号,默认处理就是终止。2号信号也在其中。
再次理解系统信号
如何理解我们按下ctrl+c组合键,就变成了对应的信号呢?
键盘的工作方式是通过中断方式进行的。比如说老师上课的时候,来了个人让他去处理某件事情,老师事情处理完后再回来上课,被打断去执行其他任务再回来,这就是中断。那么我们的操作系统在未接收到信号前是在办别的事情的,而且操作系统是能够识别组合键的,按下组合键os就会去办组合键对应的事情去了。
信号是如何被保存在进程中的呢?
当我们运行了多个进程时,极端一点,比如说100个进程,那么可能某一时刻就要存在六七十个信号的,进程多了就要被os管理,那么信号多了也是要被操作系统管理起来的,还是和进程一样,先描述再组织,31个信号,想要快速的找到并执行对应的功能,我们可以用位图来实现(如果对于位图不熟悉的同学,点传送门:【C++】位图和布隆过滤器),进程的pcb中就保存了信号的位图字段。
.
.
故信号的位图就放在task_struct中,而task_struct是存放在内核数据结构中的,普通用户无法直接访问,所以只能由os来进行相关的操作。当发送了某个信号的时候,本质上就是让os像目标进程的信号位图中“写入”信号,os直接去修改进程pcb中的指定位图结构,完成“发送信号”的过程。
.
.
所以整个过程就是:os先解释组合键,再去查找当前进程列表中在前台运行的进程,然后os再写入对应的信号到进程内部的位图结构中。
信号产生的方式
上面说了信号的处理方式,这里来说信号的产生方式。
处理方式有三种,产生方式有四种。
通过键盘产生信号
还是刚刚的signal函数。
再拿出来看看:
当进程调用了signal函数后,后续捕捉到signum就执行的是handler方法。
返回值就是调用signal之前进程对于signum信号的处理方法,前一次处理的signum函数的指针。没有什么用处,就不再演示了。
我这里把catchSignal中的exit去掉,再运行看看效果。
不管是用kill命令:
还是用ctrl+c:
都是执行catch中的打印。
当然,如果这样关不掉了,还可以用别的信号来关:
这里用的3号信号,对应键盘上的快捷键是ctrl+\。
别的信号也是可以的,只要默认的是term或者core就行。
我们命令行上也可以用大写的SIGINT:
那么可能有的同学会有一个问题:是不是把所有的信号捕捉了,去掉默认的方法,都不做退出,进程是不是就关不掉了?
答案是不会的。咱们普通人能想到的,操作系统能想不到吗?不过这里我先不讲这个,到后面了再说。
我这里再捕捉一下3号信号,用8号信号来关:
代码:
运行:
用8号信号关:
如果后续没有任何SIGINT或SIGQUIT产生,catchSignal就不会执行。
上面提到的ctrl+c和ctrl+\就是键盘产生的信号。
下面讲一下前面提到的core。
core终止
core和term的区别就是是否进行核心转储。二者都是终止进程,但是core会进行核心转储。
核心转储就是指当进程因信号所杀时,将进程中核心的数据保存到磁盘上。
我前面将进程等待的时候涉及到了这个知识,但是当时没有展开讲,因为信号内容还是挺多的,这里把这个坑填一下。
我再将进程等待的时候发过一张图:
下面的那16位,就是进程被信号杀掉时进程的退出信息位,其中的core dump就是进程是否进行核心转储的标识位。为0就是被杀没有进行核心转储,为1就是被杀进行了核心转储。
但是一般而言,云服务器的核心转储功能是关闭的,但是虚拟机不会关。我这里用的是云服务器,想要打开这个功能也很简单。
ulimit -a可以查看当前环境中相关资源的配置:
图中第一行的core file size就是核心转储时,最大转储的文件大小。可以看到这里是0,我们直接ulimit -c + 数字就能改:
这里给10240足够了。这里的改变仅仅在当前会话中有效,退出重新登录就没了。
我们这里再次运行:
用8号信号杀掉进程时,后面多了(core dumped)这句话。
而且当前目录下还多了个临时文件:
名字就是core.31208,31208就是刚刚改进程的pid。大概56KB,还不小。这个文件就是核心转储出来的文件。
当进程出现某种异常的时候,core dump就决定是否让os将当前进程在内存中的核心数据转存到磁盘中。core.pid这个转储的文件主要是为了调试(等会演示)。
我这里将两个signal函数去掉,再次执行:
kill -2 没有转储。
kill -3 核心转储了。
再把前面那张表拿出来:
可以看到,3和8都是core。而2是term。
也就是说3和8杀掉进程是会核心转储的,而2不会。
core dump用处
上面说了,core.pid可以用来调试,这里我把代码改一下:
产生了core.pid文件。
当我们不知道那里出问题了,但是想要快速找到出问题的代码位置时就可以在调试的时候用core.pid文件:
用core-file + core.pid文件可以直接跳到出错的地方。
core dump位的验证
我来验证一下进程等待中的core dump标记位,代码如下:
上面低7位和第8位看不懂的话,点传送门:【Linux】进程控制,在目录中waitpid的第二个参数status。
运行:
完全正确。
再改一下代码:
这里直接用2号信号来杀掉:
可以看到core dump标记位为0,也就是说没有发生核心转储,对应的也就没有产生core.pid文件。
继续让子进程除零,但是关闭掉核心转储功能:
可以看到,虽然是8号信号,但关掉核心转储之后,子进程没有产生core.pid文件,core dump标志位也是0。
产生的core.pid要占空间。进程用默认行为为core的信号关闭的多了,就会产生很多文件,不清理的话还是很占磁盘空间的。而且我们平时学习用的话,没必要一直打开核心转储,一般也不会取用gdb调试,挺麻烦的。真想用了再开也不迟。
总结一下这里的核心转储。
当我们打开核心转储时
发送默认行为为Term的信号时,不会发生核心转储,core dump标记位为0。
发送默认行为为Core的信号时,发生核心转储,core dump标记位为1。当我们关闭核心转储时
发送默认行为为Term的信号时,不会发生核心转储,core dump标记位为0。
发送默认行为为Core的信号时,不会发生核心转储,core dump标记位为0。
上面的键盘产生的信号也都看到了,还看到了进程异常产生的信号,后者等会会详谈。
通过系统调用函数产生信号
kill函数
这里还是库函数,名字和命令行的kill一样:
这个函数作用是向进程PID为pid的进程发送sig信号。也就是给指定进程发送指定信号。而我们命令行上的kill底层调用的就是这个接口。
代码演示:
我们甚至可以自己做一个kill命令:
运行:
成功。
还有别的函数也可发信号。
raise函数
raise是向自己进程本身发送指定信号:
代码:
abort函数
这是直接给自己发abort信号。
我先不说几号信号,各位先找找:
就是6号信号。SIGABRT,让进程直接终止。带core dump的。
我再改一下核心转储:
这里直接生成core.pid文件了。
如何理解系统调用接口
用户调用系统接口==》执行os对应的系统调用代码==》os提取参数来设置特定的数值==》os向目标进程写信号==》修改对应进程的信号标记位==》进程后续处理信号==》执行对应的处理动作
由软件条件产生信号
当软件中某一条件满足后,os就会向该进程发送特定的信号。
管道演示
这里要用一下我前面一篇管道中的知识来演示这种信号,屏幕前的你若是对管道不熟悉的话,可以看看上一篇博客:只需要看一下里面的匿名管道就行,这里只用到了匿名管道。
父子进程,父创建匿名管道,子与管道建立映射,然后让父关闭读和写,子关闭读。也就是只留下子的写端,这样的话,没有其他进程进行读取,写就没有意义了。os会对写端发送SIGPIPE信号。
代码:
运行起来子进程直接就退出了:
那么这里管道文件是软件,至少需要一端读一端写才正常,读端都关闭了,即软件条件不满足了,os就会向写端发送退出信号,13号信号,也就是SIGPIPE信号。
闹钟演示
闹钟,我们日常生活中很常见。系统中也有一个库函数alarm,其起到的就是闹钟的作用。
函数功能是设定seconds秒,seconds秒后,os就会向当前进程发送SIGALRM信号。那么seconds秒就算一个软件条件。这个条件满足后os就会发送SIGALRM信号。
函数的返回值为前一个闹钟的剩余秒数。比如说你设置了一个5s的闹钟,但2秒后你又设了一个10s的闹钟,此时返回值就是5 - 2 = 3s。
来段代码演示一下,我们主动用signal接收一下SIGALRM信号:
运行:
但是这样没啥用,我们可以用alarm来检验一下我们cpu的计算能力:
运行:
只跑了五万多。。。
不是我的CPU比较拉,是因为这里IO太多了,拉慢了整体的速度。而且云服务器还要经过网络,所以这里是cout的IO + 网络IO就慢下来了。我们不要count的打印了。直接让count搞成全局的,再用signal接收一下信号,在处理方法中打印一下count:
运行:
一下子加到五亿多了,这一下子就是10^4倍的差距。可想而知,IO速度相对于cpu来说还是太慢了。
alarm函数一旦触发了之后就自动移除了,若想周期性的做事,可以在handler中打印完后再设定一个闹钟,即信号捕捉后重设闹钟:
运行:
这里是真正意义上的自动,可能有的同学说也可以搞一个while循环,里面加上sleep,再设闹钟,但是后者是由我们人为干预了,不算是自动,前者才是一个真正定时器的功能。
我们可以用这里写的定时器来周期性的执行下面的功能:
这里面用到了包装器,也可以用函数指针数组,如果想了解包装器的同学看这篇:包装器在最下面。
运行:
那么根据这里简易版的定时器,就可以拿我们日常生活中的例子来说一说了。
os能给进程设置闹钟,那么也就能给自己设置闹钟,在文件IO中,os可以周期性地将内存缓冲区中的数据刷新到磁盘当中,比如说可以周期性的检查缓冲区是否已满,满了就进行刷新。
再比如说qq / 浏览器。登录 / 连上了之后长时间不用就会 离线 / 断开连接,就是连上之后设定某种超时检测,周期性地检测是否超时,若长时间未进行如IO交互等操作,就先设定为离线状态来节省网络资源。
闹钟可以有很多,所以也是会被管理起来的。
如何理解软件条件给进程发送信号
os先识别到某种软件条件触发/不满足 ==》os给进程写入信号 ==》后面的就和上面那些步骤一样了,就不再说了。
由硬件异常产生信号
上一个说的是软件条件,而非软件异常。因为异常是在硬件这里谈的。
键盘产生信号的那里也演示过这里硬件异常的示例,不知道各位注意到没有,就是除零操作。这里再把代码给出来:
运行:
各位发现什么问题没?
为啥这里一直在打印,上面的那些都是接受一次信号后就触发一次处理动作,为啥这里会不停的打印??
因为这里是硬件异常。
如何理解除零错误
-
进行计算的是cpu这个硬件。
-
cpu内部是有寄存器的,有一个寄存器叫做状态寄存器,有对应当前计算的状态标记位,可以当做位图来看,其中有一个溢出标记位,在cpu计算完之后,os会自动的进行计算完后的检测,如果溢出位标记为1,os就会识别到当前计算中有溢出问题。只要立即找到当前运行的进程即可并发送对应的信号即可。而想要找到当前运行的进程也很简单,内核中是有pcb指针指向当前运行进程的pcb的,os可以通过该指针提取出当前运行进程的pid,然后就可以发送信号了,进程在接收到信号后会在合适的时候进行信号的处理。
-
一旦出现硬件异常,进程一定会退出吗?
不一定,一般默认是退出,这里我们修改了后续处理动作才让其不退出的,但是即便不退出我们也做不了什么。 -
为什么会循环打印
因为寄存器中的异常一直未被解决,寄存器中的内容属于进程的上下文中的内容,进程不退出的话会一直保存在上下文中,这样就会导致异常一直存在,所以寄存器中一直是显示异常的,os也就一直发信号,最好的解决方法就是直接将进程终止,终止后进程的上下文也就没了。也就是说我要在刚刚的后续处理动作中加上exit:
所以总结一下:寄存器(硬件)中,用标记位来记录当前运算是否出错,再让os来识别并进行后续动作。
阻塞信号
后面的内容涉及到上面信号的原理,也是挺重要的,看到这的小伙伴坚持坚持。
这里也不止阻塞信号,还有其他的概念性知识。
从这里开始要说几个关于信号的标准术语了:
- 实际信号的处理动作称为信号递达(Delivery)。也就是默认、忽略、自定义三种处理方式。
- 从信号产生到递达之间的状态称为信号未决(Pending)。在位图中保存信号就是信号未决。位图也可称为pending位图,当写入信号但信号还未处理,就称信号状态为未决状态。
- 信号可以被进程屏蔽掉,屏蔽也可说成阻塞,就是进程对某个特定的信号不做后续响应,意思就是信号可以被写到pending位图当中,但是不递达。
注意这里的阻塞和前面提到的忽略的区别,忽略是指能递达,不过后续的处理动作(递达)是忽略掉,而阻塞是不会递达,阻塞在时间线上是在忽略之前的。被阻塞的信号,会一直保持在未决状态,直到进程解除对此信号的阻塞,才去执行递达的动作。
三张表
进程的pcb中有三张表,先看一下:
也就是上面图中的block、pending、handler。
pending位图
先来说pending,其实就是前面一直说的那个存放信号标记的位图,其实就是一个无符号数,用其中的某一位来表示某一信号,用0/1来表示是否有该信号。
handler函数指针数组
然后来说handler,这其实是一个函数指针数组,数组的下标就是信号的对应标号 ==》当os写入信号后pending位图中就会有某一位变为1,然后就可直接通过该位来访问到handler数组中的函数指针。
再来看一眼signal函数:
我前面演示的signal函数就是根据其参数中的signum编号在handler数组中找到对应位置,然后将signal函数参数中的handler函数地址存放到handler数组中对应位置处就完成了对信号的自定义捕捉动作。
signal.h中提供了默认行为和忽略行为的宏:
DFL就是default,默认的意思;IGN就是ignore,忽略的意思。可以看到是将0和1进行了强转,__sighandler_t其实上面也说过的,就是sighandler_t,也就是那个指向函数的返回值为void,参数为int的函数指针。
所以我们用signal函数的时候第二个参数也可以传SIG_DFL和SIG_IGN。
这里就不演示了。
当处理信号的时候,会先进行(int)handler[signal] == 0的判断,如果成立的话就执行的是默认动作;如果不是,再用(int)handler[signal] == 1的判断,如果成立就执行忽略动作;如果还不是,就执行的是handler[signal]();
block位图
最后说block。pending和未决有关,handler和递达有关,这里的block和阻塞有关。
block表也是一个位图,其结构和pending一模一样。block位图中的内容代表的含义是对应的信号是否被阻塞。
比如block中1号信号位置处为1,则即使pending中的1号信号位也为1,那也得必须等到block1号新号的位置变为0才能执行handler中的方法,否则信号就一直处于阻塞状态,也就是说一直不执行后续动作。
故一个信号被处理的完整流程是:
os发送信号,pending位置写入,处理信号的时候先到pending的位图中找1,找到了再查看block位图中对应信号位置是否为1,若为1,则不进行当前位信号的后续处理,为0才去handler中执行后续处理操作。
.
即pending ==》block ==》handler
Linux中对于位图的表示
我前面C++的博客中也模拟实现过位图,Linux系统中也有一个专门的类型来表示位图。
为了支持我们对于位图的操作,os为我们提供了一种数据类型叫做sigset_t。
这里多说一嘴,我们可以自己用C/C++等语言来为我们自己提供.h/.hpp文件来方便我们使用函数接口,一些语言自定义的类型,不光语言有,os也会有,同时os也会有系统调用接口,所以os也会为我们提供.h文件。我前面的博客中也讲了,C/C++语言级别的函数想要对外设进行访问,是一定要先通过os的,也就是说语言级别的函数是对os调用接口的封装,故语言层的.h文件也是会包含os层的.h的,只不过有时我们看不出来而已。
来看sigset_t,其是一种位图结构,也是os提供的一种数据类型,各位不必关系其细节,只要知道它是一个位图就行。我模拟的那个位图中是直接用存放char的顺序表来搞的,通过/和%运算符来设置某一位的状态。
os不允许我们直接对sigset_t直接进行位操作,所以为我们提供了专门操作位图的接口。关于接口我等会再说。
先来看一下库中的sigset_t:
系统上面这个结构体做了封装,就变成了sigset_t类型的,可以看到,其内部就是一个静态的数组。
我们普通用户可以直接像定义那些内置类型和自定义类型的变量一样来定义sigset_t类型的变量。该在栈上就在栈上,该在堆上就在堆上。
未决(pending)和阻塞(block)标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。
阻塞(block)信号集中“有效”和“无效”的含义是该信号是否被阻塞。
阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。未决(pending)信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
所以前面的三个表就可以分别叫做:pending信号集,block屏蔽字,handler处理方法表。
下面就说说与sigset_t有关的系统接口。
几个都在signal.h中。
函数sigemptyset,初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
函数sigfillset,初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。
函数sigaddset,将set中的signo位置为1。
函数sigdelset,将set中的signo位置为0。
函数sigismember:判断在set所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1。
sigemptyset、sigfillset、sigaddset和sigdelset函数都是成功返回0,出错返回-1。
上面这几个接口是对sigset_t类型变量的设置,而非将对应的信号集写入了内核数据结构中。
下面这两个才是对内核中的结构进行操作:
sigpending:
这个函数功能是获取当前调用进程的pending信号集,也就是将内核中的pending传给用户。其中的参数就是一个输出型参数,就是为了得到pending信号集。
函数调用成功了返回0,失败返-1。
sigprocmask
参数how是设定特定操作,有三种:
SIG_BLOCK是向信号屏蔽字中添加特定阻塞信号,就是mask = mask | set。
SIG_UNBLOCK是从当前信号屏蔽字中解除阻塞信号,就是mask = mask & (~set)。
SIG_SETMASK是直接将信号屏蔽字改为set,也就是mask = set。
参数set是一个输入型参数,就是通过set来设置block屏蔽字。
oldset是一个输出型参数,这个参数会将改变前的信号屏蔽字返回。
如果捕捉了所有的信号,是否会产生一个金刚不坏的进程,这里来验证一下。
测试代码如下:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
void catchSignal(int signal)
{
cout << "catch a signal ::" << signal << endl;
}
int main()
{
for(int i = 1; i <= 31; ++i)
{
signal(i, catchSignal);
}
while(1)
{
cout << getpid() << " running" << endl;
sleep(1);
}
return 0;
}
写一个循环发送kill命令的脚本(看不懂没关系,就是一个循环发送kill信号的):
#!/bin/bash
i=1
# 命令行上pidof可以获取到进程的pid,然后这里就是获取mySignal进程的pid
id=$(pidof mySignal)
while [ $i -le 31 ] #这里就是让 i <= 31
do
kill -$i $id #发送i号信号
echo "kill -$i $id" #打印一下发送了i号信号
let i++ # 让i++
sleep 1 # sleep 1秒
done
文件名是sendSig.sh,运行的话就是bash sendSig.sh就行:
先运行mySignal,然后再运行sendSig.sh:
可以看到,发送到9号命令直接将进程杀掉了,那么这里就直接说了,9号信号是一个管理员级别的信号,不能被捕捉,一定能杀死进程。还有一个19号信号也无法被捕捉,19号信号是让进程暂停的。
屏蔽2号信号演示
下面我将2号信号屏蔽,并且不断获取并打印当前进程的pending信号集。这样的话,当我突然发送一个2号信号时,就应该看到pending信号集中有一个比特位由0变为1。
signal.cpp代码如下:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
#include<assert.h>
// 打印获得到的位图
void printPending(sigset_t& pending)
{
for(int i = 1; i <= 31; ++i)
{
// 用sigismember来查看i号信号是否存在,存在了返回1,就打印1,否则打印0
// 这样就能显示出pending位图
if(sigismember(&pending, i)) cout << "1";
else cout << "0";
}
cout << endl;
}
int main()
{
// 用户=================
// block屏蔽字
sigset_t bset;
// 初始化block屏蔽字
sigemptyset(&bset);
// 向bset中添加2号信号位
sigaddset(&bset, 2);// 这里的2可以换做SIGINT
// pending位图,不需要初始化,获取pending位图时会直接覆盖
sigset_t pending;
// 前面这几步都是对用户空间的操作,并没有将位图写入到内核中
// 内核=================
// 将屏蔽字写入内核中
sigprocmask(SIG_BLOCK, &bset, nullptr);// 这里不需要获取前一次的block位图
// 默认情况下进程内核中的block位图中不会对任何信号屏蔽,所以这里添加bset只是对2号信号进行屏蔽
while(1)
{
// 不断获取pending位图并打印
sigpending(&pending);
printPending(pending);
sleep(1);
}
return 0;
}
可以看到成功了,想让进程退出直接发其他信号就行。
再改一改,改成20秒后自动解除2号信号的屏蔽,捕捉2号信号,再来查看pending位图。
代码:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
#include<assert.h>
// 2号信号的自定义方法
void catchSignal(int signal)
{
cout << "catch a signal ::" << signal << endl;
}
// 打印获得到的位图
void printPending(sigset_t& pending)
{
for(int i = 1; i <= 31; ++i)
{
if(sigismember(&pending, i)) cout << "1";
else cout << "0";
}
cout << "\t" << getpid() << "running" << endl;
}
int main()
{
// 接收一下2号信号
signal(2, catchSignal);
// 用户=================
// block屏蔽字
sigset_t bset;
// 初始化block屏蔽字
sigemptyset(&bset);
// 向bset中添加2号信号位
sigaddset(&bset, 2);// 这里的2可以换做SIGINT
// pending位图,不需要初始化,获取pending位图时会直接覆盖
sigset_t pending;
// 内核=================
// 将屏蔽字写入内核中
sigprocmask(SIG_BLOCK, &bset, nullptr);// 这里不需要获取前一次的block位图
int count = 0;
while(1)
{
++count;
// 不断获取pending位图并打印
sigpending(&pending);
printPending(pending);
sleep(1);
if(count == 20) // 20秒后解除2号信号屏蔽
{
// 先除掉bset中的2号信号位
sigdelset(&bset, 2);
// 再写入内核中
sigprocmask(SIG_SETMASK, &bset, nullptr);
cout << "2号信号解除" << endl;
}
}
return 0;
}
运行:
可以看到,当信号被屏蔽的时候,发送2号信号没有用,然后20秒后信号解除了,进程就自动去处理2号信号了,执行了自定义方法,同样处理完信号后,pending位图中的2号位就置零了。
这里说两点:
- 上面的代码中显示的是先执行了自定义处理后才解除了信号的。因为上面的代码中是有IO的,有一定的耗时,所以一定是先解除再处理的。
- 系统中没有为我们提供手动设置pending位图的接口,只能获得pending位图。因为所有信号发送的方式,都是修改pending位图的过程,我们手动改没什么意义。
屏蔽所有信号演示
我来屏蔽掉所有的信号,并且不发送9号和19号信号,用上面的接口演示一下不断发送信号pending位图的变化:
sendSig.sh:
i=1
id=$(pidof mySignal)
while [ $i -le 31 ]
do
if [ $i -eq 9 ];then
let i++
continue
fi
if [ $i -eq 19 ];then
let i++
continue
fi
kill -$i $id
echo "kill -$i $id"
let i++
sleep 1
done
signal.cpp
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
#include<assert.h>
void catchSignal(int signal)
{
cout << "catch a signal ::" << signal << endl;
}
void printPending(sigset_t& pending)
{
for(int i = 1; i <= 31; ++i)
{
if(sigismember(&pending, i)) cout << "1";
else cout << "0";
}
cout << endl;
}
int main()
{
// 用户=======================
// pending位图
sigset_t pending;
// 初始化位图 pending 是否初始化是没有影响的,因为sigpending会将pending中的数据覆盖掉
sigemptyset(&pending);
// beset 表示当前block屏蔽字 obset表示前一次屏蔽字
sigset_t bset;
// block要初始化,因为要向其中添加信号位,都为零才能确定添加的是否正确
sigemptyset(&bset);
// 添加信号屏蔽字 将所有信号设置到bset中
for(int i = 1; i <= 31; ++i)
{
sigaddset(&bset, i);
}
// 内核=======================
// 将block屏蔽字设置到内核中
int n = sigprocmask(SIG_BLOCK, &bset, nullptr);
assert(n == 0);
(void)n;
// 捕获所有普通信号
for(int i = 1; i <= 31; ++i)
{
signal(i, catchSignal);
}
while(1)
{
// 获取当前进程pending位图
sigpending(&pending);
// 打印pending位图
printPending(pending);
sleep(1);
}
return 0;
}
其中有两个0连一块了,就是18和19号,前面有张图,里面展示了重名的信号,这里就不演示了。
可以看到,我把所有的信号都屏蔽掉了,而且对所有的信号进行了捕捉,当发送除了9和19的信号外,都不会执行捕捉信号的自定义处理方法。就是因为这些信号都被屏蔽掉了。
信号的处理时机
上面代码中的例子也都看到了。信号尽管被写入到了pending位图中,也会因为block而无法立即处理。那么当block位恢复为0时,就会去处理信号。那么信号的处理时机是啥时候呢?
再来给出那个流程,pending ==》block ==》 handler。
这里的处理时机不是指block位置零的时刻。而是指block到handler的这个流程。
理论知识
这里需要各位比较理解虚拟地址空间:
这张图在我前面的博客中也有,如果各位不太了解的话,可以看我这篇博客:直接看最后的进程虚拟地址就行。
下面的理论知识较多,各位耐心观看。
首先,与信号相关的数据字段都是由进程的PCB维护的,也就是说这些信号相关的东西都是在内核范围中的,也就是上图中的3 ~ 4G。所以想要检测信号是否被屏蔽,就要在内核中检测,我们普通用户能够访问到的只是0 ~ 3G的用户空间,也就是用户态,没有访问内核数据的权限,所以想要访问内核空间中的数据,就得变成内核态。变成了内核态之后才能进行信号的检测和处理。
那么怎样进入内核态呢?
进程中使用了系统调用 / 缺陷 / 陷阱 / 异常等。而最常见的就是系统调用了,比如说open函数,也就是系统为我们提供的各种接口,这些系统调用函数最开始的代码部分,转成反汇编,会有一个int80(这里的int可不是数据类型的int),这里反汇编中的int80可以让我们由原本执行用户空间直接跳转到内核空间中,也就是下图:
int80内置在系统调用函数中,用户态是一个受管控的状态,而内核态是一个操作系统执行自己代码的一个状态,具备非常高的优先级,基本不受任何资源的约束。
我前面的博客说过,进程地址空间0 ~ 3G为用户地址空间,3 ~ 4G为内核地址空间,每个进程都有地址空间(0 ~ 4G),即每个进程都有3 ~ 4G内核空间来给内核用。前面一篇博客中说每个进程都有一个页表,其实不是很准确,因为页表也要分为用户级页表和内核级页表的。用户级页表做的就是将用户空间(0 ~ 3G)中的内容映射到物理内存中,而内核级的页表是将内核空间(3 ~ 4G)中的内容映射到物理空间中,也就是映射到操作系统的代码。
但物理内存中只有一份操作系统的代码,所以os的代码是所有的进程共享的,也就是说所有的进程是共享同一份内核级页表来映射物理内存中的os的(这种说法其实也不准确,但是各位先这样记着,后续博客还会再讲页表的),即内核级的页表可以被所有的进程看到。
所以用户想调用系统接口,也就是在进程中调用系统的接口,但是系统函数的代码真实存放地址是在物理内存的os那唯一一块空间中的,而我们用户级的页表是映射不到这块空间的,所以执行系统函数的代码时要先跳到内核中,再经过内核级页表找到物理内存的os中的相应代码。
各位不要觉得内核很神奇,内核也是在所有进程的地址空间上下文跑的。
我前面博客中讲动静态库的时候说过,动静态库是将其相应的地址加载到用户空间的共享区中的,进程调用动态库的代码时也是先跳到共享区中,然后在通过用户级页表来映射到动态库在物理内存中的空间,然后再执行相应代码的。 这里原理就和内核代码的执行一样。
进程在调度切换的时候,当前进程上下文数据都会保存,内核的数据也算,所以切换到下一个进程的时候读取的是下一个进程的上下文数据,不同的进程内核数据是不同的,切换的时候每个进程的上下文数据会保存的好好的, 不会出现无法判定某个进程的内核代码该执行到哪的情况。当处于内核态的时候就能执行os的代码。
再来讲点硬件的,CPU的寄存器可分为两类,一类是我们用户可见的,像我们调试代码看反汇编的时候的esp、ebp、eax……等等;还有一类是不可见的,是CPU自用的寄存器,其中有一个叫做CR3,CR3中有两个比特位来表示当前CPU的执行权限,1表示内核,3表示用户。系统调用接口中的int80会将CR3中的用户态切回内核态,当切到内核态时就可访问os的代码了,此时用的就是内核级页表,切换到用户态时就用的是用户级的页表。
真正处理时机
前面这些讲的是从用户态切换到内核态。
而信号真正的处理时机是在内核态切换回用户态的时候才会进行信号的检测和处理的。
两个问题:
- 为什么要从内核态切换到用户态呢?
因为用户调用系统函数完毕后,用户肯定还有剩余的代码没有执行完,比如说open打开了文件之后,肯定还有后续的读写等一系列其他操作的,不可能光打开文件之后进程就退出了,没什么意义。所以当内核态代码执行完毕后,还要返回用户态执行后续未执行的代码的。
- 为什么是返回的时候才执行信号检测和处理呢?
os中的代码优先级更高,更需要先执行,所以需要将这些代码执行完后才能进行其他操作,也就是在内核态返回到用户态的时候再进行信号的检测和处理。
前面也说了,信号在检测完毕后,执行后续处理的方式有3种,默认、忽略、自定义。
当执行默认和忽略动作的时候,会直接在内核态时进行执行,执行完毕后,信号处理完毕,再跳回用户态执行用户态的后续代码。
当执行自定义动作的时候,会先跳回到用户态执行用户态的处理动作,用户态执行完毕后再次跳回内核态去处理pending位图,此时信号处理完毕,再跳回用户态执行用户态的后续代码。(等会说为啥这么麻烦)
上面的处理过程也就是下图:
下面说说为自定义方式为啥执行起来这么麻烦:
首先确定一点,处于内核态时,权限非常高,什么事情都是可以做到的。
当进行忽略动作(内核只需要将对应pending位由0置1即可)和默认动作时,直接执行由os设计者设计好的完美代码(os都这么些年了,其代码肯定比我们写的代码完美)就行。
我前面的博客中讲过,os不相信任何人。为啥呢?
当捕捉到信号时进行自定义动作,检测时我们处于内核状态,当前状态是可以执行我们用户代码中的处理方法的。也就是说os想去0 ~ 3G的空间是随随便便的事,人家权限大得多。比如我们用调用sigaddset这个系统接口时,我们需要传一个sigset_t*类型的参数,而这里的这个指针是放在我们用户空间栈中的,系统想要修改这个参数就得要访问我们用户空间,不然就没法搞。
.
再来说一个,系统调用需要将os中的数据给用户看到,但本质上来讲还是os能访问用户空间。比如说文件IO中,调用read接口,数据是由os给用户的,实际上是os给用户拷贝过去的,所以我们在调用read接口的时候需要我们自己定义一个buf缓冲区,当调用read接口时os就会将数据拷贝到buf中,所以os是要有访问0 ~ 3G空间的权限才能做到的。
.
但是这里os不想执行用户提供的handler(自定义方法),如果让os执行用户的handler,万一用户的handler中有非法操作呢?比如说万一handler里面有rm *命令/盗取数据的命令,这些代码在内核态下是不会被阻拦的,就是因为内核态下的权限太大了。但是如果是在用户态下执行的话,会经过os的审核,如果没有对应的执行权限就会被os当场抓获并阻止。所以说,os不相信任何人,handler是普通用户写的,以内核态执行会无法被阻挡,这样就可以为所欲为了,所以不能用内核态来执行用户写的代码。就像班里面有一个学霸,人家能考100,但是每次都控分控到60,人家就是喜欢扮猪吃老虎,你又不能把人家怎么样。
所以说执行自定义方法时要先从内核态切换到用户态,用用户的身份去执行用户的代码。执行完后还要修改进程的pending位图,所以又要切换到内核状态去处理,处理完毕后再返回到用户态执行后续未完成的代码。
这里整个自定义方法的处理可以画一个简易版的流程图:
不知各位理解了没。
再次总结一下:
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。sighandler函数返回后自动执行特殊的系统调sigreturn再次进入内核态。如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
其他信号捕捉方式
上面用signal函数捕捉了信号,还有一个接口也可以,不过比较麻烦,就是sigaction:
上面描述的就是检查并改变信号行为。
说一下参数,看着是三个,其实就两个。
signum,就是你要捕捉的信号的编号
后面的act和oldact跟bset和obset一样,就是传新的,返回旧的。
不过这两个的类型是第一次见,是一个结构体,而且和函数名一样,C++中是可以这样的,不过用的时候要知名其是一个结构体,看一下这个结构体长啥样:
.
.
三个函数指针,一个sigset_t,一个int。这里只需要注意sa_handler和sa_mask就行,剩下的不做重点,sa_flag给0就行。
sa_handler就是回调函数,也就是后续处理方法,这里可以传默认(0)、忽略(1)、自定义的函数。
.
sa_mask就是block屏蔽字。想设置屏蔽信号了就直接用sigaddset就行。不设置就初始化一下就行。
来段代码:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
// 自定义方法
void catchSignal(int signal)
{
cout << "catch a signal ::" << signal << endl;
}
int main()
{
struct sigaction act, oact;
act.sa_flags = 0;
// mask初始化
sigemptyset(&act.sa_mask);
// 处理方法改为自定义
act.sa_handler = catchSignal;
// 设置进当前调用进程的pcb中
sigaction(2, &act, &oact);
// 这里强转的时候会报错,编译的时候要加选项 -fpermissive
cout << "default action : " << (int)(oact.sa_handler) << endl;
while(1) sleep(1);
return 0;
}
运行:
捕捉2号信号,执行了自定义方法。
再在最开始设置一下处理动作:
这样oact中的方法就是SIG_IGN了:
处理信号期间发送相同信号
处理信号期间,即执行三种动作时,os会如何处理?
当某信号处理函数被调用时,内核会自动将该信号加载到信号屏蔽字中,处理函数返回时才自动回复原来的os信号屏蔽字。这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
演示一下。
先说一下处理的信号被屏蔽。
代码:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
// 打印获得到的位图
void printPending(sigset_t& pending)
{
for(int i = 1; i <= 31; ++i)
{
if(sigismember(&pending, i)) cout << "1";
else cout << "0";
}
cout << endl;
}
// 自定义方法
void catchSignal(int signal)
{
cout << "catch a signal ::" << signal << endl;
sigset_t pending;
int count = 0;
while(1)
{
sigpending(&pending);
printPending(pending);
sleep(1);
++count;
if(count == 10)
{
cout << "\tover" << endl;
break;
}
}
}
int main()
{
// 设定2号信号处理动作为忽略
sigset(2, SIG_IGN);
cout << "process ::" << getpid() << " runnig" << endl;
struct sigaction act, oact;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
act.sa_handler = catchSignal;
// 设置进当前调用进程的pcb中
sigaction(2, &act, nullptr);
while(1)
{
sleep(1);
};
return 0;
}
验证成功。
自动屏蔽另外一些信号。
代码:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
// 打印获得到的位图
void printPending(sigset_t& pending)
{
for(int i = 1; i <= 31; ++i)
{
if(sigismember(&pending, i)) cout << "1";
else cout << "0";
}
cout << endl;
}
// 自定义方法
void catchSignal(int signal)
{
cout << "catch a signal ::" << signal << endl;
sigset_t pending;
int count = 0;
while(1)
{
sigpending(&pending);
printPending(pending);
sleep(1);
++count;
if(count == 20)
{
cout << "\tover" << endl;
break;
}
}
}
int main()
{
// 设定2号信号处理动作为忽略
sigset(2, SIG_IGN);
cout << "process ::" << getpid() << " runnig" << endl;
struct sigaction act, oact;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
act.sa_handler = catchSignal;
// 屏蔽这些信号
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
sigaddset(&act.sa_mask, 6);
sigaddset(&act.sa_mask, 7);
sigaddset(&act.sa_mask, 8);
// 设置进当前调用进程的pcb中
sigaction(2, &act, nullptr);
while(1)
{
sleep(1);
};
return 0;
}
运行:
正确的。
注意这里发送2号信号后,直接就将设置的3~8号信号屏蔽了,2号信号由于在处理,所以也被屏蔽了,我用命令行脚本发送了这个信号,都无法处理。但是屏蔽结束后会按照优先级最高的信号进行处理,显然这里处理的信号不是2号信号,直接退出了,不然还是会接收的。
一点扩充
可重入函数
什么意思呢?
就拿链表的头插来说。
上面的过程执行了两次头插,一次是在用户态下,一次是在内核态下,但是内核态下产生的节点会被遗忘掉,就导致了内存泄漏。
即inset被两个执行流在某一时间段内同时进入,导致产生问题。
这里有一组概念,当一个函数在某一时间段内被两个执行流同时进入,如果产生了问题,就称该函数为不可重入函数,如果没有出现问题,就称该函数为可重入函数。
可重入函数和不可重入函数时函数的一种特征,目前我们用的大多数函数都是不可重入函数。
一般来说,当函数访问了全局数据 / static数据 / 堆上的数据,就可以大致判断该函数为不可重入函数,但是现在讲这个有点偏了,等到我后面博客中讲多线程时再细说。
volatile关键字
volatile这个关键字这个关键字非常少见。我前面讲C++的类型转换的时候也讲过,这里再讲一次。
先来段代码:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
int flag = 0;
void handler(int signal)
{
cout << endl;
cout << "=================" << endl;
cout << "catch signal ::" << signal << endl;
// 自定义方法中修改flag
flag = 1;
cout << "\t修改flag ->" << flag << endl;
cout << "=================" << endl;
}
int main()
{
// 捕捉2号信号
signal(SIGINT, handler);
cout << getpid() << "running" << endl;
// flag为0时就一直循环,当发送2号信号后,flag就会变为1,此时就会退出while
while(!flag);
cout << "flag changed\t" << getpid() << " exit" << endl;
return 0;
}
flag为全局变量,当发送2号信号后执行自定义方法就会把flag变为1,就会跳出while,运行并发送2号信号:
验证成功。
但是这里我用g++编译的时候,Makefile中,长这样:
如果有不懂Makefile的同学,可以看看我这篇:点目录中的Linux项目自动化构建工具 — make/Makefile。
但是这里只是简单的编译,没有添加任何特殊选项让编译器编译的时候做一些优化,但说g++/gcc编译的时候是可以添加一些选项的:
有很多,我用红色框圈出的-O3(注意是大写字母O和数字3),算是编译器最高级别的优化。
此时我编译的时候添加上这个选项:
编译:
可以看到mySignal的大小都不一样。
此时我再运行:
flag虽然改变成了1,但是进程并没有退出。因为我写的代码中,while的整个循环体都没有改变flag,编译器认为:flag没有改变就没有必要让cpu每次都从内存中将flag加载到寄存器中。也就是这样:
因为flag没变,所以直接将flag的值存放到寄存器中,这样cpu每次查看flag的值的时候就可以直接到其寄存器中看就行了,这样效率就会高很多:
这就是编译器做的优化,将flag保存到cpu的寄存器中,不去内存中取,每次循环判断时就直接到寄存器内一看就行,不再到内存中加载,效率提高。
但是当进程接收到2号信号时,执行自定义方法,flag发生了改变,这里的改变会导致内存中的flag也发生变化,内存中的flag变为1,但此时while循环中,每次查看的时候只是去寄存器中查看,而寄存器中的flag并不会发生变化,永远是0,所以while循环一直是 while(!0),这样就死循环了。所以前面第二次加上 -O3 选项后进程就不会退出了。
这种行为是编译器自行做的优化,像这种易变的全局变量,也就4字节,完全可以存放到寄存器中,但是如果我们不想让cpu将flag存放到寄存器中,也就是说我们不想让编译器做出优化时,就要在flag前面加上volatile,这样cpu就会按照没有优化时的那样做了,也就是每次都要跑到内存中读取flag的数据然后再做出判断,这样只要flag一改,cpu就能立刻看到。
看看我这里加上volatile关键字后,运行起来:
就没有问题了。
所以,volatile关键字作用就是防止变量被编译器优化,而优化是处于编译阶段的,所以volatile关键字是在编译阶段起作用的,编译好的指令决定了cpu和os该干什么,所以volatile不是在运行时起作用的。还有一个关键字extern是在链接阶段起作用的,为啥我就不讲了,各位自己找找。
SIGCHLD信号
我在我前面进程控制的那篇博客中讲过,一个进程退出前一定要让其父进程对其进行等待,不然会导致两个问题:
- 该进程变为僵尸进程进而导致内存泄漏。
- 父进程无法得知子进程的退出结果,也就是父进程得不到子进程的退出信息,也就是父进程得不到子进程的退出码和退出信号。
故为了避免上述问题,可以通过让父进程用wait / waitpid来等待子进程,可以只解决第一个问题,不必关心子进程的退出码/退出信号。
这里要再补充一点小知识。
当子进程退出时会主动向父进程发送SIGCHLD信号,即17号信号。
可以看到,其默认行为是Ign,也就是忽略,注意,这里的忽略和我前面讲的3种退出方式的忽略是不一样的,这里的忽略是被包含在3中退出方式的默认行为中的。可能有同学不理解,没关系,等会给代码就好理解了。
这里comment中说的是,当子进程stopped或者terminated了就会向父进程发送SIGCHLD信号。也就是说有两种情况,一种是子进程暂停,一种是子进程终止。等会也会有代码展示。
下面讲四个示例:
- 证明子进程退出会向父进程发送17号信号
- 让子进程退出后自动释放其空间
- 多个子进程同时退出
- 证明子进程暂停会向父进程发送17号信号
我把最重要的1、2放在前面来讲。
证明子进程退出会向父进程发送17号信号
这个还是比较简单的,直接signal捕捉就行。
代码:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
void handler(int signal)
{
cout << "get a signal ::" << signal << endl;
}
int main()
{
// 父进程捕捉17号信号
signal(SIGCHLD, handler);
if(fork() == 0)
{
// 子进程睡一秒后直接退出
sleep(1);
cout << "child " << getpid() << "\tquit" << endl;
exit(0);
}
while(1)
{
sleep(1);
}
return 0;
}
运行:
可以看到进程退出后就是发送了17号信号。
让子进程退出后自动释放其空间
这个如何做到?
我们可以让父进程对信号进行忽略。这里的忽略是三种处理方式中的忽略SIG_IGN。
代码:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
int main()
{
// 父进程对17号信号注册忽略动作
signal(SIGCHLD, SIG_IGN);
if(fork() == 0)
{
// 子进程睡一秒后直接退出
sleep(1);
cout << "child " << getpid() << "\tquit" << endl;
exit(0);
}
while(1)
{
cout << getpid() << " parent running" << endl;
sleep(1);
}
return 0;
}
运行:
这里父进程忽略子进程发来的17号信号,是指让子进程退出后自己自动释放掉,而不是变成僵尸进程。默认处理方式中的忽略,子进程还会进入僵尸进程并让父进程对其“收尸”。而显然我代码中并没有对子进程“收尸”。
多个子进程退出
子进程退出时要让父进程等待,那么当有10个子进程呢?
某一时刻,一个子进程发来SIGCHLD信号,父进程接收到信号后去执行自定义方法(此处要在自定义方法中对子进程进行等待),此时block屏蔽字中SIGCHLD信号是被屏蔽的,也就是说若此时还有其他子进程退出,也发送了SIGCHILD信号,父进程是无法得知的。也就是说这个时刻可能还会有0 ~ 9个子进程同时发送了SIGCHILD信号,那么我们如果是在自定义方法中等待子进程,就需要对所有的进程进行等待,不然如果只进行一次等待的话,就可能会导致其他九个进程也发送了SIGCHLD信号而只进行了一次等待的操作,也就是只等了一个子进程,而其他的都没等,那么此时就会产生8个僵尸进程(还有一个释放的是屏蔽情况下,pending位图中还有一个17号位置1了,屏蔽结束后还会处理这个信号,但是处理这个信号时就不pending位图就不会再出现1了,因为所有的子进程都已经发送过信号了)。
父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下。如果有同学对于等待不清楚的话,可以看我这篇:点目录进程等待就行
那么这里要采用的就是第二种方式,轮询检测,非阻塞的效率能高一点,虽然在等,还是能做别的事情。
同一种方式,两种方法:
指定pid等待
代码:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<assert.h>
#include<vector>
#define CHILD_NUM 10
vector<pid_t> pids;
void handler(int signal)
{
cout << endl;
cout << endl;
cout << "========================================" << endl;
cout << "father get a signal ::" << signal << endl;
cout << "start to wait childs" << endl;
for(int i = 0; i < CHILD_NUM; ++i)
{
pid_t ret;
if(pids[i] != 0) // pid不为0才等待
{
cout << "wait[" << pids[i] << "]";
ret = waitpid(pids[i], nullptr, WNOHANG);
assert(ret != -1);
if(ret > 0)
{
// 等待成功后就直接将该位置的pid改为0
cout << "success" << endl;
pids[i] = 0;
}
else if(ret == 0)
{ // 等于0说明子进程还在跑
cout << "fail, it's still running" << endl;
}
}
}
cout << "========================================" << endl;
cout << endl;
cout << endl;
}
int main()
{
// 捕捉SIGCHLD信号
signal(SIGCHLD, handler);
for(int i = 0; i < CHILD_NUM; ++i)
{
// 创建CHILD_NUM个子进程
pid_t id = fork();
if(id == 0)
{// 子进程
// 睡1s直接退出
sleep(1);
exit(0);
}
// 父进程记录下每个子进程的pid
pids.push_back(id);
cout << "child[" << id << "] is created" << endl;
}
while(1)
{
// 这里代表父进程做自己的事情
cout << "father[" << getpid() << "] do own things" << endl;
sleep(1);
}
return 0;
}
运行:
任意pid等待
代码:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<assert.h>
#include<vector>
#define CHILD_NUM 10
void handler(int signal)
{
cout << endl;
cout << endl;
cout << "========================================" << endl;
cout << "father get a signal ::" << signal << endl;
cout << "start to wait childs" << endl;
pid_t id = 0;
// -1 等待任意子进程,只要返回值大于0就说明等待进程成功了
while((id = waitpid(-1, nullptr, WNOHANG)) > 0)
{
cout << "wait [" << id << "] success" << endl;
}
cout << "========================================" << endl;
cout << endl;
cout << endl;
}
int main()
{
// 捕捉SIGCHLD信号
signal(SIGCHLD, handler);
for(int i = 0; i < CHILD_NUM; ++i)
{
// 创建CHILD_NUM个子进程
pid_t id = fork();
if(id == 0)
{// 子进程
// 睡1s直接退出
sleep(1);
exit(0);
}
// 父进程记录下每个子进程的pid
cout << "child[" << id << "] is created" << endl;
}
while(1)
{
// 这里代表父进程做自己的事情
cout << "father[" << getpid() << "] do own things" << endl;
sleep(1);
}
return 0;
}
运行:
证明子进程暂停会向父进程发送17号信号
这里很简单,直接上代码:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
void handler(int signal)
{
cout << "father get a signal ::" << signal << endl;
}
int main()
{
signal(SIGCHLD, handler);
if(fork() == 0)
{
cout << "child [" << getpid() << "] is created" << endl;
sleep(100);
exit(0);
}
while(1)
{
cout << "father[" << getpid() << "] do own things" << endl;
sleep(1);
}
return 0;
}
运行后,直接给子进程发送19号信号,也就是SIGSTOP:
而父进程接收到的是17号信号,验证成功。
到此结束。。。