【Linux 系统】进程信号 -- 详解(上)https://developer.aliyun.com/article/1515688?spm=a2c6h.13148508.setting.16.11104f0e63xoTy
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 在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回 0, 出错返回 -1 。 sigismember 是一个布尔函数, 用于判断一个信号集的有效信号中是否包含某种信号, 若包含则返回 1, 不包含则返回 0, 出错返回 -1 。
(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,返回到用户区中系统调用点。
如果信号的处理动作是用户自定义函数, 在信号递达时就调用这个函数 , 这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂。
举例如下: 用户程序注册了 SIGQUIT 信号的处理函数 sighandler 。当前正在执行 main 函数, 这时发生中断或异常切换到内核态。在中断处理完毕后要返回用户态的 main 函数之前检查到有信号 SIGQUIT 递达。内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执 行 sighandler 函数, sighandler 和 main 函数使用不同的堆栈空间, 它们之间不存在调用和被调用的关系, 是两个独立的控制流程。
sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了。
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。