Linux进程信号

一、基本认识

生活中有许多关于信号的场景,比如古时烽火台的烽火、鸡叫声、闹钟的响声、信号枪的枪声等。
当这些场景被触发时,我们立马就知道接下来该做什么。
其实我们该怎么做和场景是否被触发是没有直接关联的。对于信号的处理动作,我们早就知道了,甚至远远早于信号的产生。
那么我们是如何做到的呢?我们对特定事件的反应,是被教育的结果,也就是我们记住了。

其实可以类比到进程信号:
信号是给进程发的,当进程在没有收到信号的时候,进程是知道该如何识别是哪一个信号以及如何处理它的。进程具有识别信号并处理信号的能力,是远远早于信号的产生的。
进程是如何做到的呢?曾经编写操作系统的工程师在写进程的源代码时就设置好了。

在生活中,我们收到某种信号的时候,并不一定是立即处理的,而是先记着,然后在合适的时候进行处理,原因是信号随时都可能产生,但是我们当前可能做着更重要的事情。

进程收到某种信号的时候,并不是立即处理的,而是先保存起来,然后在合适的时候进行处理,原因是进程在当前可能做着更重要的事情。

那么进程应该把信号保存在哪里呢?保存在 task_struct 里。

信号的本质也是数据
信号发送的本质是往进程的 task_struct 内写入信号数据

因为 task_struct 是一个内核结构,所以只能是 OS 向 task_struct 内写入信号数据。
无论信号如何发送,本质都是在底层通过 OS 发送的。

我们通过kill -l命令来查看 Linux 系统所支持的信号列表。

其中,1 ~ 31 号信号是普通信号,34 ~ 64 号信号是实时信号。在这里插入图片描述

在这里我们只讨论普通信号,不讨论实时信号。
可以使用man 7 signal命令查看各个信号的详细说明。

一般而言,进程收到信号后的处理方案有 3 种:
 ① 默认动作:执行信号的默认处理动作。
 ② 忽略动作:忽略信号,就是什么也不干。
 ③ 自定义动作(信号的捕捉):要求内核在处理信号时切换到用户态执行用户提供的信号处理函数。

二、signal 系统调用

signal系统调用的作用:设置某一信号的对应动作。

signal系统调用的参数:
 ① signum 表示要设置的信号。
 ② handler 是函数指针,表示 signum 的对应处理动作(要么是 SIG_IGN 或 SIG_DFL,要么是自定义动作)。

在这里插入图片描述
注意:注册 handler 函数的时候,不是调用这个函数,只有当信号到来的时候,这个函数才会被调用。


通过键盘按 Ctrl + c 时,本质是我们向指定进程发送 2 号信号。

比如,通过signal系统调用,把 2 号信号的处理动作由默认动作(终止进程)改成我们的自定义动作。这样的话,当进程收到 2 号信号时,2 这个数字就会被传递给 handler 的参数 signo,然后执行 2 号信号对应的 handler 方法。

在这里插入图片描述

当我们按 Ctrl + c 时,进程就会收到 2 号信号,然后执行对应的处理动作。在这里插入图片描述
kill命令也能发送信号。在这里插入图片描述

三、信号的产生

1.通过键盘输入产生信号

信号可以通过键盘输入产生。

在这里插入图片描述

比如通过键盘按 Ctrl + c、Ctrl + \ 、Ctrl + z ,分别对应产生 2 号、3 号、20 号信号。

在这里插入图片描述

通过键盘键入产生的信号,只能发给前台进程,不能发给后台进程。
比如按 Ctrl + c 能终止前台进程,而不能终止后台进程。

前台进程,按 Ctrl + c 可以终止:在这里插入图片描述后台进程,按 Ctrl + c 不能终止:在这里插入图片描述

但是可以通过kill -9 pid命令杀掉后台进程。

在这里插入图片描述

9 号信号不可以被捕捉(自定义)。这个好理解,如果 9 号信号可以被捕捉,由默认动作改为不能杀死进程的自定义动作,那么就会出大问题了。

在这里插入图片描述
在这里插入图片描述

2.由硬件异常产生信号

在 Linux 下,进程崩溃的本质是进程收到了对应的信号,然后执行信号的默认处理动作,就是终止进程

那么为什么进程会收到信号呢?

因为软件上面的异常或错误,通常会体现在硬件或者其它软件上。

若当前进程执行除以 0 的指令,CPU 的运算单元会产生异常。
若当前进程野指针访问,MMU 会产生异常。

而 OS 是硬件的管理者,就要对硬件的健康负责。
因为进程执行代码导致的硬件异常被 OS 识别,所以 OS 对该进程发送信号。

由此可以确认,我们在 C/C++ 当中除零、内存越界等硬件异常,在系统层面上是被当成信号来处理的。

① 比如进程由于越界或野指针导致收到 SIGSEGV 信号而终止。
在这里插入图片描述Segmentation fault:段错误,一般是有越界或野指针的情况。


在这里插入图片描述在这里插入图片描述
在这里插入图片描述

② 比如进程由于执行除以 0 的指令导致收到 SIGFPE 信号而终止。
在这里插入图片描述Floating point exception:浮点异常,一般是有除以 0 的情况。


在这里插入图片描述
在这里插入图片描述
在这里插入图片描述


当进程崩溃时,我们最想知道的是其崩溃的原因。
我们可以通过waitpid系统调用的输出型参数 status 得知子进程收到的是哪一个信号,进而得知其崩溃原因。
我们还想知道的是,进程究竟在代码的哪一行崩溃了。

在 Linux 中,
  当一个进程正常退出时,它的退出码和退出信号都会被设置。
  当一个进程异常退出时,它的退出信号会被设置,表明当前进程退出的原因。如果必要,OS 会设置退出信息中的 core dump 标志位,并将进程在内存中的数据以文件的形式转储到磁盘当中,方便我们后期调试。

在这里插入图片描述

下面我们以这段有除以 0 的代码为例进行说明:

在这里插入图片描述

默认情况下,在 Linux 云服务器上,core dump 是被关掉的。
在这里插入图片描述如果 core dump 有打开的话,还会显示 “(core dumped)” 。

使用ulimit -a命令查看系统占用资源,core file size 为 0 说明 core dump 没有打开。
我们可以将 core file size 设为 10240,允许系统进行 core dump(此操作只在本次登录有效)。
在这里插入图片描述

运行这段会导致进程崩溃的代码:

在打开了 core dump 的情况下,我们可以看到当前目录下多了一个文件,文件名称的格式是core.崩溃进程的pid
因为 OS 把崩溃进程的内存数据以文件的形式转储到磁盘当中,所以该文件是二进制文件。在这里插入图片描述

我们结合 core dump 生成的文件来分析进程崩溃的具体原因:用 gdb 对该程序(前提是该程序在用 gcc 编译生成时带上了 -g 选项)进行调试,使用core-file core文件名命令,就可以得知该进程的退出信号以及错误代码的行数。

在这里插入图片描述

把 core dump 打开,让程序直接运行出异常得到 core dump 文件,之后再用 gdb 来调试,在 gdb 内使用core-file core文件名命令,得到终止信号和错误代码的行数,这种方案我们称之为事后调试。

关于 core dump 的说明:

 ① 不是所有异常退出的进程都会被 core dump 。

在这里插入图片描述

我们发送 2 号信号给目标进程导致它异常退出,它没有被 core dump 。在这里插入图片描述
我们发送 3 号信号给目标进程导致它异常退出,它被 core dump 。在这里插入图片描述
我们发送 9 号信号给目标进程导致它异常退出,它没有被 core dump 。在这里插入图片描述

 ② 如果被 core dump ,该进程退出信息的 core dump 标志位必会被置为 1 。

在这里插入图片描述在这里插入图片描述

3.调用系统函数产生信号

可以通过某种系统函数产生信号。

kill系统调用的作用:发送信号给一个进程。

kill系统调用的参数:
 ① pid 表示目标进程的 pid 。
 ② sig 表示要发给目标进程的信号。

kill系统调用的返回值:
 ① 成功,返回 0 。
 ② 错误,返回 -1 。

在这里插入图片描述
在这里插入图片描述

示例:

在这里插入图片描述在这里插入图片描述


raise函数的作用:发送一个信号给调用者(就是给自己发信号)。

raise函数的参数:
 ① sig 表示要发给调用者的信号。

raise函数的返回值:
 ① 成功,返回 0 。
 ② 失败,返回非 0 。

在这里插入图片描述

示例:

比如进程使用 raise 函数给自己发送 8 号信号 SIGFPE 。在这里插入图片描述在这里插入图片描述


abort函数的作用:进程给自身发送 SIGABRT 终止信号(6 号信号)。

abort函数的参数:无。

abort函数的返回值:无。

在这里插入图片描述

示例:

在这里插入图片描述在这里插入图片描述

4.由软件条件产生信号

通过某种软件(通常是 OS)来触发信号的发送。
在系统层面设置定时器或者进行某种操作导致条件不就绪等场景下,触发信号的发送。

比如说在进程间通信,读端不但不读,而且还关闭了读文件描述符,写端一直在写,最终写进程会收到 SIGPIPE 信号(13 号信号),这就是一种典型的由软件条件触发的信号发送。

还有一个例子,就是alarm系统调用触发的 SIGALRM 信号的发送。

alarm系统调用的作用:设定一个闹钟,告知内核在多少秒后给当前进程发送 SIGALRM 信号(14号信号,该信号的默认处理动作是终止当前进程)。会取消之前设置的闹钟。

alarm系统调用的参数:
 ① seconds 表示过多少秒后给当前进程发信号。若 seconds 设为 0 ,会取消设置的闹钟。

alarm系统调用的返回值:
 ① 若调用该函数前,当前进程已经设置了闹钟,则返回上一个闹钟剩余的时间。
 ② 若调用该函数前,当前进程没有设置闹钟,则返回 0 。

在这里插入图片描述

示例:

 ①:在这里插入图片描述
在这里插入图片描述

 ②:在这里插入图片描述在这里插入图片描述

我们可以使用 alarm 系统调用来统计一下我们的服务器在 1 秒内能够对 int 递增到多少。
 ① 在设定的 1 秒内有 IO:在这里插入图片描述
在这里插入图片描述

 ② 在设定的 1 秒内没有 IO:在这里插入图片描述在这里插入图片描述

为什么 ① 慢了那么多呢?
因为 ② 不会进行外设的访问,只是 CPU 和内存交互,所以效率是非常高的。而 ① 有 printf 函数,它是 IO 函数,有外设的访问,所以效率很低。有无 IO 的效率差别是巨大的,基本是数量级别的差别。
因此如果我们的程序里面出现大量 IO 的时候,一定要考虑程序的效率问题。


虽然信号的产生方式有很多种,但是无论如何产生信号,本质一定是通过 OS 向目标进程发送信号

OS 是软硬件资源的管理者。
 ① 键盘输入:组合键一定是先被 OS 识别,OS 把组合键解释成信号,然后向目标进程发送。
 ② 硬件异常:由于进程异常导致了硬件错误,被 OS 识别,然后向目标进程发送信号。
 ③ 系统调用:系统调用就是 OS 提供的接口,依旧是 OS 向目标进程发送信号。
 ④ 软件条件:OS 检测,条件满足时 OS 向目标进程发送信号。

四、信号的保存

1.信号相关的常见概念

 ① 实际执行信号的处理动作称为信号递达(Delivery)。

信号递达的方式有三种:自定义捕捉、默认、忽略。

 ② 信号从产生到递达之间的状态,称为信号未决(Pending)。

本质是信号被暂存在 task_struct 的 pending 位图中,此时就叫做未决。

 ③ 进程可以选择阻塞(Block)某个信号。

本质是 OS 允许进程暂时屏蔽指定的信号。
屏蔽带来的结果:被阻塞的信号产生后将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意:忽略和阻塞是不同的,忽略是递达的一种方式,而阻塞是一种独立的状态。

2.从内核角度理解

进程的 task_struct 内一定要有对应的数据变量来保存记录关于信号的信息。

信号在内核中的表示示意图:

在这里插入图片描述

 ① 在进程的 task_struct 中是存在三张表的,分别是 block 表、pending 表、handler 表。
 ② pending 表和 block 表都是位图,而 handler 表是一个函数指针数组。
 ③ 信号编号就是位图中特定的比特位或数组下标。

(1)pending 表:位图结构,可以理解为uint32_t pending;
表示该进程是否收到信号。保存的是已经收到但是还没有被递达的信号。
 ① 比特位的位置:代表信号的编号。
 ② 比特位的内容:代表是否收到信号(1 表示收到,0表示没有收到)。

比如,0000 0000 0000 0000 0000 0000 0001 0101 表示进程收到了第 1、3、5 号信号。

OS 给进程发送信号,本质是 OS 向指定进程的 task_struct 中的 pending 位图写入比特位 1 ,即完成了信号的发送。换言之,信号的发送就是信号的写入

(2)block 表:也叫做信号屏蔽字,状态位图,可以理解为uint32_t block;
表示哪些信号被阻塞
 ① 比特位的位置:代表信号的编号。
 ② 比特位的内容:代表信号是否被阻塞(1 表示被阻塞,0表示没有被阻塞)。

比如,0000 0000 0000 0000 0000 0000 0010 0010 表示第 2、6 号信号被进程阻塞。

对于 block 表和 pending 表,比特位置 0 和置 1 的含义是不一样的,在 block 表里代表是否阻塞,在 pending 里代表是否收到信号。

(3)handler 表:函数指针数组,可以理解为void (*handler[31]) (int);
存放进程对每个信号的处理方法。

执行signal系统调用给特定信号填上自定义方法,本质是根据信号编号(signo)索引 handler 数组的下标,然后把函数地址(sighandler)填到下标对应的内容处,此时对指定信号的处理方法就发生变化了,这叫做信号的捕捉设置。

在内核中,SIG_DFL 和 SIG_IGN 其实是分别对 0 和 1 做 __sighandler_t 函数指针类型的强转。在这里插入图片描述

OS 发信号的本质其实就是对 pending 位图进行写入(对特定的比特位置 1),然后进程在合适的时候处理这个信号,执行这个信号对应的 handler 方法(信号递达,信号递达后,pending 位图的对应比特位会被置为 0)。

直到信号递达才清除该标志

Linux 进程识别信号的过程:

在这里插入图片描述
进程识别一个信号,是采用三元组的方式,结合这三张表的信息,就能知道信号该被如何处理。
所以这三张表我们应该横着看,一个信号被处理的三元组信息:是否被屏蔽,是否已经收到,处理动作是什么。
这就是 Linux 中进程识别信号的过程。

总结一下,就是一个信号在任何时候都能被 pending ,但这个信号能否被递达,看它是否被 block 。如果没有被 block ,信号在 pending 之后都可以被递达。

3. sigset_t 数据类型

OS 也会给用户提供需要配合系统调用使用的数据类型,比如shmget系统调用中的的 key_t 类型,fork系统调用中的 pid_t 类型,shmctl系统调用中的 struct shmid_ds * 类型,还有接下来要讲的 sigset_t 类型。

在 pending 位图和 block 位图中,每个信号只有一个比特位来表示对应的含义,非 0 即 1 ,不记录信号产生了多少次,因此 pending 位图和 block 位图是可以使用相同的数据类型来表示的。
这种数据类型就是 sigset_t ,称为信号集,它就是一个位图结构,用来表示每个信号的 “有效” 或 “无效” 状态。
在未决信号集中 “有效” 和 “无效” 的含义是指该信号是否处于未决状态,在阻塞信号集中 “有效” 和 “无效” 的含义是指该信号是否被阻塞。

虽然 sigset_t 是一个位图结构,但是不同操作系统对它的实现是不一样的,即便是相同操作系统的不同版本,其实现都有可能有差别,所以不能让用户直接修改 sigset_t 类型的变量,需要使用专门的信号集操作函数。

用户直接修改 sigset_t 类型的变量会编译报错。在这里插入图片描述在这里插入图片描述

set 是一个变量,和 int 、double 一样,都是在用户栈上保存的。我们需要设置好 set ,然后把这个 set 使用特定的系统调用写到操作系统里。

4.信号集操作函数

作用:
 ① sigemptyset函数:初始化 set ,将其中所有信号对应的比特位清零。
 ② sigfillset函数:初始化 set ,将其中所有信号对应的比特位置 1 。
 ③ sigaddset函数:把 signum 信号添加到 set 集合里,其实就是把 signum 信号对应的比特位由 0 置 1 。
 ④ sigdelset函数:把 signum 信号从 set 集合里删去,其实就是把 signum 信号对应的比特位由 1 置 0 。
 ⑤ sigismember函数:判定 signum 信号是否在 set 集合里,其实就是判定 signum 信号对应的比特位是否为 1 。

参数:
 ① set 是 sigset_t 类型的指针。
 ② signum 是信号

返回值:
 ① 前四个函数都是成功返回 0 ,错误返回 -1 。
 ② sigismember函数:若 signum 信号在 set 集合内,则返回 1 ,否则返回 0 。错误返回 -1 。

注:在使用 sigset_ t 类型的变量之前,一定要先调用sigemptyset函数或sigfillset函数做初始化,使信号集处于确定的状态,之后才可以调用sigaddsetsigdelsetsigismember函数。

在这里插入图片描述

5. sigprocmask 系统调用

sigprocmask系统调用的作用:获取或更改当前进程的信号屏蔽字(阻塞信号集),也就是当前进程的 block 位图。

sigprocmask系统调用的参数:
 ① how 表示调用行为,指示如何操作。
    SIG_BLOCK:set 包含了我们希望添加到当前信号屏蔽字的信号,相当于 mask = mask | set 。
    SIG_UNBLOCK:set 包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于 mask = mask & ~set 。
    SIG_SETMASK:设置当前信号屏蔽字为 set ,相当于 mask = set 。

 ② set 是输入型参数,由用户根据需要来设置。
 ③ oldset 是输出型参数,若为非空,则带回老的信号屏蔽字;若不关心,则可设为 NULL 。

sigprocmask系统调用的返回值:
 ① 成功,返回 0 。
 ② 错误,返回 -1 。

在这里插入图片描述

我们可以通过设置信号屏蔽字的方式,使得某些信号被屏蔽,不能被递达。

比如,我们设置进程阻塞 2 号信号。在这里插入图片描述

按 Ctrl + c 发送 2 号信号会终止进程,但该进程阻塞了 2 号信号,该信号不会被递达。
在这里插入图片描述

9 号信号不能被阻塞。这个好理解,如果 9 号信号可以被阻塞,那么这个进程就不可能被杀死。

在这里插入图片描述在这里插入图片描述

6. sigpending 系统调用

sigpending系统调用的作用:获取当前进程的 pending 位图。

sigpending系统调用的参数:
 ① set 是输出型参数,带回 pending 位图。

sigpending系统调用的返回值:
 ① 成功,返回 0 。
 ② 错误,返回 -1 。

在这里插入图片描述

示例:

① 屏蔽 2 号信号,查看 pending 位图的对应比特位由 0 置为 1 的过程。
设置进程屏蔽 2 号信号,不断获取当前进程的 pending 位图,并打印显示。然后我们手动发送 2 号信号,因为 2 号信号因屏蔽而不会被递达,所以 pending 位图的对应比特位不会由 1 置 为 0 。在这里插入图片描述在这里插入图片描述

② 解除对 2 号信号的屏蔽后,查看 pending 位图的对应比特位由 1 置为 0 的过程。
因为 2 号信号的默认动作是终止进程,所以一旦解除屏蔽,2 号信号就被递达,进程就被终止。为了看到解除屏蔽后 pending 位图的对应比特位由 1 置为 0 的过程,我们首先对 2 号信号进行自定义捕捉。在这里插入图片描述
在这里插入图片描述

五、信号的处理

1.内核空间和用户空间

用户的数据和代码一定要被加载到内存中,OS 的数据和代码也是一定要被加载到内存中的。

进程地址空间由内核空间和用户空间构成:
 ① 用户空间:用户的代码和数据,通过用户级页表与物理内存建立映射关系。
 ② 内核空间:OS 的代码和数据,通过内核页表与物理内存建立映射关系。

内核页表只有一份,被所有进程共享。
无论进程如何切换,都能够保证找到的是同一个 OS 。因为每个进程都有地址空间,使用同一张内核页表。

在这里插入图片描述
进程通过地址空间是能够看到用户和内核的所有内容的,但不一定能访问。
 ① 进程要访问用户空间,必须处于用户态。
 ② 进程要访问内核空间,必须处于内核态。

需要有一个权限或者身份认证,来证明进程是属于哪种工作模式的。
在进程里面是有对应的相关数据来标识进程的工作模式的(用户模式 / 内核模式),这个数据会被加载到 CPU 的其中一个寄存器当中,标识当前进程是处于用户态还是处于内核态。

2.内核态和用户态

  • 内核态:执行 OS 的代码和数据时所处的状态。OS 的代码的执行全部都是在内核态。
  • 用户态:用户的代码和数据被访问或执行时所处的状态。也就是说我们写的代码全部都是在用户态执行的。

两者的主要区别在于权限,内核态的权限要比用户态的权限大得多。

实际上,进程在执行代码时不断地在用户态和内核态之间来回切换,其中最典型的例子是调用系统调用。

系统调用的实现在内核中。
所谓的系统调用,就是进程的状态由用户态切换为内核态,然后根据内核页表找到系统函数,执行对应的代码即可。

结论:
 ① 用户态使用的是用户级页表,只能访问用户的数据和代码。
 ② 内核态使用的是内核级页表,只能访问内核的数据和代码。

系统识别进程的身份状态:CPU 内有寄存器保存了当前进程的身份状态。

如何理解进程切换?
当前进程的时间片消耗完了,进程的状态立马改成内核态,然后执行内核中进程切换的代码。

3.信号的检测和处理

进程在收到信号后先保存起来,然后在合适的时候进行处理。

那么信号什么时候被处理呢?

当进程在执行主控制流程的某条指令时,可能会因为某些情况(比如调用系统调用、中断、异常)而陷入内核,就会由用户态切换为内核态去执行内核的代码,然后在返回到用户态之前,进行信号检测和处理。

进程从内核态切换回用户态时进行信号的检测和处理。

在这里插入图片描述
若信号没有被阻塞(block)且处于未决(pending)时,就会执行对应的 handler 方法:

 ① 如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作,然后清除 pending 表对应的标志位,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行。
 ② 如果待处理信号的处理动作是自定义捕捉,则需要先返回用户态执行对应的自定义动作,执行完后再返回到内核,清除 pending 表对应的标志位,然后使用一种特殊的系统调用sys_sigreturn返回到用户态,从主控制流程中上次被中断的地方继续向下执行。

为何一定要切换成为用户态才能执行信号捕捉方法?
理论上,OS 能直接执行用户的代码,因为是内核态,权限更高,但是因为 OS 身份特殊,不能直接执行用户的代码。
假如内核态能够执行用户的信号捕捉方法,由于信号捕捉方法可能是有害的,OS 就以它自己的身份执行了这个方法,就有可能破坏 OS 。
因此,一定要切换成为用户态才能执行信号捕捉方法。

以上就是信号检测和处理的完全过程。

为了方便记忆,我们可以把上述过程进行高度地抽象概括:
在这里插入图片描述

4. sigaction 系统调用

sigaction系统调用的作用:读取或设置某一信号的对应动作,作用和signal系统调用基本一样,都是修改 handler 函数指针数组。

sigaction系统调用的参数:
 ① signum 表示要设置的信号。
 ② act 是结构体指针,输入型参数,需填充该结构体里面 signum 的对应处理动作。
 ③ oldact 是结构体指针,输出型参数,若为非空,则带回内含老的处理动作的结构体;若不关心,则可设为 NULL 。

sigaction系统调用的返回值:
 ① 成功,返回 0 。
 ② 错误,返回 -1 。

在这里插入图片描述
在这里插入图片描述

关于 struct sigaction 结构体的说明:

在这里插入图片描述
 ① sa_handler 代表 signum 的对应处理动作。
 ② sa_mask 代表在调用信号处理函数时需要额外屏蔽的信号。
 ② sa_flags 代表选项,我们在这里设为 0 。
 ③ sa_sigaction 和 sa_restorer 通常与实时信号相关联,我们在这里不关心。

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。

如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 struct sigaction 结构体中的 sa_mask 字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。

示例:

① 将 2 号信号的处理动作由默认改为自定义动作。在这里插入图片描述
在这里插入图片描述
② 将 2 号信号的处理动作由默认改为忽略。在这里插入图片描述在这里插入图片描述
③ 将 2 号信号的处理动作由默认改为默认。
在这里插入图片描述在这里插入图片描述

① 在调用信号处理函数时,除了当前的 2 号信号被自动屏蔽之外,还希望自动屏蔽 3 号信号。在这里插入图片描述
在这里插入图片描述
② 在调用信号处理函数时,除了当前的 2 号信号被自动屏蔽之外,不希望自动屏蔽其它信号了。在这里插入图片描述
在这里插入图片描述


在 Linux 中,如果把进程的某一个普通信号屏蔽了,然后 OS 给这个进程多次发送该信号,该进程只能记住一次,因为记录信号的标记位只有一个比特位。也就是说,在 Linux 中,普通信号是可能会被丢失的。

实时信号不会被丢失,因为内核是以链表队列的形式把所有的实时信号组织起来的,来一个实时信号就放入队列。

六、可重入函数

在执行信号捕捉时,其实已经进入到了另一种执行流。换言之,进程有可能只是执行我们的 main 函数代码,也有可能因为收到信号而导致进程去执行信号捕捉流程。

实际上,在单进程中也照样可能会存在多执行流的情况,它们实际上是两个毫不相关的执行流(两个独立的控制流程)。

我们来看一下下面这个例子,便于我们理解可重入函数的含义。

单链表节点的头插:
在这里插入图片描述在这里插入图片描述

main 执行流在头插的第一步刚做完时,信号来了,而且恰好进程要去捕捉这个信号了,但不幸的是,这个信号捕捉函数里面也进行了头插。执行完这个信号捕捉函数之后,就返回到 main 执行流中上次被中断的地方继续向下执行。然后执行头插的第二步,至此头插完毕。

在这里会产生丢失节点的问题,进而造成内存泄漏。
这个过程,很显然,原因就在于主执行流进入 insert 函数执行时,该函数还没返回,突然因为信号问题而导致进入信号捕捉执行流,然后在该执行流中 insert 函数又被进入了。

像上例这样,insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入。

  • 一个一旦被重入,可能会出现问题的函数,我们称之为该函数不可被重入。
  • 一个一旦被重入,不会出现问题的函数,我们称之为该函数可被重入。

是否可重入是用来描述一个函数的特点的。

我们所学到的大部分函数,以及 STL 和 boost 库中的函数,大部分都是不可重入的。
实际上,大部分的标准库函数都是不可重入的。

如果一个函数符合以下条件之一则是不可重入的:
 ① 调用了malloc/free,或者new/delete,因为它们也是用全局链表来管理堆的。
 ② 调用了标准 I/O 库函数。标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构。

七、volatile 关键字

volatile 是 C/C++ 中的一个关键字,其作用是告知编译器不要对被它修饰的变量做任何优化,每次读取时必须从内存中读取,不要读取中间缓冲区寄存器中的数据。简言之,保持内存的可见性

gcc 编译是有 -O0 到 -O3 等优化级别选项的(-O0: 不做任何优化,这是默认的编译选项)。

在这里插入图片描述

 ① gcc 普通编译,生成的可执行程序:

在这里插入图片描述
在这里插入图片描述标准情况下,键入 Ctrl + c 发送 2 号信号,2 号信号被捕捉,执行自定义动作,修改 flag=1 ,while 循环条件不满足,退出循环,进程退出。

 ② gcc 编译时采用 -O3 优化级别的选项,生成的可执行程序:

在这里插入图片描述
在这里插入图片描述
优化情况下,键入 Ctrl + c 发送 2 号信号,2 号信号被捕捉,执行自定义动作,修改 flag=1 ,但是 while 循环条件依旧满足,进程继续运行!很明显 flag 肯定已经被修改了,为什么循环条件依旧满足呢?
实际上,此时 while 循环检测的 flag,并不是内存中最新的 flag ,而是寄存器中的 flag ,因为编译器的优化,while 循环检测的 flag 被放进了 CPU 寄存器中。

我们发现,一份代码被编译器优化过后,展现出来的的运行结果跟没有被优化的结果是不一样的。
但是我们不要只是因为这个例子就认为编译器不应该优化或者优化错了。

那么应该怎么理解呢?
根本原因就在于编译器的优化其实并不能识别出代码当中的多执行流这样的情况。

下面我们解释出现上面运行结果的原因:

 ① 若是标准情况(没有编译器优化):
flag 是变量,在进程运行时开辟空间。换言之,CPU 要识别 flag ,它一定是从内存中读取 flag 。
在这里插入图片描述进程执行时,CPU 从内存中把 flag 读取到其内部,然后做判断,再把判断结果返回,不断重复这个过程。
这样的话即使信号捕捉执行流对 flag 做了修改,也能立马反馈到 while 循环的条件判断中。

 ② 若是存在编译器优化的情况(没有加 volatile 关键字):
CPU 和内存虽然是两种硬件,但实际上它们的效率差别还是挺大的。在编译器看来,main 是一个执行流,信号捕捉执行流是识别不到的,于是编译器在做优化的时候,发现这个 while 循环只对 flag 做条件判断,没有对 flag 做任何的修改,所以就直接把它优化到 CPU 的寄存器中,这样的话,在进程执行时就不需再对 flag 做内存级别的访问了。
在这里插入图片描述进程执行时,CPU 在 while 循环对 flag 做条件判断时直接在寄存器对它做判断,也就是不再到内存中读取 flag 了。于是当进程收到信号执行信号捕捉时,由于信号捕捉执行流修改的是内存中的 flag ,但 CPU 对 flag 做条件判断时是在寄存器上。所以在优化场景下,可以理解为 CPU 寄存器缓存了一部分数据导致屏蔽了内存数据。这虽然是优化后的结果,但并不是我们想要的。于是就出现了无论怎么 Ctrl + c 发送信号,进程都不退出的情况。

那么怎么去解决这个问题呢?
给 flag 变量加上一个 volatile 关键字。

给 flag 变量加上 volatile 关键字。在这里插入图片描述gcc 编译时照样采用 -O3 优化级别的选项。
在这里插入图片描述
在这里插入图片描述我们发现进程又可以退出了。

对变量使用 volatile 关键字修饰,意思是:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化。对该变量的任何操作,都必须在真实的内存中进行。

具体到上面的例子,用 volatile 修饰 flag ,意思是告知编译器,不要对 flag 做优化。要读取 flag ,必须得从内存中把数据加载进来。这样的话,当 while 循环做条件判断时,保证 flag 永远都是从内存当中被读取。

八、SIGCHLD 信号

调用waitwaitpid系统调用清理僵尸进程,父进程可以阻塞等待子进程退出,也可以非阻塞地轮询是否有子进程退出等待清理。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己工作的同时还要时不时地轮询一下,程序实现较为复杂。

其实,子进程在退出时会给父进程发送 SIGCHLD 信号,父进程对 SIGCHLD 信号的默认处理动作是忽略,所以当子进程退出时,父进程也没有反应。

验证子进程在退出时会给父进程发送 SIGCHLD 信号:
为了能明显地看到现象,我们对 SIGCHLD 信号进行自定义捕捉。在这里插入图片描述
在这里插入图片描述
我们确实发现了子进程退出时会向父进程发送 SIGCHLD 信号。
子进程退出了,父进程没有 wait ,没有回收子进程,确实也会出现僵尸进程。

父进程可以自定义 SIGCHLD 信号的处理函数,这样父进程再也不需要主动地等待子进程是否退出,能专心处理自己的工作了,子进程退出时会给父进程发送 SIGCHLD 信号来告知父进程,父进程在信号处理函数中调用waitpid系统调用回收子进程即可。

在这里插入图片描述

说明:
设置 while 循环,且非阻塞地等待任意一个(而非指定的一个)子进程,能够把所有退出的子进程全部回收到。

比如说一个父进程创建了十个子进程,十个子进程同时退出了,几乎同时向父进程发送 SIGCHLD 信号,可是父进程 PCB 的 pending 位图里面只有一个比特位来记录信号,如果不用循环,且只 wait 一次的话,那么只能回收一个子进程,剩下的子进程就没有被回收到。如果不设置 WNOHANG 非阻塞的话,父进程就有可能阻塞地等待剩下的子进程退出,就不能处理自己的工作了。

如果想要不产生僵尸进程,还有另外一种方法:父进程将 SIGCHLD 信号的处理动作改为 SIG_IGN(忽略动作),这样的话,子进程在退出时会被自动清理掉,不会产生僵尸进程,也不会通知父进程。
此方法对于 Linux 可用,但不保证在其它 UNIX 系统上都可用。

在这里插入图片描述在这里插入图片描述

所以如果不想让父进程 wait ,不关心子进程的退出结果,只是想让子进程退出时不要影响父进程,而且父进程也不想回收子进程,我们可以显式地将 SIGCHLD 信号的默认处理动作改为 SIG_IGN(忽略动作)。

系统默认的忽略动作和用户自定义的 SIG_IGN 忽略动作,通常是没有区别的,但在这里是一个特例。

具体是否需要等待子进程,是否需要回收子进程,一定是结合实际场景来决定的。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值