Linux系统编程——理解系统内核中的信号捕获
目录
一、sigaction()
使用
信号捕捉技巧
二、可重入函数
三、volatile关键字
四、SIGCHLD信号
在信号这一篇中我们已经学习到了一种信号捕捉的调用接口:signal(),为了深入理解操作系统内核中的信号捕获机制,我们今天再来看一个接口:sigaction()
一、sigaction()
sigaction
一个系统调用,用于修改和/或查询信号的处理方式。它提供了对信号处理函数更精细的控制,相较于旧的 signal
函数来说更为强大和灵活。
1、int signum:该参数需要传入指定的进程信号,表示要捕捉的信号
2、const struct sigaction *act:该参数与函数同名,是一个结构体指针如图
1、void (*sa_handler)(int);,此成员的含义其实就是 自定义处理信号的函数指针;
2、void (*sa_sigcation)(int, siginfo_t *, void *);此成员也是一个函数指针. 但是这个函数的意义是用来 处理实时信号的, 不考虑分析. (siginfo_t 是表示实时信号的结构体)
3、sigset_t sa_mask;, 此成员是一个信号集, 这个信号集有什么用呢?我们在使用时解释
4、int sa_flags;, 此成员包含着系统提供的一些选项, 本篇文章使用中都设置为0
5、void (*sa_restorer)(void);, 很明显 此成员也是一个函数指针. 但我们暂时不考虑他的意义.也就是说我们暂时将该接口的第二个参数简单理解为一个结构体指针,并且结构体里有一个成员是用来自定义处理信号的。即该参数的作用就是将指定信号的处理动作改为传入的struct sigaction的内容。
3、struct sigaction *oldact,第三个参数类似sigprocmask()接口中的第三个参数一样,都是输出型参数,且其作用是获取 此次修改信号 struct sigaction之前的原本的 struct sigaction ,如果传入的是空指针,则不获取。
使用
我们实现一个捕捉2号信号的程序,然后让他按照我们自定义的处理动作执行,且将自定义的处理动作设置为死循环,也就是让进程在收到2号信号后一直执行该信号的处理动作。
#include<iostream>
#include<unistd.h>
#include<signal.h>
using std::cout;
using std::endl;
void handler(int signum)
{
cout<<"I catch a signal::"<<signum<<endl;
sigset_t pending;
while(true){
sigpending(&pending);
int sig=1;
for(sig=1;sig<=31;sig++){//利用死循环打印未决信号集
if(sigismember(&pending,sig))
{
cout<<"1";
}
else{
cout<<"0";
}
}
cout<<endl;
sleep(1);
}
}
int main()
{
struct sigaction act,oact;//实例化出两个结构体对象作为参数
act.sa_handler=handler;//初始化自定义处理动作
act.sa_flags=0;//先设置为0
sigemptyset(&act.sa_mask);//初始化
sigaction(2,&act,&oact);//捕捉2号信号
while(true)
{
cout<<"I am a process!My pid::"<<getpid()<<endl;
sleep(1);
}
return 0;
}
可以看到在我们第一次发送了2号信号后,其打印出来的未决信号集全是0,这是因为在没有捕捉到2号信号前,该进程的未决信号集全为0,捕捉之后,第二个位置应该为1,然后开始处理自定义动作前将1又置为0,表示开始处理自定义动作,此时就一直处于死循环中,即一直在执行自定义动作。
当我们第二次发送2号信号时,它的未决信号集的对应位置就变为了1,后面再发送2号信号时,该信号都会被拦截下来导致后面的2号信号一直处于未决状态。但是发送其他信号进程又会处理其他信号。
那么我们要是想要在处理2号信号的同时还要将其他信号拦截呢?这时候就与 sa_mask 相关了,顾名思义了就是信号屏蔽字。
struct sigaction结构体的sa_mask 成员的意义是, 添加进程在处理捕捉到的信号时对其他信号的阻塞. 如果需要添加对其他信号的阻塞, 那么就可以继续在 sa_mask 中添加其他信号.
不过, 这样做有什么意义呢?
这样做可以 防止用户自定义处理信号时, 嵌套式的发送其他信号并捕捉处理.
如果 用户的自定义处理信号方法内部, 还会发送其他信号, 并且用户还对其进行了捕捉. 那么 信号的处理就无止尽了. 这种情况是不允许发生的.
所以 可以通过使用 sa_mask 来进行对其他信号的拦截阻塞.
信号捕捉技巧
为了避免捕捉不同的信号并做处理时,编写不同的处理函数太过于麻烦,我们可以考虑通过传入相同的函数指针实现对不同信号的不同处理。
当我们定义完指定信号的处理函数之后, 我们可以再定义一个 handlerAll(int signo)
函数, 并使用 switch 语句, 将不同的 signo 分别处理.
此时, 我们在使用 signal()
或者 sigaction()
捕捉信号时, 就只需要统一传入 handlerAll
的函数指针就可以了.
这是一种 解耦技巧
二、可重入函数
可重入函数(Reentrant Function)是指可以在多线程环境中安全使用的函数,即这个函数可以被多个线程同时调用而不会导致数据错误或不一致。
下面用个例子解释一下
一个进程中, 存在一个全局的单链表结构. 并且此时需要执行一个节点的头插操作:
此时需要让该结点的指向下一个节点的指针指向头节点,再将自己变为头节点
node1->next = head;
head = node1;
如果在刚执行完第一步之后, 进程因为硬件中断或者其他原因 陷入内核了
.
陷入内核之后需要回到用户态继续执行, 切换回用户态时 进程会检测未决信号, 如果此时刚好存在一个信号未决, 且此信号自定义处理.并且, 自定义处理函数中 也存在一个新节点头插操作:
此时又会执行node2节点的头插 ,执行完后node2节点暂时就成为了新的头节点
接着进程返回用户态去执行剩下的代码,即 head=node1 ,
导致的结果就是node2节点最终找不到了,这样就造成了 内存泄漏
是因为 单链表的头插函数, 被不同的控制流程调用了, 并且是在第一次调用还没返回时就再次进入该函数, 这个行为称为 重入
而 像例子中这个单链表头插函数, 访问的是一个全局链表, 所以可能因为重入而造成数据错乱, 这样的函数 被称为 不可重入函数, 即此函数不能重入, 重入可能会发生错误
反之, 如果一个函数即使重入, 也不会发生任何错误(一般之访问函数自己的局部变量、参数), 这样的函数就可以被称为 可重入函数. 因为每个函数自己的局部变量是独立于此次函数调用的, 再多少次调用相同的函数, 也不会对之前调用函数的局部变量有什么影响.
如果一个函数符合以下条件之一,则称为不可重入函数
- 调用了malloc和free,因为 malloc 也是用全局链表来管理堆的。
- 调用了标准I/O库函数, 标准I/O库的很多实现都以不可重入的方式使用全局数据结构
三、volatile关键字
在之前学习C/C++的时候,我们就已经接触过这个关键字了,它的作用是防止编译器对该关键字修饰的变量的优化,确保每次访问这个变量时都直接从内存中读取,而不是使用可能存在的寄存器中的缓存值。这是因为在某些情况下,变量的值可能会被外部因素改变,如硬件中断、多线程环境下的其他线程等。
接下来我们用一个例子分析一下该关键字
下面的程序是先定义一个全局变量flag,以该全局变量作为触发条件,当为0时,一直处于死循环状态。当为1时程序正常结束。在main()中不对flag做修改,只有在捕获到2号信号的时候,在自定义的处理函数中才会对flag做出修改。
#include <stdio.h>
#include <signal.h>
int flags = 0;
void handler(int signo) {
printf("获取到一个信号,信号的编号是: %d\n", signo);
flags = 1;
printf("我修改了flags: 0->1\n");
}
int main() {
signal(2, handler);
while (!flags)
;
// 未接收到信号时, flags 为 0, !flags 恒为真, 所以会死循环
printf("此进程正常退出\n");
return 0;
}
可以看到在发送了2号信号后程序正常结束。
- 虽然 2信号的自定义处理函数 会对flags作出修改, 但是这个函数的执行是未知的. 即 不确定进程是否会收到2信号 进而执行此函数.
- 那么对编译器来说, 就有可能对 flags 做出优化.
- 我们知道, 进程再判断数据时, 是CPU在访问数据, 而CPU访问数据时 会将数据从内存中拿到寄存器中. 然后再根据寄存器中的数据进行处理.
- 在此例中, 就是 while(!flags); 判断时, CPU会从内存中拿出数据进行判断. 当flags从0变为1时, 是内存中的数据发生了变化, CPU也会从内存中拿到新的数据进行判断
- 而 此例中编译器可以确定一定会执行的代码中, flags是不会被修改的. 那么 编译器就可能针对flags做出优化:
- 由于编译器认为进程不会修改 flags, 那么在 while(!flags); 判断时, CPU读取到flags为0 并存放在寄存器中之后, 为了节省资源 在之后的判断中 CPU 不会再从内存中读取数据, 而是直接根据寄存器中存储的数据进行判断.
- 这就会造成 即使处理信号时将 flags 改为了1, 在进行 while(!flags);判断时, CPU依旧会只根据寄存器中存储的0 来进程判断, 这就会造成 进程不会正常退出
我们可以在编译是使用 -02 选项让编译器做出这样的优化
可以看到即使是flag改成了1,程序依然不会停止
接着我们使用关键字修饰这个变量 volatile int flag=0 ,再查看结果
可以看到已经没有优化了。
四、SIGCHLD信号
我们之前讲到过,在子进程退出的时候,是需要让父进程接收退出信息的,否则子进程会进入僵尸状态,所以我们介绍了有关于进程等待的函数,让父进程主动去询问子进程是否退出,事实上,子进程退出的时候是会通知父进程的,只不过父进程会忽略而已。
在子进程退出的时候,子进程会向父进程发送一个信号,即 SIGCHID
下面我们在父进程中捕捉这个信号看看情况:
#include<iostream>
#include<cstdlib>
#include<signal.h>
#include<unistd.h>
using std::cout;
using std::endl;
using std::cerr;
void handler(int signum)
{
cout<<"Child process has exited,mypid::"<<getpid()<<"Signal num::"<<signum<<endl;
}
int main()
{
signal(SIGCHLD,handler);
pid_t id=fork();
if(id<0)
{
cerr<<"fork error!"<<endl;
exit(1);
}
else if(id==0)
{
while(true)
{
cout<<"I am child process,mypid::"<<getpid()<<endl;
sleep(2);
}
exit(0);
}
while(true)
{
cout<<"I am parent process!my pid::"<<getpid()<<endl;
sleep(2);
}
return 0;
}
17号信号即 SIGCHID信号 ,且默认动作是Ign(忽略)
但是对于该信号,内容中说明 子进程暂停或终止,即在子进程暂停或终止的时候都会发送该信号给父进程。我们做个测试,首先需要知道暂停信号是19号,继续信号是18号信号
可以看到无论是暂停、继续、还是终止子进程的时候,其都会向父进程发送该信号。
那么我们知道了这个信号又有什么用处呢?
在介绍进程等待时 提到过,waitpid()接口会等待子进程退出, 而等待的动作是主动去询问子进程是否退出.
现在我们清楚了子进程在退出的时候会发送SIGCHID信号给父进程,那我们让父进程可以通过捕捉该信号去等待子进程。
#include<iostream>
#include<cassert>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
#include<signal.h>
using std::cout;
using std::endl;
using std::cerr;
void Childprofree(int signo)
{
assert(signo==SIGCHLD);
pid_t id=waitpid(-1,nullptr,0);
if(id>0)
{
cout<<"Waiting success!pid::"<<getpid()<<"child process id::"<<id<<endl;
}
}
int main()
{
signal(SIGCHLD,Childprofree);
pid_t id=fork();
if(id<0)
{
cout<<"Perror fork!"<<endl;
exit(0);
}
else if(id==0)
{
while(true)
{
cout<<"I am child process!my pid::"<<getpid()<<endl;
sleep(2);
}
exit(0);
}
while(true)
{
cout<<"I am parent process!my pid::"<<getpid()<<endl;
sleep(2);
}
return 0;
}
上面的程序只针对单进程的情况,如果是多个进程的情况下就会有一些问题
我们利用循环创建10个子进程,这10个子进程在打印完十次之后自动退出。
#include<iostream>
#include<cassert>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
#include<signal.h>
using std::cout;
using std::endl;
using std::cerr;
void Childprofree(int signo)
{
assert(signo==SIGCHLD);
pid_t id=waitpid(-1,nullptr,0);
if(id>0)
{
cout<<"Waiting success!pid::"<<getpid()<<"child process id::"<<id<<endl;
}
}
int main()
{
signal(SIGCHLD,Childprofree);
for(int i=0;i<10;i++)
{
pid_t id=fork();
if(id<0)
{
cout<<"Perror fork!"<<endl;
exit(0);
}
else if(id==0)
{
int cnt=10;
while(cnt)
{
cout<<"I am child process!my pid::"<<getpid()<<"cnt::"<<cnt--<<endl;
sleep(2);
}
cout<<"Child process being Z!"<<endl;
exit(0);
}
}
while(true)
{
cout<<"I am parent process!my pid::"<<getpid()<<endl;
sleep(2);
}
return 0;
}
我们在右边窗口使用 while :;do ps ajx|head -1&&ps ajx|grep mydwait|grep -v grep;sleep 2;done命令循环查看进程情况
运行结果如图
可以看到等到子进程推出的时候,按道理是所有的子进程都退出都被父进程回收掉,但是只有左边红色框里这几个进程退出了,右边红色框里还有几个没有被回收掉,一直处于僵尸状态。
在Linux中,每个进程都有一个信号集,用来表示该进程当前接收到的、尚未处理的信号。这个集合有一个重要特点:它是基于信号类型的,而不是基于信号的数量。这意味着对于同一类型的信号(例如多个
SIGCHLD
),操作系统不会为每个信号单独排队,而是只会记录该类型信号至少发生过一次。换句话说,如果多个SIGCHLD
信号几乎同时到达,操作系统会将它们合并成一个信号,并只传递给父进程一次。因此,如果大量的子进程几乎在同一时间结束,父进程可能只接收到一个
SIGCHLD
信号,而实际上有多个子进程已经终止。这就导致了父进程可能没有机会处理所有终止的子进程,从而留下僵尸进程。
那么怎么修改处理信号的方式呢?我们将回收子进程的部分设置为 利用死循环回收,在没有子进程的情况下跳出循环。
一旦有收到子进程的退出信号后,这个外部的函数就会进入死循环,不断等待释放需要退出的子进程,直到没有子进程需要释放了就退出。事实上这个改动就跟收到信号没有关系了,单纯利用循环不断等待需要被释放的子进程。
void freeChild(int signo) {
assert(signo == SIGCHLD);
while(true) {
pid_t id = waitpid(-1, nullptr, 0);
if (id > 0) {
cout << "父进程等待子进程成功, child pid: " << id << endl;
}
else {
cout << "等待结束" << endl;
break;
}
}
}
可以看到最后所有的进程都退出了。
但是新的问题又出现了,一旦有子进程不退出的话,父进程就不会再运行了,因为我们设置的waitpid()的第三个参数为0是阻塞式等待,所以会一直处在外部的自定义处理函数中,不会回到main()函数
观察下面的情况,我们让一部分子进程循环5次后退出,一部分循环30次后退出
#include <cassert>
#include <cstdlib>
#include <iostream>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
using std::cout;
using std::endl;
void freeChild(int signo) {
assert(signo == SIGCHLD);
while (true) {
pid_t id = waitpid(-1, nullptr, 0);
if (id > 0) {
cout << "父进程等待子进程成功, child pid: " << id << endl;
}
else {
cout << "等待结束" << endl;
break;
}
}
}
int main() {
signal(SIGCHLD, freeChild);
for (int i = 0; i < 10; i++) {
pid_t id = fork();
if (id == 0) {
// 子进程
int cnt = 0;
if(i < 6)
cnt = 5; // 前6个子进程 5就退出
else
cnt = 30; // 后4个子进程 30退出
while (cnt) {
cout << "我是子进程, pid: " << getpid() << ", cnt: " << cnt-- << endl;
sleep(1);
}
cout << "子进程退出, 进入僵尸状态" << endl;
exit(0);
}
}
// 父进程
while (true) {
cout << "我是父进程, pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
可以看到在所有进程退出之前父进程代码并没有运行,我们只需要将 waitpid()的第三个参数改为 WNOHANG 就可以了表示 非阻塞式等待。
下面是最终版代码
#include<iostream>
#include<cassert>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
#include<signal.h>
using std::cout;
using std::endl;
using std::cerr;
void Childprofree(int signo)
{
assert(signo==SIGCHLD);
while(true)
{
pid_t id=waitpid(-1,nullptr,WNOHANG);
if(id>0)
{
cout<<"Waiting success!pid::"<<getpid()<<"child process id::"<<id<<endl;
}
else{
cout<<"Wating end!"<<endl;
break;
}
}
}
int main()
{
signal(SIGCHLD,Childprofree);
for(int i=0;i<10;i++)
{
pid_t id=fork();
if(id<0)
{
cout<<"Perror fork!"<<endl;
exit(0);
}
else if(id==0)
{
int cnt = 0;
if(i < 6)
cnt = 5;
else
cnt = 30;
while(cnt)
{
cout<<"I am child process!my pid::"<<getpid()<<"cnt::"<<cnt--<<endl;
sleep(2);
}
cout<<"Child process being Z!"<<endl;
exit(0);
}
}
while(true)
{
cout<<"I am parent process!my pid::"<<getpid()<<endl;
sleep(2);
}
return 0;
}