Linux信号机制详解:从产生到处理

文章目录

快速认识

在生活中,有很多信号,一开始我们可能无法对信号做出正确的反应,但是经过训练,在我们的大脑中就构建了信号产生和信号处理的映射方法。同样,在Linux系统中信号的产生和处理方法也是类似的,信号的陈方法,在信号产生之前,就已经准备好了,识别信号是内置的,进程识别信号,是内核程序员系的内置特性。

信号一般有三个阶段:识别信号、识别产生和动作处理,信号的处理一般有三种方式:默认处理、忽略和自定义动作。信号的处理也可以叫做信号捕捉。

综上,信号是外部或者其他人或者硬件给进程发送的一种异步的事件通知机制,通知机制指的是告诉进程什么事情发生了,异步指的是多种事件,彼此不影响,同时发生。事件指的是终止、异常、指令退出等行为。

信号的生命周期

信号的生命周期如下图所示:

当信号来临时,有可能正在做优先级更高的事情,不会立即处理,会在后面合适的时候才会进行处理,这就要求得将相关已经产生的信号记下来,这也就是需要信号保存的原因。大部分信号的默认处理方式为终止进程。

信号产生

方式

kill命令

信号产生可以用kill命令产生,可以使用"kill -l"命令查看相关信号,如下所示:

使用"kill -[信号] [进程号]"将指定的信号传给指定的进程。

键盘

键盘可以产生以下几个常见信号:

Ctrl + C:向目标进程发送2号信号,默认动作是终止进程。

Ctrl + \:向目标进程发送3号信号,默认动作是终止进程。

Ctrl + z:向目标进程发送19号信号,默认动作是暂停进程。再次唤醒对应进程默认变为后台进程。

其它信号,可以通过"man 7 signal"来进行查看,如下所示:

这里有一个细节,键盘产生的信号,只能用来控制前台进程,无法控制后台进程,这是因为,只有前台进程才能获取键盘输入。

硬件异常产生信号

如果我们的程序除0,或者存在野指针,程序就会崩溃,这是因为程序因为异常,导致收到了信号,然后让进程终止。以上错误本质都是硬件出错,操作系统是硬件的管理者,必须知道硬件出错,向正在运行的进程写信号,将硬件异常转化为软件动作,让进程崩溃。

若收到相关信号后,用户捕捉了这个信号,没有让进程退出,那么这个进程就会被操作系统继续调度,寄存器中的内容,都是当前进程的硬件上下文,当再次调这个进程时,存在的错误也会恢复,操作系统会一直发信号,造成死循环。

若我们不进行信号的捕捉,出现除0时,除了会显示对应的错误外,还会显示core dumped,如下所示:

结合上面信号的,我们发现终止方式有两种,分别为Term和Core,一般若程序中有异常退出时会以Core的方式来退出,而进程中如果没有错误,用户强制退出,一般是以Term方式推出的。Core退出方式,都是进程自身有错误,而Term退出方式,是进程主动关闭的。

core dumped也表示进程退出,但并不是正常退出,而是核心转储。如果程序异常退出,用户最想知道的是这个程序为什么异常退出,并且在哪里退出的,进程运行时大部分数据都在内存中,为了便于用户调试,所以如果是core dumped退出的,操作系统会将内存当中,进程运行时异常的信息,转储到磁盘当中,这种计数就是核心转储,为了后续的debug。我们可以通过"ulimit -a"指令查看系统给每一个进程的相关资源,如下所示:

我们发现有一个资源core file size为0,这表示默认将核心转储功能关闭了。打开核心转储可以使用"ulimit -c [大小]"指令,打开核心转储后,若程序出现异常,会形成一个core文件,在debug时加载该文件即可快速定位出现异常的位置,这种debug方式称为事后调试。

在之前将进程等待的时候,我们介绍了回收进程时可以获得退出状态和终止信号等信息,处理之外,也可以获得core dump标志,如下所示:

验证程序如下所示:

cpp 复制代码
#include <cstdio>
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>

void handler(int signo)
{
    std::cout << "收到了一个信号:" << signo << " who: " << getpid() << std::endl;
}

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        std::cout << "test sig...,pid: " << getpid() << std::endl;
        sleep(1);

        int a = 10;
        a /= 0;
        exit(0);
    }

    int status = 0;
    int n = waitpid(id, &status, 0);
    (void)n;

    printf("exit code :%d, exit signal number: %d, code dumped: %d\n",\
     (status>>8)&0xFF, status&0x7FF, (status>>7)&0x1);
}

运行结果如下所示:

综上,Term就是单纯的终止进程,而Core可能会发生核心转储,设置进程退出status core dumped标志位给父进程。

软件条件产生信号

这个方式之前我们接触过,当两个父子进程通过管道进行通信时,若读端关掉,写端所在的进程就会被杀掉,这是因为操作系统识别到了软件异常,操作系统会向写端发送13号信号(SIGPIPE),写端所在的进程就会被杀掉,这种方式我们也叫pipe broken(管道损坏)。

以软件方式发送信号,也可以使用alamr函数,声明如下:

这个系统调允许给调用者进程设置闹钟,可以在调用成功之后指定的秒数给调用者发送14号信号(SIGALEM),这个闹钟的设置是一次性的,该函数的返回值是上一个闹钟的剩余时间,如果超时返回0,若alarm传入的参数为0,表示取消闹钟。

使用示例如下:

cpp 复制代码
#include <iostream>
#include <signal.h>

void Usage(const std::string &cmd)
{
    std::cout << "Usage:" << cmd << " signumber who" << std::endl;
}

void handler(int sig)
{
    std::cout << "进程捕捉到信号:" << sig << " pid: " << getpid() << std::endl;
    int n = alarm(2);
    std::cout << "上一个闹钟的剩余时间:" << n << std::endl;
}

int main(int argc, char* argv[])
{
	signal(SIGALRM,handler);
    alarm(200);

    //int cnt = 1;

    while(true)
    {
        std::cout << "进程正在运行: " << getpid() <<std::endl;
        sleep(1);
        //cnt++;
        // sleep(2);
        // abort();
    }
}

运行结果如下:

使用函数产生信号

kill

可以使用kill系统调用向指定的进程发送指定的信号,声明如下所示:

使用示例如下:

cpp 复制代码
#include <iostream>
#include <signal.h>

void Usage(const std::string &cmd)
{
    std::cout << "Usage:" << cmd << " signumber who" << std::endl;
}

// ./mykill signumber who
int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(0);
    }

    int signumber = std::stoi(argv[1]);
    pid_t pid = std::stoi(argv[2]);

    int n = kill(pid, signumber);
    (void)n;

    return 0;
}

运行结果如下:

raise

raise和kill类似,函数声明如下:

谁调用这个函数,就发送信号给谁。使用示例如下:

cpp 复制代码
#include <iostream>
#include <signal.h>

void Usage(const std::string &cmd)
{
    std::cout << "Usage:" << cmd << " signumber who" << std::endl;
}

void handler(int sig)
{
    std::cout << "进程捕捉到信号:" << sig << " pid: " << getpid() << std::endl;
}

// ./mykill signumber who
int main(int argc, char* argv[])
{
	signal(2,handler);
    while(true)
    {
        std::cout << "进程正在运行" << std::endl;
        sleep(2);
        raise(9);
    }
}

运行结果如下:

abort

该函数可以引起进程终止,给调用函数的进程发送6号信号(SIGABRT),函数声明如下:

该函数通常用来终止进程,类似于exit(),使用abort退出,退出方式为Core,方便快速调试。这个信号可以被捕捉,但是即使被捕捉,还是会终止进程。

总结

发送信号的方式有很多,主要是围绕用户、硬件、软件各种场景展开的,但是无论以什么方式发送信号,最终都要借助操作系统之手向目标进程写信号。

信号保存

信号保存在进程的task_struct结构体中,用位图来保存进程收到的信号,比特位的位置为信号的编号,比特位的内容为是否收到了对应的信号,1表示收到,0表示未收到。向目标进程"发送"信号,本质是修改信号位图。信号位图在task_struct中,修改位图本质就是在修改task_struct内核数据结构,只有操作系统才能进行修改,因此,不管产生信号的方式有多少种,最终都要交给操作系统来向目标进程写信号,操作系统要为用户提供相关的系统调用。

相关概念

在正式介绍信号保存之前,我们先来介绍以下几个概念:

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

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

阻塞(Block):进程可以选择阻塞某个信号,被阻塞的信号产生时将保持在未决状态,直到进程解决对此信号的阻塞,才执行递达的动作。阻塞和忽略是不同的,阻塞不是信号处理的方式,属于不让信号递达的方式之一;而忽略是在递达之后可选的一种处理动作。

内核表示

信号在内核中如下图所示:

pending表和block表都是用位图来实现的,pending表对应信号未决,若信号暂时不处理,就会保存在pending表中,比特位置表示信号的编号,比特位的内容表示是否收到。block表也是位图表,代表对应信号是否阻塞,比特位的位置表示信号的编号,比特位的内容表示是否阻塞。还有一个表,就相当于sighandler_t handler[32],这是一个函数指针数组,代表捕捉到信号后要进行的的操作。这也是为什么信号还没产生的时候,进程就能识别和处理信号,因为内核程序员已经内置信号的管理和处理方法了。

sigset_t

操作系统要让用户控制信号,本质就是访问和操作上面的三张表,但这三张表都属于内核数据结构,这就需要操作系统提供对应的系统调用。

获取pending表和block表,需要涉及到一个数据类型:sigset_t,这是操作系统提供的一种类型。

根据上图,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的"有效"或者"无效"状态,在阻塞信号集中"有效"和"无效"的含义是该信号是否被屏蔽,而在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态。阻塞信号集,也叫做当前进程的信号屏蔽字(Signal Mask)。

信号集操作函数

Linux系统提供了相关信号集操作函数,如下所示:

sigprocmask

这个函数的声明如下:

这个函数会对block表进行操作,第一个参数为操作的方式,常见的三种操作如下:

SIG_BLOCK为要往blcok表中新增对特定信号的屏蔽,相当于mask=mask | set,SIG_SETMASK设置当前信号屏蔽字set所指向的值,相当于mask=set,SIG_UNBLOCK相当于从当前信号屏蔽字中解决阻塞的信号,相当于mask = mask &~set。

第三个参数为输出型参数,用来保存上一次blcok的内容。

若函数执行成功返回0,失败返回1。

sigpending

sigpending函数的声明如下:

这个函数用来操作pending表,其中set为输出型参数,用来获取pending表中的信号位获取出来,函数执行成功返回0,否则返回-1。

使用

以上函数的简单使用如下所示:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

void PrintPending(const sigset_t &pending)
{
    for (int signo = 31; signo > 0; signo--)
    {
        if (sigismember(&pending, signo))
        {
            std::cout << "1";
        }
        else
        {
            std::cout << "0";
        }
    }
    std::cout << std::endl;
}

void handler(int signo)
{
    std::cout << "--------------------------------------- enter handler" << std::endl;
    std::cout << "处理完成: " << signo << std::endl;
    sigset_t pending;
    sigemptyset(&pending);
    //获取pending表
    int n = sigpending(&pending);
    (void)n;
    PrintPending(pending);
    std::cout << "--------------------------------------- leave handler" << 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);

    int n = sigprocmask(SIG_SETMASK, &block_set, &old_set);
    (void)n;

    int cnt = 1;
    while (true)
    {
        sigset_t pending;
        sigemptyset(&pending);
        // 2.获取pending表
        n = sigpending(&pending);
        (void)n;
        // 3.打印pending表
        PrintPending(pending);

        if (cnt == 20)
        {
            // 4.解除对2号信号的屏蔽
            std::cout << "解除对2号信号的屏蔽" << std::endl;
            n = sigprocmask(SIG_SETMASK, &old_set, nullptr);
            (void)n;
        }
        cnt++;
        sleep(1);
    }

    return 0;
}

运行结果如下:

我们发现,一旦我们解除对某个信号的阻塞,该信号就会立即被递达。同时,我们也证明了,在调用handler之前,pending对应的比特位已经恢复了。

同时注意,9号信号和19号信号是不可被屏蔽的。

信号处理

用户态和内核态

两种定义如下:

用户态:执行代码,访问数据,都在访问[0, 3GB]地址空间的时候,就是访问用户自己的代码,自己的数据。

内核态::执行代码,访问数据,都在访问[3GB, 4GB]地址空间的时候,就是访问操作系统的过程。

从以上来看,内核态的权限级别更高,进入内核态的一个典型过程就是调用系统调用,系统调用完成后会再返回用户态。

之前我们介绍过,进程的虚拟地址中,会有一部分用户空间,而通过用户页表可以将物理内存映射到对应进程的用户空间中。同样地,在加载操作系统时,内核空间也会通过一个内核级页表,将物理内存中操作系统的内存块映射到进程的内核空间中。因此,进程的所有函数调用,都是在自己的虚拟地址空间内完成。同时,每一个进程都有一套自己的一套用户级页表,但是内核级页表只有一份,被所有进程共享,因此,任何一个进程进行调度的时候,想找到操作系统,随时可以找到。为了保护操作系统,操作系统是不允许用户访问3-4GB中的内容。这也是为什么要定义用户态和内核态的原因。

在Linux系统中,会存在很多地方需要进行权限管理,用户态和内核态,需要硬件级的支持,描述的是进程所处的状态,但用户态和内核态是CPU的两种执行级别,CPU通过相关寄存器来区分两种状态。

处理时机

信号处理的大概过程如下:

若对特定信号的处理方式为SIG_DFL(默认在系统当中删除进程)或SIG_IGN,在内核态即可处理完成;若处理方式为用户自定义,那么就会跳转到对应的函数处,执行完后再使用特殊系统调用转换到内核中,再返回用户态继续执行后续代码。执行对应的自定义处理函数是以用户态来执行的。当进程调度的时候,从内核态返回至用户态的时候,会通过do_signal()函数进行信号的检测和处理。

硬件中断

当我们写了一个C语言代码,里面使用了scanf函数进行输入时,程序就会被阻塞,等待用户输入,若用户完成输入,操作系统会知道键盘被按下,知道的做法有两种:操作系统自己主动轮询检测和中断。但频繁地轮询检查,会造成操作系统效率过低,因此,硬件层面上,提供了一种中断机制,中断就是暂停当前正在执行的进程,转而去执行其它进程,等时机到了再返回来继续执行未执行完成的进程。如下所示:

在CPU中,通过大量寄存器来保存硬件上下文,当某一外部设备准备好后,通过中断控制器将外部中断传给CPU。同时,每一个外设还存在唯一的中断号,在中断发生时会在中断控制器记录对应设备的中断号,CPU会从中断控制器中获得这个中断号,操作系统为提供了一组中断向量表,这张表相当于可以简单理解为函数指针数组,某一种外设准备好了,CPU可以将中断号转化为下标或索引,进而找到处理相关中断的方法。

当中断发生时,CPU也保存在当前正在执行的进程的代码和数据,因此,在CPU识别到有外部中断到来了,CPU会先将当前寄存器内部的数据临时保存起来,这个过程叫做CPU的现场保护 ,中断处理做完后,再通过上下文信息恢复现场 ,再继续执行没有执行完的进程。

综上所述,暂停CPU正在执行的任务,转而去处理硬件突发事情,结合中断号和中断向量表来完成中断处理的过程,叫做硬件中断,中断向量表是软件层面的。

在计算机中,先有硬件中断,由硬件完成处理,后来,人们发现进程也需要类似的机制,这就有了信号机制,信号机制其实是用软件的方式,模拟中断完成特定的任务处理的,信号和硬件中断原理类似,但本质完全不同。

中断向量表是操作系统的一部分,启动后就加载到内存中了,通过外部硬件中断和中断向量表,操作系统就不需要对外设进行任何周期性的检测后者轮询。

时钟中断

除了外部设备可以发送中断,也可以用一个固定的外设通过固定的频率给CPU发送属于它自己的中断,并固定地执行对应的方法。进程是由操作系统来调度运行的,但是操作系统也是个软件,那么 谁让操作系统来运行呢?

中断本身属于操作系统的一部分,所以,CPU可以通过中断的方式,定期地执行中断向量表中的指定方法,相当于CPU定期执行操作系统,这种中断就是时钟中断。这种通过外部以固定频率向CPU触发中断的现象叫做外部晶振。

在操作系统内部,会存在一些全局变量,用来统计开机到现在累计的滴答次数,开机的时间也可以获取,通过开机时间和滴答次数,就可以计算当前的时间。在进程的task_struct中会存在一个counter的成员变量,这个就是时间片,本质是一个计数器,这个计数器-1后若还大于0,说明时间片还没有被耗完;若等于0,则进行相关函数的调度,中断处理和进程的运行是串行运行的关系。

正因为有时钟中断处理函数,能让进程在一定的时间间隔内切换、调度和运行,因此,整个操作系统能够被触发运行最核心的原因就是时钟中断。操作系统执行调度算法也是靠固定的时间间隔的时钟中断来实现的。

当代计算机会将时钟源集成在CPU内部,减少了时间,如下所示:

综上,其实是时钟源,时钟中断和硬件中断促使操作系统运行的,操作系统是一个死循环,没有任务就会卡住,等待任务,有任务就会执行对应的任务。

软中断

CPU内部也会内置一些特殊的软件指令集,这些指令集若被CPU识别,CPU也会自动触发中断处理流程,这些指令集向CPU触发的中断号一般固定为0x80,CPU支持这个指令集,就代表程序员可以写这个代码,可以在自己的程序中带上这个中断,C或C++编译器也能通过一定的方式主动触发一次中断,这种由软件触发的中断方式称为软中断。

系统调用也是通过中断来实现的。在操作系统中,当调用系统调用时,在上层用汇编,将系统调用号存储到寄存器中,随后触发软中断,进入中断逻辑,所有的系统调用都放在一张表中,这张表中存放系统调用的地址,在内核层面上,每一个系统调用都存在系统调用号,这个系统调用号本质是一个数组下标,通过这个就可以找到对应的系统调用。在软中断之前的工作,是C语言标准库来做的,只要知道系统调用号等信息就可以完成,C语言会将几乎所有的系统调用进行封装,方便用户使用。

软中断也称为陷阱。

其它中断

中断还有缺页中断、内存碎片处理以及除零野指针错误等类型的中断,所以操作系统还要处理异常的中断,操作系统就是一个基于中断处理的软件集合。

自定义处理

signal

该函数的相关声明如下:

该函数可以更改指定进程更改信号处理动作,第一个参数为目标信号,第二个参数为函数指针类型,函数表明了接收到信号要执行的动作,函数指针的形参代表是收到了哪一个信号,才执行的。

对信号进行自定义捕捉时,只需要程序的开始位置定义一次即可。

函数执行成功时,返回SIG_ERR,一般为-1,代表老的动作,方便后续进行恢复。

使用如下所示:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cstdlib>

//signo: 是收到了哪一个信号,才让我执行的
void handler(int signo)
{
    std::cout << "收到了信号" <<signo <<std::endl;
    //exit(10);
}

int main()
{
    //开头处调用一次即可。
    signal(SIGINT, handler);
    signal(3, handler);
    while(true)
    {
        std::cout << "test sig...,pid:" << getpid() <<std::endl;
        sleep(1);
    }

    return 0;
}

运行结果如下:

信号自定义捕捉如果自定义的捕捉方法没有退出入口,那么进程可能就不会退出了,如果将所以的信号都自定义,那么就没办法让进程退出了吗?答案是否定的,9号信号和19号信号是不可以被自定义的,9号为管理员信号,可以直接杀掉相关进程。

信号的处理,是进程自己做的,并没有创建一个新的进程。

若想进行默认处理或者忽略处理,第二个参数可以传SIG_IGN或SIG_DFL,如下所示:

cpp 复制代码
    signal(SIGINT, SIG_IGN);
    signal(SIGINT, SIG_DFL);

我们使用Ctrl C,发现bash进程无法退出,这是因为,bash进程将除了9号信号的所有信号都忽略了。

sigaction

这个函数的功能与signal函数类似,函数声明如下:
第一个参数为要改变的信号的编号,第二个参数为结构体,结构体中包含了如何处理,第三个结构体包含了旧的处理方法。函数执行成功返回0,否则返回-1。

结构体中的信息如下:

将结构体中的sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作。

同时该函数与signal函数不同的是,当某个信号的处理函数被调用时,内核将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,也就是将block表中对应信号的内容由0变成1,处理完成后再由1变0。

如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外⼀些信号,则可以用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。

使用示例如下:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
    std::cout << "捕捉一个信号: " <<signo << std::endl;

    sigset_t pending;
    while(true)
    {  
        sigpending(&pending);
        for(int i = 31;i >= 1;i--)
        {
            if(sigismember(&pending, i))
            {
                std::cout << "1";
            }
            else
            {
                std::cout << "0";
            }
        }
        sleep(1);
        std::cout << std::endl;
    }
}

int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    sigemptyset(&(act.sa_mask));
    
    sigaddset(&(act.sa_mask), 3);
    sigaddset(&(act.sa_mask), 4);
    sigaddset(&(act.sa_mask), 5);

    act.sa_restorer = nullptr;
    act.sa_flags = 0;
    sigaction(SIGINT, &act, &oact);

    while(true)
    {
        std::cout << "进程在运行: " << getpid() << std::endl;
        sleep(1);
    }

    return 0;
}

运行结果如下:

这个程序也证明了函数会在处理过程中将当前信号进行屏蔽,函数这样设计是为了防止handler函数被嵌套调用,保证一次只会处理一次信号递达。

使用这个函数,尽量将struct sigaction都做好初始化。

可重入函数

我们来看以下链表插入节点的一个过程:

当插入node1时,插入过程执行了一部分,此时收到了信号,转而去执行signalhandler中的函数部分,函数部分中也要进行插入,插入完成后再转而去执行原来函数未执行的语句,综合上图我们发现,函数结束完成后,node2节点出现了丢失,存在内存泄漏问题,这种情况叫做insert函数被重入了。在这个场景中,insert被重入会导致内存泄漏,因此,在这个场景中,insert是不可重入的;若函数重入不会出现问题,就称函数是可重入的。

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

调用了malloc或free,因为malloc也是全局链表来管理堆的。

调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

可重入或者不可重入,并不是函数的优缺点,而是函数的一个特点。

volatile

我们来看以下代码:

cpp 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

int flag = 0;

void change(int signo)
{
    flag = 1;
    printf("change flag : 0 -> 1\n");
}

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

    while(!flag);
    printf("进程正常退出\n");
}

在上述代码中,当传送2号信号时,才会退出死循环,但是在某些情况下,flag只会被检测,不会被修改,部分编辑器检测到这个现象,会进行优化,将flag直接放到CPU寄存器中,从此以后,再也不会访问内存进而加载flag变量了,只会检测CPU中寄存器中对应的变量。

在C语言中,存在关键字register,这个关键字建议将变量优化到寄存器中。

同时,gcc可以通过"-O1"选项进行优化,将上述代码优化后再运行,并在运行过程中发送2号信号,结果如下:

这是因为编译器将flag变量优化到了寄存器中,检测时不再读取内存中的flag,只会读取寄存器中flag对应的值,是一种寄存器覆盖了内存的可见性,让内存不可见了。

若不想要进行优化,可使用volatile,这个变量就相当于告诉编译器,不要对flag进行任何内存级的优化,要保持flag的内存可见性。

SIGCHLD信号

这个信号是17号信号,当父进程创建子进程,如果子进程退出了,子进程退出的时候,会想父进程发送SIGCHLD信号,该信号相关信息如下:

这个信号退出方式为Ign,这个方式是忽略。

以下用一个程序来证明:

cpp 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>

void handler(int signo)
{
    printf("父进程获取信号:%d, pid: %d\n",signo,getpid());
}

int main()
{
    signal(SIGCHLD, handler);
    pid_t id = fork();
    if(id == 0)
    {
        printf("子进程退出,pid: %d\n",getpid());
        sleep(4);
        exit(0);
    }

    while(1)
    {
        printf("父进程在运行,pid: %d\n",getpid());
        sleep(1);
    }

    return 0;
}

运行结果如下所示;

若回收子进程,如果父进程也想同时做其他事情,同时并不想父进程因为wait、waitpid阻塞等待让父进程卡住,可以让父进程收到SIGCHLD信号之后,回收子进程,如下所示:

cpp 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>

void handler(int signo)
{
    printf("父进程获取信号:%d, pid: %d\n", signo, getpid());
    int status = 0;
    waitpid(-1, &status, 0);
    printf("status code: %d\n", WEXITSTATUS(status));
}

int main()
{
    signal(SIGCHLD, handler);
    pid_t id = fork();
    if (id == 0)
    {
        printf("子进程退出,pid: %d\n", getpid());
        sleep(4);
        exit(10);
    }

    while (1)
    {
        printf("父进程在运行,pid: %d\n", getpid());
        sleep(1);
    }

    return 0;
}

运行结果如下:

同时,查看进程,也不会存在僵尸进程。

但是上面代码存在问题,如果是10个子进程,并且子进程几乎是同时退出,父进程会收到多个SIGCHLD信号,但是父进程只能挑一个子进程让其退出。同时,如果10个子进程中9个退出,1个没退,如果一直回收的话会造成父进程的阻塞等待。

对于第一个问题,可以让父进程一直循环等待,直到回收完所有的子进程;对于第二个父进程的阻塞等待问题,在waitpid的时候可以使用非阻塞等待的方式。

最终代码如下所示:

cpp 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>

void handler(int signo)
{
    printf("父进程获取信号:%d, pid: %d\n", signo, getpid());
    int status = 0;
    while (1)
    {
        pid_t ret = waitpid(-1, &status, WNOHANG);
        if (ret <= 0)
            break;
    }
    // printf("status code: %d\n", WEXITSTATUS(status));
    printf("wait done\n");
}

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

    for (int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            if(i == 5)
            {
                printf("%d 进程不退出,pid:%d\n",i, getpid());
                sleep(5);
            }
            printf("子进程退出,pid: %d\n", getpid());
            sleep(4);
            exit(10);
        }
        sleep(1);
    }

    while (1)
    {
        printf("父进程在运行,pid: %d\n", getpid());
        sleep(1);
    }

    return 0;
}

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

cpp 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>

void handler(int signo)
{
    printf("父进程获取信号:%d, pid: %d\n", signo, getpid());
    int status = 0;
    while (1)
    {
        pid_t ret = waitpid(-1, &status, WNOHANG);
        if (ret <= 0)
            break;
    }
    printf("wait done\n");
}

int main()
{
    signal(SIGCHLD,SIG_IGN);// 用户明确忽略

    for (int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            printf("子进程退出,pid: %d\n", getpid());
            sleep(4);
            exit(10);
        }
        sleep(1);
    }

    while (1)
    {
        printf("父进程在运行,pid: %d\n", getpid());
        sleep(1);
    }

    return 0;
}

虽然用户不进行自定义捕捉,默认的处理就是忽略,但是用户设置了忽略和没设置忽略的对应的标志位是不一样的,当用户设置了SIG_IGN时,会覆盖掉原来的状态。

相关推荐
MC皮蛋侠客2 小时前
Linux C++使用GDB调试动态库崩溃问题完全指南
linux·c++
超轶绝尘2 小时前
C++学习笔记 23 宏 Macro
c++
Wang's Blog2 小时前
RabbitMQ: 消息发送、连接管理、消息封装与三种工程方案
linux·ubuntu·rabbitmq
Vect__3 小时前
初识操作系统
linux
若风的雨3 小时前
pcie bar 地址对齐规则
linux
神仙别闹3 小时前
基于QT(C++)实现的翻金币游戏
c++·qt·游戏
CQ_YM3 小时前
Linux线程控制
linux·c语言·开发语言·线程
zengxiaobai3 小时前
客户端 address 不足
linux
UP_Continue3 小时前
C++--右值和移动语义
开发语言·c++