从内核到应用层:深度剖析信号捕捉技术栈(含sigaction系统调用/SIGCHLD回收/volatile内存屏障)

Linux系列


文章目录


前言

Linux系统中,信号捕捉是指进程可以通过设置信号处理函数来响应特定信号。通过信号捕捉机制,进程可以对异步事件做出及时响应,从而提高程序的健壮性和灵活性。


一、进程对信号的捕捉

图中内容及执行流程我已在Linux系列上上篇博客中介绍了,这里就不重复了。

1.1 内核对信号的捕捉

当信号的处理动作是用户自定义函数 ,在信号递达时 就调用这个函数,这称为捕捉信号 。由于信号处理函数的代码是在用户空间 的,处理过程比较复杂,举例如下:
1、用户程序注册 (对指定信号捕捉)了SIGQUIT信号的处理函数sighandler

2、 当前正在执行main函数,这时发生中断或异常切换到内核态

3、 在中断处理完毕后要返回用户态的main函数之前 检查到有信号SIGQUIT递达。

4、 内核决定返回用户态后 ,不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandlermain函数使用不同的堆栈空间 ,它们之间不存在调用和被调用的关系,是两个独立的控制流程

5、 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。

6、 再次检测sigpending位图,如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

1.2 sigaction()函数

c 复制代码
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);

功能:捕捉指定信号,并读取和修改与指定捕捉信号相关联的处理动作。

参数

signum:指定捕捉信号的编号。

act: 输入型参数,act若非空,则根据act来修改信号的处理动作。

oldact:输出型参数,oldact若非空,则获取信号原来的处理动作。

struct sigaction:系统为用户提供的结构体类型,帮助用户访问内核级结构体:

今天我们主要使用,上面两个成员对象。

  1. 信号处理方法,该方法需要一个整形变量,函数指针类型
  2. act.sa_mask 所代表的是在信号处理函数执行期间需要阻塞的信号集合。也就是说,当 指定信号被捕获并且处理函数handler开始执行时,sa_mask 里的信号会被阻塞,一直到处理函数执行完毕。

下面我们通过两个场景来认识他们:

例一

c 复制代码
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;

void handler(int signum)
{
    cout<<"I catch a signal:"<<signum<<endl;
    return;
}
int main()
{
    struct sigaction act;
    struct sigaction olact;
    memset(&act,0,sizeof(act));//初始化内存空间
    memset(&olact,0,sizeof(olact));

    sigaction(2,&act,&olact);//对二号信号捕获,并修改处理方法

    while(true)
    {
        cout<<"I am process,Pid:"<<getpid()<<endl;
        sleep(1);
    }

    return 0;
}

可以看到这样我们,就完成了对二号进程的捕获并修改执行方法为自定义的行为。

例二

c 复制代码
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<cstring>
using namespace std;

void handler(int signum)
{
    cout<<"I catch a signal:"<<signum<<endl;
    sleep(10);//在执行handler方法期间,blocksig阻塞信号集中的信号被阻塞
    return;
}
int main()
{
    struct sigaction act;
    struct sigaction olact;

    memset(&act,0,sizeof(act));//初始化内存空间
    memset(&olact,0,sizeof(olact));

    sigset_t blocksig;
    sigaddset(&blocksig,2);//将二信号添加进blocksig

    act.sa_handler=handler;//将处理方法添加到act对象中
    act.sa_mask=blocksig;//将想要阻塞的信号位图赋值给act

    sigaction(2,&act,&olact);//对二号信号捕获,并修改处理方法

    while(true)
    {
        cout<<"I am process,Pid:"<<getpid()<<endl;
        sleep(1);
    }

    return 0;
}

从执行结构可以得到,当二号信号被捕获执行处理方法,到该方法执行结束,二号信号一直被阻塞,当解除阻塞后,二号信号再次递达。这里也可以使用SIG_IGN(忽略信号)、SIG_DFL(执行默认方法),来设定act.sa_handler。测试时建议尝试其他信号,因为即使我们不手动的将二号信号添加到阻塞信号集,系统在执行二号信号时也会将它先阻塞,下面我们来详细探讨。

1.3 信号集的修改时机

当我们完成对指定信号的捕捉并执行对应处理方法时,操作系统会在执行该方法前,先将pending位图中对应信号的标志位由1置为0,并将该信号添加到对应的阻塞信号集中。具体来说,在二号信号处理方法执行期间,即便进程再次收到二号信号,该信号也不会被递达。只有当上一个信号处理方法执行完毕并返回后,操作系统解除对二号信号的阻塞,新收到的二号信号才会被递达。

c 复制代码
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<cstring>
using namespace std;

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

    for(int i=1;i<=31;i++)//依次检测信号集
    {
        if(sigismember(&set,i))cout<<1;
        else cout<<0;
    }
    cout<<endl;
    return ;
}
void handler(int signum)
{
    int cnt=5;
    while(cnt--)
    {
        printsig();
        sleep(1);
    }
    cout<<"I catch a signal:"<<signum<<endl;
    sleep(5);//在执行handler方法期间,blocksig阻塞信号集中的信号被阻塞
    return;
}
int main()
{
    struct sigaction act;
    struct sigaction olact;

    memset(&act,0,sizeof(act));//初始化内存空间
    memset(&olact,0,sizeof(olact));

    act.sa_handler=handler;//将处理方法添加到act对象中

    sigaction(2,&act,&olact);//对二号信号捕获,并修改处理方法

    while(true)
    {
        cout<<"I am process,Pid:"<<getpid()<<endl;
        sleep(1);
    }

    return 0;
}

从程序执行结果可以得出,当方法被执行时,操作系统会先将pending信号集1--->0,并将该信号阻塞,知道上次执行结束才会完成递达。

二、可重入函数

结合图中展示,分析函数调用链

在程序运行过程中,main函数调用insert函数,打算向链表head中插入节点node1insert函数的插入操作分为两个步骤,当main函数调用的insert函数刚完成第一步时,硬件中断出现,进程被切换到内核态。在从内核态再次返回用户态之前,系统检测到有信号需要处理,于是进程转而执行sighandler函数。在sighandler函数中,同样调用了insert函数,并且向同一个链表head中插入节点node2sighandler函数中的insert操作顺利完成了两个步骤,之后从sighandler函数返回内核态,接着再次回到用户态时,恢复上下文数据,程序从main函数调用的insert函数中断处继续执行,完成了剩余的第二步操作。原本main函数和sig handler函数先后尝试向链表中插入两个不同的节点,但最终链表中实际上仅成功插入了一个节点。

在上述执行流程中,insert函数被main和handler两条执行流重复调用,这一情况引发了结点丢失问题,并进而导致内存泄漏。像insert函数这种在被重复调用时可能出错或已经出错的函数,我们称之为不可重入函数;与之相对应的,则被称为可重入函数。

不可重入函数的特点:

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

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

三、volatile关键字

接下来会通过这个关键字,拓展部分知识

例一

c 复制代码
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<cstring>
using namespace std;

int flag=0;

void handler(int signum)
{
    cout<<"I captured a signal:"<<signum<<endl;
    flag=1;
}
int main()
{
    struct sigaction act;
    memset(&act,0,sizeof(act));
    act.sa_handler=handler;
    sigaction(2,&act,nullptr);

    while(!flag);
    cout<<"process quit "<<endl;
    return 0;
}
c 复制代码
mytest:mytest.cc
	g++ -o $@ $^ -std=c++11

相信这个执行结果大家都能理解,我就不对上面代码作解释了。
这里将flag设为全局变量,是因为main和sighandler是两个独立的执行流

例二

代码同上

c 复制代码
mytest:mytest.cc
	g++ -o $@ $^ -std=c++11 -O2

从程序执行结果可知,当将g++编译器的优化级别设置为-O2时,即便通过发送二号信号(SIGINT)将flag变量修改为1,循环仍无法终止。这一现象的根源在于:当使用-O2这类高级优化级别编译代码时,编译器会对代码进行多维度优化以提升执行效率。针对while(!flag);这一循环结构,编译器通过静态代码分析发现,循环体内部不存在对flag变量的修改操作,因此推断该变量的值在循环过程中不会发生变化。

基于"内存访问速度相对较慢"这一特性,编译器为减少对内存的频繁访问,会将flag变量的值从内存加载至CPU寄存器中缓存。此后,在循环条件判断时,CPU会直接从寄存器中读取flag的值,而非重新从内存中获取最新数据,这就导致flag内存不可见了。然而,信号处理机制对flag变量的修改是直接作用于内存的,由于寄存器中的缓存值未及时刷新,导致循环条件判断始终基于寄存器中的旧值,最终造成循环无法终止的现象。

对于上面的结果我们可以,将 flag 声明为 volatile 类型,即 volatile int flag = 0;volatile 关键字的作用是保存flag的内存可见性,告诉编译器,这个变量的值可能会被意外地改变,例如被硬件或者其他线程、信号处理函数等修改,因此编译器不能对其进行优化,这里就不展示了。

四、SIGCHLD信号

之前我们探讨过使用 waitwaitpid 函数来清理僵尸进程。在处理子进程结束的问题上,父进程有两种选择:一是进行阻塞等待,直至子进程结束;二是采用非阻塞的轮询方式,周期性地检查是否有子进程结束,以便及时清理。然而,这两种方式都存在明显的弊端。若采用阻塞等待的方式,父进程在等待期间会被阻塞,无法处理自身的任务,这会极大地降低父进程的工作效率。而采用轮询方式,虽然父进程可以在处理自身工作的同时检查子进程的状态,但这要求父进程时刻记得进行轮询操作,无疑增加了程序实现的复杂度,也容易出现疏漏。实际上,当子进程终止时,它会向父进程发送 SIGCHLD 信号。该信号的默认处理方式是被忽略,但我们可以对其进行优化。父进程可以自定义 SIGCHLD 信号的处理函数,这样一来,父进程就能够专注于自身的工作,无需时刻关注子进程的状态。当子进程终止时,会自动通知父进程,父进程只需在信号处理函数中调用 wait 函数,即可完成子进程的清理工作,既高效又便捷。 下面我们通过这样的方式实现一下:

c 复制代码
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<cstring>
#include<sys/wait.h>
#include<sys/types.h>
using namespace std;

void handler(int signum)
{
    pid_t wid=waitpid(0,nullptr,WNOHANG);
    if(wid)
    cout<<"child quit success"<<endl;
    return;
}
int main()
{
    signal(SIGCHLD,handler);
    pid_t id=fork();
    if(id==0)
    {
        sleep(5);//模拟子进程工作
        exit(0);
    }
    while(true)
    {
        cout<<"I am father process"<<endl;
        sleep(1);
    }
    return 0;
}

从执行结果可以得出,子进程在退出时给父进程发送了SIGCHLD信号。

当然还有一种防止僵尸进程的方法:父进程调 用sigactionSIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程:

c 复制代码
int main()
{
    struct sigaction act;
    memset(&act,0,sizeof(act));
    
    act.sa_handler=SIG_IGN;
    sigaction(SIGCHLD,&act,nullptr);

    pid_t id=fork();
    if(id==0)
    {
        sleep(5);
        exit(0);
    }
    while(true)
    {
        cout<<"I am father process"<<endl;
        sleep(1);
    }
    return 0;
}

这个结果不方便展示,你自己尝试一下。

本篇就分享到这里了,如果文章的知识,或代码有错误请您联系我,不胜感激!!!

相关推荐
天骄t28 分钟前
嵌入式系统与51单片机核心原理
linux·单片机·51单片机
阿部多瑞 ABU1 小时前
`chenmo` —— 可编程元叙事引擎 V2.3+
linux·人工智能·python·ai写作
徐同保2 小时前
nginx转发,指向一个可以正常访问的网站
linux·服务器·nginx
HIT_Weston2 小时前
95、【Ubuntu】【Hugo】搭建私人博客:_default&partials
linux·运维·ubuntu
实心儿儿2 小时前
Linux —— 基础开发工具5
linux·运维·算法
oMcLin2 小时前
如何在SUSE Linux Enterprise Server 15 SP4上通过配置并优化ZFS存储池,提升文件存储与数据备份的效率?
java·linux·运维
王阿巴和王咕噜6 小时前
【WSL】安装并配置适用于Linux的Windows子系统(WSL)
linux·运维·windows
布史6 小时前
Tailscale虚拟私有网络指南
linux·网络
水天需0107 小时前
shift 命令详解
linux
wdfk_prog7 小时前
[Linux]学习笔记系列 -- 内核支持与数据
linux·笔记·学习