linux进程信号

进程信号

1. 关于信号的概念和前提

什么叫做信号呢? 以生活中信号为例, 比如红绿灯, 闹钟, 下课铃, 鸡叫, 手势, 外卖电话…

下面以生活中的信号为例, 来得出一批结论

  1. 比如红绿灯,红灯亮的时候,会有匹配的动作;你为什么会有这个动作呢? 因为曾经有人/有事 “培养"过你 — 所以在信号没有产生的时候,我们也直到该怎么处理它
  2. 进程就是我,信号就是一个数字,进程在没有收到信号的时候,其实它早就已经能够知道一个信号该怎么处理了,即它能够处理并识别一个信号, 因为程序员设计进程的时候,早就已经设计了对信号的识别能力
  3. 比如你网购了某件东西, 当快递员到了你楼下,你也收到快递到来的通知时,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成"在合适的时候去取"。在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你"记住了有一个快递要取"。— 信号也可能随时产生,所以在产生信号前,进程可能正在做优先级更高的事情,可能不能马上处理这个信号, 需要在后续合适的时间进行处理,那么由信号产生到信号处理之间就会存在一个时间窗口,这段时间内进程没有处理这个信号,就需要进程具有记录信号的能力
  4. 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔到床头,继续打游戏)— 处理信号的3种方式: 1. 默认动作 2. 忽略信号 3. 用户自定义动作
  5. 快递到来的整个过程,对你来讲是异步的,你不能准确判断快递员什么时候给你打电话 — 信号的产生对进程来讲是异步的
  6. 进程该如何记录对应产生的信号? 先描述再组织;如何描述一个信号呢?由0和1比特位来表示信号的有无;用什么数据结构管理这个信号呢?位图,所以在task_struct 内部必定要存在一个位图结构,用int表示: uint32_t signals: 0000 0000 0000 0000 0000 0000 0000 0000
  7. 所谓的发信号,本质其实是写入信号,直接修改特定进程的信号位图中特定的比特位,由0置1即可
  8. task_struct数据内核结构,只能由OS进行修改,即无论后面我们有多少种信号产生的方式,最终都必须让OS来完成最后的发送过程

2. 用kill -l命令察看系统定义的信号列表

在这里插入图片描述

  • 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义#define SIGINT 2
  • 编号1~31的我们称为普通信号,收到此类信号后只记录有无产生;编号34以上的是实时信号,此博客只讨论普通信号。

3. 信号的捕捉 - signal

#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);//signum:被捕捉的信号编号;handler:对指定的信号设置自定义动作
handler设置为SIG_DFL表示信号默认处理方式,SIG_ING设置为忽略处理
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<stdio.h>
#include<stdlib.h>
using namespace std;

//自定义方法
// signo: 特定信号被发送给当前进程的时候, 执行handler方法的时候, 要自动填充对应的信号给handler方法
// 我们甚至可以给所有信号设置同一个处理函数
void handler(int signo)
{
    cout<<"get a signal: "<<signo<<endl;
}

int main()
{
    // 1. 2号信号, 进程的默认动作是终止进程
    // 2. signal 可以进行对指定的信号设自定义动作
    // 3. signal(2, handler)调用完这个函数的时候, handler方法被调用了吗? 没有! 做了什么呢? 只是更改了2号信号的处理动作, 并没有调用handler方法
    // 4. 那么handler方法, 什么时候被调用? 当2号信号产生的时候!
    // 5. 默认我们对2号信号的处理动作: 终止进程, 我们用signal(2,handler), 我们在执行用户动作的自定义捕捉!
    // 6. 9号信号无法自定义捕捉

    signal(2,handler);

    while(true)
    {
        cout<<"我是一个进程, 我正在运行... pid: "<<getpid()<<endl;
        sleep(1);
    }
    return 0;
}

运行结果:

在这里插入图片描述

  • 外部发送kill -2 pid或者键盘ctrl+c都行, 默认我们对2号信号的处理动作: 终止进程, 我们用signal(2,handler), 我们在执行用户动作的自定义捕捉。
  • 9号和19号信号无法被捕捉。kill -9杀死进程,kill -19暂停进程。

4. 信号的产生

4.1 通过终端按键产生信号

比如我们写了一个死循环

当按下Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程前台进程因为收到信号,进而引起进程退出

在这里插入图片描述

如下,将一个可执行程序运行命令后面加个&可以放到后台运行, 这样的进程称为后台进程, 我们无法使用Ctrl-C键来终止它,那么如何终止呢?就需要我们调用系统调用函数来终止

在这里插入图片描述

  1. Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。

  2. Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像Ctrl-C这种控制键产生的信号。

键盘知识补充

当我们在键盘上按下对应键后通过8259到cpu的某个针脚上,这个针脚会有对应的中断号,知道中断号后去OS内中断向量表中查找对应下标,执行对应方法即从键盘中读取对应的数据就可以判断键盘那些位置被按下

在这里插入图片描述

4.2 调用系统函数向进程发信号

4.2.1 使用命令行参数模拟实现kill命令

kill函数

KILL(2) 
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);//pid:目标进程的pid。sig:几号信号
成功时(至少发送了一个信号) ,返回零。出现错误时,返回 -1设置errno

mykill.cc

#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<cassert>
#include<cstring>
#include<errno.h>
using namespace std;

//自己实现kill命令 --- 系统调用

//使用手册
void Usage(string proc)
{
    cout<<"Usage: \n\t";
    cout<<proc<<" 信号编号 目标进程\n "<<endl;
}

// ./mykill  所发信号  所发进程pid    --- 要使用命令行参数

int main(int argc,char*argv[])
{
    if(argc!=3)
    {
        Usage(argv[0]);
        exit(1);
    }

    //字符串转整数 --- atoi
    int signo = atoi(argv[1]);     
    int target_id = atoi(argv[2]);       //目标进程
    int n = kill(target_id, signo);
    if(n!=0)
    {
        cerr<<"errno"<<" : "<<strerror(errno)<<endl;
        exit(2);
    }
}

loop.cc

#include<iostream>
#include<unistd.h>
using namespace std;

int main()
{
    while(true)
    {
        cout<<"我是一个进程, 我正在运行... pid: "<<getpid()<<endl;
        sleep(1);
    }
}

运行结果:

在这里插入图片描述

4.2.2 raise() — 进程自己给自己发任意信号
RAISE(3)
#include <signal.h>
int raise(int sig);  //sig:信号编号
raise()在成功时返回0,在失败时返回非0

raise()谁调用我,我就给谁发信号

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

int main(int argc,char*argv[])
{
    signal(2,handler);
    sleep(1);
    raise(2);   //谁调用我, 我就给谁发2号信号
}

运行结果:

在这里插入图片描述

4.2.3 abort() — 进程自己给自己发6号信号
#include <stdlib.h>
void abort(void);
就像exit函数一样,abort函数总是会成功的,所以没有返回值

abort函数使当前进程接收到信号而异常终止

void handler(int signo)
{
    cout<<"get a signal: "<<signo<<endl;
}
int main(int argc,char*argv[])
{
    signal(6,handler);

    cout<<"begin "<<endl;
    sleep(1);
    abort();   //给自己发送指定的信号
    cout<<"end"<<endl;
}

运行结果:

在这里插入图片描述

4.3 由软件条件产生信号

十四号信号SIGALRM(定时器)

#include <unistd.h>
unsigned int alarm(unsigned int seconds);    返回值为定时器剩余的秒数(可能会被提前唤醒)
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动
作是终止当前进程。
alarm(0)表示取消之前设定的闹钟

验证提前让闹钟响,会返回剩余秒数

void handler(int signo)
{
    cout<<"pid: "<< getpid() <<endl;
    cout<<"get a signal: "<<signo<< "count: "<<count<<endl;
    int n= alarm(10);   //重新再设置闹钟
    cout<<"return: "<<n<<endl;
}
int main(int argc,char*argv[])     //闹钟自举的过程
{
    signal(SIGALRM,handler);
    alarm(10);   //一次性的

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

运行结果:

在这里插入图片描述

4.4 由硬件异常产生信号

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。

4.4.1 8号信号SIGFPE(除0错误可引发)

我们写一个有除0错误的代码, 编译后会有警告,运行后会出现Floating point exception(浮点数溢出)

在这里插入图片描述

那么为什么会提示Floating point exception,本质是由于除0错误触发了硬件异常,OS会向目标进程发送了8号信号SIGFPE

在这里插入图片描述

我们像上面一样去自定义捕捉这个信号

在这里插入图片描述

编译运行后发现,屏幕上疯狂死循环打印这句话,并没有打印"div zero … here",对于这个运行结果提出以下两个问题

  1. 为什么会死循环打印一句话

这个代码中有除0错误,溢出标志位由0置1,此进程并没有退出,而这个状态标志位也属于进程的上下文,出现异常后,OS会识别出异常向此进程发信号,但是此进程并没有去修复溢出标志位同时未退出,对应被置1的溢出标志位一直都在(即硬件异常还存在),所以OS会一直给此进程发送信号。

  1. 此进程为什么没有退出

第一个代码有除0错误时,运行后会出现Floating point exception随之进程退出这里执行的是默认的退出动作,此代码中做了自定义捕捉动作,进程的处理动作不再是终止进程,而是打印出一句话后向后运行,此进程在刚向后运行前,OS会立马识别到硬件异常,阻止向后运行,OS继续发送信号

4.4.2 11号信号SIGSEGV(段错误可引发)

我们写一个有野指针问题的代码, 编译运行后会出现Segmentation fault(段错误)

在这里插入图片描述

那么为什么会提示Segmentation fault,本质是由于野指针问题触发了硬件异常,OS会向目标进程发送了11号信号SIGSEGV

在这里插入图片描述

我们像上面一样去自定义捕捉这个信号

在这里插入图片描述

编译运行后发现,运行结果和上面相似,而且两个问题答案也与上面相同

5. core Dump(核心转储)

5.1 什么是core dump

当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做core dump。

5.2 查看core dump文件

先回顾一下在进程等待部分中wait和waitpid函数中的status参数

status参数我们只看尾部16个bit位,因此我们将这些拿出来,

进程正常退出时:

0~7位返回0代表正常的终止信号(返回0证明没有出问题),8~15次低8位代表子进程对应的退出码。

进程异常退出时:

我们不关心进程的退出码,0~7位返回0代表异常的终止信号,第7位就是core dump标志位,当出现异常后core dump标志位会由0置1

在这里插入图片描述

先说一下查看core dump文件的几个前提:

  1. 信号旁边写着Core的信号,都可以使用核心转储功能

man 7 signal可以查看信号的详细信息

在这里插入图片描述

  1. 云服务器默认关闭了核心转储文件。在终端输入ulimit -a显示操作系统各项资源上限;使用ulimit -c 10240开启核心转储的功能

在这里插入图片描述

验证core dump标志位

写一段有野指针问题的代码,此时该进程会受到11号信号,该信号属于Core的信号,我们可以查看打印出来的core dump标志位,第一次运行发现是0,因为我们没有开启云服务器的核心转储文件,打开后发现,core dump标志位确实是1, 关闭核心转储文件,core dump标志位又变回0。

在这里插入图片描述

Core VS Term

我们现在写下面的一段代码,运行起来后,通过发送异常信号来比较Core和Term

在这里插入图片描述

首先我们先向此进程发送2号信号,2号信号属于Term的信号,发送后这个进程终止了,查看此路径下的内容,发现没有core文件

在这里插入图片描述

重新运行此程序,向此进程发送8号信号,8号信号属于Core的信号,发送后出现Floating point exception(浮点数溢出),此进程退出,查看此路径下的内容,发现有core.pid的文件

在这里插入图片描述

总结:

Term: 终止的就是终止,没有多余的动作

Core: 终止,会先进行核心转储,然后再终止进程

5.3 core dump的意义

将程序异常的原因转储至磁盘,支持后续调试。

这样一段有野指针问题的代码,我们在运行后打开调试,core-file + core文件名就可以直接定位到错误的行数

在这里插入图片描述

在这里插入图片描述

6. 信号的保存

6.1 相关概念铺垫

1、信号递达(Delivery) :实际执行信号的处理动作;

2、信号未决(Pending):信号从产生到递达之间的状态

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

4、信号被阻塞时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.

5、阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作

6.2 信号在内核中的表示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-e6MZecy1-1685114547921)(…/AppData/Roaming/Typora/typora-user-images/image-20230428194718829.png)]

在这里插入图片描述

  • 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志 。在上图的例中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
  • SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
  • SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。

6.3 sigset_t

每个信号只有一个bit的未决/阻塞标志,非0即1,不记录该信号产生了多少次。

因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集。这个类型可以表示每个信号的“有效”或“无效”状态。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

6.4 信号集操作函数

#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置位,表示 该信号集的有效信号包括系统支持的所有信号。
  • 注意,在使用sigset_t类型的变量之前,一定要调用 sigemptyset 或 sigfillset 做初始化,使信号集处于确定的状态。初始化 sigset_t 变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。

这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含
某种 信号,若包含则返回1,不包含则返回0,出错返回-1。

6.4.1 sigprocmask

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
  • 如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。

  • 如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。

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

SIG_BLOCKset包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask | set
SIG_UNBLOCKset包含了我们希望从当前信号屏蔽字解除阻塞的信号,相当于mask=mask&~set
SIG_SETMASK设置当前信号屏蔽字为set所指向的值,相当于mask=set
6.4.2 sigpending
#include <signal.h>
int sigpending(sigset_t *set);//set:输出型参数,输出当前进程pending位图
sigending()在成功时返回0,在错误时返回-1。在发生错误时,将 errno 设置。
小实验
#include<iostream>
#include<signal.h>
#include<assert.h>
#include<unistd.h>
using namespace std;

static void handler(int signo)
{
    cout<<"对特定信号: "<< signo <<"执行捕捉动作" <<endl;
}

static void PrintPending(const sigset_t pending)
{
    cout<<"当前进程的pending位图: ";
    for(int signo=1; signo<=31;++signo)
    {
        if(sigismember(&pending,signo))  //信号是否在集合中
            cout<<"1";
        else
            cout<<"0";
    }
    cout<<"\n";
}


// 是在栈区开辟空间(用户空间), 并没有设置到当前进程的操作系统空间
int main()
{
    //1. 屏蔽2号信号
    sigset_t set,oset;

    //1.1 信号集初始化
    sigemptyset(&set);      //全部清零
    sigemptyset(&oset);

    //1.2 将2号信号添加到set中
    sigaddset(&set, SIGINT/*2*/); 


    //1.3 将新的信号屏蔽字设置到进程中
    sigprocmask(SIG_BLOCK, &set, &oset);


    //2. while获取进程的pending信号集, 并01打印

    //2.0 设置对2号信号的自定义捕捉
    signal(2, handler);
    
    int cnt=0;
    while(true)
    {
        //2.1 先获取pending信号集
        sigset_t pending;
        sigemptyset(&pending);     //不是必须的
        int n=sigpending(&pending);
        assert(n==0);              //调用成功则返回0
        (void)n;                   //保证不会出现编译时的warning

        //2.2 打印, 方便我们查看
        PrintPending(pending);

        //2.3 休眠一下
        sleep(1);

        //2.4 10s之后, 恢复对所有信号的block动作
        if(cnt++ == 10)
        {
            cout<<"解除对2号信号的屏蔽"<<endl;      //先打印
            sigprocmask(SIG_SETMASK, &oset, nullptr);
        }
    }
}

运行结果:

在这里插入图片描述

程序运行时,每秒钟把各信号的未决状态打印一遍,由于我们阻塞了SIGINT信号,按Ctrl-C将会SIGINT信号处于未决状态,同时pending位图由0置1,保存这个信号;信号被捕捉完毕后,会立即解除对该信号的屏蔽,因为pending位图对应的比特位是1,所以立即执行新的捕捉动作,同时pending位图该信号位由1清零;按Ctrl-\仍然可以终止程序,因为SIGQUIT信号没有阻塞。

6.5 深入理解信号保存(内核态与用户态)

  1. 上文提到,信号可以不是立即处理的,而是在合适的时间处理,那么什么时候是合适的时间呢?

    当进程从内核态切换回用户态的时候,进程会在OS的指导下进行信号的检测与处理。

内核态: 执行你写的代码的时候,进程所处的状态
用户态: 执行OS的代码的时候,进程所处的状态

  1. 每个进程的虚拟地址空间中有一块1G大小的内核空间,同时每也存在一张内核级页表,每个进程都可以看到同一张内核级页表,所有进程都可以通过统一的窗口看到同一个OS。
  2. 为了不让用户可以任意的访问OS的数据和代码,采用了软硬件结合的方案来进行用户态和内核态的切换,在进行用户态->内核态的切换过程中,首先通过CR3寄存器将进程状态由用户态修改为内核态(陷入内核),在本进程的内核空间中找到物理内存中的内核代码进行执行,执行完毕后将结果返回给进程。
  3. 所以OS提供的所有的系统调用,内部在正式执行调用逻辑的时候会去修改执行级别
  4. 那么什么时候会从用户态切换到内核态呢?(1)进程时间片到了,需要切换,就要执行进程切换的逻辑(2)系统调用

在这里插入图片描述

7. 信号的捕捉

7.1 原理/流程

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执sighandler 函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

在这里插入图片描述

上述过程看起来非常复杂,我们借助下面这张图来速记

速记

在这里插入图片描述

7.2 sigaction

#include <signal.h>
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
signum:信号;act:结构体对象;oldact:输出型参数,记录原来的act对象
struct sigaction {
   void (*sa_handler)(int);//回调方法
   void (*sa_sigaction)(int, siginfo_t *, void *);
   sigset_t sa_mask;//阻塞信号集
   int sa_flags;
   void (*sa_restorer)(void);//用于支持旧版本的sigaction函数的信号处理函数地址,一般不使用。
};
sigaction()在成功时返回0; 在错误时返回 -1,并设置 errno。

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。

7.3 屏蔽信号并实时打印pending位图

在2号信号被自动屏蔽的同时也可以屏蔽其他信号

#include<iostream>
#include<signal.h>
#include<assert.h>
#include<unistd.h>
#include<string.h>
using namespace std;

static void PrintPending(const sigset_t pending)
{
    cout<<"当前进程的pending位图: ";
    for(int signo=1; signo<=31;++signo)
    {
        if(sigismember(&pending,signo))  //信号是否在集合中
            cout<<"1";
        else
            cout<<"0";
    }
    cout<<"\n";
}

static void handler(int signo)
{
    cout<<"对特定信号: "<< signo <<"执行捕捉动作" <<endl;
    int cnt=30;
    while(cnt)
    {
        cnt--;

        sigset_t pending;
        sigemptyset(&pending);     
        sigpending(&pending);
        PrintPending(pending);
        sleep(1);
    }
}

int main()
{
    struct sigaction act,oldact;
    memset(&act,0,sizeof(act));
    memset(&oldact,0,sizeof(oldact));
    act.sa_handler=handler;
    act.sa_flags=0;
    sigemptyset(&act.sa_mask);

    sigaddset(&act.sa_mask,3);
    sigaddset(&act.sa_mask,4);
    sigaddset(&act.sa_mask,5);

    sigaction(2,&act,&oldact);

    while(true)
    {
        cout<<getpid()<<endl;
        sleep(1);
    }
}

运行结果: 就如同上面sigaction下面文字所说的现象

在这里插入图片描述

8. 三大补充知识点

8.1 可重入函数

在这里插入图片描述

  • main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是, main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了, 有一个节点就丢失找不到了,造成了内存泄露的问题
  • 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

8.2 volatile

volatile是C语言的关键字,大家很可能不清楚这个关键字的作用,下面就来介绍一下

首先写这样一段简单的代码, 编译运行后结果如我们所想,程序正常退出

在这里插入图片描述

然后,我们在makefile文件中添加优化选项,对此代码做优化后重新编译运行后,结果如下

在这里插入图片描述

运行发现不断的Ctrl+c会不断打印这句话,但是这个程序就是死循环不结束,我们最后只能用Ctrl+\终止它

思考为什么一旦添加优化选项就出现这样的结果呢?这份代码到底有什么地方需要优化的呢?

答案是 while(!quit); 这条语句,它一条对quit值判断的语句。

未优化前,每一次while循环检测都会把内存中quit的值load到cpu中的寄存器中, quit值修改后不满足条件,cpu中的pc指针向下移动执行后面的代码,直接循环结束

一旦优化后,在main执行流中,认为quit值没有被修改只是被检测,所以只有第一次load到cpu中的寄存器,往后判断不会load了,即使quit修改了寄存器中还是第一次的值,检测发现满足条件继续重复执行刚才的代码,继续死循环

在这里插入图片描述

我们可以在handler中打印出quit值观察,发现quit值在内存中确实是被修改了,和我们上面的解释一样

在这里插入图片描述

因为上面的这种现象于是就引入了volatile关键字,它的作用是保证内存的可见性

在上面的代码中引入volatile关键字就是告诉编译器,保证每次检测,都要尝试从内存中进行数据读取,不要用寄存器中的数据,让内存数据可见

我们现在在quit前加入volatile关键字,发现即使添加了编译优化选项运行结果仍然如同第一次。

在这里插入图片描述

8.3 SIGCHLD信号

进程部分讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。

其实无论哪一种方式都需要父进程主动地去检测,为什么需要这样呢?因为子进程退出了,父进程暂时不知道,所以需要不断地检测;那么子进程在退出的时候,是不是安安静静地退出的呢?不是,子进程在退出时会向父进程发送SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数, 这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。

我们先来验证一下子进程退出时会向父进程发送SIGCHLD信号,自定义捕捉SIGCHLD信号即可。

在这里插入图片描述

验证发现确实如上面所讲,子进程退出时会向父进程发送SIGCHLD信号

那我们可以编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定 义SIGCHLD信号的处理函数,在其中调用wait获得子进程的退出状态并打印。

信号版进程等待
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>

pid_t id;

//信号版进程等待
//编写时要注意考虑几种特殊情况:
// 1. 循环式创建多个子进程, 多个进程退出时发送多条信号, 某个信号可能暂时不处理保留下来, 
// 但是OS只有一个比特位保留信号,可能会导致信号丢失,最后只回收几个子进程无法全部回收  
//  ==> 用while循环来循环式回收子进程
// 2.不是所有的进程都退出,可能有几个退出了, 有几个没有退出 ==> 用WNOHANG来实现

void waitProcess(int signo)
{
    printf("捕捉到一个信号: %d, who: %d\n", signo,getpid());
    sleep(5);

    // 5个退出, 5个没退 ==> 用WNOHANG来实现
    while (1)
    {
        //WNOHANG: 非阻塞式等待, 子进程退出就回收子进程, 子进程没有退出就出错返回, 不会阻塞式等待
        pid_t res = waitpid(-1, NULL, WNOHANG); //-1代表等待任意一个子进程
        if (res > 0)
        {
            printf("wait success, res: %d, id: %d\n", res, id);
        }
        else break;
    }

    printf("handler done...\n");
}
int main()
{
    signal(SIGCHLD, waitProcess);

    int i=0;
    for (; i <= 10; ++i)
    {
        id = fork();
        if (id == 0)
        {
            // child
            int cnt = 5;
            while (cnt)
            {
                printf("我是子进程, 我的pid: %d, ppid: %d\n", getpid(), getppid());
                sleep(1);
                cnt--;
            }

            exit(1);
        }
    }

    // 如果你的父进程没有事干, 你还是用以前的方法
    // 如果你的父进程很忙, 而且不退出, 可以选择信号的方法
    while(1)
    {
        sleep(1);
    }

    return 0;
}

运行结果:

在这里插入图片描述

信号版进程等待特例

事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的, 但这是一个特例此方法对于Linux可用,但不保证在其它UNIX系统上都可用。请编写程序验证这样做不会产生僵尸进程。

int main()
{
    signal(SIGCHLD, SIG_IGN);         //更好的方式

    int i=0;
    for (; i <= 10; ++i)
    {
        id = fork();
        if (id == 0)
        {
            // child
            int cnt = 5;
            while (cnt)
            {
                printf("我是子进程, 我的pid: %d, ppid: %d\n", getpid(), getppid());
                sleep(1);
                cnt--;
            }
            exit(1);
        }
    }

    while(1)
    {
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

9. 信号总结

在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值