通过学习信号可以理解进程与进程的一个相对关系,还能理解操作系统与进程的关系。要注意的是进程间通信中的信号量与这里的信号没有半毛钱关系,就像老婆和老婆饼。
本文要点:
在生活中也存在着很多信号,如下课铃,闹钟,红绿灯等等,这里就有两个问题。
你为什么能认识红绿灯或者闹钟呢 ❓
因为曾经有人教过我们红绿灯或闹钟是什么,然后我们记住的。
身边没有闹钟时,你知不知道闹钟响了之后,该怎么办 ❓
当然知道,因为曾经有人教过我们,教我们的是:它是什么,为什么,怎么办。这两个问题对应是什么和怎么办,而为什么,是路上不安全,所以要认识红绿灯,对于为什么,信号这里就不多说了。
所以对于是什么和怎么办这个话题称为人能够识别信号。os 类似社会,人就是进程,社会中会有很多信号围绕着人去展开,而 os 中也会有很多信号围绕着信号去展开,所以进程要能够识别非常多的信号。这里只想说明进程能认识信号,以及信号不管到没到来进程都知道该怎么做。
在之前我发们也很简单的接触过信号,kill -l
就可以查看信号,可以看到这里不是有 64 种信号,因为中间不是连续的。其中 1~31 叫做普通信号
,而 34~64 叫做实时信号
,每个实时信号中都包含了 RT 两个字母。要说明的是这里重点谈普通信号,实时信号不考虑,简单提一下,实时信号是一种响应特别强的信号,比如着火,而普通信号对应早上的闹钟。
生活中的信号有三种生命周期,linux 下的信号也是如此,所以本文就围绕着它三研究。
测试用例 1
这里开始我们就开始融入 C++ 语法了,在 linux 下,C++ 文件的后缀可以是 .cpp,.cxx,.cc。可以看到这里的 makefile,这样写的好处是如果以后想修改依赖文件或者目标文件,那么只需要修改上面的一部分即可。
这段代码就是一段简单的死循环,当我们在键盘ctrl+c
就是向前台进程发送2)SIGINT
信号结束进程。当然可以新建 ssh 渠道验证一下,这里可以向目标进程发送 2 号信号或者它所对应的宏 SIGINT
对于相当一部分信号而言,当进程收到的时候,默认的处理动作就是终止当前进程
SIGCONT 和 SIGSTOP
这两个信号,我们之前也接触过,19)SIGSTOP
用于暂停目标进程,18)SIGCONT
用于继续目标进程。此时发送 18 号信号后,ctrl + c 也就是发送第 2 号信号不能结束目标进程,因为目标进程被发送 18 号信号后,已经变成了一个后台进程 S (ps ajx 可以看到),2 号信号无法结束,所以这里可以发送第 3,9 号信号来结束像这样的后台进程。
宏观理解
所以下面会围绕着这个宏观的理解链来展开。
产生信号:
a) kill 命令产生
b) 键盘产生
第 1 点就是 kill -2 pid,第 2 点就是 ctrl +c。
信号识别:
进程收到信号,其实不是立即处理的,而是在合适的时候再处理。
所谓什么是 “ 合适的时候 ” 会在下面谈,“ 不是立即处理 ” 是指比如你点了一个外卖,外卖小哥把饭送到楼下,发信息告诉你说你饭已经到了,但现在敌人已经上水晶了,很显然此时你不是立马下去拿饭,而是等这波团战结束。那么信号中为什么 “ 不是立即处理 ” 呢 ? —— 因为信号的产生是在进程运行的任何时间点都可以产生的,有可能进程正在做更重要的事情。
信号处理
默认方式 (部分是终止进程,部分有特定的功能)
忽略信号
比如说发送了一个信号,但是却什么都没做,这就是忽略信号
,它当然也是处理信号。
自定义信号
如果你自己想处理这个信号,就叫做自定义信号,也叫做 捕捉信号
。
信号处理无非就以下 3 种方案。比如早上的闹钟响了,然后你就起床了,这是默认; 闹钟响了,然后你继续睡,这是忽略;闹钟响了,然后你起来跳个舞,这是自定义捕捉。
当然信号的产生方式还有很多种。我们先探讨一下信号的本质。
信号的本质
从信号识别中,我们知道信号不是立即处理的,那么就意味着信号需要被保存起来。那么问题就来了。
信号在哪里保存
信号不是给硬件,网络发的,它是给进程发的,所以这个信号一定是在进程的 PCB 下,也就是在进程控制块 task_struct 中保存。
信号如何保存
由 kill -l,我们知道一共有 31 个普通信号,它们都是大写字母构成,其实就是一个一个的宏,我们可以在系统中查找到。
你点了一个外卖,对你而言,你当然知道点的是什么,而外卖员是男是女,多大年纪这不重要。对你而言,最重要的是外卖是否到了,今天中午吃的是猪脚饭。所以对进程而言最重要的无非是 “ 是否有信号 ” + “ 是谁 ”,我们知道操作系统提供了 31 个普通信号,所以我们采用位图
来保存信号,也就是说在 task_struct 结构中只要写上一个 unsigned int signals; (00000000 … 00000000) 这样一个字段即可。比特位的位置代表是哪一个信号,比特位的内容 0 1 代表是否。
信号是谁发的,如何发
至此,发送信号的本质,就是写对应进程 task_struct 信号位图。因为 OS 是系统资源的管理者,所以把数据写到 task_struct 中只有 OS 有资格,有义务。所以信号是操作系统发送的,通过修改对应进程的信号位图 (0 -> 1) 完成信号的发送。所以更朴素点说信号不是 OS 发送的,而是写的。
知道了信号的本质后,再看信号的产生 (kill,键盘),不管信号是如何产生的,最后一定要经过 OS,再到进程。kill 当然是命令,是在 bash 上的,也就是在系统调用之上,所以 kill 底层一定使用了操作系统某种接口来完成像目标进程写信号的过程。键盘是一种硬件,它所产生的各种组合键会产生各种不同的数据,OS 作为硬件的管理者,键盘上所获得的各种数据,一定是先被 OS 拿到。所以现在就可以告诉大家,信号的产生五花八门,但归根结底所有信号的产生后都是间接或直接由 OS 拿到后向目标进程发信号。
接口
signal 可以对特定的信号,自定义方法或忽略信号。signum 是信号编号;handler 的类型是 sighandler_t, 而它是一个函数指针 void (*sighandler_t)(int),如果 typedef void (*sighandler_t)(int) 就相当于给 void(*)(int) 重命名为 sighandler_t。这是回调函数
的机制,当 signum 产生时 handler 才调用,否则不会调用,这就是回调函数一个典型的应用场景。
测试用例 2
测试用例 2.1
这里就可以看到没有信号产生时,它就不会执行 signal,因为它是回调函数。而一旦 ctrl + c 收到信号,这里就调用了 handler 函数,并获取到信号编号,同样命令也是如此。虽然捕捉了 2 号信号 SIGINT,但是其它信号并没有被捕捉,所以当然可以kill -3
或ctrl+/
。那么问题来了,若捕捉完 31 个信号呢。
测试用例 2.2
当我们把全部信号捕捉时,操作系统给进程写的任何信号,进程只是说知道了知道了,然后给你一句话就完了,然后继续跑路,是不是就意味着写了一个 “ 金刚不坏 ” 的进程呢。Linux 操作系统当然需要考虑这种场景,如果允许所有的信号被捕捉,那么非法用户就很容易创建了一个非法进程,这个进程各种申请资源就是不还,并且还把所有的信号全部捕捉或忽略,这就导致操作系统知道是这个进程的问题,还拿它没办法,这就是系统设计上的 bug。所以 Linux 系统中有若干个信号不能被捕捉或自定义,最典型的信号就是第 9 号信号 SIGKILL
,快捷键ctrl+\
,它叫做管理员信号
,是所有信号中权力最大的。那么忽略信号的现象是什么呢。
测试用例 2.3
可以看到SIG_IGN
对应的就是把 1 强制成函数指针类型,它依旧是一个回调函数 (这里 grep -ER 在筛选时后面可以 -n 以获取行号,在 vim 时也可以在其后 +24 以定位所在行号)。此时系统发送信号给进程,它一句话也不说,继续跑路,直接忽略 (不过这里看到 ctrl + c 时有反应),我们当然知道它不能对所有信号进行忽略,所以发送第 9 号 SIGKILL 杀掉进程。
所以第 9 号进程 SIGKILL 既不能被捕捉,也不能被忽略。上面说过进程运行的任何时间点都可以产生信号,所以信号产生和进程运行是异步
的 (当然也有同步,这也就是在上文谈信号量时只谈了异步的原因,同步这个名词有不同解释,场景不同意思也不同,同步和异步有时表示的是执行流的关系,有时是进程访问临界资源的问题。后者好比老师在上课过程中烟瘾犯了,然后跟学习不好的张三说,你去帮我拿包烟,我们先休息会等你,你回来后我们再开始上课,此时课程的进度跟张三回来要同步,互相影响,这叫做同步
;还是老师在上课过程中烟瘾犯了,然后跟学习好的李四说,你去帮我拿包烟,然后老师继续上课,而李四在跟老板吵着架,此时两件事是同时进行的,互不影响,这叫做异步)。换言之这里想说明的是若两个进程是毫无关系,一个进程在执行时,可能随时会收到信号,而信号是用户还没发,准备发,已经发,所以进程就不等信号了,这就是异步。到此我们仅仅是比较粗力度的来谈这三个阶段,所以接下来需要更加深入的了解其细节了。
在此之前我们要回答在进程等待中遗留 core dump 标志位的问题,这个问题和本章不是很强相关,由测试用例 3 演示。
测试用例 3
测试用例 3.1
这当然也很正常,因为大部分信号都会终止掉进程
设置 core file size,kill -8/11 后,发现报错信息中多了一个 (core dumped),且 ll 还发现多了两个 core 文件
在进程等待时我们说过一个概念,父进程中 waitpid 可以获取子进程的退出信息,其中 status 中,低 7 位表示进程退出时的终止信号,次低 8 位表示进程退出时的退出码,而低 8 位中的最后 1 位还没有谈,它表示进程是否 core dump,core dump 是一个标志位。当一个进程被异常退出时,退出码无意义,你不仅想知道它的退出信号,更想知道的是它在代码的哪一行触发的信号。因为是云服务器默认看不到现象,如果是虚拟机就可以看到。所以为了让云服务器能够看到,我们需要设置一下。这里ulimit -a
查看系统资源,其中ulimit -c 1024
就设置好了 core file size
。
在上面运行报错后,有一个 (core dumped),它叫做核心转储
,也就是说当一个进程崩溃时,OS 会将进程运行时的核心数据 dump 到磁盘上,方便用户进行调试,一旦发生了核心转储,core dump 标志位就会被设置 1,否则就是 0。一般而言线上环境,核心转储是被关闭的,因为程序每崩溃一次,就会 dump 一次,而这一个 core 文件有 56 万多个字节,还不说这个文件不大。若线上环境核心转储是打开的,在公司项目中,有几千台机器,那肯定是自动运行的,此时若有大量错误时,一运行就 dump,一 dump 就运行,过了一晚,你一看服务器都登不上了,原因是磁盘已经被大量的 core 文件占用了。
测试用例 3.2
此时我们就可以利用核心转储生成的 core 文件来定位 bug,需要 makefile 中 -g 先生成 release 文件。gdb 中直接core-file + core 文件
即可。以前我们找 bug 是一行一行的调试,而现在是不管三七二十一,让你先崩掉,然后配合 gdb 定位 bug,这种调试方案叫做事后调试
。
测试用例 3.3
除 0
野指针
这里还有一个细节,除 0 异常和 kill -8 报的错误是一样的,野指针异常和 kill -11 报的错误也是一样的,这就说明的是信号产生的第三种方式是程序异常
,这里更准确来说应该是硬件异常
,因为除 0 和野指针都有对应的硬件资源,下面会解释。
8)SIGFPE
是指进程在运行时发生了算术异常,比如除 0 或者浮点数溢出等。11)SIGSEGV
是段错误,指进程在运行时访问了不属于自己的内存地址或者访问已经被释放的内存地址,比如野指针。
站在语言的角度这叫做程序崩溃,本质应该是进程崩溃,因为站在系统的角度这叫做进程收到了信号。换言之,一般程序崩溃是因为你的代码有非法操作被 OS 检测到了,然后向你的进程发送了信号。当然在语言层也可以使用异常捕捉
来进行语言层面上的检测。如果没有信号,那么出现野指针等内存问题时,OS 作为软硬件资源的管理者设计的健壮性就很差,所以信号存在的价值也是为了保护软硬件等资源。
测试用例 3.4
测试用例 3.5
这就演示了实际在发送信号报错的时候,并不是所以信号都会 core dump,只有一些与你编写代码强相关的才会 core dump,比如除 0,野指针,越界等。换言之当你的进程触发这些错误时也会由 OS 识别到,然后给目标进程发送信号,来达到终止进程的目的。
到此就理解一个进程可以收到信号,收到信号后它会捕捉,忽略。比如忽略处理完后,进程就要退出了,然后释放资源,这都能理解。但是像除 0,野指针/越界这些错误,OS 是如何具备识别异常的能力 ?—— OS 是软硬件资源的管理者,好的情况和坏的情况都知道,对于除 0,野指针/越界:在语言上都叫做这几种报错,但实际上这它们都对应不同软硬件 —— 除 0,对应 CPU 中的状态寄存器 (除 0 就是溢出,而状态寄存器用来检测每次计算有无溢出); 野指针/越界,对应内存,页表,内存管理单元 MMU
(MMU 是负责的是虚拟地址与物理地址的转换的一种硬件,并且提供硬件机制的内存访问授权,如果野指针了,就会被检测你的这个地址没有权限去访问)。坏的情况下,操作系统当然知道是哪个进程干的:如果是 CPU 除 0,那么当前是哪个进程执行代码就是哪个干的;如果是内存野指针/越界,当前用的是哪个进程的页表,完成是哪个进程的转换,那么也就是哪个进程干的。换言之,OS 可以知道是哪个进程出错了,哪个进程干的,所以 OS 当然可以向这个进程发送信号。这里就点到为止了,我们毕竟是玩软件的,再深入就是具体的硬件了。
软件条件不是错误,当某种条件被触发时,OS 会向目标进程发送信号。好比,你拿了你妈的 100 块钱,你妈发现时是你拿的,相当于你发了信号给你妈,然后你妈检测到异常把你揍了一顿,这叫做进程出问题,被 OS 检测到,然后发信号终止进程。又好比,你叫你妈明早叫你起床,然后你妈明早就准时叫你起床,此时你和你妈之间的交互没有任何硬件单元存在,这叫做软件条件产生信号。
实际关于软件条件产生信号我们已经接触过了,只是当时没有提。在进程间通信中我发们说过一个场景,读端不光不读,且把读关闭,写端在写的时候就会收到 SIGPIPE 信号,进而导致写进程退出。在底层 OS 一定会提供支持,所以在写入时,OS 一定是设置了你能成功写入的条件,比如读端的文件是打开的写端就可以写,否则写端再写就会被操作系统发送信号。所以在 OS 层面上这是一种软件条件产生的信号。对于 SIGPIPE 这里就不多演示了,这里介绍alarm
接口和14)SIGALRM
信号 (alarm 也并不常用,只是想通过 alarm 来演示软件条件产生信号,仅此而已)。
alarm
调用 alarm 函数可以设定一个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发送 SIGALRM 信号,该信号的默认处理动作是终止当前进程。当你设置好闹钟后,默认结束进程时,却被其它原因提前结束进程,当你在重新设置 alarm 时,就会返回上一次闹钟剩余的时间。如果 second 是 0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟还余下的秒数 。比如 alarm 30 秒,但 20 秒时进程结束了,于是重新 alarm 15,此时就会返回以前设定闹钟时还余下的时间 10 秒。
测试用例 4
这里还要说明一下两个问题:验证它收到的是第 14 号信号;为什么 1 秒内打出来的值才累加到 20 万左右,不应该更多吗。
注意 abort 和 raise 是立即发送,而 alarm 是延时 seconds 秒发,abort 只能向自己发第 6 号信号,raise 是向自己发第 sig 信号。
abort
raise
kill
可以看到产生信号的方式有很多,如果想验证一下键盘上一些产生信号的其它的组合键,就把所有信号都捕捉,然后死循环的验证,就可以看到各种组合键所对应的信号编号。至此,我们回答了:
至此,信号产生阶段已经完成了,开始进入信号识别阶段。对于如何保存,上面也仅仅是只谈了个位图,还有很多内容没有谈。
信号相关常见概念
实际执行信号的处理动作称为信号递达(Delivery)
。
递达的动作无非就是默认,忽略,自定义捕捉这三种。
信号从产生到递达之间的状态,称为信号未决(Pending)
。
进程可以选择阻塞(Block)
某个信号。
本章中谈的阻塞和之前在 waitpid 上谈的阻塞没有任何关系。这里的阻塞是指进程可以允许某些信号不会被递达,直到解除阻塞后方可递达。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意,阻塞和忽略是不同的:忽略是信号已经递达处理了,忽略是递达的一种;阻塞是信号根本不会递达,此时就暂时保存在未决中,直到结束阻塞。
信号内核示意图:
实际在 Linux kernel 的 task_struct 中还包含了一些信号相关字段,如上图,这个图应该横着来看:SIGHUP(1),没有收到 pending,也没有收到 block,所以默认处理是 SIG_DFL;SIGINT(2),收到 pending,因为也收到了 block,所以不会处理 SIG_IGN;SIGQUIT(3),没有收到 pending,收到了 block,如果没有收到对应的信号,照样可以阻塞信号,所以阻塞更准备的理解是它是一种状态;信号的自定义捕捉方法是用户提供的,是在用户权限下对应的方法。后续学习信号,操作上都是围绕着这三个表来展开。
pending (未决):它是一个无符号整型的位图,比特位的位置代表信号的编号,比特位的内容 0 1 代表是否收到信号,OS 发送信号本质是修改 task_struct ➡ pending 位图的内容。
handler (递达):它是一个函数指针数组,它是用信号的编号,作为 handler 数组的索引,找到该信号编号对应的信号处理方式,然后执行对应的方法。
block (阻塞):它是一个无符号整型的位图,比特位的位置代表信号的编号,比特位的内容 0 1 代表是否阻塞该信号。
你可以理解为了能让我们更好的对上面的三张表操作,OS 给我们提供了一种系统级别sigset_t
类型,这个类型 OS 内部的当然也有定义,我们可以使用这个数据类型在用户空间和内核交互,此时就一定需要系统接口。
从上图来看,每个信号只有一个 bit 的未决标志,非 0 即 1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,sigset_t 称为信号集
,这个类型可以表示每个信号的 “ 有效 ” 或 “ 无效 ” 状态,在阻塞信号集中有效
和无效
的含义是该信号是否被阻塞,而在未决信号集中有效和无效的含义是该信号是否处于未决状态。
再解释一下,如果 sigset_t 定义的变量 set 当然是在栈上开辟空间,这个栈就是用户栈,实际我们在进程地址空间中谈的代码段、数据段、堆区、内存映射段、栈区、命令行参数、环境变量都是在用户空间,而将来要把用户空间中的进程信号属性设置到内核,所以除了 sigset_t,一定还需要系统接口。
准备工作
当然光有 sigset_t 这个类型还不够,这个类型本身就是一个位图。实际我们不支持或者不建议直接操作 sigset_t,因为不同平台,甚至不同位数的 OS,sigset_t 位图的底层组织结构实现可能是不一样的,所以 OS 提供了一些专门针对 sigset_t 的系统接口,这些接口会先在用户层把信号相关的位图数据处理好。sigset_t 类型对于每种信号用一个 bit 表示 “ 有效 ” 或 “ 无效 ” 状态,至于这个类型内部如何存储这些 bit,则依赖于系统的实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作 sigset_t 变量,而不应该对它的内部数据做任何解释,比如 printf 直接打印 sigset_t 变量是没有意义的。
#include
int sigemptyset(sigset_t* set);//全部置0
int sigfillset(sigset_t* set);//全部置1
int sigaddset(sigset_t* set, int signo);//指定位置置为1
int sigdelset(sigset_t* set, int signo);//指定位置置为0
int sigismember(const sigset_t* set, int signo);//判断特定信号是否已经被设置
sigprocmask
include
int sigprocmask(int how, const sigset_t* set, sigset_t* oset);
return 0 onsuccess and -1 on error.
how:
SIG_BLOCK,添加新的set屏蔽信号,同mask=mask|set
SIG_UNBLOCK,解除set阻塞的屏蔽信号, 同mask=mask&set
SIG_SETMASK,设置当前信号屏蔽字为set所指向的值,同mask=set
set:
输入型参数,由用户层把信号屏蔽字拷贝到内核
oset:
输出型参数,把老的信号屏蔽字返回,方便恢复,不想保存可设置NULL
notice:
这个系统接口是你的进程要执行的,所以对应设置的就是你这个进程的信号屏蔽字
sigprocmask
可以读取或更改进程的信号屏蔽字(阻塞信号集)。传入一个 set 信号集,设置进程的 block 位图,一般把用户空间定义的信号集变量或对象设置成进程 block 位图,这样的信号集叫做信号屏蔽字(Signal Mask)
,阻塞信号集也叫做当前进程的信号屏蔽字,这里的屏蔽应该理解为阻塞而不是忽略。
sigpending
#include
int sigpending(sigset_t* set);
return 0 on success and -1 on error.
set:
输出型参数,获取进程的pending信号位图
测试用例 8
1) 屏蔽(阻塞) 2 号信号
2) 不断的获取 pending 信号集,并输出
3) 发送 2 号信号给进程
#include
#include
#include
using std::cout;
using std::endl;
void show_pending(sigset_t* pending)
{
for(int i = 1; i <= 31; i++)
{
if(sigismember(pending, i))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
int main()
{
//user
sigset_t in, out;
sigemptyset(&in)//置0
sigemptyset(&out);
sigaddset(&in, 2)//01000...
//kernel
sigprocmask(SIG_SETMASK, &in, &out);//屏蔽 new:0100... old:0000...
sigset_t pending;
while(true)
{
sigpending(&pending);
show_pending(&pending);
sleep(1);
}
return 0;
}
测试用例 9
1) 屏蔽(阻塞) 2 号信号
2) 不断的获取 pending 信号集,并输出
3) 发送 2 号信号给进程
4) 20 秒后取消屏蔽 2 号信号
2 号信号经过屏蔽又取消屏蔽,如果默认不自定义捕捉,那么一取消屏蔽,2 号信号立马从未决到递达,执行默认方法终止,所以就看不到现象,所以就需要自定义捕捉(实际我们很少要对信号自定义捕捉)。
#include
#include
#include
using std::cout;
using std::endl;
void show_pending(sigset_t* pending)
{
for(int i = 1; i <= 31; i++)
{
if(sigismember(pending, i))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
void handler(int signo)
{
cout << "get a signo: " << signo << endl;
}
int main()
{
//需要自定义捕捉
signal(2, handler);
//user
sigset_t in, out;
sigemptyset(&in)//置0
sigemptyset(&out);
sigaddset(&in, 2)//01000...
//kernel
sigprocmask(SIG_SETMASK, &in, &out);//屏蔽 new:0100... old:0000...
int count = 0;
sigset_t pending;
while(true)
{
sigpending(&pending);
show_pending(&pending);
sleep(1);
if(count == 20)
{
sigprocmask(SIG_SETMASK, &out, &in);//解除屏蔽 new:0000... old:0100...
cout << "my: ";
show_pending(&in);//虽然有点不合适,但仅仅是做个测试
cout << "recover default: ";
show_pending(&out);
}
count++;
}
return 0;
}
至此,我们信号识别阶段也已完成了,开始进入信号处理中阶段。对于如何处理,上面也谈过 signal 接口了,如 signal(2, handler),所以对 2 号信号执行 handler 捕捉动作,本质是 OS 去 task_struct 通过 2 号信号作索引,找到内核中 handler 函数指针数组中对应的方法,然后把数组内容改成你自己在用户层传入的 handler 函数指针方法。这里我们要谈的是上面遗留下来的问题 —— 进程收到信号时,不是立即处理的,而是在合适的时候再处理,其中不是立即处理我们已经解释过了,而合适的时候是什么时候呢 ?—— 所谓合适的时候就是进程从内核态返回用户态时,尝试进行信号检测与捕捉执行,后面我们就会知道内核态切换成用户态时,其实是一个非常好检测进程状态的一个时间点,后面再谈多线程切换时也是这个时间点,当然不仅限于此。
对于上图,我们一定不陌生,早在进程地址空间时就说过。进程如果访问的是用户空间的代码,此时的状态就是用户态
,如果访问的是内核空间,此时的状态就是内核态
。我们时常需要通过系统调用访问内核,系统调用是 OS 提供的方法,执行 OS 的方法就可能访问 OS 中的代码和数据,普通用户当然没有权限。所以在调用系统接口时,系统会自动进行身份切换 user ➡ kernel。而 OS 是怎么知道现在的状态是用户态还是内核态 ? —— 因为 CPU 中有一个状态寄存器或者说权限相关的寄存器,它可以表示所处的状态。我们之前说过每个用户进程都有自己的用户级页表,还要说的是 OS 中也有且只有一份内核级页表。也就是说诸多进程可以通过权限提升,访问同一张内核页表,每个进程变成内核态时,访问的就是同一份数据。所以 OS 区分是用户态还是内核态,除了寄存器保存了权限相关的数据之外,还要看你使用的是哪个种类的页表。
什么情况下会触发从用户态到内核态呢 ?—— 这里有很多种方式:比如你自己写的一个 cin 程序一运行就卡在那里,你摁了 abc,然后程序就会拿到 abc,其中本质是键盘在触发的时候,被 OS 先识别到,然后放在 OS 的缓冲区中,而你的程序在从 OS 的缓冲区中读取。其中 OS 是通过一种中断技术,这个中断指的是硬件方面的中断,如 8259 中断器,它是一种芯片,用于管理计算机系统中的中断请求,通常和 CPU 一起使用。还比如如果了解过汇编,可能听说过 int 80,它就是传说中系统调用接口的底层原理,系统调用的底层原理就是通过指令 int 80 来中断陷入内核。还有一种比较好理解的,调用系统接口后,就陷入内核,然后就可以执行内核代码。当然这个不用太过掌握它,只需要知道从用户态到内核态是有很多种方式的。然后当从内核态返回用户态时就更简单了,你调用完系统接口,就返到用户态了。千言万语,这里只想表达用户态到内核态是有诸多方式可以切换的。
其次这里我们只要理解,用户态和内核态的权限级别不同,决定了能看到的资源是不一样的。内核态的权限级别一定更高,但它并不代表内核态能直接访问用户态,马上展开。上面又说信号捕捉的时间点是内核态 ➡ 用户态的时候,信号被处理叫做信号递达,递达有忽略、默认、自定义,自定义动作就叫做捕捉动作,只要理解了捕捉,那么忽略和默认就简单了。上图就是整个信号的捕捉过程:在 CPU 执行你的代码时,一定会调用系统调用,系统调用当然是函数,是 OS 提供的,也有代码,需要被执行,那么应该以 “ 什么态 ” 执行 ?—— 实际上用户态中进程调用系统调用时必须得陷入内核,以用户态身份执行,执行完毕又返回用户态,继续执行用户态中的代码,那么问题就是可以直接以内核态的身份去执行用户态中的代码吗,马上解释。此时从内核态返回到用户态之前,OS 会做一系列的检测捕捉工作,它会检测当前进程是否有信号需要处理,如果没有就会返回系统调用,如果有,那就先处理 (具体它会遍历识别位图: 假如信号 pending 了,且没有被 block,那就会执行 handler 方法,比如说终止进程,那就会释放这个进程,如果是暂停,那就不用返回系统调用,然后把进程 pcb 放在暂停队列中,如果是忽略那就把 pending 中对应的比特位由 1 变为 0,然后返回系统调用)。所以可以看到比较难处理的是就是自定义捕捉,当 3 号信号捕捉时,且收到了 pending,没有被 block,那么就会执行用户空间中的捕捉方法,换言之我们因为系统调用而陷入内核,执行系统方法,执行完方法后做信号检测,检测到信号是自定义捕捉,那么就会执行自定义捕捉的方法。此时,应该以 “ 什么态 ” 执行信号捕捉方法 ?—— 理论上,内核态是绝对可以的,因为内核态的权限比用户态的权限高,但实际并不能以内核态的身份去执行用户态的代码,因为 OS 不相信任何人写的任何代码,这样设计就很有可能让恶意用户利用导致系统不安全。所以必须是用户态执行用户空间的代码,内核态执行内核空间的代码,所以你是用户态要执行内核态的代码,你是内核态要执行用户态的代码,必须进行状态或者说权限切换。所以说,信号捕捉的完整流程就是在用户区中因为中断、异常或系统调用,然后切换权限陷入内核执行系统方法,然后返回发现有信号需要被捕捉执行,然后切换权限去执行捕捉方法,然后再执行特殊的系统调用sigretum
再次陷入内核,然后再执行sys_sigreturn()
系统调用返回用户区。注意切换到用户态执行捕捉方法后不能直接返回系统调用,因为曾经执行捕捉方法时是由 OS 进入的,所以必须得利用系统接口再次陷入内核,最后由内核调用系统接口返回用户区。
上面的图和文字都说的太复杂了,这里我们简化一下,宏观来看信号的捕捉过程就是状态权限切换的过程,这里的蓝点表示信号捕捉过程中状态权限切换的次数。其中完整流程就是:
对于修改 handler 表的操作接口,我们已经了解过 signal 了,这里我们再谈谈 sigaction,sigaction 相比 signal 有更多的选项,不过我们只要知道它怎么用就行了,因为它兼顾了实时信号。
#include
int sigaction(int signum, const struct sigaction* act, struct sigaction* oldact);
return 0 on success;on error,-1 is returned.
signum:
指定捕捉信号的编号.
act:
如何处理信号,它是一个结构体指针,第2与第5个字段是实时信号相关的,我们不管.
struct sigaction{
void(*sa_handler)(int);
void(*sa_sigaction)(int,siginfo_t*, void*);
sigset_t sa_mask;
int sa_flags;
void(*sa_restorer)(void);
}
其中:
sa_handler 是将来想怎么捕捉signum信号.
sa_mask是需要额外屏蔽的信号.
sa_flags是屏蔽自己的信号.
oldact:
如果需要可以把老的信号捕捉方式保存,不需要则NULL.
注意这里有一个语法概念:我们从没写过函数名和类型名一样的代码出来,系统接口都这样写了,说明是没问题的,不过建议自己写的时候别这么做.
sa_mask 和 sa_flags
如果正在进行 2 号信号的捕捉处理,此时 OS 又向进程写了一个 2 号信号,那么一定不允许在前者处理过程中立即处理后者,而应该先把后者 block,当把前者处理完毕,再取消 block,也就是说默认当一个信号在 handler 过程中,另一个信号不能被 handler,而应该被短暂的 block,直到前者处理完毕。配图所释就是,收到 1 号信号进行捕捉,当捕捉时,把 pending 置 0 的同时,也把 block 置 1,所以即使再收到 1 号信号,因为它有 block,所以不能被递达,而前者调用 sys_sigreturn 返回时再把 block 置 0,此时后者就允许被 handler,但是现在前者还没返回,所以后者只能下次再处理。这也是 OS 为了防止大量信号产生时导致进程频繁处理的一种策略。
当某个信号的处理函数被调用时,内核会自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它就会被阻塞到当前,直到当前处理结束为止,这是sa_flags
。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask
字段说明这些需要被额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
通常我们要使用 sigaction,理论上只需要 signum,其它默认为 0 就足够了,这里也可以测试一下。
测试用例 10
此时 sigaction 就同 signal,实现最基本的捕捉动作。
#include
#include
#include
using std::cout;
using std::endl;
void handler(int signo)
{
cout << "get a signo: " << signo << endl;
}
int main()
{
//初始化结构体
struct sigaction act, oact;
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);//sa_mask的类型是sigset_t,所以当然可以sigemptyset.
//act.sa_restorer = nullptr;//实时信号就不管了
//act.sa_sigaction = nullptr;
sigaction(SIGINT, &act, &oact);
while(1)
{
cout << "process is running...\n" << endl;
sleep(1);
}
return 0;
}
测试用例10.1
测试 sa_mask 以捕捉额外 3 号信号,此时运行程序,因为 2 号信号被捕捉了,所以一发送 2 号信号,这里就会死循环的执行这里的自定义方法,此时再发送 3 号信号,也不会影响,因为 3 号信号已经被 sa_mask 然后 sigaddset 了,所以一发送 4 号信号直接终止进程。
#include
#include
#include
using std::cout;
using std::endl;
void handler(int signo)
{
while(true)
{
cout << "get a signo: " << signo << endl;
sleep(1);
}
}
int main()
{
//初始化结构体
struct sigaction act, oact;
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);//sa_mask的类型是sigset_t,所以当然可以sigemptyset.
sigaddset(&act.sa_mask, 3);
//act.sa_restorer = nullptr;//实时信号就不管了
//act.sa_sigaction = nullptr;
sigaction(SIGINT, &act, &oact);
while(1)
{
cout << "process is running...\n" << endl;
sleep(1);
}
return 0;
}
普通信号使用位图来保存,如果把 2 号信号 block 了,若连续发 10 个 2 号信号,pending 位图保存一个后,剩余 9 个就丢失了,这里只想说明普通信号可能会丢失。而相对应的实时信号不会丢失,OS 不仅会维护普通信号,还会维护实时信号,系统内也会存在大量的实时信号,OS 也要将其管理,OS 内实时信号是由struct siginfo
描述的,再用链表将其组织起来,所以它不会丢失。实时信号几乎不使用,所以就不详谈了。这里只想说普通信号是由位图来维护的,允许丢失,同时也存在不允许丢失的实时信号。
测试用例 11
#include
#include
#include
using std::cout;
using std::endl;
void show(int signo)
{
int i = 0;
while(i < 10)
{
cout << "get a signo: " << signo << endl;
i++;
sleep(1);
}
}
void handler(int signo)
{
show(signo);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3);
//act.sa_restorer = nullptr;
//act.sa_sigaction = nullptr;
sigaction(SIGINT, &act, &oact);
show(9999);
return 0;
}
可重入函数描述的是一种执行现象。假设 signal 或 sigaction 执行捕捉动作时调用 show 函数,而 main 函数内也调用 show 函数,就有可能出现 main 函数正在调用 show 函数时,10 秒内正好来了个信号,然后陷入内核并且捕捉信号,然后也执行 show 函数。可以看到现象:程序一运行,main 函数执行 show 函数,发送 2 号信号后,执行信号捕捉执行 show 函数,然后又回到 main 函数执行 show 函数。在多进程时我们都知道当然有可能一个函数被多个执行流同时进入执行,而今天在信号这里,main 执行流在执行 show 函数,突然捕捉执行流也进到这个函数了,此时函数就被多个执行流同时进入的情况,叫做重入函数
。
这样当然有问题,比如下面链表的例子,我们把头插封装成 insert 函数。进程中 main 函数刚执行完 insert 函数中 p->next = head 时,突然收到并执行捕捉信号 sighandler,其中又调用 insert 函数执行完代码,然后返回 main 函数执行还未执行的 head = p,本来 head 指向 node2,最后 head 指向 node1,此时 node2 就会造成内存泄漏。
所以上面的代码当然也有问题,所以一旦多个执行流同时执行一个函数时:若访问是不安全的,叫做不可重入函数
;相反访问是安全的就叫做可重入函数
。也就是说不可重入函数和可重入函数在多执行流下是否会出问题。很不幸,我们现在所学的大部分函数都是不可重入的,比如 STL 容器等,不可重入函数一般都是函数内使用了一些全局的空间,比如堆空间等。所以可重入函数是少之又少。
volatile
是属于 C 语言中的关键字,也叫做易变
关键字(被它修饰后的变量,就是告诉编译器这个变量是易变的)。不记得也没关系,因为在此之前我们也压根没使用它,它的作用是保持内存的可见性
。
测试用例 12
这里给一个全局标志位 flag,利用 flag 让程序死循环执行,此时就可以通过信号捕捉,在捕捉方法中改变 flag 的值,然后结束死循环。可以看到 gcc 和 g++ 它们的运行现象是一样的。
C
#include
#include
int flag = 0;
void handler(int signo)
{
flag = 1;
printf("handler signo: %d, set flag = 1\n", signo);
}
int main()
{
signal(2, handler);
while(!flag);
printf("process end...\n");
return 0;
}
C++
#include
#include
int flag = 0;
void handler(int signo)
{
flag = 1;
cout << "handler signo: " << signo << ", set flag = 1" << endl;
}
int main()
{
signal(2, handler);
while(!flag);
cout << "process end..." << endl;
}
上面可以看到 main 函数中没有更改 flag 的任何操作,所以可能会被优化,所以 flag 一变化不会立马被检测到。这里也看到了默认 gcc 和 g++ 并没有优化这段代码,所以 flag 一变化立马就被检测到。其实 gcc 和 g++ 中有很多优化级别,man gcc 文档筛选后就可以看到 gcc 有 -O0/1/2/3 等优化级别,gcc -O0 表示不会优化代码。经过验证(注意这里不同平台结果可能不一样):
gcc 在 -O0 时不会作优化处理,此时同上默认,进程一收到信号,进程就终止了。
gcc 在 -O1 等时会作优化处理,此时发现 flag 已经置为 1 了,但是进程并没有终止。
因为这里主执行流下并没有对 flag 的修改操作,所以 gcc -O1 在优化时,可能会将局部变量 flag 优化成寄存器变量,定义 flag 时一定会在内存开辟空间。此时 gcc 在编译时发现以 flag 作为死循环条件,且主执行流中没有对 flag 修改的操作,所以就把 flag 优化成寄存器变量。一般默认情况没有优化级时,gcc -O0 while 循环检测的是内存中的变量,而在优化情况下 gcc -O1 会将内存中的变量优化到寄存器中,然后 while 循环检测时只检测寄存器中 flag 的值,当执行信号捕捉代码时,flag = 1 又只会对内存修改,而此时 wihle 循环只检测寄存器中的 flag = 0。所以短暂的出现了内存数据和寄存器数据不一致的现象,然后就出现了好像把 flag 改了,但是 while 循环又不退出的现象。因为要减少代码体积和提高效率,所以在优化时需要优化成寄存器变量。而这个优化还有些问题。
测试用例 13
所以在 gcc -O1 优化时还需要加上 volatile,此时就告诉编译器,不要把 flag 优化到寄存器上,每次检测必须把 flag 从内存读到寄存器中,然后再检测,不要因为寄存器而干扰 while 循环的判断。这就叫做保持内存的可见性。所以 volatile 为什么要在系统这里谈,当然语言也不是不可以谈,无非就是通过反汇编拿到对应的指令做对比,有兴趣可以看看 《蛋哥 C 语言深度解剖》。其次这里就不测试 C++ 下的 volatile 了。
#include
#include
volatile int flag = 0;
void handler(int signo)
{
flag = 1;
printf("handler signo: %d, set flag = 1\n", signo);
}
int main()
{
signal(2, handler);
while(!flag);
printf("process end...\n");
return 0;
}
SIGCHLD 是第 17 号信号。在进程控制中说过子进程退出时父进程可以通过 wait/waitpid 来等待子进程并回收相关资源,以免造成僵尸进程,而父进程可以通过阻塞或非阻塞轮询来检测子进程的状态,前者父进程什么也做不了,后者父进程需要不断的去检测,两者都比较麻烦,且都是父进程主动的。这里要介绍的 SIGCHLD 就是第三种方案,其实在子进程退出时,会主动向父进程发送17)sigchld
,因为它的默认动作是什么都不做,所以让父进程在这里进行信号捕捉。
测试用例 14
#include
#include
#include
#include
using std::cout;
using std::endl;
void handler(int signo)
{
cout << "father process... " << getpid() << " " << getppid() << " signo: " << signo << endl;
}
int main()
{
signal(SIGCHLD, handler);
if(fork() == 0)
{
//child
int count = 7;
while(count)
{
cout << "child process... " << getpid() << " " << getppid() << " count: " << count << endl;
count--;
sleep(1);
}
cout << "child quit...!" << endl;
exit(0);
}
//father
//sleep(10);
int ret = sleep(10);
cout << "ret: " << ret << endl;
return 0;
}
测试用例 15
#include
#include
#include
void handler(int sig)
{
pid_t id;
while((id = waitpid(-1, NULL, WNOHANG)) > 0)
{
printf("wait child success: %d\n", id);
}
printf("child is quit! %d\n", getpid());
}
int main()
{
signal(SIGCHLD, handler);
pid_t cid;
if((cid = fork()) == 0)
{
//child
printf("child: %d\n", getpid());
sleep(3);
exit(1);
}
while(1)
{
printf("father proc is doing some thing!\n");
sleep(1);
}
return 0;
}
事实上,由于 UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 sigaction 将 SIGCHLD 的处理动作置为 SIG_IGN,这样 fork 出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用 sigaction 函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于 Linux 可用,但不保证在其它 UNIX 系统上都可用。