⚪前言
注意:进程间通信中的信号量跟下面要讲的信号没有任何关系。
一、从不同角度理解信号
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
设置 core file size,kill -8/11 后,发现报错信息中多了一个(core dumped),且 ll 还发现多了一个 core 文件
ulimit 命令改变了 Shell 进程的 Resource Limit,test 进程的 PCB 由 Shell 进程复制而来,所以也具有和 Shell 进程相同的 Resource Limit 值,这样就可以产生 Core Dump 了。
前面讲进程等待的时候说过一个概念,父进程中 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 向目标进程写信好 -> 修改对应进程的信号标记位 -> 进程后续会处理信号 -> 执行对应的处理动作
【Linux 系统】进程信号 -- 详解(下)https://developer.aliyun.com/article/1515695?spm=a2c6h.13148508.setting.15.11104f0e63xoTy