从内核到应用层:深度剖析信号捕捉技术栈(含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;
}

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

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

相关推荐
漫谈网络10 分钟前
Ollama工具调用(Tool Calls)业务应用案例
linux·ai·aigc·工具调用·ollama·tool calls
unique_落尘11 分钟前
java操作打印机直接打印及详细linux部署(只适用于机器和打印机处于同一个网段中)
java·linux·打印机
前进的程序员1 小时前
在Linux驱动开发中使用DeepSeek的方法
linux·运维·服务器·人工智能
彭友圈1012 小时前
CE第二次作业
linux·服务器·网络
银河麒麟操作系统2 小时前
【银河麒麟高级服务器操作系统】磁盘只读问题分析
java·linux·运维·服务器·jvm
孙克旭_2 小时前
day002
linux
苏生要努力2 小时前
VulnHub-DC-2靶机渗透教程
linux·安全
Alive~o.02 小时前
【网络应用程序设计】实验四:物联网监控系统
linux·网络·python·物联网·课程设计
小鑫仔_x3 小时前
使用 VMware 安装一台 Linux 系统之Centos
linux·运维·centos
hnlucky3 小时前
CentOS 7 系统中,防火墙要怎么使用?
linux·运维·网络·网络安全·centos