信号篇终章
文章目录
- 前言
- 一、信号的处理
- 1.可重入函数
- 2.volatile关键字
- 3.SIGCHLD信号
- 总结
前言
在前两篇linux文章中我们详细的讲解了信号的产生和信号的保存,今天来到最后一个重点信号的处理,对于信号的处理我们会重新引入进程地址空间的知识,并且我们会详细的介绍内核是如何对信号进行捕捉的以及什么是可重入函数。
一、信号的处理
上一篇我们讲过,信号是可以不用被立即处理的,如果一个信号之前被block了,当他解除阻塞状态的时候信号会立即被递达,这是因为信号的产生是异步的,当前进程可能正在做着更重要的事情,那么什么时候再处理之前没处理的信号呢?是在当进程从内核态切换回用户态的时候,进程会在操作系统的指导下,进行信号的检测与处理,这里有三种方式:1.默认 2.忽略 3.自定义捕捉
而用户态的意思是:执行用户所写的代码的时候进程所处的状态。
内核态:执行操作系统代码的时候,进程所处的状态。而一般什么时候切换状态呢?1.进程时间片到了,需要切换,就要执行进程切换逻辑。 2.系统调用也需要转为内核态
下面我们引入进程地址空间来讲解一下内核态:
如上图所示:进程首先找到对应的进程地址空间的地址,然后由页表映射到物理内存,而这里的空间都是用户空间【0,3G】,下面我们加入【3G,4G】的内核空间:
我们的系统代码是放在内核空间的,CPU加载系统代码也需要先在进程地址空间的内核空间中经过内核级页表找到操作系统在物理内存中存放的数据才加以运行。
1.所有的进程【0,3GB】是不同的,每一个进程都要有自己的用户级页表。
2.所有的进程【3GB,4GB】是一样的,每一个进程都可以看到同一张内核级页表,所有进程都可以通过统一的窗口,看到同一个操作系统。
3.操作系统运行的本质,其实都是在进程的地址空间内运行的。也就是说,无论进程如何切换,【3GB,4GB】是不变的,看到操作系统的内容与进程切换无关。
4.所谓的系统调用的本质,其实就如同调用.so中的方法,在自己的地址空间中进行函数跳转并返回即可。
那么如何从用户态切换到内核态呢?如下图:
在CPU中有一个CR3寄存器,当这个寄存器里面存的是0时代表正在运行的进程执行级别是内核态,当这个寄存器里面存的是3时代表正在运行的进程执行级别是用户态。所以我们在使用系统调用接口的时候,内部在正式执行调用逻辑的时候会去修改执行级别为内核态才可以。
那么操作系统的本质是什么呢?操作系统本质是一个软件,本质上是一个死循环。并且操作系统有一个时钟硬件,每隔很短的时间向操作系统发送时钟中断,然后操作系统要执行对应的中断处理方法,而进程被调度就是时间片到了,然后将进程对应的上下文等进行保存并切换,然后再选择合适的进程。
下面我们讲解信号的处理过程:
如上图所示,上面是用户态下面是内核态,有一个进程正在运行自己的代码,当时间片到了后就切换为内核态,陷入内核态后处理完我们的任务,而在操作系统内部会包含进程的PCB的相关结构的,这个时候操作系统会检查PCB里面的相关信号的信息,看哪些信号需要被处理,有三种处理方法我们上面已经说过了,我们就将最麻烦的用户自定义方法,如果操作系统发现信号中用户自定义对这个信号的使用比如上图中的handler方法,当操作系统识别到这个方法是被自定义捕捉的就跳转到对应的自定义方法中,当信号捕捉完成后要返回到信号处理的过程中然后回到用户态执行后续的代码,当我们识别到信号没有被block跳转到我们自己写的方法中时,我们用的是内核态还是用户态来执行这个方法呢?这里当然是用户态了,如果是内核态的话系统代码很容易被修改。
由于上面的过程太过复杂,下面我们用一个简单的方式让大家理解信号捕捉过程:
大家理解的时候可以将上图理解为无穷符号,以上就是我们自定义信号捕捉的简略图。
下面我们学习一下sigaction这个接口:
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signum是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体.
int main()
{
struct sigaction act,oldact;
memset(&act,0,sizeof(act));
memset(&oldact,0,sizeof(oldact));
act.sa_handler = handler;
act.sa_flags = 0;
sigaction(2,&act,&oldact);
return 0 ;
}
下面我们将代码运行起来:
代码运行起来我们可以看到确实如signal函数一样可以对信号进行捕捉,上面的解释说sa_mask会自动恢复原来的信号屏蔽字,下面我们就写个代码来验证一下:
我们可以看到第一次处理了2号信号后,2号信号被block了也就是说内核确实自动将2号信号加入进程的信号屏蔽字。
1.可重入函数
我们先用一张图来引出现象:
有一个insert插入函数,每次进行头插,但是当有多个结点都要插入到头节点的下一个位置时,就会出现节点丢失,内存泄漏的问题,如图4原先链接node2但是当另一个函数重入后,head连接上了node1,这个时候node2这个节点就丢失了,导致内存泄漏的问题。
2.volatile关键字
volatile关键字我们在学C/C++的时候应该都认识,这个关键字是防止编译器对我们的代码做优化。
下面我们用一份简单的代码用信号来引出volatile的原理。首先创建两个文件,一个.c文件一个makefile。
#include <stdio.h>
#include <signal.h>
int quit = 0;
void handler(int signo)
{
printf("change quit from 0 to 1\n");
quit = 1;
}
int main()
{
signal(2,handler);
//注意这里我们故意没有携带while的代码块,故意让编译器认为在main中,quit只会被检测
while (!quit);
printf("main quit 正常\n");
return 0;
}
上面这段代码的意思是:首先我们将2号信号捕捉让其执行我们自己的handler方法,然后我们有一个全局变量quit,进入自定义方法后先打印quit即将由0变成1,然后将quit变成1,然后进入main函数中,while循环中是quit的逻辑反,本来循环中quit为真就一直在循环中,取反后变成了只要quit为假也就是0就会一直在死循环,正常情况下不会进入循环直接打印main quit ,下面我们看一下gcc的优化级别:
我们可以看到有O0,O1,O2,O3这几个优化级别,我们直接用O2这个级别演示一下,只需要在gcc后面加上级别即可:
运行后我们可以发现程序一直在死循环中,下面我们不用gcc的优化试一下:
我们可以看到当编译器对我们的代码没有优化的时候反而我们的代码可以正常运行,也就是说编译器的优化并不都是往好的方式优化,下面我们解释一下原理:
如上图所示:代码是存储在内存中的,而while判断也是一段代码,这段代码也是会被CPU所执行的,而在while循环内部有quit的判断,这个时候会将quit这段代码加载到CPU的寄存器当中,根据寄存器中quit的大小来在while循环中做判断,(具体CPU如何知道执行到哪一句代码是因为CPU中会有一个PC指针,这个指针指向下一段代码的位置)。而正常的情况如下图:
当我们的信号捕捉的自定义方法将quit改为1时,内存中的quit就变成1了,CPU读取代码将quit=1这段代码加载到寄存器当中然后判断while,这个时候不满足while循环的条件打印一句话并退出即可。但是经过编译器的优化后就变成了一下这样:
由于while是一个循环,每次都会将quit的数据加载到寄存器中判断while是否满足条件,当编译器发现while这个循环内部没有任何更改quit判断语句的代码块,这个时候编译器就会觉得不用每次都将quit加载到寄存器中做判断,所以优化后就变成了:第一次将quit=0加载到寄存器判断while语句后,以后就不再从内存加载quit的数据到寄存器中了,而是直接用第一次加载到寄存器中的quit值来判断while,所以这就是while一直死循环的原因,因为quit在while中判断的时候一直为假!
那么为了不让编译器给我们的代码优化,我们就要告诉编译器保证每次检测都要尝试着从内存中进行数据读取,不要用寄存器中的数据,让内存数据不可见。
下面我们演示一下:
这个时候我们的程序就不会被优化,从而可以按照我们的预期来运行了。在这里我们要说明一下,我们理解编译器的优化实际上是:1.编译器优化的本质是在代码上动手脚。2.CPU其实很笨,用户喂给它什么代码它才执行什么代码。
volatile 的作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
3.SIGCHLD信号
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
void handler(int signo)
{
printf("捕捉到一个信号:%d,who: %d\n",signo,getpid());
}
int main()
{
signal(SIGCHLD,handler);
pid_t id = fork();
if (id==0)
{
//child
int cnt = 5;
while (cnt)
{
printf("我是子进程,我的pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
cnt--;
}
exit(1);
}
//父进程
while (1)
{
sleep(1);
}
return 0;
}
以上代码的意思是:首先创建一个子进程,在子进程中我们设置一个为5的计数器,在这5S内打印子进程的pid和子进程的ppid,为了不要太快我们中途sleep1秒。5秒后直接退出子进程。然后进入父进程,父进程什么也不干一直死循环。当子进程结束会给父进程发SIGCHLD信号,我们将这个信号捕捉一下,如果捕捉到了我们就打印这个信号最终是发给谁的,如果是发给父进程的就打印父进程的pid。下面我们运行起来:(在运行前记得将编译器的优化关闭)
通过结果我们可以看到确实子进程在退出前会给父进程发送17号SIGCHLD信号,下面我们将进程等待加进去,当捕捉到子进程退出的信号时我们就让父进程等待子进程防止子进程变成僵尸状态:
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
pid_t id;
void handler(int signo)
{
printf("捕捉到一个信号:%d,who: %d\n",signo,getpid());
sleep(5);
pid_t res = waitpid(-1,NULL,0);
if (res>0)
{
printf("wait success,res:%d,id:%d\n",res,id);
}
}
int main()
{
signal(SIGCHLD,handler);
id = fork();
if (id==0)
{
//child
int cnt = 5;
while (cnt)
{
printf("我是子进程,我的pid:%d,ppid:%d\n",getpid(),getppid());
sleep(1);
cnt--;
}
exit(1);
}
//父进程
while (1)
{
sleep(1);
}
return 0;
}
下面我们在解释一下wait函数的参数:
我们可以看到当第一个参数为-1时意思是等待任意一个子进程。我们原先第一个参数都是要等待进程的id,现在我们可以用用其他参数。第三个参数设为0意思就是等待方式为阻塞式等待,我们讲过阻塞式等待会让等待的进程什么事也干不了只能等待子进程。第二个参数是退出结果,我们不关心退出结果所以设置为空即可。最后判断一下如果等待成功我们就打印等待成功进程的pid,在这里要注意waitpid的返回值,当我们等待成功默认返回的就是子进程的pid。
通过以上代码我们可以看到最后成功等待了子进程。也就是说我们现在这样的方法比我们之前让父进程什么也不干只等待子进程退出的效率高多了。
注意:以上代码的要求,如果你的父进程没有事干,那么还是用以前的方法也就是阻塞式等待,因为父进程闲着也是闲着。
如果你的父进程很忙,而且不退出,就可以用上述我们信号的方法。
当然我们以上的代码实际上不完全对,因为遇到多个子进程退出的时候是有问题的,下面我们用代码写出这个问题:
现在我们会有10个子进程,但是我们之前说过,pending只有一个符号位记录17号信号,第一个子进程退出后pending位变成1,下一次来了还是变成1,那么这就出问题了,因为当父进程忽略子进程的退出信号的时候pending位还是1,这个时候下一个子进程再发信号将pending的位又置为1那么前面那个忽略的信号就丢失了,我们后面想回收之前忽略的信号也没法回收,所以代码的现象是10个进程只能回收几个子进程。
运行起来后我们发现10个进程才回收了2个子进程,要解决这个情况很简单,只需要让父进程死循环的等待回收所有子进程即可:
通过运行结果我们可以看到确实将所有的子进程都成功回收了,那么如果我们有10个子进程,但是只有10个子进程需要退出,这样的情况该怎么办呢?我们只需要将等待方式设为非阻塞式等待,也就是WNOHANG
下面我们演示第二种方法:父进程调用sigaction将SIGCHLD的处理动作设置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程也不会通知父进程,下面我们写一下代码:
可以看到此方法非常简单,但是除了linux不保证其他系统也可以这样哦。
总结
以上就是我们信号处理的所有内容了,对于信号处理这个章节我们需要理解进程地址空间以及内核是如何进行信号捕捉的,下一篇我们发布的linux文章是线程。