Linux: 进程信号
首先说明:进程信号跟信号量没有任何关系,尽管名字很像
一.信号的理论与基本操作
1.信号的概念,特点以及为何要有信号
为何要有信号呢?
生活当中 : 是为了让人能够响应各种事件并且做出不同的处理方式
在Linux系统当中也是如此:
是为了让进程能够响应各种事件并且做出不同的处理方式
从而才能更好的管理进程
2.常见信号与信号的产生
这些名字,比如SIGKILL,其实就是宏,也就是它的编号
因此发送信号时SIGKILL和9是完全一样的
大家可以使用 man 7 signal查看所有的信号信息
1.kill命令
我们都知道kill -9用于杀死一个进程,这点我们就不谈了,其他信号的发送方式跟9一样
因此我们介绍一下对于信号的处理方式
为了方便发送信号,我们打印一下pid
注意:这里设置的是对该信号的处理方式,只有进程收到该信号,并开始处理时才会触发这个处理方式
如果该进程永远都没有收到该信号,那么这个处理方式就不做了
- 自定义处理方式
- 忽略该信号
2.键盘产生
1.介绍
我们都知道ctrl+c可以终止一个进程,其实ctrl+c就是发送kill -2这个信号给对应的进程
今天我们要另外介绍其他的几种键盘产生信号的方式
ctrl+\ : SIGQUIT(3号信号)
ctrl+z : SIGTSTP(20号信号)
下面我们小小验证一下
直接捕捉3和20号信号看一下
2.理解
其实我们的键盘当中输入的数据只有两种形式:
(1)字符数据
(2)命令(比如:F1,F2…ctrl,shift,Tab,ctrl+c,ctrl+v,ctrl+z…)
当键盘进行输入时,如果输入到了某些命令键:比如F1,ctrl+c等等
此时该键盘就会向CPU发送某种信号{例如: 电压,电频等等的改变}(注意CPU是跟键盘等硬件通过主板上有电路方面的相连的哦)
此时CPU接收到这种信号之后,就会发生硬件中断,把OS找过来让OS处理该信息
OS肯定是知道这是命令还是数据,当前什么情况,该命令需要被解析成什么,发送给谁…等等等等,OS都会搞定
分析完成之后,如果是命令:OS会提供一个中断向量表(本质就是函数指针数组),来让CPU执行里面对应的方法即可
因此当我们输入ctrl+c时就能向进程发送2号信号,同理,ctrl+z,ctrl+…也是如此
3.系统调用/库函数
1.kill系统调用
其实我们的kill命令就是kill系统调用的封装,下面我们自己实现一个kill命令
kill系统调用是对任意进程发送任意的信号,下面我们再来看几个系统调用
2.raise库函数
raise不是系统调用,不过它内部也是复用了kill系统调用函数
1.补充: SIGKILL与僵尸进程
下面我们用一下
这里给大家演示一下是为了告诉大家9号信号的威力,不过9号信号再厉害,它也不敢对僵尸进程动手,
一旦系统当中出现了僵尸进程,除非父进程回收它,否则这个僵尸进程将永远存在(除非重启系统,使用守护进程等等的方式,可以处理掉僵尸进程),下面我们演示一下
2.raise的使用
下面我们回来继续用一下raise
这次我们捕捉2号信号吧,9号捕不了
raise是对自己发送指定的信号
3.abort
其实abort这个函数在我们学习C/C++的过程当中也见过很多次了,虽然大家可能没怎么留意过
下面我们用一下
当然,你要是这么玩,那SIGABRT也束手无策:
4.软件条件
软件条件就是指: 某个进程的执行不满足某种软件资源时,OS就会给该进程发送某些信号
比如:管道当中: 当读端都不读了且都关闭了读fd时,写端仍然尝试写入数据,此时OS就会给写端发送SIGPIPE信号终止写端进程
对于SIGPIPE而言,我们学习管道的时候都验证过,今天就不赘述了
我们介绍另外一个软件条件: alarm(闹钟)函数与SIGALRM信号
1.alarm函数的介绍与使用
下面我们用一下,设定多个闹钟,第一个闹钟5s之后响,后面的闹钟都是每隔3s响一次,直到响完第5个闹钟,程序终止
那么如何关闭闹钟呢?
每次设置闹钟后,时间都会重置,那么我直接把时间重置成0不就行了吗?
是的,alarm(0)就是取消闹钟,我们来玩一下吧
一开始5s,然后4s,3s,2s,1s,0s(关闭闹钟,然后先休眠20s,然后通过exit(0)终止进程)
只要这20s内没有在被捕捉,那就说明alarm(0)的确是关闭闹钟
验证成功
挺好玩的,不过有啥用呢?
对我们而言,我们可以使用这个函数来量化般测试一下IO操作和纯内存级操作的差距
1.测试1s内CPU能执行多少次IO操作
我试了很多次,大概是7万多次,不过有一次是5万多次,有一次是11万多次
2.测试1s内CPU能执行多少次纯内存级操作
大概是5.6亿次
5.6亿和7.5万差了7466倍,已经差了非常大的差距了
当然不同版本的CPU的处理速度肯定不一样,大家可以自己试一下玩一玩
2.alarm函数为何叫做软件条件,它有什么用处呢?
OS当中有些功能是需要通过定时来完成的,
比如:将文件的内核级缓冲区当中的数据定时刷新到磁盘上,支持用户完成某些定时任务(比如: 定时备份系统文件,发送提醒通知等),管理电源(自动进入休眠模式等等)…
这些功能都是需要通过定时的技术来完成的,而alarm函数只是OS实现定时机制的方式之一而已,OS还有很多其他的实现定时机制的方式,我们就只拿着一个alarm来看看吧
OS内部可以存在很多闹钟,因此OS必然要先描述,在组织,从而进行管理
因此我们可以大胆地猜测一下,OS内部可能就会这么来描述这个闹钟结构体
struct alarm
{
unsigned int seconds;//该闹钟要响的时间戳(该闹钟设定的时间+该闹钟设定时的时间戳)
pid_t pid;//该闹钟是哪个进程设置的
task_struct *p;//指向对应进程的进程描述结构体的指针,负责给该进程发送信号
//...
};
如何来组织呢?
每次OS都要按照seconds从小到大取闹钟进行执行
因此可以维护一个小堆,每次取堆顶元素即可
当然,这只是我们大胆猜测的,实际的情况我们并没有去核实
不过正是因为闹钟也要维护自己的内核数据结构,因此alarm函数属于软件条件!!!
5.硬件异常
某些异常是跟硬件息息相关的,这些异常被硬件检测到之后,硬件就会通知OS来处理这个问题,
然后OS调查发现该异常是由于某些非法操作而产生的话,OS就会给对应的进程发送信号
我们在这里重点介绍两个硬件异常:
(1)野指针的非法访问
(2)除0错误
在我们学习C/C++的过程当中都知道这两种错误都会导致程序崩溃
今天我们来看一下为何会崩溃呢?
它们分别对应的信号是:
野指针… : SIGSEGV信号(11号信号) : 作用:终止该进程
除0: SIGFPE信号(8号信号) : 作用:终止该进程
1.野指针非法访问
1.理论
我们都知道,语言当中的这些地址都是虚拟地址,当我们进程运行时,CPU会通过虚拟地址和页表来找到对应的物理地址进行访问和操作
准确来说,这一过程是通过MMU完成的:MMU(Memory Management Unit: 内存管理单元): 负责管理虚拟内存和物理内存的硬件单元
不过在现在的技术当中,MMU基本都被集成到了CPU当中
当CPU通过虚拟地址在页表当中查找映射时,如果没有该虚拟地址,或者该虚拟地址跟任何物理地址完成映射,或者该虚拟地址完成了映射但是页表当中相应位置的权限不满足指令的条件时
CPU就会发生硬件中断,把OS叫过来,让OS处理这个问题,当OS调查得知是因为该进程发生了野指针的非法访问时,
OS就明白该进程的执行已经出现了非常严重的错误了,此时OS就会给该进程发送SIGSEGV信号来终止该进程
2.演示与现象
我们模拟一下野指针非法访问,并自定义对11号信号的处理方式
我们都知道,野指针非法访问是一个非常严重的问题,但如果我今天就是不处理这个错误呢?
这个异常一直被我们捕获,为何?? 我不是处理了吗??? 因为:
我们这个进程的确发生了对野指针的非法访问,这个错误是很严重的,必须且只能通过终止程序的方式进行处理,
(因为OS和CPU不知道你这个进程后面还会做什么更过分的动作,而且你这个程序已经错误了,你能解决这一问题的唯一方式就是抓紧终止,避免出现更大的问题,)
因此在这个进程终止之前,OS会向这个进程持续发送SIGSEGV信号(或者说这个进程的SIGSEGV信号是无法被消除的)!!
因此每次CPU调度运行这个进程的时候,都会执行这个进程对该信号的信号处理方式
只不过这个错误允许我们使用signal来捕捉而已
我们可以只对该进程发送11号信号,而不让该进程发送野指针的非法访问
验证成功
可是我们可能想说了,C++当中的异常throw并catch之后这个异常就不会再继续发生了啊,因为它已经被捕获了啊
可是我这里的捕获为何不行呢?
C++当中,野指针解引用和除0错误发生之后,程序照样崩,甭管你是不是在try块内部,而且这种异常是需要提前throw的哦,发生了就晚了
除0也是这样,我们就不演示了
3.注意点
我们之前演示的都是发送信号导致需要处理该问题,那种情况下这个信号只要被捕捉一次就行了,就已经算做被处理了,这是对信号的常规处理机制
但是如果是因为进程发生了某种软件条件和硬件异常方面的错误的话,那么该进程就会持续收到信号
如果是软件条件:那么进程就会持续执行相应动作,并且等待该条件满足之后恢复正常执行
如果是硬件异常:那么进程就会持续执行相应动作,直到对应的问题被解决了之后恢复正常执行
如果该问题无法解决,条件无法满足,那么就只能通过进程终止的方式来解决了
4.演示:管道的软件条件不满足的情况
我们来玩一下: 管道的读端都退了,没人读我数据了,我创建一个子进程读我数据,并利用闹钟避免出现僵尸
思路和方式:
演示:
注意:当我解决了SIGPIPE问题之后,这个信号我父进程再也不需要执行了!!!
代码:
#include <iostream>
using namespace std;
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/wait.h>
#include <cstring>
//以[!!!命名!!!]管道为例 : 父进程负责写,子进程负责读
//子进程关闭rfd之后,父进程就会收到SIGPIPE信号,然后我们自定义处理方式
//收到5次信号之后,父进程再创建一个子进程,让那个子进程以r方式打开该该管道
//为何不用匿名管道呢? 因为我们需要让该管道的rfd全部关掉,父进程创建新的子进程时是没有rfd的,新子进程继承不到rfd,解决不了bad pipe问题
//为什么用父子进程呢? 因为我不想生成两个可执行程序来玩,反正命名管道也支持有血缘关系的进程通信,而且这只是一个示例代码而已
int cnt=0;
const char* path="./fifo";
void f(int num)
{
cout<<"This is the "<<++cnt<<" time I've received this signal"<<endl;
if(cnt==5)
{
cout<<"i will fork a child to read my data"<<endl;
pid_t id=fork();
if(id==0)
{
alarm(20);
cout<<"i am new child, 20s later, i will quit"<<endl;
int rfd=open(path,O_RDONLY);
char buf[1024];
while(true)
{
int n=read(rfd,buf,sizeof(buf)-1);
buf[n]='\0';
cout<<buf<<endl;
}
}
}
}
int main()
{
int n=mkfifo(path,0666);//先创建管道
signal(SIGPIPE,f);
pid_t id=fork();
if(id==0)
{
int rfd=open(path,O_RDONLY);
cout<<"i am old child , i will close my rfd , 30s later, i will quit"<<endl;
close(rfd);
alarm(30);
while(true){}
}
int wfd=open(path,O_WRONLY | O_CREAT | O_TRUNC,0666);
const char* message="hello child,i am father";
//死循环,定个15s的闹钟,15s后我会终止,我的子进程会变成孤儿,由1号init进程负责回收
alarm(15);
while(true)
{
write(wfd,message,strlen(message));
sleep(1);
}
return 0;
}
2.除0错误
数学当中为何不允许除0? : 是因为任何数除以0之后都是无穷大,而无穷大是无法用确切的数值来表示的,因此不允许除0
计算机当中为何不允许除0? : 一方面是为了符合数学逻辑,一方面是因为无穷大是无法用任何数据类型表示的,因为它太大了,根本无法表示
而我们知道,计算机当中的绝大多数数学运算都是要通过CPU来完成的,这些数据都保存在CPU的寄存器当中
CPU的寄存器当中有一个标志寄存器(FLAGS寄存器),当发生除0导致的算数溢出时,这个标记位会对该情况进行标记,然后发生硬件中断,把OS叫过来处理该问题
然后OS调查发现是因为该进程执行了除0运算,而这个操作是不合法的,因此OS就给该进程发送SIGFPE信号来终止该进程
这两种错误都跟硬件息息相关,而且都是通过硬件和OS协力发现并处理的,因此才被称为硬件异常
我们就不演示了,跟野指针非法访问那一套是一样的
3.发送信号的本质与信号产生的理解
1.发送信号的本质
每个进程都可以接收各种各样的信号,而且对应的信号都可以在合适的时机进行处理,因此进程需要把信号保存下来
那么如何保存呢??
每个进程只关心我又没有收到过这个信号,也就是只关心有和无这两种状态,因此使用位图的方式进行管理是最好的
我们一开始的时候就说了本篇博客我们只考虑1~31这31个信号(0号信号代码进程正常执行)
因此可以用一个int变量的每一个比特位来对应每一个信号,采用位图的方式对信号进行set,reset,test…操作
因此
所谓的发送信号,本质上其实就是 给对应进程的进程描述结构体当中的那个int变量采用位图方式设置信号
2.信号产生的理解
信号的产生方式有:
- kill命令(本质上就是kill系统调用接口的封装,是通过OS发送信号的一种方式)
- 键盘产生(本质上时OS通过对键盘上输入的命令信息做分析,然后给对应进程发送信号,也是通过OS发送信号的一种方式)
- 系统调用/库函数(本质上都是对kill系统调用接口的封装与复用,是OS发送信号的一种方式)
- 软件条件(本质上是当进程执行不满足某种软件资源时,OS就会给对应的进程发送信号,也是OS发送信号的一种方式)
- 硬件异常(本质是进程的不合法操作被硬件检测到了,硬件通过硬件中断来让OS处理这一问题,如果该操作不合法,OS就会给对应的进程发送信号,本质上也是OS发送信号的一种方式)
我们可以看出,所有的信号的产生与发送都是通过OS来完成的,都是OS提供的产生信号的方式,适用于不同的场景与需求
3.小小总结与思考
- 所有的信号的产生与发送都是由OS执行的,为何??
因为OS是软硬件资源的管理者,所以OS必定是进程的管理者,因此只有OS才有资格掌控所有进程的生死 - 信号的处理是否是立即处理的呢?
不是,而是在合适的时候,进程会先将该信号记录到自己的task_struct的相应字段当中,用位图的方式进行管理 - 如何理解OS向进程发送信号,完整的过程是什么样的?
进程/用户通过OS提供的产生信号的方式产生了信号之后,OS会捕捉这个信号,并且识别出该信号的类型以及目标进程,
然后OS会将该信号写入到目标进程的task_struct中保存下来,对应的信号就会被目标进程在合适的时机进行处理
当该进程被CPU再次调度时,如果对应的信号还没有被处理,那么CPU就会对该进程执行这个处理操作(默认处理方式/自定义处理方式/忽略)
4.core dump核心转储功能
1.引入
我们之前就见到过core dump这个东西,那么它到底是什么呢?
下面这张图是我们在介绍进程等待的时候写的,当时我们说core dump是进程退出信息当中的一个标志
而我们刚才进行野指针非法访问和除0错误的时候也出现了core dumped,而core dump的意思就是核心转储,下面我们来谈一下它
2.概念与功能
核心转储会生成一个core文件,主要作用是在gdb调试导致错误的程序时,输入core-file core/core.pid即可直接跳转到发生异常的位置,帮助我们定位错误(注意使用gdb调试,需要加上-g选项按照DEBUG方式编译)
但是因为core文件会存储调试信息已经程序发生异常的位置等信息,所以core文件通常会比较大,所以为了防止core文件太多占据磁盘空间太多,所以云服务器默认把core dump核心转储功能给关闭了
而如果该进程因为异常而退出,且该异常需要生成核心转储文件,那么core dump标记为就是1,否则就是0
二.信号的保存与深入了解信号
1.引入
前面我们介绍了信号的产生与发送,我们又知道: 一个信号到来之后,该进程可以先将该信号保存下来,在合适的时候进行处理
也就是说该进程可以暂时不立即响应该信号,也就是该进程可以阻塞该信号!!!(注意: 阻塞不是忽略)
阻塞是我这个进程没有看到这个信号,而忽略是我这个进程对该信号的处理方式是忽略它
大家可以理解为: 阻塞就是不接收这个消息,而忽略是已读不回
2.相关概念与信号在内核当中的表示(三张表)
那么进程是如何阻塞一个信号的呢?
先介绍一些专业术语:
下面介绍三张表:
pending : 记录/保存信号的位图 (OS向进程发送信号时就是对该位图进行位操作)
block : 阻塞信号的位图 (对应的比特位为1: 阻塞该信号 ; 为0:不阻塞该信号)
handler : 表示信号处理方法的函数指针数组 (有三种状态: 默认SIG_DEL(default),忽略:SIG_IGN(ignore),自定义状态(用户提供的xxx函数))
去handler表当中拿函数去执行 -> 其实就是信号递达 -> 也就是handleri;
在信号递达之前,如果该信号已经产生了,那么该信号就是处于信号未决状态,处于这种的原因是该信号被阻塞了,当信号不再被阻塞时,该信号就能进行递达了
注意: 信号在递达之前会先将pending表当中相应位置的比特位置为0,然后在递达
下面我们演示一下:
3.信号集与信号集操作函数
1.引入:什么是信号集
几个问题 :
(1) OS会不会允许用户直接修改进程的这三张表呢? ->
当然不会,因为task_struct属于内核数据结构,用户是没有权限直接访问的,而且不同系统对于这三张表的映射和实现方式各有不同,
因此即使是为了屏蔽底层实现细节,OS也不能允许用户直接操作哦
(2) OS会不会允许用户间接修改进程的这三张表呢? -> 当然会
(3) OS如何允许用户修改间接进程的这三张表? -> 必然需要对用户提供系统调用接口才可以
(4) 这三张表OS要不要管理 ? -> 需要,先描述,在组织 -> 因此信号集sigset_t就是描述pending表和block表的内核数据结构类型
而描述对应函数指针数组的内核数据结构是struct k_sigaction,我们不需要去管
注意:
阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略哦!!!
2.信号集操作函数
下面这个是重点:
所有的这些函数,都是执行出错返回-1,执行成功返回0
除了sigismember执行成功返回0或1
注意:
因为不同系统的pending表(sigset_t)的底层实现不同,所以不能无脑cout/printf,只能使用穷举来打印pending表哦
这个j是负责打印空格的,加不加都可以哦
4.使用与演示
1.SIGKILL,SIGSTOP的特例
我们前面证明过9号信号是不能用signal来捕捉的,同理9号信号也是不能被阻塞的哦
因为如果所有信号都能被捕捉或阻塞,那么就意味着这个进程就彻底不能被杀死了,只能由它主动退出才可以
如果这个进程是某个计算机病毒,那不就完蛋了… 所以9号信号就是用来兜底的,谁都干不掉你的时候只能由9号信号出马了
其实不仅仅是9号(SIGKILL,强制进程终止{杀死进程})不能被捕捉和阻塞,19号(SIGSTOP,进程暂停信号)也不能被捕捉和阻塞
18号信号SIGCOUT(进程继续信号解除阻塞会影响其他信号,这点我们知道即可,不重要)
为何19号也不能被捕捉和阻塞呢? 因为有些计算机病毒我可能需要分析一下它,此时我不能让它继续运行,也不能杀死它,只能先让它暂停下来,才能更好的分析它嘛
2.使用与演示
1.场景
直接给出负责发送信号的代码了,没啥难的,就是一个kill命令+命令行参数而已
打印pending表的函数我们之前已经给出了,这里就不在赘述了
2.代码
TestSig.cpp
#include <iostream>
using namespace std;
#include <signal.h>
#include <unistd.h>
#include <unordered_set>
//因为底层实现不同,所以不能无脑cout/printf,只能使用穷举
void Print(const sigset_t& set)
{
//我们打印31个比特位,按照习惯,从右往左比特位增大,信号增大
//所以按照信号从大到小,从左往右打印
for(int i=31,j=1;i>=1;i--,j++)//每打印4个比特位打印一个空格,用j来搞
{
if(sigismember(&set,i)==0)
{
cout<<0;
}
else cout<<1;
if(j%4==0) cout<<" ";
}
cout<<endl;
}
int main()
{
//1.将所有信号都设为阻塞
sigset_t sset,oset;
sigfillset(&sset);//比特位全都置为1: 阻塞所有的
sigprocmask(SIG_SETMASK,&sset,&oset);//直接赋值
//2.死循环获取并打印pending表
//3.对所有信号都进行捕捉,50s后进行捕捉
int cnt=0;
unordered_set<int> mask={6,9,18,19};
auto f=[](int num){
cout<<"catch signal, num : "<<num<<endl;
};
cout<<"block finish please send signal..."<<endl;
while (true)
{
sigpending(&sset);
Print(sset);//打印pending表的函数
sleep(1);
if(++cnt==50)
{
cout<<"i will catch and unblock signal..."<<endl;
for(int i=1;i<=31;i++)
{
if(mask.count(i)) continue;
signal(i,f);
sigemptyset(&sset);//清空所有的比特位
sigaddset(&sset,i);//将对应比特位设置为1
sigprocmask(SIG_UNBLOCK,&sset,&oset);//在block表当中取消对应的阻塞
sleep(1);
}
cout<<"unblock finish, please stop,continue and kill me"<<endl;
}
}
return 0;
}
SendSig.cpp
#include <iostream>
#include <unordered_set>
#include <unistd.h>
using namespace std;
#include <signal.h>
// ./mykill pid
int main(int argc,char* argv[])
{
if(argc==1)
{
cout<<"Usage: "<<argv[0]<<" pid"<<endl;
exit(0);
}
unordered_set<int> mask={9,18,19};
int pid=stoi(argv[1]);
for(int i=1;i<=31;i++)
{
if(mask.count(i)) continue;
kill(pid,i);
cout<<"send signal = "<<i<<" to , pid ="<<pid<<endl;
sleep(1);
}
return 0;
}
Makefile:
.PHONY:xxx
xxx:clean all
clean:
rm -f mykill mysignal
all:mysignal mykill
mysignal:
g++ -o $@ $^ TestSig.cpp -std=c++11
mykill:
g++ -o $@ $^ SendSig.cpp -std=c++11
3.演示
由现象可得:
我们可以清晰地看到当对一个信号阻塞之后,该信号将会一直处于未决状态,不会被递达,当解除阻塞之后,该信号就会被递达了
当一个信号没有被阻塞的时候就能够直接处理该信号,而且也能够看到18号信号的特殊之处(大家知道即可,18号信号不是我们本篇博客的重点)
分析总结一下:
阻塞一个信号和是否收到了这个信号,有关系吗?
-> 没有任何关系,因为可以在收到信号之前阻塞哦,而且阻塞是在block表上玩,收到是在pending表上玩
当一个信号在pending表中比特位为1,且在阻塞表当中比特位为0时,该信号才能被递达哦
因此一个信号被递达可能是因为
pending[i]由0变1,block[i]一直是0 (该信号被接受且没有被阻塞)
block[i]由1变0,pending[i]一直是1 (该信号的阻塞状态被解除,且该信号之前就被接收了)
证明一个结论:
信号递达的时候会先把对应的pending位图给清0,然后再递达
无论这个信号递达是因为没有阻塞而进行的递达,还是因为阻塞消除而进行的递达
我们证明一下,为了不改动代码我们用一下条件编译了哦,不过还是得改一下Makefile…(不改代码就行啊)
演示成功
注意: 进程自己调用abort时给自己发送的SIGABRT无法被阻塞,依然会递达并成功执行
而其他进程给该进程发送的SIGABRT信号可以被阻塞…
因为进程自己调用abort的时候不会检查自己是否阻塞了SIGABRT,只会检查是否捕捉了它而已
5.承上启下
三.信号的捕捉与重谈进程地址空间(用户态和内核态,理解OS)
关于内核态与用户态,大家先记住几句话:
- 进程处于用户态时,权限低,无法访问内核数据结构
- 进程处于内核态时,权限高,能够间接访问内核数据结构,但是必须通过OS来完成访问,而且这个访问也会严格受到OS的管控
- OS进行访问时,有权利直接修改进程的相应数据和内核数据结构,并且会将执行情况返回给进程,因此在外界看来,对应的指令跟进程自己执行没什么区别
- 内核态是OS代码执行的环境,OS本身不能直接处于用户态,因此当一个进程遇到自己的权限无法解决的问题时,
就必须要进入内核态,让OS来处理这个问题
1.信号是在什么时候被处理的
1.先抛结论
进程从内核态切换回用户态时,信号会被检测并处理
联系一下信号的产生,发送,未决和递达的过程:
2.SIG_DFL和SIG_IGN
对于SIG_DFL和SIG_IGN : 信号处理方式跟用户无关
因此形状是这样的
注意:
大多数信号[60%]左右的SIG_DFL都是将进程退出
有少部分[20~30%]是暂停和继续该信号
剩下的是忽略该信号
3.用户自定义捕捉函数
而对于我们今天要着重介绍的处理方式是用户自定义捕捉函数来说 :
我们可以画出一个无穷大的符号
下面我们解释一下每一次状态切换的原因
为何进程执行完自定义捕捉函数时,无法直接回到起点呢?
因为起点位置跟用户自定义的捕捉函数不存在函数调用与被调用的关系,而是两个互相独立的执行流,因此无法直接回到起点哦
现在我们回答这个问题:
希望大家能够记住这个无穷大的符号和整个信号的处理流程
2.信号是如何被处理的? -> 重谈进程地址空间
为了方便大家理解,我们描述的通俗易懂一些,并不是特别的严谨,如果大家需要非常严谨的描述的话,可以去看OS相关的书籍
我们在学习C/C++的时候就已经学习了栈,堆,正文代码,未初始化和初始化数据段,我们在Linux之前的学习当中学习了命令行参数,环境变量,共享区,可是我们一直都没有提这个内核空间哦!!
今天我们的主角就是内核空间与正文代码
1.直接上结论
在理解层面上: 大家可以把内核空间当成共享区来理解,把OS当成动态库来理解 !! (注意:是理解层面,这方面的知识非常复杂,非常庞杂,作为初学者,我们就不去深入研究了…)
是不是有点像调用动态库的库函数啊
每个系统调用都有自己在进程地址空间当中的虚拟地址,也都有自己在页表当中的映射
因为系统调用对于所有进程来说都是可以使用的,也就是说系统调用为所有进程所共享,
因此没有必要为所有的进程各自分配一份内核级页表,而是让它们共有这同一张页表,且内核空间完全相同即可
因此对于所有进程来说,它们的进程地址空间当中的0 ~ 3GB都是不同的,都是自己这个进程所独有的,而且每个进程都独有一份用户级页表
而3 ~ 4GB属于内核空间,所有进程的3 ~ 4 GB都是完全相同的,且它们共用同一份内核级页表
2.重新理解用户态和内核态
你刚才说了这两句话,现在我们有一个问题 :
1.进程的用户态与内核态是如何切换/表示的呢??
我们知道,当一个进程被运行时,一定是在CPU上运行的,所有的代码/指令都是CPU执行的,因此进程此时能否访问内核数据结构
其实就是正在运行这个进程的CPU有没有权限访问内核数据结构
因此在CPU当中存在一个PSW(程序状态寄存器),PSW当中包含了很多信息,其中就包括了表示当前CPU/进程处于内核态还是用户态的字段
当这个字段为0时:处于用户态,此时进程/CPU权限低
当这个字段为1时:处于内核态,此时进程/CPU的权限高,但是执行任何需要访问内核的指令时,必须由OS来执行
因此确保了进程的用户态与内核态的相应切换和相应的权限
2.理解系统调用
进程将要执行系统调用时,需要进入内核态来提高自己的权限,此时会将CPU的PSW当中的标记字段置为1从而提升权限,
然后这个进程才能要求OS来为他执行这个系统调用,当OS执行完系统调用返回之后,
将该CPU的PSW当中的标记字段置为0来降低权限,表示该进程由内核态切换为了用户态
因此:
进程执行系统调用的过程实际上是一个从用户态切换到内核态,再切换回用户态的过程.
所以说执行系统调用的开销比普通函数的调用开销要大,所以我们就能更好地理解用户级缓冲区的意义了
不就是为了少调用几次read和write -> 少调用系统调用 -> 少进行频繁地切换 -> 从而提高效率吗
3.联想一下写时拷贝
当进程进行写时拷贝时会触发页表的缺页中断,OS会进行介入,此时该进程就会进入内核态,
由OS来重新开辟物理空间,拷贝数据,重新建立页表映射,然后由用户态返回内核态,(还有进行信号检测和信号递达)
之后该进程才能继续执行它的代码
也就是说软件条件也会导致进程由用户态切换为内核态哦
我们放慢一点考虑一下这个问题:
进程正常运行 -> 发生页表的缺页中断,由用户态进入内核态(因为这问题进程处理不了,只能让OS处理) -> OS进行介入 -> 此时该进程暂停运行,等到OS处理完成这个缺页中断之后(还有信号检测…) -> 然后将该进程由内核态切换为用户态 -> 进程继续正常运行
其实整个系统调用也是类似的,(不严谨的来说,写时拷贝可不可以看作系统调用,可以啊,信号处理,硬件中断,硬件异常呢? 都可以啊,其实最早版本的Linux内核就是有大堆函数指针数组来搞的一个回调机制)
当进程进入内核态之后,它的代码就会先保存下来,CPU会转而调用OS的代码(相当于是OS来处理这个问题,不要忘了OS也是一个软件)
等到OS处理完毕之后(包括信号…),才会将进程由内核态切换为用户态,继续执行该进程的代码
此时我们就能理解内核态和用户态的意义了哦
4.内核态和用户态的意义
我们回过头来想一下内核态和用户态的意义到底是什么呢?
我们发现了一个共性,无论是收到/递达信号,系统调用,软件条件,硬件异常,硬件中断,这所有的让进程进入内核态的原因都是因为进程无法处理对应的问题,只有OS才知道该怎么处理,也只有OS才能进行处理
我们再次理解一下无穷大:
因此当一个进程进入内核态,就是将自己的控制权暂时交由OS,并且提高自己的权限,让OS来处理这些问题,给进程反馈结果(第一次: 用户 -> 内核)
当进程即将由内核态切换为用户态时,OS此时拥有该进程的绝对管理权,因此不妨检测一下信号,将信号递达一下,然后再将该进程由内核态切换为用户态
当进程切换回用户态之后,就是拿回了自己的控制权,降低了自己的权限,
如果此时不是为了执行自定义捕捉,那这个进程该怎么执行依旧怎么执行(第一次由内核 -> 用户)
如果是为了执行自定义捕捉,那么在执行完之后就会默认调用sigreturn系统调用,(第二次由用户 -> 内核)
再次将自己的管理权交由OS,然后由OS恢复该进程的执行流 (第二次由内核 -> 用户)
(不考虑可能会发生SIGABRT的强制退出)
而上述所有的情况都是为了让OS来处理问题,也就是需要OS介入
因此我们得出:
当进程需要操作系统介入时,它必须进入内核态,并将自身的一部分控制权交给操作系统来处理.
四.补充
1.OS是如何运行的?
你刚才说这么多,那OS是如何运行的呢? 这个太复杂了,涉及到一大堆知识,我们只能说一些皮毛,大家大概了解一下即可
我们的进程信号其实就是模拟硬件中断进行的
我们的计算机当中有一个硬件叫做时钟芯片,通常位于主板上,有的也可能集成到了CPU上,时钟芯片以一个固定频率不断产生中断信号(这个中断信号就叫做时钟周期中断)
- 当计算机启动时,BIOS(Basic Input/Ouput System)(基本输入/输出系统)和它当中的Boot Block会加载引导程序,
当引导程序被加载到内存当中时,它就会立即执行,从而将OS从磁盘加载到内存当中, - 然后OS就会初始化时钟芯片,让时钟芯片以固定的频率向OS产生时钟周期中断
- 每次发生时钟周期中断时,OS就会进行运行,来快速地执行各种任务,执行完之后就会等待下一次的时钟周期中断
- 往复运行,只要时钟周期中断的频率足够高,就可以让OS看上去像是一直运行
为何不能让OS真的一直运行呢?
因为大多数计算机CPU只有一个,同一时刻只能运行一个进程,如果OS一直运行,那就会导致其他进程分配到的CPU资源较少,因此为了提高计算机的效率,必须要通过硬件的方式来让OS能够以一定的周期性进行运行
2.捕捉信号的其他方式: sigaction
sigaction跟signal很像,只不过功能比signal更强大,但是比signal更复杂一点
1.介绍
2.代码+解释
也就是说当一个信号正在被处理时,OS会将该信号直接阻塞,防止在处理过程中又收到该信号,导致嵌套递归式处理该信号,否则如果该信号处理时间比较长,且该信号一直频繁产生时可能会发生栈溢出哦
我们演示一下
栈溢出:
因此sigaction提供了当我们能够指定处理该信号时屏蔽哪些信号,就是为了防止栈溢出
如果允许阻塞呢?
此时就不会发生栈溢出了(这么恶意的代码也拿我们的进程没招了)
3.sigset_t的一些补充点
- 当一个信号被阻塞了,且该信号产生过很多次,怎么办?
Linux当中:常规信号的递达之前产生多次只记一次,而实时信号在递达之前,产生多次,可以依次放在一个队列里 - 当一个信号被处理时,默认会对该信号进行阻塞,等到处理完毕时会移除对该信号的阻塞,也可以通过sigaction来屏蔽其他信号
- 当一个信号产生了,但是因为被阻塞所以不能递达时,计算它的处理动作时SIG_IGN,但是在还没有解除阻塞之前依旧不能处理该信号,也就是不能忽略该信号,即使它当前的处理动作就是SIG_IGN
(因为当前信号是阻塞的,进程可以在取消阻塞之前改变处理动作哦!!)
3.SIGCHLD
1.引入
- 回想一下,父进程wait或者waitpid等待回收子进程时,对于阻塞等待:父进程啥也不能做,对于非阻塞的轮询访问:父进程能做一些事,但做的不多,代码也不好看
- 今天我们要说的是: 当子进程退出时,会向父进程发送SIGCHLD信号!!(不过该信号的默认处理方式是SIG_IGN)
那么我们能不能玩一下SIGCHLD呢?
让父进程不用一直干等着子进程退出,还能回收他呢?
我们一起来探索一下
2.初步探索
这不简单,直接让父进程捕捉一下SIGCHLD,在捕捉函数里面直接waitpid不就行了吗
我们先试试,先写个代码再说
这不是成功了吗?
刚才只有一个进程,你不要忘了:一个进程可以有很多子进程哦,而且
因此就算你把阻塞解除了,也没用!!!
(1)没解除阻塞
(2)解除阻塞
还是有很多僵尸进程的哦
还记得吗?
这就是我们调成非阻塞也白搭的原因,信号异步产生并发送,我们无法及时处理所有相同的信号
3.进一步探索
我搞成死循环不就行了吗?
我们试一试,阻塞我们就不取消了
这不就解决了吗? 100个子进程全回收了
这是可以的,因为: 即使SIGCHLD信号被阻塞,父进程仍然可以通过调用wait或waitpid来回收子进程的资源.
这些系统调用与信号的处理是独立的,因此不会受到信号阻塞的影响
不过你这样做跟直接wait有什么区别?
你一直执行死循环,别的代码还执不执行了…
所以这不是我们的初衷
那么我们用返回值判断一下,当返回值<0就直接break试试?
并不能符合我们的需求
父进程在这后50个子进程退出之前并没有执行任务(这就是一种浪费)
4.最终版本
那怎么办?
我们注意到:
- 只要父进程收到SIGCHLD信号,不就意味着有子进程已经退出了吗? -> 此时直接wait立即就有结果!!
- 就算存在子进程同时退出,导致SIGCHLD信号被重叠,无所谓,因为wait/waitpid不受SIGCHLD的影响
因此把waitpid的方式改成非阻塞等待即可!!!
成功满足了我们的需求
5.另一种版本
在Linux当中,要想不产生僵尸进程还有一种很绝的方法:
父进程调用signal/sigaction将SIGCHLD的处理方式改成SIG_IGN,这样的话fork出来的子进程在进程结束时会被自动清理掉,不会保留PCB来让父进程读取退出状态,也不会给父进程发送SIGCHLD信号
父进程默认的对于SIGCHLD的处理方法就是SIG_IGN啊,因此这是一个特例
对于Linux是有用的,但是对于其他UNIX系统则不一定哦
我们验证一下
这样做固然能够完全解决僵尸进程的问题,但是父进程也拿不到子进程的退出信息了
因此对于僵尸进程来说,大家根据具体需求灵活选择这两种方法即可:
(1)捕捉SIGCHLD+死循环+waitpid+失败退出
(2)捕捉SIGCHLD,设为SIG_IGN(缺点:只在Linux下保证有用,其他平台不一定哦)
4.可重入函数(为多线程做铺垫)
可重入在多线程阶段更容易理解和解决,可是在信号部分当中就已经出现了它的身影
所以我们简单介绍一下:
先看一个现象:
我这里就纯硬编码了,因为没到多线程之前,我们很难演示
所以大家体谅一下…
1.现象
可是谁能想到:
怎么解决呢?我们在多线程当中会介绍,在那里解决这个问题更方便和重要
2.注意点
几点需要注意的事项:
- 一个函数被不同的控制流程调用,有可能在第一次调用还没结束时就进行了第二次调用(这称为重入),该函数称为不可重入函数
- 如果一个函数只访问自己的局部变量/参数,那么该函数被称为可重入函数
- 我们用到的大部分函数都是不可重入函数
- 最好把可重入与不可重入当作函数的特点来理解,而不是优缺点,虽然也有点那方面的意思
- 如果一个函数符合以下条件之一则是不可重入的:
调用了malloc或free,因为malloc也是用全局链表来管理堆的
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
以上就是Linux: 进程信号的全部内容,希望能对大家有所帮助!!