0


【Linux】信号

生活中的信号

其实在我们中有各种各样的信号,比如闹钟响了,红灯停绿灯行等等,对于信号,我们有如下总结:

a.信号在生活中,随时可以产生,信号的产生和我是异步的

b.我们能认识这个信号

c.我们知道信号产生了,信号该怎么处理,也就是能识别并处理这个信号

d.我们可能做着更重要的事情,对到来的信号暂不处理,但是我记得这个事,在合适的时候去处理

把这里的 “我” 换成进程,就是我们今天要学习的信号。

Linux中kill -l信号

其实,每个信号都是一个宏,前面的序号是宏对应的值。我们之前用过kill -9杀掉进程,如果向一个关闭读端的管道去写,进程会收到SIGPIPE信号,所以我们之前对信号有过一点粗浅的认识。

信号概念的基本储备

信号,Linux系统提供的一种,向指定进程发送特定事件的方式,系统会进行识别和处理。此外,信号是异步产生的。

对于信号处理,我们有三种方式:

1.默认动作

如果进程处理信号时,不做任何处理,那么都会采用默认处理,这通常包括终止自己、暂停、忽略。

2.忽略动作

3.自定义处理(信号的捕捉)

当我不想执行信号的默认动作的时候,而是采用我们所设计的方法,就可以采用自定义处理,所采用的函数调用是signal函数:

signal函数的第二个参数handler是函数指针,其返回值为void,参数类型是int,把函数名传递给signal的第二个参数,当进程收到signum之后,就会执行handler函数,handler函数的参数就是我们收到的信号,比如收到2号信号,那么sig就是2。我们写了如下代码来测试:

在这段代码中,一旦进程收到2号信号,就会去执行handler函数,我们看一下结果:

在上面的代码中,如果一直不产生2号信号会怎么样呢?handler方法一直不会被调用!也就是说只有收到了2号信号,才能执行handler方法。

此外,我们还可以对更多的信号进行捕捉,我们再来看:

实验结果表明,我们给进程发几号信号,进程就会接受几号信号,并将信号值传给handler的sig参数。

那么,2号信号(SIGINT)到底是什么信号呢?是终止进程!

我们还发现,给进程发2号信号和给进程按下ctrl+c,都会让进程捕捉到2号信号,都会执行handler方法。所以我们不难明白,我们之前说过如果异常进程,按ctrl+c键就可以终止进程,为什么能直接终止呢?因为ctrl+c就是给目标进程发送2号信号,而2号信号的默认动作就是终止进程。除了ctrl+c之外,ctrl+\ 也可以给进程发送信号终止进程(3号信号)。

那么,我们再来探讨一个问题:如何理解信号的发送与保存呢?

普通信号的数字是131,其实是用位图来保存收到的信号的!!!进程都有对应的task_struct,它是一个结构体,而结构体中会有很多成员变量,就可以用一个uint32_t signals这个变量来保存,它就是一个32个比特位(0000 0000 0000 0000 0000 0000 0000 0000,最后一个bit位不用),剩下正好131个信号。之后,如果发送1号信号就将1位的bit位由0置1,就代表进程收到了1号信号,以此类推。所以,发送信号,其实是修改指定进程pcb中的信号的指定位图,0->1,与其说是发信号,不如说是写信号。pcb是内核数据结构,只有OS才有资格修改其值。

信号的三个阶段

接下来,我们围绕信号的三个阶段依次学习:信号产生、信号保存、信号处理。

信号产生

信号产生的方式有以下几种:

1.通过kill命令,向指定的进程发送指定的信号。

2.键盘可以给进程发信号。(比如ctrl+c)

我们还可以用系统调用kill,向指定进程发送指定信号,

使用kill系统调用,我们自己写一个mykill程序:

#include<iostream>
#include<sys/types.h>
#include<unistd.h>
#include<signal.h>

// ./mykill 2 1234
int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        std::cerr << "Usage: " << argv[0] << " signum pid" << std::endl;
        return 1;
    }
    pid_t pid = std::stoi(argv[2]);
    int signum = std::stoi(argv[1]);
    kill(pid, signum);
}

所以,我们的第三个产生信号的方式:

3.系统调用 int kill(pid_t pid , int sig) raise abort

除了kill之外,我们还可以使用raise系统调用来给进程发送信号:

与kill所不同的是,谁调用raise,就给谁发送信号。也就是kill(getpid(),xxx) == int raise(int sig)。

我们还可以使用abort系统调用来终止进程,这其实是6号信号SIGABRT,

像abort这样的信号,虽然允许捕捉,但是被捕捉后仍然会被终止进程。

学到这里,我们可能有这样的问题:

a.如果我把所有信号都捕捉了,会怎么样?

事实上,我们考虑到的,系统早就考虑到了,9号信号不允许捕捉

b.如何理解上面的信号发送?

虽然我们可以通过上面三种方式产生信号,但是真正发送信号的只能是OS!!!因为发送信号的本质其实是修改进程pcb中的位图,只有OS有资格去修改它自己定义申请的数据结构。

4.软件条件产生信号

我们之前学过,如果一个管道的读端直接关闭,而写端一直进行,此时OS会想写进程发送SIGPIPE(13号信号),这其实是软件条件。

这次我们要学习的是alarm系统调用,其作用是终止进程,

闹钟是给未来几秒(seconds)之后设置的,这其实对应14号信号SIGALRM,如果seconds设置为5,那么就表示进程5s后收到SIGALRM信号。

1s后,我们程序中这个循环大概能执行20000多次,

接下来我们稍微改一下程序,不在每次循环中都打印输出,

我们发现,cnt的值已经加到了5亿多次,比上面的程序快了非常多,因为此时在循环里++的时候,是一个纯内存级的数据递增,而之前的每次需要打印在显示器上,中间经历了云服务器,需要跨网络,所以我们可以得出一个结论:IO很慢

实际上,进程中会存在很多进程,所以OS要对闹钟做管理,还是我们熟悉的先描述再组织,闹钟其实是一个结构体struct alarm:

struct alarm
{
    time_t expired;//未来的超时时间=seconds+Now()
    pid_t pid;
    func_t f;
    ...
}

那如何对闹钟进程管理呢?其实,可以按未来的超时时间设置一个最小堆,堆顶就是下一个要执行的闹钟,对闹钟的管理就可以转换为对最小堆的增删查改。

那alarm系统调用的返回值是什么呢?如果alarm的参数为0(alarm(0)),它的意思是取消闹钟,返回值为上一个闹钟的剩余时间。

我们看下面的代码,闹钟设置一次,就默认只被触发一次

如果我们想一直触发闹钟该怎么办呢?实际上,可以在handler函数中,再设一个闹钟,这样每次调用handler后,都会重新设置闹钟。

5.异常

我们之前或多或少可能都会遇到这样的异常情况(除0、野指针) :

遇到这样的异常,程序为什么会崩溃呢?这是因为非法访问,导致OS给进程发送信号啦!!具体来说,如果发生除0,是进程收到了8号信号SIGFPE,我们可以使用signal函数调用验证一下:

可见,当发生除0异常时,会给进程发生8号信号。

如果发生野指针异常,会给进程发送11号信号SIGSEGV,同样地,我们可以使用signal函数调用验证一下:

程序崩溃了为什么会退出?因为OS给进程发的信号默认是终止进程。可以不退出吗?可以,因为可以捕获异常,但是推荐终止进程,为什么推荐终止进程呢?下面我们深入探讨一下:

a变量存在于内存中,算数运算和逻辑运算在CPU中进行,而CPU中存在很多寄存器,包括ecp、ebp等这样的普通寄存器,还有状态寄存器eflag,CPU是可以做计算的,可是数据是来自于用户的,这注定了有些运算是正确的,有些是错误的,那CPU如何得知运算时正常的还是异常的呢?实际上,状态寄存器中存在溢出标记位,当CPU在计算10/0时,除法运算会被转为加法运算,CPU中加法器一直做累加,累加到一定程度时发生了数据溢出,溢出标记位由0置1,如果计算正常,CPU会把计算结果返回给内存,如果异常,溢出标记位被置1,这就表示运算出错了。OS是软硬件资源的管理者,OS要随时处理这种硬件问题,也就是向目标进程发送信号。

还有一个问题,发生异常时OS给进程发一次信号不就可以了吗?为什么刚才会疯狂打印呢?我们知道,寄存器只有一套,但是寄存器里面的数据是属于每一个进程的,由于要进行进程切换,所以要有硬件上下文的保存和恢复。这个异常进程由于被捕获异常不退出,当重新切换到这个进程时,把之前保存的数据重新恢复到寄存器里,这就又给OS恢复了错误的数据,就会一直触发溢出标记位的错误。 像状态寄存器这种,进程无法访问到,自然无法更改其中的值。

最后,回到问题本身,为什么出异常后,推荐终止进程呢?终止进程的本质是释放上下文数据,包括溢出标志数据或者其他异常数据!!进程出异常后,进程就没有意义了,就应该把进程的数据删掉了。

上面我们所说的是发生除0异常,那野指针异常也是一样的情况吗? CPU中有一个寄存器叫CR2,叫做页故障线性地址寄存器,当虚拟地址向物理地址转化失败,会把虚拟地址放在CR2里,这就表明CPU中出现了硬件错误,OS发现CR2中有了内容,OS就会向进程发送11号SIGSEGV信号,然后每次进程切换后,之前保存的数据重新恢复到寄存器里,OS就会一直给进程发信号。

现在我们再来讨论几个小问题,我们看到,进程信号有的事Core,有的是Term,它们有什么区别呢?其实,是Term的进程信号会使进程异常终止,是Core的进程信号会使进程异常终止,但是他会帮我们形成一个debug文件,这个debug文件是进程退出时的镜像数据(比如执行到哪里异常,异常的代码等核心数据),这个过程我们称为核心转储,存储到磁盘上。core是协助我们进行debug的文件,便于事后调试。下图是我们在学习进程控制时的获取子进程status参数,其中的core dump标志如果为0,表示没有核心转储,为1,则表示有核心转储。

那么,我们来写代码看一下core dump标志位:

int Sum(int start, int end)
{
    int sum = 0;
    for(int i = start; i <= end; i++)
    {
        sum /= 0; 
        sum += i;
    }
    return sum;
}

int main()
{
    // int total = Sum(1,100);
    // std::cout<< "total: " << total << std::endl;
    pid_t id = fork();
    if(id == 0)
    {
        sleep(1);
        //child
        Sum(1,100);
        exit(0);
    }
    //father
    int status = 0;
    pid_t rid = waitpid(id,&status,0);
    if(rid == id)
    {
        printf("exit code: %d,exit sig: %d,core dump: %d\n",(status>>8)&0xFF , status&0x7F,(status>>7)&1);
    }

    return 0;
}

在子进程调用Sum函数时,故意写了除0错误,我们运行以上程序,得到下面结果

发现core dump为1,说明子进程是以Core方式退出,形成了core文件。

信号保存

先来看几个概念:

1.实际执行信号的处理动作称为信号递达(Delivery)。

有三种处理方式:默认、忽略、自定义捕捉。

2.信号从产生到递达之间的状态,称为信号未决(Pending)。

信号已经产生了,但是还没处理,在进程pcb中进行保存。

3.进程可以选择阻塞某个信号。

阻塞一个信号,那么对应的信号一旦产生,就永不递达,一直未决,直到主动解除阻塞,解除之后,该信号才会被递达。一个信号如果阻塞和它有没有未决无关!!

信号在内核中的表示示意图:

由三张表组成,block、pending、handler。

第一张表pending

其中,pending是一个int位图(0000 0000 0000 0000 0000 0000 0000 0000,最高位不用),比特位的位置表示信号编号,比特位的内容表示信号是否收到,也可以叫做未决信号集。

** 第二张表handler**

我们之前使用过signal指令,用来对指定信号自定义捕捉,

其第二个参数是参数值为int、返回值为void的函数的函数指针,问题是,为什么这样做就能完成信号捕捉呢?这就涉及到了handler表,handler表是一个函数指针数组sighandler_t handler[32],而我们的普通信号是1~31的数字,信号的编号就相当于数组的下标,可以采用信号编号,索引信号处理方法!OS为每个进程都维护了一张handler表,这个表写了每个信号该如何被处理,未来假如进程收到2号信号,就会看到SIG_IGN所对应的处理方法。学到这里我们应该就明白,之前signal(2,handler)是在干什么呢?其实是拿着信号编号在该handler数组里进行索引,把自己写的函数地址写到handler数组对应的位置,此时OS就知道对于2号信号该怎么处理它。

第三张表block

block和pending类似,也是一张位图,和pending类型完全一样,(0000 0000 0000 0000 0000 0000 0000 0000,最高位不用),比特位的位置表示信号的编号,比特位的内容表示信号是否阻塞。

对于这3张表,我们应该一行一行看,比如对于上图的1号信号,block为0,pending为0,那么就使用handler表中的SIG_DFL递达。对于上图的2号信号,由于block为1,被阻塞,所有当接收到2号信号时,pending为1,不能被递达,直接接触阻塞,才能被递达。假设4号信号的对应的block为0,pending为1,表示接收到了4号信号,由于未被阻塞,可以被递达,去handler表中找对应的方法执行。所以,OS为每个进程维护了两张位图+一张函数指针数组,这样就让进程识别到了信号。我们总结两条结论:

1.被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
2.注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

信号相关的操作,到目前为止我们只学了signal捕捉信号,未来对信号的操作无非就是围绕上面所说的3张表,要么是block表,要么是pending表,要么是handler表,因为内核数据结构是这三个表,那提供的系统调用也是这三个的。因为signal函数可以对handler函数数组进行修改了,所以对signal的操作难度不大,关键在于block表和pending表之前没有获取过,它俩还是位图,所以,为了对block和pending表进行操作,所以Linux提供了一种Linux上的用户级的数据类型sigset_t,把sigset_t这种数据类型叫做信号集,这个类型可以表示可以表示每个信号“有效”和“无效”的状态。sigset_t其实是一个结构体,里面封装了位图。在阻塞信号集中“有效”和“无效”表示信号是否被阻塞,在未决信号集中“有效”和“无效”表示该信号是否处于未决状态。其中,阻塞信号集又叫做信号屏蔽字,这里的阻塞应理解为阻塞而不是忽略。

但是,我们不能自己手动去修改sigset_t,OS为我们提供了一批函数,

#include <signal.h>
int sigemptyset(sigset_t *set);  //把所有位清0
int sigfillset(sigset_t *set);   //把所有位置1
int sigaddset (sigset_t *set, int signo); //把指定的信号signo加到位图中,由0置1
int sigdelset(sigset_t *set, int signo);  //把指定的信号signo从位图中去掉,由1置0
int sigismember(const sigset_t *set, int signo); //判断signo是否在位图中

sigprocmask

函数sigprocmask可以用来读取或更改进程的信号屏蔽字(阻塞信号集)。先来看一下这个函数的定义:

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

其中,sigprocmask函数的第一个参数how有几个选项(SIG_BLOCK、SIG_UNBLOCK、SIG_SETMASK),第二个参数set是一个输入型参数,

然而,无论SIG_BLOCK、SIG_UNBLOCK、SIG_SETMASK哪个选项,都是用来修改信号屏蔽字。那如果想恢复成修改之前的信号屏蔽字,该怎么办呢?这就设计到了第三个参数oldset,这是一个输出型参数,在对进程的BLOCK修改之前,都会保存老的信号屏蔽字返回给用户,我们可以不用它,但是当我们想用的时候,就可以重新设置成原来的信号屏蔽字。


上面介绍的是如何修改BLOCK表,那么如何修改pending表呢?实际上,我们之前所讲的信号产生的5种方式,每一种都是在修改pending表,那我们怎么获取pending表呢?

sigpending

int sigpending(sigset_t *set);

我们通过函数sigpending来获取pending位图,其参数set是一个输出型参数。

#include<iostream>
#include<cstdio>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>

void PrintPending(sigset_t& pending)
{
    std::cout << "curr process[" << getpid() <<"] pending: "; 
    for(int signo = 31 ; signo >= 1 ; signo--)
    {
        if(sigismember(&pending, signo))
        {
            std::cout << 1;
        }
        else
        {
            std::cout << 0;
        }
    }
    std::cout << "\n";
}

void handler(int signo)
{
    std::cout << signo << "号信号被递达!!!" << std::endl;
}

int main()
{
    //0.捕捉2号信号
    signal(2,handler);
    //1.屏三蔽2号信号
    sigset_t block_set,old_set;
    sigemptyset(&block_set);
    sigemptyset(&old_set);
    sigaddset(&block_set,SIGINT);
    //1.1 设置进入进程的Block表中
    sigprocmask(SIG_BLOCK,&block_set,&old_set);//真正修改当前进行的内核block表,完成对2号信号的屏蔽
    int cnt = 10;
    while(1)
    {
        //2.获取当前信号的pending信号集
        sigset_t pending;
        sigpending(&pending);

        //3.打印pending信号集
        PrintPending(pending);
        cnt--;    
    
        //4.解除对2号信号的屏蔽
        if(cnt == 0)
        {
            std::cout << "解除对2号信号的屏蔽!!!" << std::endl;
            sigprocmask(SIG_SETMASK,&old_set,&block_set);
        }

        sleep(1);
    }
    return 0;
}

当解除屏蔽后,如果信号之前被pending了,一般会立即处理当前被解除的信号。那么pending位图对应的信号也要被清0,是在信号被递达前被清0,还是在信号被递达后被清0?在信号被递达前!

信号处理

什么是信号处理呢?不就是递达信号么?!其实,我们之前信号处理有三种方式:

signal(2,handler);//自定义捕捉
signal(2,SIG_IGN);//忽略一个信号
signal(2,SIG_DFL);//信号的默认动作

那么,SIG_IGN和SIG_DFL具体是什么呢?这其实是宏,我们转到其定义,

发现其实是对1和0强转成了函数指针类型,到这里,其实我们应该明白,之所以signal第二个参数要传入这样的函数指针类型,因为handler表是一个函数指针数组。另外,如果不对信号进行处理,那么默认就是执行SIG_DFL。

我们知道,信号可能不会被立即处理,而是在合适的时候进行处理,这里合适的时候其实是指进程从内核态返回到用户态的时候进行处理!这里出现了两个新名词,内核态和用户态。内核态简单来说就是处于操作系统的状态,用户态就是只要执行我自己的代码、访问我自己的数据,这个时候就叫做用户态。在使用系统调用的时候就进入和内核态,在处理完内核后,先检查当前进程可递达的信号,如果无可递达的信号,那么返回主控制流程继续执行,否则,就处理信号。如果信号对应的handler是SIG_DFL或者SIG_IGN,那么直接执行就好。但是,如果信号是自定义捕捉,那就需要按照下图的方式进行。

但是,操作系统不能直接转过去执行用户提供的handler方法,因为handler方法是用户提供的,而用户写的方法里面写了什么代码OS并不清楚,万一里面写了rm、exec等执行删除命令了,那这不就是利用OS做坏事了吗?!所以,在执行handler方法时,不能使用内核身份,必须使用用户身份!!OS不相信任何用户。所以,在进行信号捕捉时,要从内核态切换到用户态(3->4步)。但是main函数和sig_handler函数没有调用关系,所以在执行完sig_handler后不能直接跳转会main函数,只能通过特殊系统调用返回到内核中(第5步),才能返回main函数。

所以,信号捕捉的流程类似∞,信号的捕捉过程,要经历4次状态切换!在内核态切换回用户态的时候,进行信号的检测和处理

内核态和用户态

上面我们肤浅地了解了内核态和用户态,现在我们要更深入理解。为此,我们先要谈几个问题:

再谈地址空间

以32位机器为例,我们之前学过,[0,3]GB的地址空间被用户所用(包括堆区、栈区、静态区等),那么还剩下[3,4]GB的最后的1GB空间是给OS用的。当电脑开机的时候,OS是第一个被加载到内存的软件,那怎么把[3,4]GB地址空间映射到OS的内存呢?其实是通过内核级页表,这意味着OS本身就在进程的地址空间中!!

但是,OS里会有好多进程,这么多进程其实都是用的同一个内核级页表,也就是内核级页表只有需要维护一份,用户级页表可以有很多张。无论进程如何切换,我们总能找到OS!!通过访问[3,4]GB空间,就可以找到OS所有代码和数据。我们访问OS,其实还是在我们的地址空间中进行的,和访问库函数没区别!!由于OS不相信任何人,所以用户访问[3,4]GB空间时,要受到一定的约束!只能通过系统调用!!

谈谈键盘输入数据的过程

简单来说,就是当按下键盘后,发生硬件中断,键盘有对应的中断号,发给CPU,然后由CPU通知OS执行对应的读取键盘的方法。

谈谈如何理解OS如何正常运行

1.如何理解系统调用

OS为了支持系统调用,为我们提供了系统调用表,这其实是一个函数指针数组,我们只要找到特定数组下标的方法,就能执行系统调用了。而这个特定数组下标叫做系统调用号

因此,执行系统调用需要系统调用号和系统调用函数指针数组。当我们使用系统调用(比如fork)时,其实是先把系统调用号放到寄存器里(move 2 eax),然后再执行int 0x80这样的中断,然后在OS内部形成中断号,然后执行提前注册的中断向量表中的方法,然后从寄存器中把中断号读取出来,再执行对应的方法,至此系统调用就可以被调用起来了。

由外部形成的中断叫做外部中断。把CPU中直接形成的可以直接产生中断的叫做陷阱或缺陷(0x80)。

2.OS是如何运行的

OS本质就是一个死循环,从开机之后直到关机OS一直在跑,再加上时钟中断,不断调度系统的任务


当用户调用系统调用时,首先需要找到OS所在的地址空间。但是,OS不是不相信任何用户吗?用户无法直接跳转到[3,4]GB地址空间范围,这是怎么做到的呢?另外,用户必须在特定的条件下才能跳转过去,这又是如何做到的?实际上,要做到这两点,是需要CPU配合的,CPU中有一个叫cs(code semgment)的寄存器,里面表示的是代码区的范围,如果我们想让CPU执行代码区的代码,cs里就放代码区的范围,想让CPU执行OS的代码,cs里就放OS代码的范围(3~4GB)。其中,cs的最后两个比特位如果是0,表示内核,如果是3,表示用户。也就是说,如果是0,那就可以执行OS的代码,如果是0,那就只能执行用户级的代码。所以,所谓的用户态和内核态,指的就是这两个比特位是0还是3的问题。由用户态切换为内核态,就是把寄存器的值由3变为0,反之相反。

所以现在我们就可以回答第一个问题,因为用户对应的是3,当想直接跳转到OS的系统调用时,CPU肯定先要做状态检查,发现是[3,4]GB范围内的数据,就会去检测这两个比特位,如果是3不是0,就会拦截,这就访问不到OS的代码了。

现在就可以回答第二个问题,在特定的情况下,比如执行fork调用,会将3->0,这样就能跳转过去。

现在我们回过头看执行流的时候,用户层在执行中断、异常或者系统调用时进入OS,要执行int 0x80中断,把系统调用号放到寄存器中,把cs最后两个比特位由3->0,然后再跳转地址空间,进入OS内执行系统调用,系统调用完再进行信号检测。


好,下面开始另一个话题。在信号捕捉时,我们之前说的使用signal调用进行捕捉,现在我们再来说另一种方法:sigaction。这看起来复杂一些~。

其中,参数signum是要捕捉的信号编号,后两个参数是和函数名同名的结构体指针,第二个参数是一个输入型参数,是我们想要设置的信号捕捉方法。第三个参数是一个输出型参数,用来保存之前的信号捕捉方法,以便恢复原来的信号捕捉方法。

void handler(int signum)
{
    std::cout << "get a sig: " << signum << std::endl;
    exit(1);
}
int main()
{
    struct sigaction act,oact;
    act.sa_handler = handler;
    sigemptyset((&act.sa_mask));
    act.sa_flags = 0;

    sigaction(2, &act, &oact);

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

我们其实发现,这看起来和signal没有什么区别嘛!

实际上,当前如果正在对2号信号进行处理,默认2号信号会被屏蔽。当对2号信号处理完成的时候,会自动解除对2号信号的屏蔽。这样就避免了对2号信号的连续处理,这里的2号信号可以换成任意特定新号。为什么突然说这个呢?我们看到sigaction结构体里有一个成员是sa_mask,如果想在处理2号信号(此时OS自动屏蔽2号)的时候,同时对其他信号也进行屏蔽。我们使用sigaddset(&act.sa_mask,3);对3号信号也进行屏蔽,这时处理2号时,对2号和3号信号都进行了屏蔽。

可重入函数

向上面这样,在insert中执行p->next=head时,如果此时收到信号,进入handler,如果里面还是调用了insert函数,这样出来之后,就会造成node2内存泄漏。在这个过程中,insert函数被重复进入了,也就是被重入了,会出现问题(比如内存泄漏),insert函数就叫做不可重入函数。实际上,我们之前学过的大部分函数都是不可重入的。函数是否可重入并不是优缺点,而是特点。

volatile

我们先来看这样一段代码:

int gflag = 0;

void changedata(int signo)
{
    std::cout << "get a signo:" << signo << ",change flag 0->1" << std::endl;
    gflag = 1;
}

int main()
{
    signal(2, changedata);

    while(!gflag);
    std::cout << "process quit normal" << std::endl;

    return 0;
}

我们按ctrl+c(2号信号),会出现如上结果,这是很正常的。

gflag归根结底是存放在物理内存中的,CPU主要负责算数运算和逻辑运算,而while(!gflag)的本质是CPU不断对gflag做检测,CPU把内存中的gflag加载进来逻辑运算然后判断。然而,编译器可能对我们上面这段代码进行优化。Linux中,对代码的优化有几个级别:

默认是O0(无优化),优化级别依次递增。

如果把优化级别拉到-O1,对编译器在编译时,会发现main函数中没有对gflag进行修改,编译器就想我没必要每次都从内存中拿数据呀,直接从内存中拿一次gflag的值,以后就只检测寄存器的值,如果以后代码对gflag修改了,这其实是修改内存里的值,和CPU无关了,CPU再也看不到了,也就是寄存器隐藏了内存中的真实值。这就是编译器过度优化导致的问题。

为了要求CPU每次都必须从内存读取数据,即保持内存的可见性,就提供了volatile关键字,用volatile修饰gflag。

SIGCHLD

实际上,子进程在退出时,不是静悄悄退出的,会给父进程发送信号--SIGCHLD。那为什么之前子进程退出时看到信号呢?是因为SIGCHLD是Ign的。

void notice(int signo)
{
    std::cout << "get a signal: " << signo << " pid: " << getpid() << std::endl;
    pid_t rid = waitpid(-1, nullptr, 0);
    if(rid > 0)
    {
        std::cout << "wait child success,rid: " << rid << std::endl;
    }
}

void DoOtherThing()
{
    std::cout << "DoOtherThing" << std::endl;
}

int main()
{
    signal(SIGCHLD, notice);
    pid_t id = fork();
    if(id == 0)
    {
        //child
        std::cout << "I am child process,pid: " << getpid() << std::endl;
        sleep(3);
        exit(1);
    }
    //father
    while(true)
    {
        DoOtherThing();
        sleep(1);
    }

    return 0;
}

这样,父进程可以专心做自己的事,当子进程退出时,会自动向父进程发送SIGCHLD信号,在回收完子进程后,父进程就可以继续做自己的事。

我们来对上面的代码挑问题:

问题1:如果一共有10个子进程,且同时退出呢?

如果10个子进程同时退出,这就意味着在很短时间内子进程退出时都会向父进程发送SIGCHLD信号,而SIGCHLD作为普通信号,是用pending位图来表示收到的信号的,虽然同时收到10个SIGCHLD信号,但pending只会记录一次,就只会回收一个子进程,那怎么办呢,我们可以这样做:

void notice(int signo)
{
    std::cout << "get a signal: " << signo << " pid: " << getpid() << std::endl;
    while (true)
    {
        pid_t rid = waitpid(-1, nullptr, 0);
        if (rid > 0)
        {
            std::cout << "wait child success,rid: " << rid << std::endl;
        }
        else if (rid < 0)
        {
            std::cout << "wait child success done" << std::endl;
            break;
        }
    }
}

void DoOtherThing()
{
    std::cout << "DoOtherThing" << std::endl;
}

int main()
{
    signal(SIGCHLD, notice);
    for (int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            // child
            std::cout << "I am child process,pid: " << getpid() << std::endl;
            sleep(3);
            exit(1);
        }
    }
    // father
    while (true)
    {
        DoOtherThing();
        sleep(1);
    }

    return 0;
}

这样就一次创建了10个子进程,并且一次回收了10个子进程。

问题2:如果一共有10个子进程,5个退出,5个永远不退出呢?

会发生阻塞!!!就会一直停在notice里,main函数也一直不会被返回,也不会并发执行DoOtherThing,因此,waitpid时应采用非阻塞方式

void notice(int signo)
{
    std::cout << "get a signal: " << signo << " pid: " << getpid() << std::endl;
    while (true)
    {
        pid_t rid = waitpid(-1, nullptr, WNOHANG);//非阻塞方式等待
        if (rid > 0)
        {
            std::cout << "wait child success,rid: " << rid << std::endl;
        }
        else if (rid < 0)
        {
            std::cout << "wait child success done" << std::endl;
            break;
        }
        else // rid == 0 还有部分子进程未退,所以不用回收了,退出即可
        {
            std::cout << "wait child success done" << std::endl;
            break;
        }
    }
}

实际上,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作
置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。

int main()
{
    signal(SIGCHLD, SIG_IGN);//手动设置对SIGCHLD进行忽略即可
    pid_t id = fork();
    if(id == 0)
    {
        int cnt = 5;
        while(cnt)
        {
            std::cout << "child process running" << std::endl;
            cnt--;
            sleep(1);
        }
        exit(1);
    }
    while(true)
    {
        std::cout << "father running" << std::endl;
        sleep(1);
    }

    return 0;
}

那是不是我们以后直接把子进程的退出信号忽略这样做就行了,别忘了,等待子进程有两个目的,其一是获取子进程退出信息,其二是回收子进程,所以如果根本不关心子进程的退出信息,那直接这样用是最简单的。但是如果想要获取子进程的退出信息,那么就必须要wait。如果细心的话,我们发现SIGCHLD本身就是Ign的,那这里为什么还设置了SIG_IGN,其实,这两个含义是不同的,Ign是系统维护的,用户设置了SIG_IGN表示用户不用去回收子进程了,会自动释放子进程。

,

标签: linux 运维 服务器

本文转载自: https://blog.csdn.net/qq_48460693/article/details/141894871
版权归原作者 核动力C++选手 所有, 如有侵权,请联系我们删除。

“【Linux】信号”的评论:

还没有评论