目录
前言:
信号在保存后,当OS准备对信号进行处理的时候,还需要到合适的时机才能进行处理,那么什么是合适的时机?
一、信号被处理的时机:
1、理解:
信号的产生是异步的
首先,要将信号进行处理之前就需要让OS知道某个进程收到了信号了,所以进程就需要在合适的时机查查其对应的block,pending,handler表,但是这三个表都属于内核级别的,我们用户级别的进程是不允许访问的,所以这里就自然涉及了进程的用户态和内核态之间的转化
合适的时机:在进程从内核态转化到用户态的时候对信号进行检测,如果有并未被屏蔽就进行处理
怎么进行处理:默认,忽略,用户自定义
2、内核态与用户态:
1、概念:
进程在执行代码的时候不仅仅是只执行用户的代码,还有操作系统的代码,想要访问操作系统就需要变成内核态,执行用户的代码就要变成用户态:
用户态:进程只能访问用户自己的代码和数据
内核态:进程允许访问操作系统的代码和数据
用户态-->内核态
进程时间片到了,进行进程切换的时候
调用系统调用接口:open,read等等
产生异常,中断的时候等等
内核态-->用户态
进程切换完毕
系统调用结束后
异常,中断处理完后
OS是不会收到信号就立即执行的,比如当前我正在进行系统调用或者正在切换进程等等,在从内核态转化为用户态的时候,就进行信号的查询三表和处理信号,所以要等到OS将更重要的事情忙完后,在进程从内核态到用户态的时候就进行信号的检测和处理
2、重谈地址空间:
如下,我们回顾下我们地址空间的知识:
每个进程都有其独有的虚拟地址空间,进程具有独立性
通过页表的映射+MMU机制进行虚拟到物理地址之间的转化
进程都具有其对应的虚拟地址空间,能够让进程以统一的时间看待我们的代码和数据
虚拟地址空间又可分为两个空间0~3GB的空间为用户空间,3~4GB的空间为内核空间
为什么要区分内核空间和用户空间?
因为内核空间中存放的是OS的代码和数据,是不允许进程随便访问的,需要进程切换到内核态才能进行访问,并且规划也能够OS进行更好的管理
其中:用户空间就是我们的代码和数据,当进程在用户态的时候就能够访问这段空间的代码和数据
内核空间中存放的就是OS的代码和数据,这里的虚拟地址通过特有的内核级页表从虚拟地址空间映射到物理内存中,由于OS是最先被加载的程序,所以其映射应该在较为底部的位置
我们知道每一个进程都有其对应的进程地址空间, 那么是不是每一个进程都有其独特的内核空间和内核级页表呢?
答案是只有一份
对应用户空间和用户级页表有很多份的,因为进程具有独立性
但是内核级页表只有一份,内核空间比较特殊,所有进程最终映射到物理内存都是同一块区域,进程只是将操作系统代码和数据映射入自己的进程地址空间而已,其中内核级页表只需将虚拟地址空间映射到物理内存,所有进程都是如此
所以,每一个进程看到的内核空间中的内容,看到的内核级页表资源,最后映射到的物理内存中的代码和数据都是一样的
所以在用户空间的代码区中执行对应的系统调用,
首先将进程从用户态转化到内核态,
然后在自己的内核空间中找到对应的系统调用方法,
然后通过内核级页表映射到物理内存找到对应的代码和数据,
然后在返回(在返回的时候进程从内核态转化为用户态),
这样就相当与在进程自己的进程地址空间中进行系统调用
那么上述的切换是如何进行切换的呢?
首先,我们知道,CR3 寄存器存储当前进程的页目录表物理地址,用于分页机制下的内存管理,
在CPU中同样还有一个叫做ESC寄存器的东西,这个寄存器的作用就是用来表示当前进程的状态:是用户态还是内核态
那么这个寄存器是怎样实现的呢?
在这个寄存器中,有着最后两个比特位,两个比特位有4种表示方式:00 01 10 11,其中只使用两种方式,00和11也就是对应十进制的0和3,
当这个寄存器最后两个比特位为0的时候,表示当前进程处于内核态
当这个寄存器最后两个比特位为1的时候,表示当前进程处于用户态
所以切换进程的状态就需要将ESC寄存器中的最后两个比特位修改为对应的值,CPU为我们提供了一种方法来修改自己的工作状态:int 0x80指令
小总结:
1、每一个进程中的0~3GB中的内容是不一样的,因为进程具有独立性
2、每一个进程中的3~4GB中的内容是一样的,在整个系统中,无论进程再怎么切换, 3~4GB中的内核空间内容是不变的
3、在进程视角:我们进行系统调用,就是在我自己的地址空间中进行执行的
4、操作系统视角:任何一个时刻,都有进程执行,想执行OS的代码可以随时执行
5、操作系统本质是一个基于时钟中断的一个死循环
怎么理解操作系统的执行逻辑呢或者说操作系统的本质是什么?
其可以理解为一种基于中断驱动的“死循环”模型,核心在于通过时钟中断等机制实现任务调度,资源管理和实时响应
主框架循环:
操作系统的核心代码是一个死循环(for(; ;) pause(); ),这个循环并非是空转的,而是通过中断机制被动唤醒的,当没有外部事件(用户输入,I/O完成)或内部事件(如时间片耗尽)时,操作系统会进入低功耗状态或执行空闲任务
其中操作系统本会一直卡在pause()这个行代码暂停,等到发生中断机制,被进程“推着走”才能够让代码得以运行
那有什么中断机制呢?
时钟中断:每过一定很短的时间产生一次,用于更新系统时间、检查进程时间片、触发调度
硬件中断:如键盘输入,由外设通过中断控制器通知CPU
软件中断:如系统调用,允许用户程序访问内核空间
进程是如何被操作系统调用的呢?
进程被调度,就意味着它的时间片到了,操作系统会通过时钟中断,检测到是哪一个进程的时间片到了,然后通过系统调用函数schedule()保存进程的上下文数据,然后选择合适的进程去运行
3、处理时机:
有了上述铺垫的知识,接下来可以理解:
我们前面了解到,在进程从内核态转化到用户态的时候对信号进行检测,处理方式如下图:
对于上述的执行用户自定义有两个问题:
为什么要切回用户态再执行对应的方法:
因为如果在内核态中执行用户自定义的方法,可能自定义方法中存在危害操作系统的代码,这是不安全的
为什么在用户态执行完毕后还要返回内核态再到用户态
需要回到内核态找到进程的上下文,将上下文带出到用户态才行,并且自定义的动作和待返回的进程是属于不同的堆栈,不能够直接返回
对于信号捕捉的理解可以看看下面这张图
如上,这是一个横着的8,然后用一横贯穿,上面是用户态,下面是内核态,注意有4个交点,并且8的交点是在横线下方的也就是在内核态中
接下来解释上图:
四个绿色的圈圈就表示两态之间的切换了四次,当进程时间片到了,进行进程切换,产生异常,中断的时候等等就进行用户态到内核态之间的转化
进程切换完毕,系统调用结束后,异常,中断处理完后进行内核态到用户态之间的转化
此时就进行信号的检测:首先看pending表,如果其为1并且该信号没有被阻塞就执行对应动作,如果被屏蔽或者为0就继续从pending表向下找下一个,以此类推
二、补充知识:
1、sigaction:
其中有个新的结构体:sigaction,其内部成员如下
我们只关心第一个和第三个成员:
成功返回0,失败返回-1,错误码被设置
第一个参数signum:信号编号
第二个参数act: 传入该类结构体,设置屏蔽信号什么的在之前就要设置好
第三个参数oldact:保存修改前的结构体
其中sigaction中的第一个成员变量:就是signal的第二个参数,需要自己设置自定义
第三个成员变量就是屏蔽的信号集,怎么理解呢?
首先我们知道,比如当在处理2号信号的时候,如果sa_mask默认,那么OS就只会屏蔽2号信号,如果还想要屏蔽更多信号,就需要sigaddset(&act.sa_mask,1);这样在待传入结构体中的sa_mask进行更多的设置来屏蔽
void Printpending()
{
sigset_t set;
sigpending(&set);
for(int signo = 31; signo>=1; signo--)
{
if(sigismember(&set,signo)) cout <<"1";
else cout<<"0";
}
cout << endl;
}
void myhandler(int signo)
{
cout << "get a signo : " << signo << endl;
while(1)
{
Printpending();
sleep(1);
}
}
int main()
{
struct sigaction act,oact;
memset(&act,0,sizeof(act));
memset(&oact,0,sizeof(oact));
//清空信号集
sigemptyset(&act.sa_mask);
//添加屏蔽信号
sigaddset(&act.sa_mask,1);
sigaddset(&act.sa_mask,3);
sigaddset(&act.sa_mask,4);
sigaddset(&act.sa_mask,9);
//设置自定义方法
act.sa_handler = myhandler;
sigaction(2,&act,&oact);
while(1)
{
cout << "i am a process mypid : " << getpid() << endl;
sleep(1);
}
return 0;
}
如上,这样就将1 3 4号都屏蔽了,9号和19号信号可以经过试验发现不可屏蔽,
2、函数重入:
可以被重复进入的函数称为可重入函数
如下是一个场景:
如上,当在insert函数中捕捉到了信号,并且在信号的自定义动作中又调用了insert,这样函数就会重入,这样就会导致函数节点丢失,在释放的时候无法释放node2,就会导致内存泄漏
这就是函数重入导致的内存泄漏问题
我们把这种函数称为可重入函数(注意:这个是特性,不具有褒贬含义)
3、volatile:
int flag;
void myhandler(int signo)
{
cout << "get a signo : " << signo << endl;
flag = 1;
}
int main()
{
signal(2,myhandler);
while(!flag); //flag = 0 为假 !flag 为真
cout << "a process quit ! " << endl;
return 0;
}
如上,上述本来是一个死循环,但是当给当前进程发送2号信号的时候就会进入我们的自定义函数调用,这个时候在里面将flag修改为1,这样就能够退出while死循环了,并且我们的代码运行起来也是达到预期了
但是在编译中,有个-O1/O2/O3这种的优化,如下,我们把这种优化带着,在运行代码试试
如上,此时发现尽管我们给当前进程发送2号信号,并且也看到了自定义代码中打印的字符串,但是进程却没有退出while这个死循环
这是为什么呢?
这是因为在编译的时候进行了O1的优化
如上,正常情况下,CPU在每次进行逻辑检测的时候,每次都从内存中进行读入,这种IO比较费事,当进行O1的优化之后,就会对整个代码进行检测,此时没有信号就检查不到flag被修改了,此时就会将flag放入逻辑检测的寄存器中,这样,当在自定义中修改了flag,这只是把内存中的flag修改了,但是CPU寄存器中的flag并没有被修改,所以就会一直继续死循环
为了防止这种过度优化,保存内存的可见性,可以在全局变量前面加上volatile关键字修饰,这样就无法将flag放入到寄存器中,老老实实地每次都从内存中进行读入
4、SIGCHLD:
在前面实现进程等待的时候,每次当子进程退出的时候,父进程都需要等待,回收子进程,防止其成为僵尸进程造成内存泄漏问题
父进程有两种方式等待子进程:设置0为阻塞等待或者设置WNOHANG为非阻塞轮询
但是上述两种方式都有缺陷,要么父进程阻塞,不能做其自己的事情,要么每次工作的时候都还要关心关心子进程的状态
那么有没有一种方式能够让父进程不在关心子进程,当子进程退出的时候自动回收呢?
有的有的:
首先要了解:子进程在退出的时候会向父进程发送17号信号,所以我们可以在父进程中将17好信号捕捉,然后在自定义函数中等待,这里设置第一个参数为-1为等待任意进程
void myhandler(int signo)
{
pid_t rid = waitpid(-1,nullptr,0);
cout << "a signo get : " << signo << " mypid " << getpid() << " rid : " << rid << endl;
}
int main()
{
signal(17,myhandler);
pid_t id = fork();
if(id == 0)
{
//child
cout << "child process " << getpid() << " myppid : " << getppid() << endl;
sleep(5);
exit(0);
}
//father
while(1)
{
cout << "father process " << getpid() << endl;
sleep(1);
}
return 0;
}
如上,这样父进程就可以不用管子进程了,能够完成子进程的自动回收
但是这样还不够,如果有多个子进程呢?能够全部回收吗
这显然是不会的,SIGCHLD这是一个信号,当父进程收到了第一个信号的时候,会将block表中的17号信号置为1使其屏蔽,这样,其他子进程的信号就丢失了,就会导致僵尸进程
那么如何解决呢?
我们在自定义中采取while循环的方式回收即可
当然,还有一种更方便的,但是只能在Linux中有效:
将SIGCHLD这个信号的默认动作设置为忽略,这样父进程不会对其处理,但是当子进程退出之后,OS会对其负责,这样的话就会自动清理资源并回收,不会产生僵尸进程引起内存泄漏