跳到主要内容 深入理解 Linux 信号机制:从 task_struct 到信号递达全过程 | 极客日志
C++ 算法
深入理解 Linux 信号机制:从 task_struct 到信号递达全过程 系统梳理了 Linux 信号机制,从内核层面的 task_struct 结构出发,详细讲解了信号的发送、保存及递达全过程。文章重点阐述了信号未决 (Pending)、阻塞 (Block) 与处理句柄 (Handler) 三个核心概念,解释了操作系统如何利用位图高效保存信号状态。通过 sigset_t 类型及 sigprocmask、sigpending、sigaction 等 API 的使用示例,演示了信号的屏蔽、查询与自定义处理逻辑。此外,还探讨了信号递达时机、可重入性问题以及 SIGCHLD 信号在子进程回收中的应用,帮助开发者深入理解信号在内核与用户态交互中的完整生命周期。
星辰大海 发布于 2026/3/24 更新于 2026/4/18 4 浏览深入理解 Linux 信号机制
在学习 Linux 进程间通信时,信号往往是最早接触、却又最容易被'用而不懂'的一种机制。很多时候,我们能够熟练使用 kill、signal、sigaction,却并不清楚 信号在内核中究竟经历了哪些阶段 。
本文将结合 Linux 内核中的 task_struct 结构,围绕信号的 Pending、Block 与 Handler 三个核心概念,对信号的发送、保存以及递达过程进行系统梳理,帮助读者从内核层面真正理解 Linux 信号机制。
什么是信号的发送
一个进程在同一时刻可以接收并保存多个信号,但同一种普通信号最多只会被记录一次。操作系统使用位图的方式对信号进行保存,用一个整数即可表示所有的信号状态。对应位置将比特位由 0 置为 1 即可。由于没有 0 号信号,0 号位置置为 0。
往具体点讲,操作系统是修改 task_struct(PCB)的信号位图中对应的比特位。因此,信号发送的本质就是写信号。
信号的保存
信号的处理方式有三种:默认处理方式、忽略和自定义方式。无论哪种方式的信号处理方式,实际执行信号时的处理动作称为信号递达。
将信号保存到进程的 PCB 中,用位图的方式表示信号存储,表示信号从产生到递达之间的状态,称为信号未决 (Pending)。
信号在内核中的表示示意图:
普通信号的范围是 1-31,每一种信号都要有一种自己的处理方法。在进程的 PCB 中,操作系统会为进程维护一个 handler 表,这是一个指针数组。每个元素是一个函数指针,默认情况下执行操作系统设定好的函数方法,如果用户自己设置了一个新的方法,就将对应位置的函数指针指向用户自己设置的函数地址,使用的接口是 signal()。
signal 函数的第一个参数是 int 类型,充当数组下标快速定位 handler 表中的位置;第二个参数是函数指针。
在进程 PCB 中存在三张表:
信号未决 (Pending) 表:用来保存哪些产生了哪些信号。
Handler 表:用来记录当信号进行递达的时候,该使用何种方法进行处理。
Block 表:用于阻塞某个信号。目前了解到的内容就是信号一旦产生,先到 Pending 位图中进行等待。但事实上操作系统允许对一些信号进行屏蔽。一旦该信号被屏蔽了,即使信号处于未决状态,信号也不会被处理,直到进程解除对这个信号的阻塞之后,才会执行信号递达的动作。
注意:阻塞和忽略是不同的。只要信号被阻塞就不会递达,而忽略是递达之后可选的一种处理动作。
阻塞可以在信号产生之前设置,与该信号是否产生是没有关系的。
在进程中除了有 pending 表和 handler 表,还有一张 block 表,这个表和 pending 表一模一样,都是位图表。当对应信号的 block 表为 0 时,表示不阻塞该信号;当对应信号的 block 表为 1 时,表示阻塞该信号。
每个信号都有两个标志位分别表示阻塞 (block) 和未决 (pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
SIGINT 信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号。
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
SIGQUIT 信号未产生过,一旦产生 SIGQUIT 信号将被阻塞,它的处理动作是用户自定义函数 sighandler。
sigset_t 每个信号只有一个 bit 的未决标志,非 0 即 1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,sigset_t 称为信号集,这个类型可以表示每个信号的'有效'或'无效'状态。
上面的三张表都是属于操作系统的内核数据结构,不允许用户直接修改。操作系统提供了对应的函数调用接口供我们使用。用户想要获取对应的 block 和 pending 表,操作系统必须在用户层设置一种数据类型,sigset_t 就是这样产生的。
信号集操作函数 #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 做初始化。
这四个函数都是成功返回 0,出错返回 -1。sigismember 是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回 1,不包含则返回 0,出错返回 -1。
sigprocmask 调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字 (阻塞信号集)。
如果 oldset 是非空指针,则读取进程的当前信号屏蔽字通过 oldset 参数传出。如果 set 是非空指针,则更改进程的信号屏蔽字,参数 how 指示如何更改。
sigpending 这个系统调用接口十分简单,就是读取当前进程在内核中的未决信号级 (保存的信号内容),通过 set 参数传出。调用成功返回 0,失败则返回 -1。
首先实现一个先将 2 号信号进行屏蔽,然后通过键 ctrl+c 的组合键向进程输入 2 号信号,整个过程可以看到,该进程的 pending 信号集从全 0 到 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;
}
int main () {
sigset_t set, oldset;
sigemptyset (&set);
sigemptyset (&oldset);
sigaddset (&set, 2 );
sigprocmask (SIG_SETMASK, &set, &oldset);
sigset_t pending;
while (1 ) {
sigpending (&pending);
printPending (pending);
sleep (1 );
}
return 0 ;
}
可以看到我们的 pending 信号集确实由于我们屏蔽了 2 号信号,导致 2 号信号一直处于未决状态。那么是不是当我们将 2 号信号再次解除之后,2 号信号就会递达?答案是肯定的。但是由于 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 set, oldset;
sigemptyset (&set);
sigemptyset (&oldset);
sigaddset (&set, 2 );
sigprocmask (SIG_SETMASK, &set, &oldset);
int count = 1 ;
sigset_t pending;
while (1 ) {
sigpending (&pending);
printPending (pending);
count++;
if (count == 20 ) {
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 中断等),异常,或者系统调用等进入内核。
当进程处理完相应的事件之后,准备返回用户态之前,处理当前进程中可以递达的信号。先查看 pending 表,哪些信号已经产生了,然后再看 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 系统调用创建子进程之后,我们的父进程必须等待子进程结束,然后父进程对子进程进行回收,因为如果不这样做,子进程就会变为僵尸进程,而关于子进程的相关资源就会一直无法得到释放,这就会造成内存泄漏的问题。而且父进程还不知道子进程何时才能退出,因此这就会使得父进程要么使用阻塞的方式进行等待,这就导致父进程无法处理自己的工作,要么就是父进程采用轮询的方式进行等待,也就是在处理自己工作的同时,时不时的看看子进程是否结束,程序相当的麻烦。这就会导致父进程一直被子进程牵着鼻子走,十分的难受。
那么我们先来想一想子进程时是如何退出的?其实,子进程在退出的时候,是会给我们的父进程发送信号的,这个信号就是 SIGCHLD(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;
}
}
事实上,要想不产生僵尸进程还有另外一种办法:就是将 SIGCHLD 的处理动作设置为 SIG_IGN,这样 fork 出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。
这就是关于信号的内容,在后续文章中我们再来看看多线程是什么样的,本篇博客就到这里。