在学习 Linux 进程间通信时,信号往往是最早接触、却又最容易被"用而不懂"的一种机制。很多时候,我们能够熟练使用 kill、signal、sigaction,却并不清楚 信号在内核中究竟经历了哪些阶段。
本文将结合 Linux 内核中的 task_struct 结构,围绕信号的 Pending、Block 与 Handler 三个核心概念,对信号的发送、保存以及递达过程进行系统梳理,帮助自己也帮助读者,从内核层面真正理解 Linux 信号机制。
什么是信号的发送

大家其实可以先来想一个问题就是我们的进程只能收到一个信号吗?答案当然是不是了,其实一个进程在同一时刻可以"接收并保存多个信号",但同一种普通信号最多只会被记录一次,那么一个进程接收到这么多信号,是如何对这些信号进行保存的呢?
我猜大家第一个想到的就是数组,当哪一个信号发给进程之后,进程就用数组就将其保存起来,但是其实操作系统并不是这样做的,因为用数组保存起来的话,我们的一个整数就占用4个字节,接收的越多,这不就占用的空间就越大,不节省空间。
操作系统使用的特别聪明的方式,它选择了位图的方式对信号进行了保存,用一个整数就可以表示所有的信号,表示如图:

所以我们可以通过这样的方式用来表示我们接收到了哪些信号,到对应的位置将这个比特位由0别为1即可,又因为没有0号信号,所以0号位置我们置为0即可,其它位置接收到哪个信号,就置为1就好,这样就可以将接收到的信号都保存下来,并且还节省了空间。
还有一点要补充的就是,之前我们一直说操作系统给进程发信号,其实往具体点讲就是操作系统给进程的PCB发送信号,那什么是发信号呢?其实就是我们学科中的一种表述形式,真实的将就是操作系统去修改task_struct(PCB)的信号位图中对应的比特位。因此,鄙人觉得用写信号更好一点。至此,信号发送才算完成。
信号的保存
在上一篇博客中,我们就有所了解信号的处理方式,有三种分别是:默认处理方式,忽略和自定义方式,现在我们对于这种无论哪种方式的信号处理方式,都将实际执行信号时的处理动作称为信号递达。所以之后就将执行信号时的实际行为都称为信号递达。
还有我们将信号保存到进程的PCB中,也就是上面用位图的方式表示信号存储,表示信号从产生到递达之间的状态,称为信号未决(Pending)。也就是理解为保存信号。
信号在内核中的表示示意图

又因为普通信号的范围是1-31,每一种信号都要有一种自己的处理方法,所以在进程的PCB中,操作系统会为进程维护一个handler表,而这个handler表就是一个指针数组

这个数组中的每个类型都是一个函数指针,这个函数指针默认情况下都执行操作系统设定好的函数方法,但是如果一旦用户自己设置了一个新的方法,就将对应位置的函数指针指向用户自己设置的函数地址,用的就是之前我们使用的函数调用接口signal()。

可以看到signal函数调用的第一个参数就是个int类型,这个就可以充当数组的下标快速定位handler表中的位置,且第二个参数就是一个函数指针,所以只需要将这个函数指针填入对应的handler表中,就可以实现信号的自定义行为。
所以现在我们就了解了在我们的进程PCB中肯定存在这样两张表,一张是信号未决(Pending)表,用来保存哪些产生了哪些信号,还有就是一张handler表,用来记录当信号进行递达的时候,该使用何种方法进行处理。
其实信号还有一个概念就是进程可以选择阻塞某个信号。目前,我们了解到的内容就是信号一旦产生,先到我们的Pending位图中进行等待,在之后合适的时机,我们都会对其进行处理。但事实上操作系统也是可以让我们对一些信号进行屏蔽的。一旦该信号被屏蔽了,即使信号处于未决状态,信号也不会被处理,直到进程解除对这个信号的阻塞之后,才会执行信号递达的动作。这就好比你和你女朋友吵架了,这个时候你女朋友特别生气,于是接收到你的任何消息都不回答你,直到你诚恳的道歉之后,你的女朋友才会转头回复你的消息。这就是在你没有道歉之前,你女朋友已经把你屏蔽了,直到你去解开这个屏蔽之后,才能够得到你女朋友的回复。
注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是递达之后可选的一种处理动作。这就好比未读和已读不回,未读就好比阻塞了,就是你发送的消息,对方还没有接收到,而已读不回就是你发送的消息,对方已经接收到了,但是对方就是不搭理你,就是这样,joker。
那么现在有一个问题就是是不是只有信号产生了,我们才会对信号进行阻塞呢?答案当然是不是了,就比如有时候即使你和你女朋友并没有吵架,但是当你女朋友在追剧中看到某个主人公和你做过一样的事情之后,也依旧莫名的又开始生你的气,然后你的女朋友就又不想和你说话了,这个时候明明没有吵架,你女朋友依旧把你屏蔽了。或者我们还可以理解为我们特别讨厌吃榴莲或者螺狮粉什么的,即使我们这时候并没有打算要吃榴莲或者螺狮粉,但是你已经把他们屏蔽了,所以即使是信号没有产生,我们也可能会对该信号进行阻塞,与该信号是否产生是没有关系的。也就是讨厌一个人是不需要理由的。
所以在我们的进程中除了有pending表和handler表,还有一张表就是block表,这个表和pending表一模一样,都是位图表,当对应信号的block表为0时,表示不阻塞该信号;当对应信号的block表为1时,表示阻塞该信号。
所以我们是如何对信号进行保存的,就是通过两张位图表(block和pending)以及一个函数指针表示处理动作,所以之后之后我们对信号的处理,也肯定与这三张表息息相关。

- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子 中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前 不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
sigset_t
从上面的介绍来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号 的"有效"或"无效"状态,在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有 效"和"无效"的含义是该信号是否处于未决状态。

上面的三张表都是属于操作系统的内核数据结构,而操作系统是不相信任何用户的,是不允许用户直接修改这三张表的,如果用户想要直接读取这三张表的内容是不被允许的,因为要是像我这样的小白直接读取,很有可能一不小心就造成操作系统的不可逆损失,这是万万不可以的,所以操作系统就是考虑到我这样的小白,所以它提供了对应的函数调用接口供我们使用,并且我们要获取的是内核数据结构,这就导致我们在使用函数调用的时候,一定会在内核和用户空间中来回的进行数据的拷贝,所以我们一定会在函数调用的时候设置一定的输入输出型参数,这就好比进程等待中的waitpid中的第二个函数参数status,我们需要现在用户空间定义一个整型变量,最后将这个整形变量的地址传递给函数调用接口,最后函数调用会将结果输出到这个整形变量中。
所以同理,用户想要获取对应的block和pending表,所以就要求操作系统就必须在用户层设置一种数据类型,所以这个sigset_t就是这样产生的,也就是所谓的pending和block表的位图结构,是我们在我们的用户空间,也就是我们的程序中可以直接使用的数据类型,所以我们只要在我们的用户空间定义好该数据类型的变量,最后将这个变量的地址将给系统调用接口之后,我们就可以得到对应的结果了,十分的方便,完全不需要我们自己去通过位操作自己去设置,用户体验也是极佳。
信号集操作函数
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
- 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
- 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置为1,表示该信号集的有效信号包括系统支持的所有信号。
- 注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含 某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集,也就是block信号集)。

如果oldset是非空指针,则读取进程的当前信号屏蔽字通过oldset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oldset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

sigpending

这个系统调用接口就十分的简单,就是读取当前进程在内核中的未决信号级(保存的信号内容),通过set参数传出。调用成功返回0,失败则返回-1。
现在接口介绍完成,下面我们来通过代码来感受一下具体是如何操作的,毕竟眼见为实耳听为虚。
现在我们来实现一个我们先将2号信号进行屏蔽,然后通过键ctrl+c的组合键向进程输入2号信号,整个过程我们可以看到,该进程的pending信号集从全0(0000000000000000000000000000000)到2号信号未决(0000000000000000000000000000000)的整个过程
#include <iostream>
#include <unistd.h>
#include <signal.h>
void printPending(sigset_t &pending)
{
for (int i = 31; i >= 1; i--)
{
if (sigismember(&pending, i) == 1)
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
int main()
{
//我们定义两个sigset_t类型的变量,一个是用来屏蔽2号信号的block信号集,一个用来保存内核中原本的block信号集
sigset_t set, oldset;
//将这两个变量进行初始化
sigemptyset(&set);
sigemptyset(&oldset);
//将其中一个信号集中的2号信号进行屏蔽
sigaddset(&set, 2);
//将屏蔽了2号信号的信号集设置进内核的block信号集,同时将之前的信号集进行保存
sigprocmask(SIG_SETMASK, &set, &oldset);
//用于进程在内核中的pending信号集的保存
sigset_t pending;
while (1)
{
//获取进程在内核中的pending信号集,看看现在进程中有哪些信号被保存
sigpending(&pending);
//将内核中的pending信号集进行答应
printPending(pending);
sleep(1);
}
return 0;
}

可以看到我们的pending信号集确实由于我们屏蔽了2号信号,导致2号信号一直处于未决状态,于是pending信号集就会一直保存2号信号,那么是不是当我们将2号信号再次解除之后,是不是2号信号就会递达,进而我们就可以看到pending信号集再次变为全0呢?答案肯定是是的,但是由于2号信号如果递达的话,我们的进程就会结束,所以为了让我们看到整个现象,我们需要对2号信号的信号处理方式进行自定义,让我们来一起见证一下这整个流程。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void printPending(sigset_t &pending)
{
for (int i = 31; i >= 1; i--)
{
if (sigismember(&pending, i) == 1)
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
void myhandler(int signum)
{
std::cout << "get a signal : " << signum << std::endl;
}
int main()
{
signal(2, myhandler);
// 我们定义两个sigset_t类型的变量,一个是用来屏蔽2号信号的block信号集,一个用来保存内核中原本的block信号集
sigset_t set, oldset;
// 将这两个变量进行初始化
sigemptyset(&set);
sigemptyset(&oldset);
// 将其中一个信号集中的2号信号进行屏蔽
sigaddset(&set, 2);
// 将屏蔽了2号信号的信号集设置进内核的block信号集,同时将之前的信号集进行保存
sigprocmask(SIG_SETMASK, &set, &oldset);
int count = 1;
// 用于进程在内核中的pending信号集的保存
sigset_t pending;
while (1)
{
// 获取进程在内核中的pending信号集,看看现在进程中有哪些信号被保存
sigpending(&pending);
// 将内核中的pending信号集进行答应
printPending(pending);
count++;
if (count == 20)
{
// 由于我们之前保存了原本的block信号集,这次再将其设置回内核即可
sigprocmask(SIG_SETMASK, &oldset, nullptr);
}
sleep(1);
}
return 0;
}

可以看到整个流程就和我们预先设想的是一样的结果,也确实验证了将信号屏蔽之后,如果此时再次产生信号,我们的进程也不会进行任何的处理,但是一旦将对应信号的屏蔽解除,这个信号就会递达,从而执行handler表中的方法。
了解到这里,我相信有一些同学又动起小心思了,既然之前修改默认信号的行为不可以,那么现在我将信号屏蔽了,进程就不执行对应的信号处理方法,那么如果我将所有的信号都屏蔽了,是不是就做到了一个杀不死的进程了呢?答案当然是不可能的,你的那点小九九,在人家设置操作系统的时候就已经想到了,防的就是你这种好奇心重的人,我们接下来就来试一试,答案就是和之前一样,9号信号和19号信号是无法屏蔽的。


可以看到除了9号和19号信号是无法被屏蔽之外,剩下的普通信号都是可以被屏蔽的。
这就是信号保存的内容。接下来我们来看看信号的捕捉。
信号的捕捉处理
结合之前我们所了解的内容,我们直到信号产生之后,我们可能不能立即处理这个信号,这个时候就要将这个信号暂时的保存起来(信号未决),当到了合适的时候,这个信号就会根据相应的handler表执行相应的动作,那么现在有一个关键的问题就是,这个合适的时候到底是什么时候?也就是信号是何时才会被处理?答案就是我们的进程从内核态返回用户态时,进行对信号的检测和处理!
这是为什么呢?其实我们可以这样理解,我们的进程如果要处理一个信号的前提是,我们要知道这个信号已经收到了,如何知道呢,我们就得查看我们的上面关于信号的3张表(pending,block,handler),而这三张表都属于内核数据结构,而处于用户态的进程是无法查看内核的数据结构的,所以我们的进程只有从用户态切换为内核态的时候,这个时候才有了访问这三张表的权限,所以我们的进程对信号的处理就是在当进程从内核态返回用户态之前进行处理。
大家听到内核态和用户态可能有点懵,现在我简单带大家了解一下进程的内核态和用户态。
进程从用户态转变为内核态有三种,分别是异常,中断,和系统调用,其中大家直观感受最快的就是系统调用,大家可能会说我自己写的程序一般都不用系统调用,其实不然,大家写的任何程序都或多或少都会进行系统调用,就比如大家经常使用的C标准库中的scanf和printf函数,它们两个的底层调用是系统调用接口read和write,所以我们在写程序的时候总会使用系统调用,而当我们使用系统调用的时候,我们不要仅仅只是简单的认为我们陷入内核使用一个内核数据结构就可以了,我们进入内核是需要有权限的,调用系统调用时,操作系统就会帮助我们完成身份的转变,将我们的身份从用户态转变为内核态,进而执行相关的系统调用。当系统调用执行结束之后,我们的进程就会返回,同时,将我们的身份从内核态再转变为用户态。

在 Linux 系统中,每个进程都都会拥有属于自己的一份虚拟地址空间,这个空间的管理核心由 task_struct 和 mm_struct 两个结构体承担。task_struct 是进程的"身份信息",保存进程状态、PID、调度信息以及指向虚拟内存描述符 mm_struct 的指针。而 mm_struct 则具体描述了进程的整个虚拟地址空间布局。
在 32 位 Linux 下,虚拟地址空间通常划分为 4GB :其中 低 3GB 为用户空间,高 1GB 为内核空间。用户空间内存从低地址向高地址依次分布为:代码段、字符常量区、已初始化数据区、未初始化数据区(BSS)、堆、共享区以及栈。堆向高地址增长,而栈则向低地址扩展。用户程序的命令行参数和环境变量也存放在栈附近的高地址区域。每个进程的用户空间都是互不干扰的,都是独立的,这样就保证了进程的独立性
而内核空间占据虚拟地址的高 1GB,并且在所有进程中都是共享的,这就意味着每个进程在执行用户态代码时都无法直接访问内核空间,从而保证了系统安全。当进程发生系统调用或中断切换到内核态时,CPU 会通过内核页表访问内核空间对应的物理内存,包括内核代码和数据。
虚拟地址最终通过页表映射到物理内存。每个进程拥有自己的 用户级页表 来映射其独立的用户空间,而所有进程共享 内核级页表 来映射内核空间。物理内存中内核代码和数据只有一份,但由于内核页表的存在,它们可以被所有进程访问。
总之就是有几个进程就有几个用户级页表,而内核级页表只有一份,每一个进程在自己的虚拟地址空间的3-4G的东西都是一样的,无论切换为哪一个进程,3-4G内容的空间是不变的,所以当我们站在进程的视角,我们使用系统调用,其实就是在进程自己的虚拟地址空间就可以执行;
之前我们了解的共享空间,以及动静态库等等,它们都是在进程地址空间的内存映射区(共享区),都是属于用户空间,所以不涉及到权限的问题,但是今天我们要访问的是内核中的三张信号表,所以我们必须先要获取对应的权限,才能够进行内核,完成信号的处理,那么具体是如何从用户态转变为内核态的呢?其实在我们的CPU中有一个CS寄存器,叫做代码段寄存器,在这个寄存器的低2位中就可以表示用户态还是内核态,两个比特位可以出现四种情况,分别是00,01,10,11,其中01,11很少使用,而当CS寄存器的低2位为00时,表示进程处于内核态,而当CS寄存器的低2位为11时,表示进程处于用户态。所以我们是否有权限执行操作系统的代码和数据,就是要看CS寄存器的低2位是否为00。
所以整个信号的捕捉处理就是如图:

- 当进程在执行主程序中,由于发生中断(时钟中断,I/O中断等),异常,或者系统调用等进入内核。
- 当进程处理完相应的事件之后,准备返回用户态之前,处理当前进程中可以递达的信号。先查看panding表,哪些信号已经产生了,然后再看block表,再看哪些信号被阻塞了,最后根据handler表进行信号的处理。
- 当信号的处理动作为自定义行为,这个时候就需要进程从内核态转变为用户态,执行对应的自定义信号处理函数。
- 当执行完自定义信号处理函数之后再次进入内核。
- 最后返回用户态再次从主程序中中断的地方继续向下执行。
现在有两个问题就是:
- 为什么执行用户自定义信号处理函数的时候,需要我们从内核态转变为用户态,难道内核态的权限还不能执行用户态的程序吗?
- 为什么返回用户态执行完信号的自定义处理函数之后要返回内核态。
对于第一个问题,内核态的权限肯定是可以执行用户态的程序的,操作系统选择返回用户态,就是害怕你再自定义的处理函数中使用一些不合法的手段,万一你写了盗取人家账户密码的程序这怎么办,所以操作系统本着防小人不防君子的手段,让你重新回到用户态再继续执行。
对于第二个问题,内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数(也就是我们的自定义函数),sighandler 和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
这就是信号的捕捉处理,我们现在知道了,信号产生之后,我们会先将在内核中的pending表中的对应的信号比特位由0置1,然后根据block表,查看时候信号被阻塞,最后我们通过handler表中对应的方法实现信号的处理。这是没有问题的,但是现在我想要知道的是,我们的信号未决的时候,pending表中的对应信号的比特位为1,那什么时候再次由1变为0呢?是在信号处理之前由0变为1,还是在信号处理之后由0变为1呢?所以我们接下来在来看一看这这是在递达之前还是在递达之后。
我们先来看看一个函数调用接口:
sigaction


| 参数 | 含义 |
|---|---|
signum |
信号编号(如 SIGINT、SIGTERM) |
act |
新的信号处理方式 |
oldact |
保存旧的处理方式(可为 NULL) |
- sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,失败则返回-1。signum 是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体。
那么我们应该如何进行验证呢,我们用2号信号做实验,我们可以在信号的自定义行为中进行打印pending表,如果这个时候对应pending表中对应的2号信号已经变为0了,说明在信号递达前就已经变为0了,如果这个时候还不是0,则说明实在信号递达之后才变为0,到底是哪一种情况呢?我们一试便知。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void printPending()
{
sigset_t pending;
sigemptyset(&pending);
sigpending(&pending);
for (int i = 31; i >= 1; i--)
{
if (sigismember(&pending, i) == 1)
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
void myhandler(int signum)
{
printPending();
std::cout << "get a signal : " << signum << std::endl;
}
int main()
{
struct sigaction act;
act.sa_handler = myhandler;
sigaction(2, &act, nullptr);
while (1)
{
std::cout << "this is a process : " << getpid() << std::endl;
sleep(1);
}
return 0;
}

从结果来看,我们现在可以确定了,我们的pending由1变为0是在信号处理之前就已经完成的。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
这句话的意思就是当我们在执行2号信号的处理动作时,如果这个时候又来了一个2号信号,那么我们的操作系统就会自动将其屏蔽,直到我们处理完成2号信号之后,才会将这个2号信号的屏蔽解除,这样做的目的就是防止套娃,万一你对信号的自定义行为中有系统调用,这个时候一旦又来了一个2号信号,如果不屏蔽的话,就导致我们再次执行信号的处理。所以为了避免这一情况的发生,当我们的进程在处理2号信号的时候,如果再来2号信号,这个时候2号信号是被屏蔽的,我们只有在pending表中看到2号信号被保存了。(记住我们的信号的处理之前就会将2号信号的pending表由0置1,所以这个时候再次收到2号信号,由于此时信号被屏蔽了,所以信号就会处于pending表中,pending表中对应的2号信号的比特位又变为了1。)
所以我们只需要将我们上面的信号处理函数进行修改即可。
void myhandler(int signum)
{
std::cout << "get a signal : " << signum << std::endl;
while(1)
{
printPending();
sleep(1);
}
}

可以看到,实验的结果和我们的预期是一模一样的,那么现在如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望可以屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。只需要我们在main函数中增加相应的代码即可,我们现在就来试一试。
int main()
{
struct sigaction act;
act.sa_handler = myhandler;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 1);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaction(2, &act, nullptr);
while (1)
{
std::cout << "this is a process : " << getpid() << std::endl;
sleep(1);
}
return 0;
}

这样我们就可以在我们的信号进行处理的时候同时屏蔽掉另外一些信号。
可重入函数

- main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为时钟中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。这就导致节点丢失,内存泄漏了。
- 像上面这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
这就是可重入函数,我们在这里简单介绍一下,由于我们现在所写的代码都是单个执行流,不好让大家有一个直观的感受,所以在下一篇博客关于多线程时,我们再详谈。
SIGCHLD信号
在我们之前的博客,我们知道一个知识点就是当我们使用fork系统调用创建子进程之后,我们的父进程必须等待子进程结束,然后父进程对子进程进行回收,因为如果不这样做,子进程就会变为僵尸进程,而关于子进程的相关资源就会一直无法得到释放,这就会造成内存泄漏的问题。而且父进程还不知道子进程何时才能退出,因此这就会使得父进程要么使用阻塞的方式进行等待,这就导致父进程无法处理自己的工作,要么就是父进程采用轮询的方式进行等待,也就是在处理自己工作的同时,时不时的看看子进程是否结束,程序相当的麻烦。这就会导致父进程一直被子进程牵着鼻子走,十分的难受,这就好比我们在打游戏的时候,你的女朋友这个时候一直打电话关心你,你这个时候必须得一心二用,无法专心致志。所以现在我们就来想办法解决这个父进程被子进程牵着鼻子走得问题。
那么我们先来想一想子进程时是如何退出的?其实,子进程在退出的时候,是会给我们的父进程发送信号的,这个信号就是SIHCHLD(17号信号),现在我们就先来验证一下,子进程在退出的时候,会给父进程发送17号信号。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void myhandler(int signum)
{
std::cout << "get a signal : " << signum << std::endl;
}
int main()
{
signal(SIGCHLD, myhandler);
pid_t id = fork();
if (id == 0)
{
int cnt = 5;
while (cnt--)
{
std::cout << "this is child process , pid : " << getpid() << std::endl;
sleep(1);
}
std::cout << "child process quit!" << std::endl;
exit(1);
}
while (1)
{
std::cout << "this is father process, pid : " << getpid() << std::endl;
sleep(1);
}
return 0;
}

可以看到,子进程在退出之后,我们的父进程确实是收到了17号信号。那么我们是如何解决父进程被子进程牵着鼻子走的问题呢?其实结合信号的内容,我们其实可以自定义17号信号的信号处理方式,当子进程退出之后,给父进程发送信号之后,然后这个时候我们在信号的自定义处理方式中增加进程等待,这样我们的父进程就不需要被子进程牵着鼻子走了,父进程可以处理自己的事情,当子进程退出的时候,这个时候父进程收到信号,然后对子进程进行回收,这样我们就可以达到双赢的局面。所以我们只需要在自定义信号处理的方法中增加进程等待即可。
void myhandler(int signum)
{
sleep(5);
int rid = waitpid(-1, nullptr, 0);
std::cout << "get a signal : " << signum << " , rid : " << rid << std::endl;
}

可以看到实验的结果和我们的预期是一模一样的,刚开始父子进程各自执行自己的代码,然后5s之后子进程退出,这个时候我在自定义信号处理中等待了5s中,可以看到确实由于还没有父进程对子进程进行回收,我们子进程此时处于僵尸状态,5s之后执行进程等待之后,我们子进程才得以释放,这样我们就既可以使得子进程可以安全的回收,还可以让父进程不必一直等待准备回收子进程,这样我们就达到了双赢的局面。
但是现在还有以一个问题就是,如果当我们的父进程通过fork()调用一下子创建了许多的子进程,并且如果这些子进程同时退出的时候,这个时候就会有大问题,因为我们的执行一次信号处理的同时,会屏蔽掉当前信号,并且我们的进程在多个子进程发送的信号之后,由于pending图是记录比特位的,所以只会记录一次,这个意味着可能只会有一两个子进程被回收,剩下的子进程都会变为僵尸进程,造成内存泄漏,那么我们应该如何处理这种情况呢?
其实很简单,既然信号解决不了这个问题,我们就不用信号了,我们可以在接收到17号信号之后,让父进程一直回收子进程,知道将所有的子进程回收之后,再结束掉信号的处理,也就是我们再一次信号处理的过程中,将所有等待回收的进程全部回收。
void myhandler(int signum)
{
sleep(5);
int rid = waitpid(-1, nullptr, 0);
while (rid > 0)
{
rid = waitpid(-1, nullptr, 0);
std::cout << "get a signal : " << signum << " , rid : " << rid << std::endl;
}
}
只要waitpid的返回值大于0,就一直进行进程等待,这样就可以将所有的子进程全部回收。
那么现在还有一个问题就是,假如我们的子进程一半退出,一半还在执行,这个时候又应该如何处理呢?
其实这个也十分的简单,我们可以使用非阻塞的方式进行等待,这样将想要退出的子进程进行回收之后,我们非阻塞查询到没有子进程想要退出了,这个时候waitpid就会返回0,我们的信号处理也就结束了,当剩下的子进程也处理完成之后,只需要再次进行信号的处理即可。
void myhandler(int signum)
{
sleep(5);
int rid = waitpid(-1, nullptr, WNOHANG);
while (rid > 0)
{
rid = waitpid(-1, nullptr, 0);
std::cout << "get a signal : " << signum << " , rid : " << rid << std::endl;
}
}
waitpid的返回值:

事实上,要想不产生僵尸进程还有另外一种办法:就是将SIGCHLD的处理动作设置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。
这就是关于信号的内容,在下一篇博客中我们再来看看多线程是什么样的,本篇博客就到这里,谢谢大家!!!