【linux】进程信号(三)信号的处理,信号的捕捉,sigaction,可重入函数,volatile,SIGCHLD信号

小编个人主页详情<—请点击
小编个人gitee代码仓库<—请点击
linux系列专栏<—请点击
倘若命中无此运,孤身亦可登昆仑,送给屏幕面前的读者朋友们和小编自己!
在这里插入图片描述



前言

【linux】进程信号(二)信号的发送,信号的保存,sigset_t,sigprocmask,sigpending——书接上文 详情请点击<——,本文会在上文的基础上进行讲解,所以对上文不了解的读者友友请点击前方的蓝字链接进行学习
本文由小编为大家介绍——【linux】进程信号(三)信号的处理,信号的捕获,sigaction


一、浅度理解信号的处理

  1. 在之前的文章中,我们已经讲解了信号的预备工作,信号的产生,信号的发送,信号的保存,接下来我们进入信号的捕捉处理阶段,其实信号的捕捉处理阶段可以再次划分为两个阶段,即信号的捕捉和信号的处理阶段,那么接下来我们先来讲解一下信号的处理,之后再来谈谈信号的捕获
    在这里插入图片描述
  2. 信号什么时候进行处理?当我们的进程从内核态(允许用户访问操作系统的代码和数据)返回用户态(只能访问用户自己的代码和数据)的时候,进行信号的检测与处理
  3. 那么什么时候进程从用户态进入内核态呢?当调用系统调用的时候,操作系统是会做“身份”切换的,用户身份(用户态)变成内核身份(内核态),或者反着来,如何理解反着来呢?
  4. 当用户调用系统调用时,此时被切换为内核身份(内核态),执行完操作系统的代码和数据的时候,操作系统同样会做身份切换,将内核身份(内核态)切换为用户身份(用户态)
  5. 其中int 80指令就是从用户态陷入内核态,即用户身份切换为内核身份
  6. 其实上面,小编仅仅是将概念罗列了出来,那么究竟该如何理解用户态和内核态,以及该如何理解用户态和内核态之前的相互切换呢?我们还需要一定的地址空间的知识的铺垫,下面我们先讲解一下地址空间,这次的地址空间是小编在linux系列文章中,第三次谈地址空间

二、重谈地址空间3

  1. 如何理解地址空间呢?请看下图
    在这里插入图片描述
  2. 对于一个进程,有进程的PCB,进程地址空间,以及页表,以及用户在物理内存中的代码和数据,其实这个页表是用户级页表,如何理解呢?之前小编一直在谈地址空间中0~3GB的用户空间,其实这个0~3GB的虚拟地址是通过用户级页表映射到物理内存中用户自己的代码和数据,我们执行用户的代码是在0~3GB的用户的虚拟地址空间中的正文代码开始执行的,可是当用户代码中使用了系统调用或者使用了一些printf之类的底层封装了系统调用的函数,那么此时要执行这个系统调用,这个系统调用可是属于操作系统的代码和数据,那么关于这个系统调用又该如何执行呢?
  3. 我们知道操作系统在计算机开机的时候需要最先被运行起来,既然要运行,那么就必须被需要加载到物理内存,那么此时就需要通过页表建立虚拟地址和物理地址的映射关系才能运行,那么此时就需要有一个内核级页表,以及在进程地址空间上要有属于操作系统的空间,即要有属于内核的空间,所以在进程地址空间上的3~4GB空间就属于内核(操作系统),通常的我们把3~4GB空间叫做内核空间,所以当计算机开机的时候,操作系统就要加载到内存,从内核级页表上建立虚拟地址到物理地址的映射关系
  4. 接下来我们思考一下,用户级页表有几份?内核级页表有几份?
  5. 由于进程具有独立性,所以当进程有几个对应的用户级页表就有几份,同时由于操作系统只有一个,并且对于所有进程来讲,进程地址空间上固定区域3~4GB都是相同的,即对应的都是内核空间,整个系统中,进程再怎么样进行切换,3~4GB空间的内容是不变的,所以内核级页表只需要一份,换句话来讲,这一份内核级页表被所有的进程共享,共同使用(其实现代操作系统已经可以做到通过虚拟地址减去3GB就直接可以找到物理内存中的操作系统的代码和数据,因为操作系统的代码和数据是最先被加载到物理内存中的起始地址处,所以可以通过计算的方式直接找到物理内存中的操作系统的代码和数据进行访问,这点我们仅需要知道即可,但是实际理解的话,还是按照内核级页表进行理解)
    在这里插入图片描述
  6. 我们先抛弃用户是否有权限访问内核空间,假设进程有权限访问内核空间,那么当进程运行,当在0-3GB的用户空间上执行正文代码,此时要执行正文代码中的系统调用,我们知道系统调用是操作系统提供的函数方法,那么执行系统调用本质就是在访问操作系统的代码和数据,那么每一个进程在自己的进程地址空间上的3-4GB上的内核空间,都可以找到内核(操作系统),通过内核级页表找到操作系统的代码和数据对应的物理地址进行访问操作系统的代码和数据,所以当执行系统调用的时候,那么进程就会从地址空间0-3GB的用户空间的正文代码区域跳转到地址空间3-4GB的内核空间去执行
  7. 站在进程视角:我们调用系统调用(系统中的方法),就是在我自己的地址空间上执行的,可是上述的讲解是站在小编假设了进程有权限访问内核空间的基础上进行理解的,可是用户本质上是没有权限访问内核空间的,用户只能访问自己的用户空间,那么关于这个权限又该如何理解呢?
    在这里插入图片描述
  8. 处于用户身份只能访问用户的代码和数据,处于内核身份可以访问操作系统的代码和数据,进程代表着用户,那么进程的初始的身份肯定就是用户身份,即进程处于用户身份只能访问用户的代码和数据,其实在我们的CPU中,有一个寄存器叫做ecs寄存器,这个ecs寄存器中有一个字段,这个字段即两个比特位,那么使用这两个比特位可以表示4种二进制数据,即00,01,10,11,其中二进制00为0,使用00表示的是内核态(内核身份),二进制11为3,使用11表示的是用户态(用户身份)
  9. 进程在CPU上运行,进程初始的时候是用户身份,即在ecs寄存器的字段上,两个比特位为11,表示用户身份,当进程需要执行系统调用的时候,操作系统会检测到进程需要访问内核空间,即访问操作系统的代码和数据,那么就会让CPU执行int 80指令,让进程陷入内核,即将进程的身份从用户身份(用户态)切换为内核身份(内核态),所以此时进程拥有了访问内核空间的权限,即进程拥有了访问操作系统的代码和数据的权限,那么就会去进程地址空间中的3-4GB上去执行系统调用对应的代码,这就类似于共享区代码的执行,只不过执行系统调用添加了权限的约束
  10. 那么当进程执行完系统调用之后此时就该会到原代码的执行位置继续向后执行用户的代码了,但是此时进程的身份还是用户身份,操作系统就会先将进程身份从内核身份切换为用户身份,防止用户以内核身份执行用户代码,因为?以内核身份执行用户代码,如果用户代码中想要窃取或者修改我操作系统的数据呢?所以我操作系统绝对不允许这样的事情发生,所以当进程回到原代码的位置向后执行用户代码的时候,我操作系统必须也一定要先将进程身份从内核身份切换为用户身份

三、理解操作系统

在这里插入图片描述

  1. 那么站在操作系统视角:任何一个时刻,都有进程执行,如果我想要执行操作系统的代码,那么随时都可以执行
    在这里插入图片描述
  2. 我们知道,操作系统是进程的管理者,所有的进程统一由操作系统调度执行,那么进程由操作系统推动,谁又来推动着操作系统调度进程呢?时钟
  3. 其实操作系统的本质是一个基于时钟中断的死循环,如何理解呢?请看下图
    在这里插入图片描述
  4. 在计算机硬件中,有一个时钟芯片,每隔很短很短时间(1纳秒)就给计算机发送中断,即时钟芯片作为硬件可以产生硬件中断发送给CPU,CPU上有针脚可以接收这个硬件中断,当CPU接收到这个硬件中断之后,会根据针脚的序号进而就可以确定中断向量号,这个中断向量号会被CPU内的一个寄存器保存起来,那么此时CPU接收到中断就会唤醒操作系统,操作系统在被唤醒之前在干什么呢?睡觉,是的什么都不做,操作系统在被唤醒之前一直执行pause暂停,即操作系统暂停期间什么都不做
  5. 此时操作系统被唤醒,就回去CPU中保存中断向量号的寄存器中读取中断向量号,然后操作系统就会去它的代码区域的中断向量表中根据中断向量号作为数组下标执行中断向量表中的中断向量方法,此时对应的时钟产生的硬件中断对应的就是进程调度的中断方法,所以此时操作系统就会去执行进程调度的方法(要知道在操作系统角度,任意时刻,都会有进程的执行,所以操作系统在任意时刻都可以找到自己的代码去执行,而它自己的代码中就会有中断向量表,中断向量表中就会指向对应的中断方法)
  6. 那么此时操作系统执行进程调度的方法,那么就会检查一下进程里面的时间片有没有到时间,如果进程的时间片没有到时间,那么我就返回,继续执行我操作系统的pause(),如果此时进程的时间片到时间了,那么就会将目前正在处于CPU上执行的这个进程拿下来,即如果这个进程执行完了,那么就释放对应的资源,如果这个进程没有执行完,那么就保存进程的上下文,将进程放回运行队列中,那么此时操作系统就会根据调度器中的调度算法选出一个处于运行队列中的进程,将这个进程放到CPU上运行即可,然后操作系统就会返回继续执行死循环pause(),所以我们可以说操作系统的本质就是一个基于时钟中断的死循环
  7. 并且我们还可以看出,当进程的时间片到了,但是进程的代码还没有执行完,操作系统就会将这个进程的上下文保存,将进程从CPU拿下来之后放到运行队列中,这个进程切换的过程,此时进程一定是处于内核态,因为操作系统需要去这个进程的内核空间上,找到对应的中断向量表的方法,即进程调度的代码去执行,而要执行内核的代码一定要进程处于内核身份(内核态),并且当进程从运行队列选出来,放到CPU执行,一定是经历了内核态到用户态的过程,因为进程在运行队列中处于内核态,当进程放到CPU上执行的时候,执行的是用户的代码,要执行用户的代码,所以操作系统也一定会进行身份切换,将内核身份切换为用户身份,以用户的身份执行用户代码
  8. 有关从硬件中断到根据中断向量号执行对应的中断向量表的方法,小编在后方蓝字链接有进行详细讲解,第二点预备工作中的键盘数据如何输入给内核,ctrl + c如何变为信号的——谈谈硬件详情请点击<——

四、深度理解信号的处理

  1. 所以下面我们就基于下面的图,深入理解一下信号的处理过程
    在这里插入图片描述

  2. 当进程执行代码执行系统调用的时候,此时操作系统就会让CPU执行int 80指令,将ecs寄存器的字段对应比特位的值修改为00,即内核态,让用户身份从用户身份(用户态)切换为内核身份(内核态),此时进程处于内核态,就可以访问自己进程地址空间上的3-4GB对应的内核空间,去访问操作系统的代码和数据,进而就可以去找到对应系统调用的区域去进行执行系统调用,当执行完系统调用的时候,此时进程处于内核态,那么要返回用户态继续执行用户的代码,就在这个内核态返回用户态的过程中,会进行信号的检测与处理,如何进行检测?检测进程PCB中的三张表即可
    在这里插入图片描述

  3. 首先进程会去检测PCB中的pending信号集,pending信号集中保存着信号,即进程是否收到信号,如果pending信号集的对应比特位位置的内容是1,那么就代表着进程收到了该信号,例如比特位位置是1,那么就代表着1号信号,比特位位置的内容,即比特位是1,那么就代表进程收到了1号信号,反之,如果为0,那么就代表没有收到

  4. 这里我们假设进程收到了1号信号,之后就会去检测block信号集对应位置的比特位,如果比特位为1,那么就代表屏蔽该进行,此时就不会处理该信号,让信号处于未决状态,如果比特位为0,那么就代表没有屏蔽该信号,那么就会去handler表根据比特位位置找到对应的方法去执行,针对不同的方法有两种处理方式

  5. 第一种处理方式:如果在handler表中用户对对应比特位位置的方法进行了自定义捕捉,那么就会和下图一样,即操作系统将进程从内核态切换到用户态,以用户的身份执行用户的自定义方法sighandler,当执行完毕之后,会通过特殊的系统调用,即从用户态陷入内核态,之后再从内核态执行对应的系统调用,从内核态切换到用户态,从main函数的执行系统调用的位置继续向后执行
    在这里插入图片描述

  6. 第二种处理方式:如果用户没有对该信号进行信号捕捉,那么就会执行信号的默认动作,有可能信号的默认处理动作是终止进程,也有可能是暂停进程,具体行为由内核自定义,不需要返回用户态继续执行

  7. 所以说,如果用户对信号进行了捕捉,那么整个信号的处理过程较为不好记忆,那么如何记忆呢?一个正无穷+一根线即可
    在这里插入图片描述

  8. 首先画一个正无穷,即一个躺着的8,然后在正无穷的两个圈交点的上方拉一根横线即可,这根横线的上方是用户态,这个横线的下方是内核态,此时进行对应方向的标注,那么此时根据这个图我们就可以展开理解了,首先从左上方开始,此时正常执行用户代码,但是此时用户调用了系统调用,那么此时就要进行身份切换,从用户态陷入内核态,那么进程去进程地址空间的内核空间执行完成对应的系统调用之后,就要返回用户态

  9. 这时在返回用户态前,即上图蓝色圆圈的位置,进行信号的检测,如何检测?检测PCB中的三张表即可,进行检测,如果没有信号则会直接切换为用户态,从main函数调用系统调用的位置向后继续执行,可是如果收到了信号,并且信号没有被屏蔽,并且用户自定义捕捉了信号,那么就会进行上图红线的操作,即进行身份切换,从内核态切换为用户态,以用户的身份执行用户的sighandler方法,当执行完成后,随着方向继续向后走,执行特殊的系统调用,进行身份切换,从用户态切换为内核态,那么此时就会执行线的方法,执行特殊的系统调用,即再次进行身份切换,从内核态切换为用户态,从main函数调用系统调用的位置向后继续执行

  10. 有的读者友友心中可能会有疑问,小编,小编,如果在用户的代码中没有调用系统调用,那么进程不就不会陷入内核,也就不会有内核态到用户态进行信号的检测与处理的场景了吗?不,还是有场景的,例如:当进程A在CPU上运行,进程A的时间片到了,但是进程A的代码还没有执行完,那么CPU将其放到运行队列中,此时这个进程A处于内核态,后面进行进程调度,当这个进程A再次从运行队列中选出来放到CPU上执行的时候,恰好是内核态到用户态,在这个切换的过程中同样可以进行信号的检测与处理

五、信号的捕捉,sigaction

  1. 如果信号的处理动作是用户自定义函数,那么在信号递达时调用用户自定义函数,我们称之为信号捕捉,其实关于执行用户自定义函数的信号处理动作的流程,小编在上面深度理解信号的处理中已经讲解了,接下来小编就不再过多赘述了
  2. 之前的文章中,小编已经讲解了使用signal信号捕捉函数的使用,那么接下来我们讲解一下另外一个信号捕捉函数sigaction
    在这里插入图片描述
  3. sigaction这个函数第一个参数是signum,即你想要捕捉的信号,第二个参数是一个struct sigaction类型的act,用于传入要设置的自定义方法,以及暂时屏蔽的信号,我们只关心其中的sa_handler以及sa_mask,其中sa_handler是一个函数指针,这不就和signal的第二个参数类似,那么对于sa_mask,小编待会再讲解
  4. sigaction第三个参数同样是一个struct sigaction类型的oldact,用于获取老的数据,如果不想获取,那么默认设置为nullptr即可
  5. 下面我们就来使用一下这个sigaction来对2号信号进行自定义捕捉,对于自定义捕捉后执行的自定义方法handler,那么就打印收到的信号编号即可
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>

using namespace std;

void handler(int signo)
{
    cout << "catch a signal, signo: " << signo << endl;
}

int main()
{
    struct sigaction act, oldact;
    memset(&act, 0, sizeof(act));
    memset(&oldact, 0, sizeof(oldact));

    act.sa_handler = handler;
    
    sigaction(2, &act, &oldact);

    while(true)
    {
        cout << "i am a process, pid: " << getpid() << endl;
        sleep(1);
    }    

    return 0;
}

运行结果如下
在这里插入图片描述
此时就可以将2号信号进行捕获,当进程收到2号信号的时候,那么就会执行用户的自定义动作handler

  1. 我们知道,当进程收到一个信号的时候,会先将pending信号集对应的比特位位置设置为1,表示进程收到了该信号,如果信号没有被阻塞/屏蔽,那么进行信号的递达工作,此时信号已经递达了,那么操作系统肯定要将pending信号集对应的比特位位置设置为0,表示信号已经被递达,那么这个置为0的操作,究竟是在信号递达之前做的,还是在信号递达之后做的呢?我们不清楚,但是我们可以验证一下,如何验证?
  2. 我们在2号信号的捕捉方法中,打印一下pending信号集即可,如果打印的pending信号集中对应的2号对应的比特位是0,那么表示是在信号处理前置0,如果对应2号的比特位是1,那么表示是在信号处理之后置0
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>

using namespace std;


void PrintPending()
{
    sigset_t set;
    sigemptyset(&set);
    sigpending(&set);

    for(int i = 31; i >= 1; i--)
    {
        if(sigismember(&set, i))
            cout << '1';
        else
            cout << '0';
    }
    cout << endl;
}

void handler(int signo)
{
    PrintPending();
    cout << "catch a signal, signo: " << signo << endl;
}

int main()
{
    struct sigaction act, oldact;
    memset(&act, 0, sizeof(act));
    memset(&oldact, 0, sizeof(oldact));

    act.sa_handler = handler;
    
    sigaction(2, &act, &oldact);

    while(true)
    {
        cout << "i am a process, pid: " << getpid() << endl;
        sleep(1);
    }    

    return 0;
}

运行结果如下
在这里插入图片描述
对应的2号比特位上为0

  1. 经过上面的验证,我们得知当进程收到信号的时候,并且信号没有被屏蔽的时候,进行信号递达前,要先将pending信号集对应信号的比特位位置置为0,然后再进行信号的递达
  2. 小编还有一个问题,先看场景:用户对信号进行了捕捉,当进程收到信号的时候,pending信号集对应的比特位位置要设置为1,并且在进行信号递达前要对这个pending信号集设置为0,然后才进行信号的递达,切换到用户态执行用户的自定义方法,那么如果在信号的递达,即信号的处理期间,即此时正在用户的自定义方法,用户调用了printf,我们知道printf的作用是向显示器进行打印,即访问硬件外设显示器,而硬件又归操作系统管理,操作系统不相信任何人,操作系统不允许其它人直接跨过操作系统访问它所管理的硬件,所以当想要访问硬件的时候,操作系统会提供对应的系统调用进行使用
  3. 所以printf的底层必定封装了系统调用,那么printf如果想要访问系统调用,系统调用属于操作系统的代码,所以访问系统调用的本质就是访问操作系统的代码和数据,即访问内核空间,那么此时进程处于用户态,身份是用户身份,用户身份不能访问内核空间,用户身份不能访问操作系统的代码和数据,所以此时操作系统会给当前进程切换身份,即从用户态切换成内核态,所以此时进程以内核身份就可以访问内核空间,即访问操作系统的代码和数据,当访问完成之后,要返回用户的自定义方法中继续执行,所以操作系统此时就势必会让进程以用户身份执行用户的代码,所以操作系统此时势必会再次将进程从内核态切换为用户态,此时不就可以符合信号检测的场景了
    在这里插入图片描述
  4. 如果此时不断的给进程发送该信号,那么此时不就会一直的陷入嵌套调用用户的自定义方法了,并且对于操作系统来讲可能还要疲于切换用户态内核态,明明我操作系统已经在处理这个信号,正在调用用户的自定义函数了,而你还不断的给我发送这个信号,让我一直不断嵌套调用处理这个信号,所以我操作系统期望在处理这个信号期间如果这个信号又发送过来了,我不响应这个信号,等到我处理完成了这个信号之后再进行检测处理这个信号,即操作系统期望同时时间只会处理一个信号,那么如何做呢?
  5. 很简单,在操作系统处理递达这个信号之前,将这个信号暂时屏蔽即可,那么如果在操作系统处理这个信号期间,那么如果再次收到这个信号,那么这个信号由于已经暂时被屏蔽了,那么此时这个收到的信号只能在pending信号表中将对应位置设置为1,即信号处理未决状态,那么此时操作系统处理递达这个信号的期间,就不怕再有信号持续不断的发送了
  6. 其实操作系统中也正是这样设计的,那么下面我们来验证一下,首先我们对2号信号进行自定义捕捉,handler方法中,打印一条语句之后,就一直睡眠,如果我们发送了2号信号之后,程序陷入睡眠,表示此时操作系统已经递达处理这个信号,如果此时我在不断的按ctrl+c给进程发2号信号,那么观察是否还会捕获2号信号打印语句
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>

using namespace std;

void handler(int signo)
{
    cout << "catch a signal, signo: " << signo << endl;

    while(true)
    {
        sleep(1);
    }
}

int main()
{
    struct sigaction act, oldact;
    memset(&act, 0, sizeof(act));
    memset(&oldact, 0, sizeof(oldact));

    act.sa_handler = handler;
    
    sigaction(2, &act, &oldact);

    while(true)
    {
        cout << "i am a process, pid: " << getpid() << endl;
        sleep(1);
    }    

    return 0;
}

运行结果如下
在这里插入图片描述
如上小编发送了2号信号,程序陷入睡眠,说明此时信号已经被递达,按照我们的猜想,此时进程已经对2号信号进行了暂时性屏蔽,如果接下来我持续不断的发送2号信号,那么此时应该不响应
在这里插入图片描述
果然,此时我不断的发送2号信号,那么此时并没有处理2号信号,也并没有打印对应的语句

  1. 所以如上我们验证了,确实,当进程收到一个信号的时候,当这个信号要处理递达前,那么此时进程就要暂时性的屏蔽这个信号,即此时信号属于未决状态,那么既然此时信号属于未决状态,那么对应这个信号的pending信号集中的对应比特位位置应该设置为1,所以我们验证一下,捕获一下2号信号,当进程收到2号信号的时候,自定义动作我们间隔1秒不断的打印一下进程的pending信号集,那么此时按照预期应该是全0,因为进程在递达2号信号之前会将pending信号集的对应位置置为0,并且进程在递达2号信号之前同样也会将2号信号暂时性的屏蔽,所以此时我们再不断的发送2号信号,由于2号信号已经被暂时性的屏蔽,那么此时再次收到的2号信号就应该处于未决状态,即pending信号集打印对应的2号比特位位置应该为1,所以我们下面再次验证一下
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>

using namespace std;


void PrintPending()
{
    sigset_t set;
    sigemptyset(&set);
    sigpending(&set);

    for(int i = 31; i >= 1; i--)
    {
        if(sigismember(&set, i))
            cout << '1';
        else
            cout << '0';
    }
    cout << endl;
}

void handler(int signo)
{
    cout << "catch a signal, signo: " << signo << endl;

    while(true)
    {
        PrintPending();
        sleep(1);
    }
}

int main()
{
    struct sigaction act, oldact;
    memset(&act, 0, sizeof(act));
    memset(&oldact, 0, sizeof(oldact));

    act.sa_handler = handler;
    
    sigaction(2, &act, &oldact);

    while(true)
    {
        cout << "i am a process, pid: " << getpid() << endl;
        sleep(1);
    }    

    return 0;
}

运行结果如下
在这里插入图片描述
如上2号比特位位置为1,确实当我们再次发送2号信号的时候,此时由于2号信号被暂时性屏蔽,所以再次收到的2号信号只能处于未决状态

  1. 那么接下来我们再次验证一下,我们自定义捕捉一下信号,当进程收到信号后,那么此时pending信号集中对应信号的比特位位置应该为1,那么在信号在被递达之前pending信号集的比特位位置应该要被置为0,并且将block信号集中对应的信号的比特位位置设置为0,即对信号进行屏蔽暂时性,那么此时再次收到该信号后,该信号由于已经被屏蔽,会处于未决状态,但是当信号处理递达完成之后,是否会解除对该信号的暂时性屏蔽,并且再次处理处于未决状态的该信号呢?
  2. 所以小编就将信号的自定义处理动作的死循环打印pending信号集使用一个cnt计数,当cnt小于7之前,小编给进程发送对应的2号信号,所以此时再次发送的2号信号是在pending信号集中的对应的比特位位置要设置为1,即处于未决状态,当cnt == 7的时候,那么就退出死循环,所以此时第一次的信号就会递达处理完成,接下来我们仅需要观察,是否再次收到的信号会处理即可,并且自定义动作每次信号进行递达的第一条都是打印语句,所以我们仅需要观察是否有语句打印,并且以及对应的pending信号集是否打印了即可
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>

using namespace std;


void PrintPending()
{
    sigset_t set;
    sigemptyset(&set);
    sigpending(&set);

    for(int i = 31; i >= 1; i--)
    {
        if(sigismember(&set, i))
            cout << '1';
        else
            cout << '0';
    }
    cout << endl;
}

void handler(int signo)
{
    cout << "catch a signal, signo: " << signo << endl;

    int cnt = 0;
    while(true)
    {
        PrintPending();
        sleep(1);
        cnt++;
        if(cnt == 7)
            break;
    }
}

int main()
{
    struct sigaction act, oldact;
    memset(&act, 0, sizeof(act));
    memset(&oldact, 0, sizeof(oldact));

    act.sa_handler = handler;
    
    sigaction(2, &act, &oldact);

    while(true)
    {
        cout << "i am a process, pid: " << getpid() << endl;
        sleep(1);
    }    

    return 0;
}

运行结果如下
在这里插入图片描述
对应的语句打印了,并且pending信号集也打印了,同时我们还可以看出,小编发送了3次2号信号,那么仅仅会被当作一次进行处理,这是很正常的,因为pending信号集中只有一个比特位用于保存2号信号,所以发送了多次也只会当作一次进行处理,注意这是建立在2号信号正在处理期间,并且2号信号已经被屏蔽的情况下,发送的多次2号信号,2号信号被屏蔽了,因为pending信号集中只有一个比特位用于保存2号信号,所以发送了多次也只会当作一次进行保存处理

  1. 现在有了前面知识的铺垫,那么接下来小编终于可以给大家揭露类型为struct sigaction类型中的类型为sigset_t类型的成员sa_mask的面纱了
    在这里插入图片描述
  2. 当我们对2号信号进行了自定义捕捉,捕捉方法为间隔1秒打印pending信号集,当2号信号被递达前,那么首先要将block信号集中对应2号信号的比特位位置置为1,表示对2号暂时性阻塞,可是如果我作为用户,在2号信号递达前不仅仅想要屏蔽2号信号,我还想要屏蔽更多的信号,例如顺带的将1,3,4号信号进行暂时性屏蔽,又该如何做呢?定义一个sigset_t类型的成员set,然后将想要进行屏蔽的信号进行设置,然后传参给sigset_t类型的成员sa_mask即可
  3. 所以此时我将程序运行起来,那么我ctrl + c给进程发送2号信号,此时进程收到2号信号,那么在2号信号在被处理之前,那么就应该将2号信号进行了屏蔽,并且小编还使用sigset_t类型的成员sa_mask对1,3,4号信号进行了屏蔽,所以当2号信号被递达的时候,那么我使用kill指令给进程发送1,2,3,4号进程,那么就应该观察到打印的pending信号集中第1,2,3,4号比特位应该为1,其余为0的情况
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>

using namespace std;

void PrintPending()
{
    sigset_t set;
    sigemptyset(&set);
    sigpending(&set);

    for(int i = 31; i >= 1; i--)
    {
        if(sigismember(&set, i))
            cout << '1';
        else
            cout << '0';
    }
    cout << endl;
}

void handler(int signo)
{
    cout << "catch a signal, signo: " << signo << endl;

    while(true)
    {
        PrintPending();
        sleep(1);
    }
}

int main()
{
    struct sigaction act, oldact;
    memset(&act, 0, sizeof(act));
    memset(&oldact, 0, sizeof(oldact));

    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, 1);
    sigaddset(&set, 3);
    sigaddset(&set, 4);

    act.sa_handler = handler;
    act.sa_mask = set;
    
    sigaction(2, &act, &oldact);

    while(true)
    {
        cout << "i am a process, pid: " << getpid() << endl;
        sleep(1);
    }    

    return 0;
}

运行结果如下
在这里插入图片描述
无误,sigset_t类型的成员sa_mask确实可以让用户,在信号被递达前,对多个信号进行暂时屏蔽的效果

六、可重入函数

  1. 首先了解一下单链表的头插
    在这里插入图片描述
  2. 如上,本来单链表是只有一个节点NodeB,同时还有一个节点的指针head指向NodeB,现在进行头插一个NodeA节点,那么先执行(1)让NodeA的next指针指向head指针所指向的NodeB,那么此时单链表的头节点就是NodeA,接下来执行(2)让head指针指向当前单链表的头节点即可,很容易理解
    在这里插入图片描述
  3. 那么假设有现在这样的场景,main函数中给单链表的头插insert一个节点,信号捕捉的自定义函数handler中也给单链表的头插insert一个节点,并且main函数和自定义函数handler中头插insert的是同一个单链表,这有没有可能,完全有可能,将单链表定义成全局的即可
  4. 规定:上图0中head开始指向的节点的名字叫做node节点,所以当我main函数执行头插上图中的步骤一:node1,那么刚将node1指向head指向的节点,那么此时进程接收到信号,刚好用户之前就已经对这个信号进行了自定义捕捉执行handler方法,并且恰好此时进程的时间片到了,那么此时head就来不及指向新的头节点node1,main函数的执行流就已经被保存到进程的上下文中带走了,并且将进程放到了运行队列中,几轮后经过调度器的调度,又将该进程选出来放到CPU上运行,此时恰好是内核态到用户态的切换
    在这里插入图片描述
  5. 那么此时恰恰符合上图信号检测的时机,那么此时进行信号的检测,收到了一个信号,那么就会执行信号对应的handler方法,进行头插(注意,此时main函数的执行流处于中断,还未继续向后执行),所以就执行handler方法
    在这里插入图片描述
  6. handler方法中,要对单链表进行头插node2,此时head指向的是仍然是中间的node节点,那么此时信号的执行流中开始进行头插node2,那么就是执行上图中的步骤2,将先将node2的next指向head指针指向的node,那么接下来执行步骤3,将head指向当前单链表的头节点node2,此时信号的执行流中进行头插完毕,那么切换成内核态,再切换成用户态,开始执行main函数中的执行流(注意handler和main函数是两个独立的栈帧,所以有两个独立的执行流很正常)
  7. 那么此时就会执行main函数中的执行流,还记得main函数中的执行流执行到哪里了吗?即将执行上图中的步骤4,head指向当前链表的头节点node1,那么此时读者友友们有没有意识到什么问题?是的,没有错,node2节点丢失,内存泄漏
  8. 上图中的现象,即insert函数被main函数和handler函数执行流重复进入,造成节点丢失,内存泄漏
  9. 如果一个函数,被重复进入的情况下,出错了,或者可能出错,那么我们就称这个函数为不可重入函数,目前我们学过的大部分函数都是不可重入函数
  10. 同样的,如果一个函数,被重复进入的情况下,不可能出错,那么我们就称这个函数为可重入函数

七、volatile

  1. 我们可以设置全局变量标志位flag = 0,当main函数中使用标志位while(!flag)执行死循环的时候,可以使用signal自定义捕捉信号,当捕捉到对应的信号的时候,例如:2号信号,那么此时就执行handler方法,handler方法中有将标志位设置为1,那么此时main函数中的死循环while(!flag)就会退出,所以main函数就会正常向后执行,进程正常退出
  2. 所以类似的,我们就可以使用ctrl+c以发送2号信号形式退出main函数中的死循环,进而就可以结束进程
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>

using namespace std;

int flag = 0;

void handler(int signo)
{
    cout << "catch a signal, signo: " << signo << endl;
    flag = 1;
}

int main()
{
    cout << "process begin run" << endl;
    signal(2, handler);
    cout << "process use signal set 2 in handler" << endl;

    while(!flag);

    cout << "process quit normal" << endl;

    return 0;
}

运行结果如下
在这里插入图片描述
此时进程已经使用signal函数对2号信号进行了设置,并且此时进程正在执行while(!flag)死循环
在这里插入图片描述
那么小编使用ctrl+c给进程发送了2号信号之后,此时执行handler方法,flag设置为1,那么main函数中的while(!flag)就会结束死循环,进而进程就会正常退出

  1. 下面是g++中的几个优化选项,其中-03的优化程度已经够高了,我们下面使用g++的-03选项去优化编译源文件
    在这里插入图片描述
mysignal:mysignal.cc
	g++ $^ -o $@ -O3 -std=c++11

.PHONY:clean
clean:
	rm -f mysignal

运行结果如下
那么小编加上-O3这个编译选项,重新编译形成可执行
在这里插入图片描述
那么我们将可执行程序跑起来之后,此时进程已经使用signal函数对2号信号进行了设置,并且此时进程正在执行while(!flag)死循环
在这里插入图片描述
此时小编使用ctrl+c给进程发送2号信号之后,奇怪!!!进程居然没有退出,也就是说while(!flag)仍然在运行,那么说明!flag为真,即此时flag仍然为0,变相的说明信号捕捉中执行的handler函数中的flag = 1在main函数中没有起到作用,那么这究竟是什么原因呢?

  1. 其实是由于编译器的优化造成的,观察下面main函数的代码
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>

using namespace std;

int flag = 0;

void handler(int signo)
{
    cout << "catch a signal, signo: " << signo << endl;
    flag = 1;
}

int main()
{
    cout << "process begin run" << endl;
    signal(2, handler);
    cout << "process use signal set 2 in handler" << endl;

    while(!flag);

    cout << "process quit normal" << endl;

    return 0;
}

在这里插入图片描述

  1. 上述代码,本质上我们没有进行修改flag,仅仅只是将flag进行逻辑判断,所以在我们给g++添加-O3优化选项之后,那么此时编译器就会告诉CPU,这个flag在main函数没有进行修改,仅仅是进行了逻辑判断,而一般CPU拿数据都是从物理内存中拿数据,但是访问内存也是需要消耗时间的,所以进行了优化之后,CPU为了提高访问内存效率,针对这个flag会将flag的值0拷贝到CPU的寄存器中,那么此时CPU每次想要拿flag,那么就不再需要从内存中取了,而是直接从寄存器中拿数据,那么此时CPU每次拿flag的数据都是0
  2. 所以此时小编给进程发送2号信号,那么就会执行自定义捕捉方法,将物理内存中的flag修改为1,那么此时CPU执行while(!flag)再进行判断,但是由于进行了优化,CPU想要拿数据不会从物理内存中拿flag的值了,而是从自己的寄存器中拿flag的值0,但是这一拿不要紧,那么这就造成了无论你物理内存中的flag的值变成什么,我CPU的从自己寄存器中拿到的flag永远是0,所以这也就造成了!flag == 1永远成立,所以while(!flag)也永远成立,while循环永远退不出去,所以进程也就无法通过2号信号的方式正常退出了,所以因为编译器的优化,让CPU和物理内存形成了一个屏障,最终造成了内存的不可见
  3. 那么该如何解决呢?此时关键字volatile就可以闪亮登场了,被volatile修饰的变量,可以防止变量被编译器过度优化,保持内存的可见性,即让CPU取被volatile修饰的变量的值的时候,每次都访问物理内存,从物理内存中取被volatile修饰的变量的值,所以这样就打破了CPU和物理内存的屏障,所以我们现在使用volatile修饰全局变量flag观察现象
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>

using namespace std;

volatile int flag = 0;

void handler(int signo)
{
    cout << "catch a signal, signo: " << signo << endl;
    flag = 1;
}

int main()
{
    cout << "process begin run" << endl;
    signal(2, handler);
    cout << "process use signal set 2 in handler" << endl;

    while(!flag);

    cout << "process quit normal" << endl;

    return 0;
}

运行结果如下
在这里插入图片描述
所以尽管此时使用了g++加上-O3的优化选项编译形成可执行程序
在这里插入图片描述
我们使用ctrl+c仍然可以正常退出程序

八、SIGCHLD信号

  1. 回忆起我们之前学习进程等待的时候,详情请点击<——,小编讲解到,当子进程退出的时候,父进程需要阻塞式等待或者非阻塞式轮询等待子进程的退出,否则子进程就会变成僵尸进程,造成资源泄漏
  2. 那么子进程退出的时候,是静悄悄的退出吗?不是,相反子进程在退出的时候要给父进程发送17号信号,即SIGCHLD,如何证明呢?
    在这里插入图片描述
  3. 那么我们就使用handler对17号信号捕捉一下,捕捉方法是handler方法,即打印收到的信号编号,子进程打印信息,5秒时候子进程退出,当子进程退出的时候,看是否父进程对17号信号进行打印
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;

void handler(int signo)
{
    cout << "i am father, my pid: " << getpid() << ", i catch a signal, signo: " << signo << endl;
}

int main()
{
    signal(17, handler);

    pid_t id = fork();
    if(id == 0)
    {
        //child;
        int cnt = 5;
        while(cnt--)
        {
            cout << "i am child, my pid: " << getpid() << " my ppid: " << getppid() << endl;
            sleep(1);
        }
        exit(0);
    }
    //father
    int rid = waitpid(id, nullptr, 0);
    if(rid == id)
    {
        cout << "wait child success, child pid: " << rid << endl;
    }

    return 0;
}

运行结果如下
在这里插入图片描述
此时父进程确实是执行的handler方法,打印了收到的信号编号17号,代表着子进程退出的时候确实是给父进程发送了17号信号

  1. 那么我们不妨摒弃之前的等待子进程的方法,让父进程做自己的事情,既然子进程退出的时候是会给父进程发送17号信号,那么我父进程完全可以提前捕捉17号信号,收到17号信号的时候,在handler方法内对子进程进行回收即可,但是这个过程必须保证父进程的运行时间比子进程的时间长,即父进程要晚于子进程退出,如果父进程早于子进程退出,那儿可怜的子进程不就成了孤儿进程了,所以父进程必须晚于子进程退出,那么子进程打印信息,运行5秒之后退出即可,由于父进程必须晚于子进程退出,所以我们让父进程死循环睡眠即可
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;

void handler(int signo)
{
    int rid = waitpid(-1, nullptr, 0);//由于此时无法获取子进程的pid,所以设置-1等待任意一个子进程
    if(rid > 0)
    {
        cout << "i am father, my pid: " << getpid() << ", i wait child success, child pid: " << rid << endl;
    }
}

int main()
{
    signal(17, handler);

    pid_t id = fork();
    if(id == 0)
    {
        //child;
        int cnt = 5;
        while(cnt--)
        {
            cout << "i am child, my pid: " << getpid() << " my ppid: " << getppid() << endl;
            sleep(1);
        }
        exit(0);
    }
    //father
    while(true)
    {
        sleep(1);
    }

    return 0;
}

运行结果如下
在这里插入图片描述
此时父进程就可以做自己的事情,基于子进程发送17号信号,父进程捕获17号信号,handler方法再对子进程进行回收

  1. 可是今天如果有10个子进程呢?10个子进程同时退出,那么此时10个子进程会同时给父进程发送17号信号,那么此时如果采用上面的方式那么同一时间只能有一个子进程发送的17号信号被递达,在这一个子进程的17号信号在被递达前,要对17号信号进行屏蔽,那么其它子进程发送17号信号就会被屏蔽,即此时当一个子进程的17号被递达处理期间,进程收到的其它的9个子进程发送的17号信号,只能被屏蔽,即处于未决状态,并且进程收到的这9个信号只能最终被保存一个,因为pending信号集中对应的用于保存17号信号的比特位只有1个,所以9号信号只能被保存一个
  2. 那么当最开始的那一个子进程发送的17号信号被handler处理完成之后,最开始的那一个子进程被回收释放了,之后接触对17号信号的屏蔽,此时pending信号即保存的17号信号会被递达,那么就会回收等待任意一个子进程,所以10个子进程,最终只能有2个子进程被回收释放,这固然会造成其它的8个子进程无法被回收称为僵尸进程,造成资源泄漏,那么我们应该如何处理呢?
  3. 很简单,非阻塞式轮询+循环等待即可,当父进程收到了17号信号的时候,那么此时一定代表着有子进程退出,因为正是子进程的退出,子进程会给父进程发送17号信号,父进程才会收到17号信号,所以父进程就可以执行while循环不断的采用非阻塞轮询+循环的方式执行waitpid等待任意一个子进程,while判断waitpid的返回值,当waitpid的返回值大于0的时候才继续运行继续等待,那么当等待完成当前退出的子进程之后,如果再有子进程退出,那么此时这个while的判断条件waitpid会直接对退出的子进程进行回收,当没有子进程退出的时候,那么waitpid就会返回0,此时就会退出while循环,结束对信号的捕捉handler
  4. 同样的就算子进程是分多批次不同时间段退出的,那么父进程也不怕,但是始终要保持一点,即父进程一定要晚于所有子进程退出,当不同时间段,子进程退出,那么对于父进程来讲无所谓,来便是,那么子进程退出给父进程发送17号信号,父进程收到信号执行handler回收释放子进程,当同一时间段没有子进程再次退出之后,那么由于是非阻塞轮询+循环所以返回值为0,那么就会退出循环,结束对子进程的等待,几秒后很多个子进程退出,给父进程发送17号信号那么父进程执行handler轮询式等待多个子进程即可,也就是说一个非阻塞轮询+循环可以再同一时间处理等待释放多个子进程,并且每一个子进程进行退出的时候都是要给父进程发送17号信号的,所以无论子进程是同一时间还是不同时间,我父进程使用非阻塞轮询+循环+信号的方式都可以把握的住
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;

void handler(int signo)
{
    pid_t rid = 0;
    while((rid = waitpid(-1, nullptr, WNOHANG)) > 0)
    {
        cout << "i am father, my pid: " << getpid() << ", i wait child success, child pid: " << rid << endl;
    }
}

int main()
{
    signal(17, handler);

    for(int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if(id == 0)
        {
            //child;
            int cnt = 1;
            while(cnt--)
            {
                cout << "i am child, my pid: " << getpid() << " my ppid: " << getppid() << endl;
                sleep(1);
            }
            exit(0);
        }
    }
    //father
    while(true)
    {
        sleep(1);
    }

    return 0;
}

运行结果如下
在这里插入图片描述
此时父进程就可以实现完美的等待多个子进程了

  1. 小编,小编,还有没有更厉害的等待方式,有,父进程如果不关心子进程的退出信息,即退出码,退出信号等,那么可以将等待释放子进程的这个工作交给操作系统来做,并且子进程在这个过程中也不会变成僵尸进程,而是当子进程退出的时候,会瞬间直接被操作系统回收释放,那么如何开启这个法宝呢?对SIGCHLD这个17号信号设置处理方式为忽略SIG_IGN即可,SIGCHLD,其中的CHLD是child的意思
  2. 使用监控脚本监视子进程有没有变成僵尸进程
while :; do ps axj | head -1 && ps axj | grep mysignal | grep -v grep; sleep 1; done
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;

int main()
{
    signal(17, SIG_IGN);

    for(int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if(id == 0)
        {
            //child;
            int cnt = 1;
            while(cnt--)
            {
                cout << "i am child, my pid: " << getpid() << " my ppid: " << getppid() << endl;
                sleep(1);
            }
            exit(0);
        }
    }
    //father
    while(true)
    {
        sleep(1);
    }

    return 0;
}

运行结果如下
在这里插入图片描述
此时父进程无需等待子进程,父进程只需要专心的做自己的事情即可,那么子进程退出的时候由操作系统进行子进程的回收以及资源释放的工作,并且子进程也没有变成僵尸进程,但是要注意,必须保证父进程退出时间要晚于子进程

  1. 此时很多读者友友心中不禁有一个疑问:小编,小编,那么既然这里父进程对子进程进行了忽略,那么子进程就不会变成僵尸进程,并且可以进行正常的退出,那么我们之前的学士的时候,我们也“忽略”没有管子进程,可是为什么子进程就会变成僵尸进程呢?
  2. 注意此忽略非彼“忽略”,信号的处理方式有三种:1. 默认动作,2. 忽略动作,3. 自定义动作,这里我们是对17号信号signal设置了,将17号信号的处理动作从默认动作设置为了是忽略动作,父进程忽略了子进程,所以操作系统会帮助父进程完成子进程的回收以及资源释放的工作
  3. 而如果我们不对17号信号进行捕捉,那么父进程对17号信号的处理动作是默认动作,默认动作的动作是忽略子进程,所以操作系统会认为你父进程对子进程的17号信号的处理动作是默认动作,操作系统认为你父进程对子进程进行默认动作的处理了,所以操作系统就不会帮助你完成子进程的回收资源释放的工作,而此时你父进程的代码中,根本没有wait/watipid等待子进程,所以子进程才会变成僵尸进程,期望让父进程回收子进程,获取子进程
    在这里插入图片描述
  4. 注意:处理动作是默认动作是进行忽略,和处理动作是忽略动作是两个维度的事物,请读者友友注意区分
  5. 同样的如果父进程需要获取子进程的退出信息,退出码,退出信号的话,那么父进程必须自己wait/waitpid等待子进程,如果父进程不需要获取子进程退出信息,那么我们大可以采用signal对17号信号设置处理动作为忽略SIG_IGN,让操作系统进行子进程的回收与资源释放工作,并且父进程此时还可以专心做自己的事情,岂不美哉

总结

以上就是今天的博客内容啦,希望对读者朋友们有帮助
水滴石穿,坚持就是胜利,读者朋友们可以点个关注
点赞收藏加关注,找到小编不迷路!

评论 30
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值