【Linux】信号保存、信号处理、可重入函数、volatile关键字、SIGCHLD信号

目录

一、信号保存

1.1 信号相关的概念名词

1.2 在内核中的表示

1.3 sigset_t与操作函数

1.4 信号设定

二、信号处理

2.1 内核空间与用户空间

2.2 内核态和用户态

2.3 信号的捕捉流程

2.4 sigaction 函数

三、可重入函数

四、volatile

五、SIGCHLD信号


一、信号保存

1.1 信号相关的概念名词

当信号产生时,信号的处理可选操作有:1.忽略;2.默认处理;3.自定义处理。

  • 实际执行信号的处理动作称为信号递达(Delivery)
  • 信号从产生到递达之间的状态,称为信号未决(Pending)
  • 进程可以选择阻塞(Block)某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
  • 注意,阻塞和忽略是不同的,只要信号被则阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

1.2 在内核中的表示

在内核中,信号的产生、处理与下面两张位图和一个函数指针数组有关,示意图如下:

block 和 pending 位图就表示着信号状态,接下来我们就来分析这些结构存在的意义以及相应的信号处理动作。


pending 位图

该位图称为 pending 信号集

  • 用映射的比特位位置来表示对应的信号编号,用0和1来表示是否收到信号。
  • OS就是通过修改pending表中对应的比特位来进行信号的发送。

block 位图

block 位图 称之为阻塞信号集,也称为当前进程的信号屏蔽字(Signal Mask),这里的屏蔽应理解为阻塞。

block 位图与peding相同,位图中的内容代表的含义是对应的信号是否被阻塞。


handler 数组

handler 数组被称为 handler 处理方法表。

handler 本质就是一个函数指针数组,其中存放着对应信号的默认处理函数。当 pending 位图中某个比特位被修改时,就会去对应的 handler 数组调用该函数进行信号的处理。

所以,signal函数的本质:

根据信号编号将数组下标处的处理函数换为我们自定义的函数。示意图如下:

 而其中这些SIG_DEL、SIG_IGN是用于让OS进行信号判断的,确定接下来的处理动作。

(typedef void (*__sighandler_t) (int),__sighandler_t 被声明定义为函数指针)

步骤如下:


一个信号被处理,是怎样的一个处理过程呢?

  1. 发送信号:本质就是OS修改 pending 位图。
  2. 处理信号:检测 pending 位图是否有信号产生,再检查block位图判断该信号是否被阻塞,阻塞则不处理该信号;如果没有被阻塞再去对应的 handler 数组中判断是哪种处理方式,然后进行处理。

1.3 sigset_t与操作函数

是操作系统为我们提供的一种位图结构。

sigset_t位图只能通过系统调用接口进行操作,不允许用户自己的接口进行操作。

sigset的接口与普通位图操作非常相似,常用接口如下:

#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 所指向的信号集,使其中所有信号对应的比特位清零,表示该信号不包含任何有效信息。
  • sigfillset函数:初始化 set 所指向的信号集,使其中所有信号对饮的比特位置1,表示该信号集的有效信号包括系统支持的所有信号。
  • siaddset函数:在 set 所指向的信号集中添加某种有效信号。
  • sigdelset函数:在 set 所指向的信号集中删除某种有效信号。
  • sigismember函数:判断 set 所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用返回-1; 

以上是 signet_t 的操作接口,那signet_t 能完成什么功能呢?

接下来我们再来学习一些接口:

sigpending 接口:

功能:

        获取当前进程的 pending 信号集。即可以拿到内核中的pending位图

返回值:

        成功返回0,失败返回-1,错误码被设置。

sigprocmask 接口:

功能:

        检测并更改当前进程的 block信号集。

参数1: (进行以下哪种操作)

参数2:

        参与操作的位图,与参数1搭配使用

参数3:

        是一个输出型参数,返回修改前的位图结构,用于记录保存,不需要可以设为 NULL 。

返回值:

        成功返回0,失败返回-1,错误码被设置。

1.4 信号设定

有了上面的一系列接口,我们可以就可以实现一些信号相关的问题:

  1. 如果我们对所有的信号都进行了自定义捕捉,那这个进程是不是就不能被任何信号终止?可以实现吗?结果如何?
  2. 如果将2号信号block,并且不断获取当前进程的pending信号集;此时我们突然发送2号信号,可以看到pending信号集中有一个比特位由0变为1吗?
  3. 如果对所有的信号进行block,那这个进程是不是就不能被任何信号终止?可以实现吗?结果如何?

关于问题一的代码:

void catchSig(int signum)
{
    cout << "捕捉到一个信号" << signum << endl;
}

int main()
{
    // 对所有信号进行自定义捕捉
    for (int i = 1; i <= 31; i++)
    {
        signal(i, catchSig);
    }
    while (1)
        sleep(1);
    return 0;
}

结果如下:

结论:

  • 无法实现不受信号的控制的进程,虽然我们尝试设定了所有信号的自定义捕捉方式,但是9号信号是管理员信号,无法进行自定义捕捉,所以该假设无法实现。

问题二:

int main()
{
    // 1.定义信号集
    sigset_t bset, obset, pending;
    // 2.初始化
    sigemptyset(&bset);
    sigemptyset(&obset);
    // 3.添加要进行屏蔽的信号---屏蔽2号信号
    sigaddset(&bset, 2);
    // 4.设置set到内核对应的block信号集中,(默认情况进程不会对任何信号进行block)
    sigprocmask(SIG_BLOCK, &bset, &obset);
    // 5.重复打印当前进程的pending 信号集
    while (1)
    {
        // 初始化pending位图
        sigemptyset(&pending);
        // 将当前进程的pending信号集放置到pending位图中
        sigpending(&pending);
        // 打印pending位图.
        for (int sig = 1; sig <= 31; sig++)
        {
            // 如果该位是1,则打印1,反之亦然
            if (sigismember(&pending, sig))
                cout << "1";
            else
                cout << "0";
        }
        cout << endl;
        sleep(1);
    }
    return 0;
}

 打印结果:

 结论:

  • 可以看到比特位由0置1,因为pending位图就是信号的记录位图,而我们使用系统调用接口实时地打印pengding位图的情况,就可以看到2号信号比特位由0置1。

问题三:

将所有信号都block,能实现不受信号控制的进程吗?

void blockSig(int sig)
{
    sigset_t bset;
    sigemptyset(&bset);
    sigaddset(&bset, sig);
    sigprocmask(SIG_BLOCK, &bset, nullptr);
}

int main()
{
    // block所有信号
    for (int sig = 1; sig <= 31; sig++)
    {
        blockSig(sig);
    }
    // 打印block信号表
    sigset_t pending;
    while (1)
    {
        sigpending(&pending);
        for (int sig = 1; sig <= 31; sig++)
        {
            // 如果该位是1,则打印1,反之亦然
            if (sigismember(&pending, sig))
                cout << "1";
            else
                cout << "0";
        }
        cout << endl;
        sleep(1);
    }
    return 0;
}

一个简单获取进程pid的命令:pidof + 进程名

 接下来我们再写一个脚本,让脚本帮助我们发送1-32号信号

i=1; id=$(pidof mysignal); while [ $i -le 31 ] ; do kill -$i $id; echo "send signal $i" ; let i++; sleep 1; done

运行结果如下: (9号信号仍然不受影响)

 接下来再写一段脚本,跳过发送9号信号。

i=1
id=$(pidof mysignal)
while [ $i -le 31 ] 
do 
    if [ $i -eq 9 ];then
        let i++
        continue
    fi
    kill -$i $id
    echo "kill -$i $id"
    let i++
    sleep 1
 done

运行结果如下:

一个小现象是:19号和20号都是和暂停相关的信号,也是不允许阻塞的。

二、信号处理

2.1 内核空间与用户空间

每一个进程都有自己的进程地址空间,该进程地址空间由内核空间和用户空间构成:

  • 用户代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
  • 操作系统代码和数据存储在内核空间,通过内核级页表与物理内存之间建立映射关系。

其中,内核级页表是一个全局页表,它是用来维护操作系统的代码和进程之间的关系的。

因此,每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容。

 每个进程都能看到内核空间,但并不意味着每个进程都能随时对其进行访问。

那如何理解进程切换呢?

  1. 在当进程的进程地址空间中的内核空间,找到OS的代码和数据。
  2. 执行OS的代码,将当前进程的代码和数据剥离下来,并换上另一个进程的代码和数据。

当访问用户空间时处于用户态,而当你访问内核空间时必须处于内核态。

2.2 内核态和用户态

内核态与用户态:

  • 内核态通常用来执行操作系统的代码,是一种权限非常高的状态。
  • 用户态是一种用来执行普通用户代码的状态,是一种受监管的普通状态。

进程收到信号后,并不是立即处理信号,而是在合适的时候,进行信号的处理。

这个合适的时候就是指从内核态切换回用户态的时候

内核态和用户态之间是如何进行切换的?

用户态切换为内核态通常有以下几种情况:

  1. 需要进行系统调用时。
  2. 当进程进行时间片轮转时,导致进程切换进入内核态。
  3. 产生异常、中断、陷阱等情况时。

从内核态切换为用户态通常有以下几种情况:

  1. 系统调用结束返回。
  2. 进程切换完毕。
  3. 异常、中断、陷阱处理完毕。

2.3 信号的捕捉流程

信号的捕捉流程其实可以被分为两种

  • 一种是系统直接执行默认或忽略动作;
  • 另一种是执行我们的自定义动作;

默认处理或忽略处理时:

如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清楚对应的pending标志位,如果没有新的信号进行递达,则直接返回用户态,从主控制流程中上次被中断的地方继续向下执行。

执行自定义动作:

如果待处理的信号是自定义捕捉的,即该信号的处理动作是用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理动作,执行完后再通过特殊的系统调用 sigretur 再次陷入内核并清除对应的 pending 位图标志位,如果没有新的信号要递达,就直接返回用户态,继续执行主流程的代码。

 注意:

sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用与被调用的关系,是两个独立了的控制流程。

 通俗理解自定义捕捉流程与结论总结:

结论:

        其中该图形与直线有几个交点就代表在这期间有几次状态切换,而箭头的方向就代表着此次状态切换的方向,圆形中间的圆点就代表着检查 pending 位图表。

此时引入一个问题:

当识别到信号的处理动作是自定义时,能直接在内核态直接执行用户空间的代码吗?

  • 理论上是可以的,因为内核态是一种权限非常高的状态,但是绝对不能这样设计。
  • 如果允许内核态直接执行用户空间的代码,那么用户就可以在代码中设计一些非法操作,比如清空数据等,虽然用户态没有足够的权限做到清空数据,但是内核态有足够的权限能执行此类代码。
  • 所以为了防止此类操作,操作系统不会在内核态下执行用户代码。因为操作系统无法保证用户的代码是合法代码,即操作系统不信任用户的行为。

2.4 sigaction 函数

捕捉信号除了前面用过的 signal 函数之外,我们还可以使用 sigaction 函数对信号进行捕捉:

功能:

        检查并更改信号的处理动作。简而言之就是捕捉信号

参数:

        参数1: 要自定义的信号编号,传入宏或信号编号。

        参数2:输入型参数,传入信号的新处理方法。

        参数3:输出型参数,返回信号旧的处理方法。

返回值:

        成功返回0,失败返回-1,并设置错误码。

sigaciton 结构体:

成员1 (sa_handler):

       将sa_handler赋值为常数SIG_IGN传给 sigaction 表示忽略信号,赋值为常数 SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。

        即回调函数,传入我们自定义的信号处理函数即可,例act.sa_handler=handler(handler是一个函数)。

成员2 (sa_sigaction):

        实时信号的处理函数接口,因为暂时不处理实时信号,不用设置~

成员3 (sa_mask):

        是一个sigset_t(系统位图)结构,直接使用sigemptyset(&act.sa_mask)清空即可。

成员4 (sa_flags):

        与实时信号相关,暂时无关,设置为0即可。

成员5 (sa_restorer):

        暂时不用设置~

接下来我们使用一下 sigaction 函数,目的如下:

使用 sigaction 捕捉2号信号,并查看handler数组中的处理动作是什么:

void handler(int signum)
{
    cout << "获取了一个信号" << signum << endl;
}
int main()
{
    struct sigaction act, oact;
    act.sa_flags = 0;
    sigemptyset(&act.sa_mask);
    //设置自定义信号处理函数
    act.sa_handler = handler;
    // 设置进当前进程的pcb中
    sigaction(2, &act, &oact);
    cout << "default action " << (int)(oact.sa_handler) << endl;
    while (1)
        sleep(1);
    return 0;
}

运行结果:

 block位图的意义:

  • 当某个信号的处理函数被调用时,内核自动将当前信号对应的 block 位图比特位置为1,表示阻塞,当信号处理函数返回时自动恢复原来的bloc位图状态,这样就保证了在处理莫格信号时,如果这个信号再次发生,那么它就会阻塞到当前处理结束为止
  • 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还系统自动屏蔽另外一些信号,就可以使用 sigaciton 结构体 中的 sa_mask 字段进行设置额外屏蔽的信号,则当一个信号发生时,这些被添加的信号再发生时,对应 block 位图的会被设置为1,而当信号处理函数返回时会自动恢复原来的状态,这便是sa_mask字段的作用以及block位图的意义。

接下来有一段代码可以验证sa_mask的作用:

设置了2号信号的自定义函数,并将sa_maks中设置3、4、5、6、7信号。

即,2号信号产生时,3、4、5、6、7信号的 block 位图被置1,通过打印pending位图,即使3、4、5、6、7号信号发生,这些信号也不会被处理。

代码如下:

// block位图的意义:
void showPending(sigset_t *pending)
{
    for (int sig = 1; sig <= 31; sig++)
    {
        if (sigismember(pending, sig))
            cout << "1";
        else
            cout << "0";
    }
    cout << endl;
}

void handler(int signum)
{
    cout << "获取了一个信号: " << signum << endl;
    cout << "获取了一个信号: " << signum << endl;
    cout << "获取了一个信号: " << signum << endl;
    cout << "获取了一个信号: " << signum << endl;

    sigset_t pending;
    int c = 20;
    while (true)
    {
        // 获取pending位图并打印
        sigpending(&pending);
        showPending(&pending);
        c--;
        if (!c)
            break;
        sleep(1);
    }
}

int main()
{
    cout << "getpid: " << getpid() << endl;
    // 内核数据类型,用户栈定义的
    struct sigaction act, oact;
    act.sa_flags = 0;
    sigemptyset(&act.sa_mask);
    // 设置自定义函数
    act.sa_handler = handler;

    // sa_maks中设置3、4、5、6、7信号,即2号信号发生会阻塞这些信号
    sigaddset(&act.sa_mask, 3);
    sigaddset(&act.sa_mask, 4);
    sigaddset(&act.sa_mask, 5);
    sigaddset(&act.sa_mask, 6);
    sigaddset(&act.sa_mask, 7);

    // 设置进当前调用进程的pcb中
    sigaction(2, &act, &oact);

    cout << "default action : " << (int)(oact.sa_handler) << endl;
    while (true)
        sleep(1);
    return 0;
}

结果:

注意,信号捕捉,并没有创建新的进程或线程。

三、可重入函数

一个函数在一个时间段内被多个执行流重复进入,这种情况就叫做重入函数;

而重入时没有发生问题的叫可重入函数,会发生问题的叫做不可重入函数。可重入和不可重入是函数的一种特征,我们大部分编写的函数都是不可重入函数。

那什么特征的函数是不可重入函数?

  1. 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  2. 调用了标志I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
  3. 比如函数用了全局数据, errno 错误码就是一个全局变量,大部分函数不可重入函数。

举一个链表结点的插入例子来形象理解可重入/不可重入:

 如果是一个执行流该代码不会有什么问题,如果是一段时间被多个执行流反复跳转,则下面这个普通的链表插入结点都会产生错误。

四、volatile

volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性。

首先我们写一段代码进行引入。

因为flag==0,所以main函数处于死循环状态,我们自定义设置2号信号的处理函数,当2号信号产生时我们将flag置为1,则主函数跳出死循环。

int flag = 0;
void changFlag(int signum)
{
    cout << "change flag:" << flag;
    flag = 1;
    cout << "->" << flag << endl;
}
int main()
{
    signal(2, changFlag);
    while (!flag)
        ;
    cout << "进程正常退出:" << flag << endl;
    return 0;
}

 我们使用的g++,有不同级别的优化选项:

接下来我们不使用默认的优化策略,设置优化选项为-O3,结果如下:

发现,使用Ctrl+C发送2号信号无法终止该进程。

必然是优化选项对flag做了特殊处理,导致该代码收到2号信号后仍无法终止。

原因如下:

因为我们对 flag 的频繁访问,编译器将flag放入了寄存器中。正常的优化是:需要检测flag时去内存中 将其读入寄存器然后进行检测,而2号信号产生时,改动了内存中!的flag,而寄存器中的flag没有被改动,所以检测时一直检测的寄存器中的flag,所以该死循环无法被终止。

总结一句话是,cpu无法看到内存中的flag了。

所以我们要使用关键字volatile显性地告诉编译器,不要将一些变量放入寄存器中,保持内存的可见性。

现在我们使用volatile修饰flag,再观察结果:

那这个优化是在编译时进行的还是执行时进行优化的呢?

编译时进行优化的,因为编译后,gcc让CPU将flag放入寄存器中,这个举动是在编译后就确定了,只不过是运行时才能体现出效果。

五、SIGCHLD信号

子进程暂停或退出时会主动向父进程发送SIGCHLD(17号)信号。而父进程对17号信号的默认处理动作是忽略。

  • 为了避免出现僵尸进程,父进程需要使用wait或waitpid函数等待子进程结束,父进程可以阻塞等待子进程结束,也可以非阻塞地查询的是否有子进程结束等待清理,即轮询的方式。采用第一种方式,父进程阻塞就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
  • 其实,子进程在终止时会给父进程发生SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理动作,这样父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait或waitpid函数清理子进程即可。

首先我们写一段代码验证一下子进程退出是否会给父进程发送17号信号:

//子进程退出会向父进程发送信号
void handler(int signum)
{
    cout << "子进程退出" << signum << endl;
}

int main()
{
    signal(SIGCHLD, handler);
    if (fork() == 0)
    {
        sleep(1);
        exit(0);
    }
    while (1)
    {
        sleep(1);
    }
}

SIGCHLD与waitpid使用场景:

那我们可以在捕捉信号中可以进行子进程的 等待wait 操作。那接下来,父进程下有10个子进程,

比如同时有5个子进程退出,位图中只有一个比特位记录退出的情况,而不会记录退出的子进程个数,所以我们要进行遍历检查10个子进程是否有退出的情况,而此时我们不能使用阻塞式等待,因为如果有一个子进程没有退出,那父进程就一直阻塞等待该进程退出了。

所以我们要使用while遍历所有的子进程,并使用waitpid采取非阻塞的方式进行等待。这样只要有子进程退出,父进程就会将该子进程回收,并且父进程自身不会阻塞。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>

void handler(int signo)
{
	printf("get a signal: %d\n", signo);
	int ret = 0;
	while ((ret = waitpid(-1, NULL, WNOHANG)) > 0){
		printf("wait child %d success\n", ret);
	}
}
int main()
{
	signal(SIGCHLD, handler);
	if (fork() == 0){
		//child
		printf("child is running, begin dead: %d\n", getpid());
		sleep(3);
		exit(1);
	}
	//father
	while (1);
	return 0;
}

接下来我们会设置一下对SIGCHLD的忽略动作,我们实现的忽略动作是用户层的,而默认的忽略是系统层的,两者会有一些区别。

子进程退出会对父进程发送信号,然后父进程执行默认的忽略。所以我们设置当子进程退出时,捕捉该信号,然后对子进程进行回收,

观察操作系统的忽略动作和用户层设定的忽略动作有何不同:

// 系统默认忽略动作:
int main()
{
    if (fork() == 0)
    {
        cout << "child:" << getpid() << endl;
        sleep(5);
        exit(0);
    }
    while (1)
    {
        cout << "parent:" << getpid() << "father process:执行任务......" << endl;
        sleep(1);
    }
    return 0;
}

脚本监视代码:

while :; do ps ajx | head -1 && ps axj | grep SIGCHLD | grep -v grep ; sleep 1; echo "--------------------------------"; done

操作系统默认的忽略动作现象(子进程处于僵尸状态):

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

接下来就是我们设置当SIGCHLD信号产生时对SIGCHLD进行忽略(代码):

用户层设置对子进程退出SIGCHLD信号的忽略(子进程被回收):

由上面对比发现,OS默认的忽略就是忽略,不进行子进程僵尸状态的回收,而我们设置的忽略动作进行了僵尸状态的回收。

可以理解为操作系统的忽略和用户级的忽略程度不同。

操作系统的忽略就是什么都不做,即使子进程进入了僵尸状态也不做处理,如果我们设置了忽略操作,操作系统会先进行回收子进程,然后进行忽略,两者忽略的程度不一样。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Brant_zero2022

素材免费分享不求打赏,只求关注

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值