⚪前言
注意:进程间通信中的信号量跟下面要讲的信号没有任何关系。
一、从不同角度理解信号
1、生活角度的信号
- 你在网上买了很多件商品,在等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递,也就是你能 “识别快递”。
- 当快递员到了你楼下,你也收到快递到来的通知,但是此时你正在打游戏,需 5min 之后才能去取快递。那么在在这 5min 之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成 “在合适的时候去取”。
- 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间内,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你 “记住了有一个快递要去取”。
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1、执行默认动作(幸福的打开快递,使用商品);2、 执行自定义动作(快递是零食,你要送给你你的女朋友);3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)。
- 快递到来的整个过程对你来讲是异步的,你不能准确断定快递员什么时候给你打电话。
在生活中也存在着很多信号,比如闹钟、电话铃响、红绿灯等等,这里就有下面两个问题:
为什么我们能认识红绿灯或者闹钟呢?
因为曾经有人教过我们红绿灯或着闹钟是什么,然后我们记住的。
身边没有闹钟时,我们是否知道闹钟响了之后,该怎么办?
当然知道,因为曾经有人教过我们,教我们的是:它是什么,为什么,怎么办。这两个问题对应是什么和怎么办。而为什么,是我们需要被提醒,所以要认识闹钟。
所以对于是什么和怎么办这个话题称为人能够识别信号。OS 类似社会,人就是进程,社会中会有很多信号围绕着人去展开,而 OS 中也会有很多信号围绕着信号去展开,所以进程要能够识别非常多的信号。这里只想说明进程能够认识信号,以及信号不管到没到来进程都知道该怎么做。
2、技术应用角度的信号
(1)用户输入命令,在 Shell 下启动一个前台进程。
用户按下 Ctrl+C,这个键盘输入产生一个硬件中断,被 OS 获取,解释成信号,发送给目标前台进程。前台进程因为收到信号,进而引起进程退出。
将生活例子和 Ctrl+C 信号处理过程相结合,解释一下信号处理过程:进程就是你,操作系统就是快递员,信号就是快递。
3、注意
- Ctrl+C 产生的信号只能发给前台进程。一个命令后面加个 & 可以放到后台运行,这样 Shell 不必等待进程,结束就可以接受新的命令,启动新的进程。
- Shell 可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl+C 这种控制键产生的信号。
- 前台进程在运行过程中用户随时可能按下 Ctrl+C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
4、信号概念
信号是进程之间事件异步通知的一种方式,属于软中断。
5、用 kill -l 命令可以察看系统定义的信号列表
我们前面也简单的接触过信号,kill -l 就可以查看信号,仔细观察可以发现这里不是 64 种信号,因为中间并不是连续的,一种有 62 种信号(其中,没有 32 和 33 信号)。其中,1~31 叫做普通信号,而 34~64 叫做实时信号,每个实时信号中都包含了 RT 两个字母。
下面将重点谈谈普通信号,实时信号不考虑,简单提一下即可,实时信号是一种响应特别强的信号,比如着火,而普通信号则对应我们每天早上的闹钟。
- 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在 signal.h 中找到,例如其中有定义 #define SIGINT 2
6、信号处理常见方式概览
(sigaction 函数后面会详细介绍),可选的处理动作有以下三种:
- 忽略此信号。
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为自定义捕捉(Catch)一个信号。
生活中的信号有三种生命周期,Linux 下的信号也是如此,所以下面就围绕着这三种生命周期进行研究。
这段代码就是一段简单的死循环,当我们在键盘 Ctrl+C 就是向前台进程发送 2) SIGINT 信号结束进程。当然可以新建 ssh 渠道验证一下,这里可以向目标进程发送 2 号信号或者它所对应的宏 SIGINT
对于相当一部分信号而言,当进程收到的时候,默认的处理动作就是终止当前进程。
SIGCONT 和 SIGSTOP
这两个信号,我们之前也接触过,19)SIGSTOP 用于暂停目标进程,18)SIGCONT 用于继续目标进程。此时发送 18 号信号后,Ctrl+C 也就是发送第 2 号信号不能结束目标进程,因为目标进程被发送 18 号信号后,已经变成了一个后台进程 S(ps ajx 可以看到),2 号信号无法结束,所以这里可以发送第 3,9 号信号来结束像这样的后台进程。
(1)产生信号
- kill 命令产生。
- 键盘产生。
第 1 点就是 kill -2 pid,第 2 点就是 Ctrl+C。
(2)信号识别
进程收到信号,其实并不是立即处理的,而是选择在合适的时候再处理。
什么是 “合适的时候” 呢?下面会详细说明,“不是立即处理” 指的是,我们前面取快递那个例子。
那么信号中为什么 “不是立即处理” 呢?
因为信号的产生可以在进程运行的任何时间点,那么进程可能正在做更重要的事情。
(3)信号处理
- 默认方式(部分是终止进程,部分有特定的功能)。
- 忽略信号。比如说发送了一个信号,但却什么都没做,这就是忽略信号,它当然也是处理信号。
- 自定义信号。如果你想自己处理这个信号,就叫做自定义信号,也叫做捕捉信号。
举例:早上的闹钟响了,然后你就起床了,这是默认; 闹钟响了,然后还选择继续睡,这就是忽略;闹钟响了,然后起来跳个舞,这是自定义捕捉。
7、信号的本质
从信号识别中,我们可以知道信号不是立即处理的,那么就意味着信号需要被保存起来。
信号在哪里保存?
信号不是给硬件,网络发的,它是给进程发的,所以这个信号一定是在进程的 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 拿到后向目标进程发信号。
二、产生信号
1、通过终端按键产生信号
SIGINT 的默认处理动作是终止进程,SIGQUIT 的默认处理动作是终止进程并且 Core Dump,下面就来验证一下。
在 Linux 下,C++ 文件的后缀可以是 .cpp,.cxx,.cc,可以看到这里的 makefile,这样写的好处是如果以后想修改依赖文件或者目标文件,那么只需要修改上面的一部分即可。
如果后续没有任何 SIGINT 信号产生,catchSig 会不会被调用?
永远也不会被调用。
signal 函数仅仅只是修改进程对特定信号的后续处理动作,不是直接调用对应的处理动作。
当我们在键盘 Ctrl+C 就是向前台进程发送 2)SIGINT
信号结束进程相当于向目标进程发送它所对应的宏 SIGINT 或者是发送 2 号信号。
可以看到没有信号产生时,它就不会执行 signal,因为它是回调函数。而一旦 Ctrl+C 收到信号,这里就调用了 catchSig 函数并获取到信号编号,同样命令也是如此。虽然捕捉了 2 号信号 SIGINT,但是其它信号并没有被捕捉,所以可以 Ctrl+/ 或者是其它信号。那么问题来了,如果将 31 个信号都捕捉完呢。
假设当我们把全部信号捕捉时,操作系统给进程写的任何信号,进程只是默认知道,然后给你一句话就完了,接着继续跑路,是不是就意味着写了一个 “金刚不坏” 的进程呢?Linux 操作系统当然需要考虑这种场景,如果允许所有的信号被捕捉,那么非法用户就很容易创建了一个非法进程,这个进程各种申请资源就是不还,并且还把所有的信号全部捕捉或忽略,这就导致操作系统知道是这个进程的问题,还拿它没办法,这就是系统设计上的 Bug。所以,Linux 系统中有若干个信号不能被捕捉或自定义,最典型的信号就是第 9 号信号 SIGKILL,快捷键 Ctrl+\,它叫做管理员信号,是所有信号中权力最大的。那么忽略信号的现象是什么呢?
可以看到 SIG_IGN 对应的就是把 1 强制成函数指针类型,它依旧是一个回调函数(这里 grep -ER 在筛选时后面可以 -n 以获取行号,在 vim /usr/include/bits/signum.h 时也可以在其后 +24 以定位所在行号)。此时系统发送信号给进程,它一句话也不说,继续跑路,直接忽略(不过这里 Ctrl+C 时有反应),我们知道它不能对所有信号进行忽略,所以发送第 9 号 SIGKILL 杀掉进程。
所以第 9 号进程 SIGKILL 既不能被捕捉,也不能被忽略。上面说过进程运行的任何时间点都可以产生信号,所以信号产生和进程运行是异步的(当然也有同步,这也就是在前面讲信号量时只谈了异步的原因,同步这个名词有不同的解释,场景不同表达的意思也就不同。同步和异步有时表示的是执行流的关系,有时是进程访问临界资源的问题。后者就好比老师在上课过程中烟瘾犯了,然后跟学习不好的张三说,你去帮我拿包烟,我们先休息会等你,你回来后我们再开始上课,此时课程的进度跟张三回来要同步,互相影响,这叫做同步;还是老师在上课过程中烟瘾犯了,然后跟学习好的李四说,你去帮我拿包烟,然后老师继续上课,而李四在跟老板吵着架,此时两件事是同时进行的,互不影响,这叫做异步)。换而言之,想说明的是如果两个进程是毫无关系,一个进程在执行时随时可能会收到信号,而信号是用户还没发,准备发,已经发,所以进程就不等信号了,这就是异步。
(1)Core Dump
先解释一下什么是 Core Dump,当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是 core,这叫做 Core Dump。进程异常终止通常是因为有 Bug,比如非法内存访问导致段错误,事后可以用调试器检查 core 文件以查清错误原因,这叫做 Post-mortem Debug(事后调试)。一个进程允许产生多大的 core 文件取决于进程的 Resource Limit(这个信息保存在 PCB 中)。默认是不允许产生 core 文件的,因为 core 文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生 core 文件。 首先用 ulimit 命令改变 Shell 进程的 Resource Limit,允许 core 文件最大为 1024K: $ ulimit -c 1024
前面讲进程等待的时候说过一个概念,父进程中 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 文件占用了。
A. 除 0 错误
此时,我们就可以利用核心转储生成的 core 文件来定位 bug,需要 makefile 中 -g 先生成 release 文件。gdb 中直接 core-file + core 文件即可。我们之前找 bug 是一行行调试,而现在是什么都不管,直接让你先崩掉,然后配合 gdb 定位 bug,这种调试方案叫做事后调试。
B. 野指针异常
这里还有一个细节,除 0 异常和 kill -8 报的错误是一样的,野指针异常和 kill -11 报的错误也是一样的,这就说明的是信号产生的第三种方式是程序异常,这里更准确来说应该是硬件异常,因为除 0 和野指针都有对应的硬件资源,后面会解释。
- 8)SIGFPE 是指进程在运行时发生了算术异常,比如除 0 或者浮点数溢出等。
- 11)SIGSEGV 是段错误,指进程在运行时访问了不属于自己的内存地址或者访问已经被释放的内存地址,比如野指针。
站在语言的角度这叫做程序崩溃,本质应该是进程崩溃。因为站在系统的角度来说,这就叫做进程收到了信号。换而言之,一般程序崩溃是因为你的代码有非法操作被 OS 检测到了,然后向你的进程发送了信号。当然,在语言层也可以使用异常捕捉来进行语言层面上的检测。如果没有信号,那么出现野指针等内存问题时,OS 作为软硬件资源的管理者设计的健壮性就很差,所以信号存在的价值也是为了保护软硬件等资源。
验证进程等待中的 core dump 标志位:
2、调用系统函数向进程发信号
(1)kill 命令
A. 接口介绍
kill 命令是一个系统接口,它是调用 kill 函数实现的,可以给一个指定的进程发送指定的信号。
B. 手动写一个 kill 命 —— 用系统调用接口来向系统发送指定信号
(2)raise 函数
A. 接口介绍
可以给当前进程发送指定的信号(自己给自己发信号)。
kill 和 raise 两个函数都是成功返回 0,错误返回 -1。
(3)abort 函数
A. 接口介绍
使当前进程接收到信号而异常终止,通常用来终止进程,就像 exit 函数一样,abort 函数总是会成功的,所以没有返回值。
注意:abort 和 raise 是立即发送,而 alarm 是延时 seconds 秒发,abort 只能向自己发第 6 号信号,raise 是向自己发第 sig 信号。
如何理解系统调用接口?
用户调用系统接口 -> 执行 OS 对应的系统调用代码 -> OS 提取参数,或者设定特定的数值 -> OS 向目标进程写信好 -> 修改对应进程的信号标记位 -> 进程后续会处理信号 -> 执行对应的处理动作
3、由软件条件产生信号
软件条件不是错误,当某种条件被触发时,OS 会向目标进程发送信号。就好比你拿了你妈妈的 100 块钱,你妈妈发现是你拿的,相当于你发了信号给你妈妈,然后你妈妈检测到异常把你揍了一顿,这就叫作进程出问题被 OS 检测到,然后发信号终止进程。又好比,你叫你妈妈明早叫你起床,然后你妈妈明早就准时叫你起床,此时你和你妈妈之间的交互没有任何硬件单元存在,这叫做软件条件产生信号。
在学习进程间通信的时候就验证过:读端不光不读且把读端关闭,写端一直在写,那么 OS 会自动终止对应的写端进程,通过发信号的方式,写端就会收到 13)SIGPIPE 信号,进而导致写进程退出。(验证方法:创建匿名管道,让父进程进行读取,子进程进行写入。让父进程关闭读端 && waitpid(),子进程一直写入就行,子进程退出,父进程的 waitpid 拿到子进程的退出 status,提取退出信号)在底层 OS 一定会提供支持,所以在写入的时候,OS 一定是设置了我们能成功写入的条件,比如读端的文件是打开的写端就可以写,否则写端再写就会被操作系统发送信号。所以,在 OS 层面上这是一种软件条件产生的信号。
SIGPIPE 是一种由软件条件产生的信号,在 “管道” 中已经介绍过了,下面介绍 alarm 接口和 14)SIGALRM 信号(alarm 其实并不常用,只是想通过 alarm 来演示软件条件产生信号)。
(1)alarm
A. 接口介绍
这个函数的返回值是 0 或者是以前设定的闹钟时间还余下的秒数。
调用 alarm 函数可以设定一个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发送 SIGALRM 信号,该信号的默认处理动作是终止当前进程。打个比方,我要小睡一觉,设定闹钟为 30 分钟之后响,20 分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为 15 分钟之后响,“以前设定的闹钟时间还余下的时间” 就是 10 分钟。如果 seconds 值为 0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。比如 alarm 30 秒,但 20 秒时进程结束了,于是重新 alarm 15,此时就会返回以前设定闹钟时还余下的时间 10 秒。
为什么 1 秒内打出来的值才累加到 五万多?
如果只是单纯累加能加到很高,是亿级别的。
但是涉及到了 I/O 数据传输,而且这里是云服务器,还涉及到了网络数据传输,效率自然就低了。
B. 闹钟定时性功能
C. 补充功能
如何理解软件条件给进程发送信号?
OS 先识别到某种软件条件触发或者不满足,然后 OS 构建信号发送给指定的进程。
4、硬件异常产生信号
到此,我们就可以理解一个进程能够收到信号,收到信号后它会捕捉,忽略。比如忽略处理完后,进程就要退出了,然后再释放资源,这都能理解。但是像除 0,野指针 / 越界这些错误,OS 是如何具备识别异常的能力?
OS 是软硬件资源的管理者,好的坏的情况都知道,对于除 0,野指针 / 越界:在语言上都叫作报错,但实际上它们对应不同的软硬件。
理解除 0:对应 CPU 内部的状态寄存器(除 0 就是溢出,而状态寄存器有对应的溢出标志位用来检测每次计算有无溢出)。
理解野指针 / 越界:都必须通过地址找到目标地址,语言上对应的地址都是虚拟地址,对应内存、页表、内存管理单元 MMU(Memory Manager Unit 是负责的是将虚拟地址转换成物理地址的一种硬件,MMU 转化的时候一定会报错,并且提供硬件机制的内存访问授权。如果出现野指针,就会被检测你的这个地址没有权限去访问)。
坏的情况下,操作系统当然知道是哪一个进程做的:如果是 CPU 除 0,那么当前是哪个进程在执行代码就是哪个进程干的;如果是内存野指针/越界,当前用的是哪个进程的页表,完成是哪个进程的转换,那么也就是哪个进程干的。换而言之,OS 知道是哪个进程出错了,哪个进程干的,所以 OS 就可以向这个进程发送信号。
总结而言,硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
一旦出现硬件异常,进程一定会退出吗?
不一定。一般默认是退出,但是即便不退出,我们也做不了什么。
上述内容为什么会死循环呢?
寄存器中的异常一直没有被解决。
捕捉玩信号之后退出就不会出现死循环了。
5、总结
上面所说的所有信号产生,最终都要有 OS 来进行执行,这是为什么呢?
因为OS 是进程的管理者。
信号的处理是否是立即处理的?
不是,而是在合适的时候。
信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
需要,记录在 PCB 对应的信号位图当中。
一个进程在没有收到信号的时候,能否知道自己应该对合法信号作何处理呢?
能知道,因为这是程序员写好的。
如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
简单来说,OS 去修改恢复,根据信号编号去修改特定比特位,由 0 至 1 就完成了信号的发送过程。
三、阻塞信号
1、 信号其他相关常见概念
- 实际执行信号的处理动作(忽略、默认、自定义捕捉)称为信号递达(Delivery)。
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞(Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意 :阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
2、在内核中的表示
实际在 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 代表是否阻塞该信号。
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP 信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT 信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT 信号未产生过,一旦产生 SIGQUIT 信号将被阻塞,它的处理动作是用户自定义函数 sighandler。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号
3、sigset_t
可以理解为了能让我们更好的对上面的三张表操作,OS 给我们提供了一种系统级别 sigset_t 类型,这个类型 OS 内部的当然也有定义,我们可以使用这个数据类型在用户空间和内核交互,此时就一定需要系统接口。
仔细观察前面的图,可以发现每个信号只有一个 bit 的未决标志,非 0 即 1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,sigset_t 称为信号集,这个类型可以表示每个信号的 “有效” 或 “无效” 状态,在阻塞信号集中 “有效” 和 “无效” 的含义是该信号是否被阻塞,而在未决信号集中 “有效” 和 “无效” 的含义是该信号是否处于未决状态。
如果 sigset_t 定义的变量 set 当然是在栈上开辟空间,那么这个栈就是用户栈,实际上我们在进程地址空间中谈的代码段、数据段、堆区、内存映射段、栈区、命令行参数、环境变量都是在用户空间,而将来要把用户空间中的进程信号属性设置到内核,所以除了 sigset_t,一定还需要系统接口。
sigset_t 是不允许用户自己进行位操作的,和用内置类型 && 自定义类型没有差别,OS 给我们提供了对应的操作位图的方法,也一定需要对应的系统接口来完成对应的功能,其中系统接口需要的参数可能就包含了 sigset_t 定义的变量或者对象。
4、信号集操作函数
当然光有 sigset_t 这个类型还不够,这个类型本身就是一个位图。实际我们不支持或者不建议直接操作 sigset_t,因为不同平台,甚至不同位数的 OS,sigset_t 位图的底层组织结构实现可能是不一样的,所以 OS 提供了一些专门针对 sigset_t 的系统接口,这些接口会先在用户层把信号相关的位图数据处理好。
sigset_t 类型对于每种信号用一个 bit 表示 “有效” 或 “无效” 状态,至于这个类型内部如何存储这些 bit 则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作 sigset_ t 变量,而不应该对它的内部数据做任何解释,比如用 printf 直接打印 sigset_t 变量是没有意义的。
#include <signal.h>
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);//判断特定信号是否已经被设置
- 函数 sigemptyset 初始化 set 所指向的信号集,使其中所有信号的对应 bit 清零,表示该信号集不包含任何有效信号。
- 函数 sigfillset 初始化 set 所指向的信号集,使其中所有信号的对应 bit 置位,表示该信号集的有效信号包括系统支持的所有信号。
注意:在使用 sigset_ t 类型的变量之前,一定要调用 sigemptyset 或 sigfillset 做初始化,使信号集处于确定的状态。初始化 sigset_t 变量之后就可以在调用 sigaddset 和 sigdelset 在该信号集中添加或删除某种有效信号。
(1)sigprocmask
A. 接口介绍
调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。传入一个 set 信号集,设置进程的 block 位图,一般把用户空间定义的信号集变量或对象设置成进程 block 位图,这样的信号集叫做信号屏蔽字(Signal Mask),阻塞信号集也叫做当前进程的信号屏蔽字,这里的屏蔽应该理解为阻塞而不是忽略。
- set:输入型参数,由用户层把信号屏蔽字拷贝到内核。
- oset:输出型参数,把老的信号屏蔽字返回,方便恢复,不想保存可设置 NULL。
如果 oset 是非空指针,则读取进程的当前信号屏蔽字通过 oset 参数传出。如果 set 是非空指针,则更改进程的信号屏蔽字,参数 how 指示如何更改。如果 oset 和 set 都是非空指针,则先将原来的信号屏蔽字备份到 oset 里,然后根据 set 和 how 参数更改信号屏蔽字。假设当前的信号屏蔽字为 mask,下表说明了 how 参数的可选值。
返回值: 若成功则为 0, 若出错则为 -1。
如果调用 sigprocmask 解除了对当前若干个未决信号的阻塞,则在 sigprocmask 返回前,至少将其中一个信号递达。
(2)sigpending
A. 接口介绍
获取当前调用进程的 pending 信号集, 通过 set 参数传出。调用成功则返回 0, 出错则返回 -1 。
- set:输出型参数,获取进程的 pending 信号位图。
如果对所有的信号都进行自定义捕捉,那是不是就相当于写了一个不会被异常或者用户杀死的进程?
不是的,OS 的设计者也想到了这一点,所以,设定了 9 号信号属于管理员信号。
如果将 2 号信号 block 并且不断的获取并打印当前进程的 pending 信号集,此时如果我们突然发送一个 2 号信号,那我们就应该看到 pending 信号集中的第 2 个比特位,由 0 -> 1。
没有提供接口用来设置 pending 位图(所有的信号发送方式都是修改 pending 位图的过程),我们是可以获取 sigpending。
- 屏蔽(阻塞)2 号信号。
- 不断的获取 pending 信号集,并输出。
- 发送 2 号信号给进程。
- 屏蔽(阻塞)2 号信号。
- 不断的获取 pending 信号集,并输出。
- 发送 2 号信号给进程。
- 20 秒后取消屏蔽 2 号信号。
程序运行时, 每秒钟把各信号的未决状态打印一遍, 由于我们阻塞了 SIGINT 信号, 按 Ctrl+C 将会使 SIGINT 信号处于未决状态, 按 Ctrl+\ 仍然可以终止程序, 因为 SIGQUIT 信号没有被阻塞。
9 号信号不能被屏蔽或阻塞。
四、捕捉信号
对于如何处理信号,前面也讲了 signal 接口,如 signal(2, handler),所以对 2 号信号执行 handler 捕捉动作,本质是 OS 去 task_struct 通过 2 号信号作索引,找到内核中 handler 函数指针数组中对应的方法,然后把数组内容改成你自己在用户层传入的 handler 函数指针方法。这里我们要讨论的是上面遗留下来的问题 —— 进程收到信号时,不是立即处理的,而是在合适的时候再处理,那合适的时候是什么时候呢 ?
所谓合适的时候就是进程从内核态返回用户态时,尝试进行信号检测与捕捉执行,后面我们就会知道内核态切换成用户态时,就是一个非常好检测进程状态的一个时间点,后面再讲多线程切换时也是这个时间点,当然不仅限于此。
1、用户态和内核态
进程如果访问的是用户空间的代码,此时的状态就是用户态;如果访问的是内核空间,此时的状态就是内核态。我们经常需要通过系统调用访问内核,系统调用是 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 进入的,所以必须得利用系统接口再次陷入内核,最后由内核调用系统接口返回用户区。
2、内核如何实现信号的捕捉
信号的捕捉示意图:
上面的图和文字都说的太复杂了,这里我们简化一下,宏观来看信号的捕捉过程就是状态权限切换的过程,这里的蓝点表示信号捕捉过程中状态权限切换的次数。其中完整流程就是:
- 调用系统调用,陷入内核。
- 执行完系统任务。
- 进行信号检测。
- 执行捕捉代码,调用 sigturm 再次陷入内核。
- 调用 sys_sigreturn,返回到用户区中系统调用点。
3、sigaction
对于修改 handler 表的操作接口,前面已经了解过 signal 了,下面再讲讲 sigaction,sigaction 相比 signal 有更多的选项,不过只需要知道它怎么用就行了,因为它兼顾了实时信号。
(1)接口介绍
- 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。
- sigaction 函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回 0,出错则返回 -1。signo 是指定信号的编号。若 act 指针非空,则根据 act 修改该信号的处理动作。若 oact 指针非空,则通过 oact 传出该信号原来的处理动作。act 和 oact 指向 sigaction 结构体。
- 将 sa_handler 赋值为常数 SIG_IGN 传给 sigaction 表示忽略信号,赋值为常数 SIG_DFL 表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为 void,可以带一个 int 参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被 main 函数调用,而是被系统所调用。
(2)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 就足够了。
20s 到了之后进程停止:
4、补充
信号捕捉并没有创建新的进程或线程。
五、可重入函数
main 函数调用 insert 函数向一个链表 head 中插入节点 node1,插入操作分为两步,刚做完第一步时,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到 sighandler 函数,sighandler 也调用 insert 函数向同一个链表 head 中插入节点 node2,插入操作的两步都做完之后从 sighandler 返回内核态,再次回到用户态就从 main 函数调用的 insert 函数中继续往下执行,之前做第一步后被打断,现在继续做完第二步。结果是 main 函数和 sighandler 先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
1、概念
像上面这样 insert 函数被不同的控制流程调用,有可能在第一次调用还没返回就再次进入该函数,这称为重入。insert 函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数。反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant)函数。可重入函数和不可重入函数是函数的一种特征,没有好坏之分,我们用的 90% 的函数都是不可重入函数(易于编写)。
可重入函数描述的是一种执行现象。假设 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 就会造成内存泄漏。
所以一旦多个执行流同时执行一个函数时:如果访问是不安全的,叫做不可重入函数;相反访问是安全的就叫做可重入函数。
如果一个函数符合以下条件之一,则是不可重入的:
- 调用了 new/malloc 或 free,因为 malloc 也是用全局链表来管理堆的。
- 调用了标准 I/O 库函数。标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构。
六、volatile
volatile 是属于 C 语言中的关键字,也叫做易变关键字(被它修饰后的变量就是在告诉编译器这个变量是易变的),它的作用是保持内存的可见性。
这里给一个全局标志位 flag,利用 flag 让程序死循环执行,此时就可以通过信号捕捉,在捕捉方法中改变 flag 的值,然后结束死循环。下面这份代码在 gcc 和 g++ 运行看到的现象是一样的。
上面可以看到 main 函数中没有更改 flag 的任何操作,那么可能会被优化,所以 flag 一变化不会立马被检测到。这里我们可以看到默认 g++(gcc 也一样) 并没有优化这段代码,所以 flag 一变化立马就被检测到。其实,gcc 和 g++ 中有很多优化级别,man gcc 文档筛选后就可以看到 gcc 有 -O0/1/2/3 等优化级别,gcc -O0 表示不会优化代码。经过验证(注意这里不同平台结果可能不一样):
gcc 在 -O0 时不会作优化处理,此时同上默认,进程一收到信号,进程就终止了。
gcc 在 -O1/2/3 时会作优化处理,此时发现 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 循环又不退出的现象。因为要减少代码体积和提高效率,所以在优化时需要优化成寄存器变量。
所以在 gcc -O1(gcc -O3) 优化时还需要加上 volatile,此时要告诉编译器:不要把 flag 优化到寄存器上,每次检测必须把 flag 从内存读到寄存器中,然后再进行检测,不要因为寄存器而干扰 while 循环的判断。这就叫做保持内存的可见性。
volatile 作用:保持内存的可见性,告知编译器:被该关键字修饰的变量不允许被优化,对该变量的任何操作都必须在真实的内存中进行操作。
七、SIGCHLD 信号(了解)
SIGCHLD 是第 17 号信号。子进程退出时,父进程可以通过 wait/waitpid 来等待子进程并回收相关资源,以免造成僵尸进程,而父进程可以通过阻塞等待子进程结束,或非阻塞轮询来检测子进程的状态。前者父进程什么也做不了,后者父进程需要不断的去检测,两者都比较麻烦,且都是父进程主动的。这里要介绍的 SIGCHLD 就是第三种方案,其实在子进程退出的时候,子进程会主动向父进程发送 17)sigchld,因为该信号的默认处理动作是忽略,所以让父进程可以自定义 SIGCHLD 信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用 wait 清理子进程即可。
可以看到在子进程退出后,向父进程发送了第 17 号信号:
当然,也可以在不想等子进程的同时,也不想有僵局进程。除了父进程 fork 子进程,子进程 exit 终止,父进程自定义 SIGCHLD 信号,然后捕捉等待。还有一种方案:如果父进程必须得等子进程,那就 wait/waipid,如果父进程不关心子进程,那就让 SIGCHLD 的处理动作变成 SIG_IGN 进行显示的忽略,此时 fork 出来的子进程会在终止时自动清理掉,不会产生僵尸。注意这仅仅是在这 centos 7.6 平台下,其它的类 Linux 平台不能保证。此时就会看到当子进程 5 秒后退出,不会看到有 Z 状态的进程,其次 sleep 的返回值是 0,因为 sleep 没有被信号唤醒,父进程是正常结束的,它并没有关心子进程,子进程退出时也没有发 SIGCHLD 给父进程,但是因为 SIG_IGN,OS 也把子进程资源释放了。
事实上,由于 UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 sigaction 将 SIGCHLD 的处理动作置为 SIG_IGN,这样 fork 出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用 sigaction 函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于 Linux 可用,但不保证在其它 UNIX 系统上都可用。
一会说子进程要被 wait,一会又说子进程不被 wait,那么应该如何决定是否需要 wait 子进程?
因为我们是站在僵尸进程内存泄漏角度来讲的,那么此时我们想等就可以 wait,不想等也可以通过捕捉 SIGCHLD 来设置 SIG_IGN,避免僵尸进程。所以,站在内存泄漏这个角度可以等也不以不等,但是等待的目的不仅是只有释放资源这一个目的,还有可能需要获取子进程的退出码。如果父进程不关心,可以不进行 wait;如果父进程关心,就必须 wait。