信号篇终章
文章目录
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时代表正在运行的进程执行级别是用户态。所以我们在使用系统调用接口的时候,内部在正式执行调用逻辑的时候会去修改执行级别为内核态才可以。
那么操作系统的本质是什么呢?操作系统本质是一个软件,本质上是一个死循环。并且操作系统有一个时钟硬件,每隔很短的时间向操作系统发送时钟中断,然后操作系统要执行对应的中断处理方法,而进程被调度就是时间片到了,然后将进程对应的上下文等进行保存并切换,然后再选择合适的进程。
下面我们讲解信号的处理过程:
内核如何实现信号的捕捉?
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复
main函数的上下文继续执行了。
如上图所示,上面是用户态下面是内核态,有一个进程正在运行自己的代码,当时间片到了后就切换为内核态,陷入内核态后处理完我们的任务,而在操作系统内部会包含进程的PCB的相关结构的,这个时候操作系统会检查PCB里面的相关信号的信息,看哪些信号需要被处理,有三种处理方法我们上面已经说过了,我们就将最麻烦的用户自定义方法,如果操作系统发现信号中用户自定义对这个信号的使用比如上图中的handler方法,当操作系统识别到这个方法是被自定义捕捉的就跳转到对应的自定义方法中,当信号捕捉完成后要返回到信号处理的过程中然后回到用户态执行后续的代码,当我们识别到信号没有被block跳转到我们自己写的方法中时,我们用的是内核态还是用户态来执行这个方法呢?这里当然是用户态了,如果是内核态的话系统代码很容易被修改。
由于上面的过程太过复杂,下面我们用一个简单的方式让大家理解信号捕捉过程:
大家理解的时候可以将上图理解为无穷符号,以上就是我们自定义信号捕捉的简略图。
下面我们学习一下sigaction这个接口:
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signum是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体.
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用.
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flflags字段包含一些选项,本章的代码都把sa_flflags设为0,sa_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这个节点就丢失了,导致内存泄漏的问题。
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
也就是说可重入函数的概念就是:不同的执行流中同一个函数被重复进入,如果进入没有问题,该函数被称为可重入函数,如果进入有问题,那么该函数被称为不可重入函数。那么如何知道一个函数是否可以重入呢?
**第一:调用了malloc或者free函数,因为malloc是用全局链表来管理堆的,所以调用malloc和free一定不可以重入。第二:调用了标准IO库函数,标准IO库的很多实现都以不可重入的方式使用全局数据结构。**
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信号
我们在进程那一篇文章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
下面我们编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定义SIGCHLD信号的处理函数, 在其中调用wait获得子进程的退出状态并打印。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。下面我们会编写程序验证这样做不会产生僵尸进程。
下面我们先演示第一种方法:
#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文章是线程。
版权归原作者 朵猫猫. 所有, 如有侵权,请联系我们删除。