当前位置: 首页 > news >正文

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

Linux系列


文章目录

  • Linux系列
  • 前言
  • 一、进程对信号的捕捉
    • 1.1 内核对信号的捕捉
    • 1.2 sigaction()函数
    • 1.3 信号集的修改时机
  • 二、可重入函数
  • 三、volatile关键字
  • 四、SIGCHLD信号


前言

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()函数

#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 里的信号会被阻塞,一直到处理函数执行完毕。

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

例一

#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;
}

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

例二

#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);//将二信号添加进blocksigact.sa_handler=handler;//将处理方法添加到act对象中act.sa_mask=blocksig;//将想要阻塞的信号位图赋值给actsigaction(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,并将该信号添加到对应的阻塞信号集中。具体来说,在二号信号处理方法执行期间,即便进程再次收到二号信号,该信号也不会被递达。只有当上一个信号处理方法执行完毕并返回后,操作系统解除对二号信号的阻塞,新收到的二号信号才会被递达。

#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关键字

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

例一

#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;
}
mytest:mytest.ccg++ -o $@ $^ -std=c++11

在这里插入图片描述

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

例二
代码同上

mytest:mytest.ccg++ -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 函数,即可完成子进程的清理工作,既高效又便捷。 下面我们通过这样的方式实现一下:

#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出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程:

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;
}

这个结果不方便展示,你自己尝试一下。
本篇就分享到这里了,如果文章的知识,或代码有错误请您联系我,不胜感激!!!

相关文章:

  • ROS 快速入门教程03
  • 运维打铁:Centos 7使用yum安装 Redis 5
  • 【FAQ】PCoIP 会话后物理工作站本地显示器黑屏
  • centos挂载新的硬盘
  • Docker配置DNS方法详解及快速下载image方法
  • SpringBoot自定义拦截器以及多个拦截器执行顺序
  • 安卓adb shell串口基础指令
  • 【金仓数据库征文】加速数字化转型:金仓数据库在金融与能源领域强势崛起
  • 修改el-select背景颜色
  • 第9章 多模态大语言模型
  • vue element使用el-table时,切换tab,table表格列项发生错位问题
  • mysql快速在不同库中执行相同的sql
  • 金融机构典型欺诈场景
  • 【element plus】解决报错error:ResizeObserver loop limit exceeded的问题
  • JBoltAI 赋能金融文档:基于 RAG 的基金招募说明书视觉增强方案
  • 致远oa部署
  • 在Vue3中,如何在父组件中使用v-model与子组件进行双向绑定?
  • 【计算机视觉】CV实战项目 - 基于YOLOv5与DeepSORT的智能交通监控系统:原理、实战与优化
  • 【C++】内存管理:内存划分、动态内存管理(new、delete用法)
  • 【KWDB 创作者计划】_嵌入式硬件篇---寄存器与存储器截断与溢出
  • 中央空管办组织加强无人机“黑飞”“扰航”查处力度
  • 84%白化!全球珊瑚正经历最严重最大范围白化现象
  • 刺激视网膜可让人“看”到全新颜色
  • 从“龙队”到“龙副”,国乒这批退役球员为何不爱当教练了
  • 央行副行长:上海国际金融中心建设是我国参与国际金融竞争的核心载体
  • 厦门国贸去年营收约3544亿元,净利润同比减少67.3%