Linux之进程信号

目录

一、生活中的信号

背景知识

生活中有没有信号的场景呢?

是不是只有这些场景真正的放在我面前的时候,我才知道怎么做呢?

进程在没有收到信号的时候,进程知道不知道应该如何识别哪一个是信号?以及如何处理它?

但是对于日常生活中,信号场景产生的时候,我们立即可以做出相应的动作吗?

既然信号不能被立即处理,已经到来的信号,是不是应该暂时被保存起来?

二、技术应用角度的信号 

验证信号

键盘CTRL+C的本质就是我们向指定进程发送2号信号!如何证明呢?

验证下键盘中的按键对应哪些信号

让不同的信号执行不同的动作

三、信号产生前

1.键盘产生信号

2.程序中存在异常问题,产生信号,导致进程收到信号退出。 

为什么我会收到信号呢?

当进程崩溃退出的时候,最想知道什么?

如何证明core dump是被关掉的呢?

不一定所有的信号都会形成core dump文件

3.通过系统调用接口,产生信号

kill系统调用 

raise调用

abort函数

4.软件条件也能产生信号        

alarm

为什么第一种计数很慢呢?

如何理解OS给进程发送信号?

四、信号产生中

信号其他相关常见概念

忽略和阻塞有区别吗?

信号在内核中的表示

信号处理的过程

代码验证信号

sigset_t

信号集操作函数

sigprocmask

sigpending

五、信号发送后

为什么是合适的时候?

什么是合适的时候(信号什么时候被处理)?

对内核态与用户态的理性认识       

信号处理

​编辑为什么一定要切换成用户态才能执行信号捕捉方法?

sigaction函数

六、可重入函数 

七、volatile

如何解决这个问题呢?

为什么是这种现象呢?

八、SIGCHLD信号


一、生活中的信号

信号和信号量两个有关系吗?

毫无关系,就如同老婆和老婆饼毫无关系。

背景知识

生活中有没有信号的场景呢?

肯定有,eg:闹钟,红绿灯,烽火台的烽火,你妈的脸色,信号枪,鸡哥叫声...当这些个场景触发的时候,我立马就知道我接下来该做什么,eg:闹钟响会意识到该起床了,红绿灯变绿意识到该过马路了,鸡哥叫意识到你太美...  这些场景都是给人看的。

是不是只有这些场景真正的放在我面前的时候,我才知道怎么做呢?

其实和场景是否被触发,没有直接关联的!对于信号的处理动作,我们早就知道了,甚至远远早于信号的产生。我们是怎么知道该怎么做的呢?我们对特定事件的反应,是被教育的结果!被教育的本质是你记住了。         

在linux中,信号的产生 -> 信号是给进程发的 ->进程要在合适的时候,要执行对应的动作。 

进程在没有收到信号的时候,进程知道不知道应该如何识别哪一个是信号?以及如何处理它?

必须知道。它是怎么知道的呢?曾经编写OS的工程师,在写进程源代码的时候就设置好了。

进程具有识别并处理信号的能力,远远早于信号的产生!        

但是对于日常生活中,信号场景产生的时候,我们立即可以做出相应的动作吗?

答案是否定的,听到鸡叫,你不一定会立马起来学习;听到闹钟,你有可能会关了闹钟继续睡觉,或者压根管都不管,继续睡觉;听到信号枪,你有可能因为在想其他事情也不会立马起跑....在生活中,我们收到某种“信号”的时候,并不一定是立即处理的。因为信号随时都会产生,但是我当前可能做着更重要的事情!eg:你在等外卖的时候,正在调试代码。外卖小哥敲门的时候,你正调试到关键时刻,所以你就没有立马开门,而是让外卖小哥多等了几分钟你才拿的外卖。

在linux中,进程收到某种信号的时候,并不是立即处理的,而是在合适的时候。

既然信号不能被立即处理,已经到来的信号,是不是应该暂时被保存起来?

应该,eg:你在等外卖的时候,正在调试代码。外卖小哥敲门的时候,你正调试到关键时刻,所以你就没有立马开门,而是让外卖小哥多等了几分钟你才拿的外卖,在小哥等待过程中,你就记着外卖小哥在等你这个事情,作为人,我们会将信号保存到大脑里面。

在linux中,进程收到信号之后,需要先将信号保存起来,以供在合适的时候处理,保存信号的地方就是进程控制块struct task_struct中!

信号本质也是数据!信号的发送就是往进程task_struct内写入信号数据。

task_struct是一个内核数据结构,可以定义进程对象。内核不相信任何人,只相信自己!那么是谁向进程的task_struct内写入信号数据呢?

答案是OS,无论我们的信号如何发送,本质都是在底层通过OS发送的!!!

二、技术应用角度的信号 

验证信号

对于这样一段代码

#include<stdio.h>    
#include<unistd.h>    

int main()    
{    
                                                                                                                                                   
  while(1)    
  {    
    printf("hello world\n");    
    sleep(1);    
  }    
} 

执行结果显而易见的是一直在打印hello world。但如果我想终止这个进程我们就可以直接Ctrl+c

在linux中,我们可以用kill -l 查看信号列表,一共62个信号(因为这里main没有32,33)

前31个称为普通信号(1-31),后31个称为实时信号(34-64) 

键盘CTRL+C的本质就是我们向指定进程发送2号信号!如何证明呢?

首先了解一个函数signal

  • 作用:修改指定的一个信号,每个进程对信号的处理是有默认动作的,我们可以通过该函数修改进程对信号的默认处理动作 。
  • 第一个参数:是int类型,刚刚kill -l查看的信号我们除了可以看见信号名称外还能看到编号,在系统中,信号编号就是整数,每一个信号都是被#define定义出来的,我们既可以使用信号名又可以使用数字。这里的参数就是信号编号。
  • 第二个参数:是返回值为void,参数为int的函数指针。

对上述代码进行改进

执行结果:

我们首先看到进程一直跑,并没有调用handler这个函数,只有当我们Ctrl+C的时候,此时就执行了handler函数,并且发现确实是对18537这个进程发送了2号信号。

我们发送2号信号也是同样的结果

 ps:crtl+\ 终止这个进程

验证下键盘中的按键对应哪些信号

执行结果:

我们发现Ctrl + c 对应2号信号  Ctrl + \ 对应3号  Ctrl + z 对应20号。

我们如果运行./mytest + & 叫做该进程在后台运行。我们发现此时crtl+c/ \ / z是没有任何结果的。

如何使该进程停下呢?

 我们可以使用kill -9 命令杀死该进程。 

信号的产生方式其中一种就是通过键盘产生,键盘产生的信号,只能用来终止前台进程,也就是说只能用来终止那些阻塞你执行命令的程序。后台进程我们可以使用kill命令杀死进程。

一般而言,进程收到信号的处理方式有三种:

  • 1.默认动作:一部分是终止自己,暂停等等。
  • 2.忽略动作:是一种信号处理的方式,只不过动作就是什么也不干。
  • 3.自定义动作:我们刚刚使用的signal方法,就是修改信号的处理动作:默认变为自定义动作。我们一般把自定义动作称为信号的捕捉。

让不同的信号执行不同的动作

执行结果:

我们发现crtl+c和crtl+\ 或者发送2号或者3号信号都执行我们设置的动作,但是9号信号缺不符合我们的预期,没有打印出对应的动作,因为9号信号不可以被捕捉(自定义)!

三、信号产生前

信号产生的方式有哪些呢?

1.键盘产生

2.进程异常,也能产生信号

1.键盘产生信号

比如:在程序运行的时候,我们可以通过键盘的ctrl+c,crtl+\,crtl+z终止进程

2.程序中存在异常问题,产生信号,导致进程收到信号退出。 

eg: 进程异常,产生信号

在vs下这叫做程序的崩溃,准确来说这个叫做进程的崩溃。

进程为什么会崩溃呢?

当我们去掉上面的注释,再次运行。

我们发现此时程序直接收到11这个信号,在疯狂打印。

我们稍微的改下handler,让其执行完switch后直接退出进程。

再次执行:

对比刚开始的代码,起初我们运行是会进程崩溃的,但是现在收到了11号信号。因此可以得出程序崩溃就是因为收到了11号信号。如何证明呢?

我们将代码进行修改:

执行结果:

 程序正常运行,当我们发送11号信号的时候,进程果然崩溃。

我们的进程在运行时崩溃的根本原因在于:程序在运行时出现了野指针问题,导致进程收到了11号信号,进而导致进程崩溃。

eg2:

我们发现终止的时候收到了8号信号。

 我们不进行信号捕捉 

此时就会告诉我们浮点数指针异常。

在windows或者linux下进程崩溃的本质是进程收到了对应的信号,然后进程执行信号的默认处理动作(杀死进程)。

为什么我会收到信号呢?

除0操作对应CPU,野指针对应内存,只要是硬件就要受到CPU的管理工作。而你自己的代码只要除0了,是CPU给你除0了,CPU内部是有状态寄存器的,会记录运算结果。如果除0了就会在硬件上有所体现;当进行野指针访问的时候,我们要把虚拟地址转换成物理地址,就要借助页表+MMU。当虚拟地址和物理地址没有对应的关系,那么帮助转化的硬件也会报错。所以软件上面的错误,通常体现在硬件或者其他软件上!一旦进程里面有异常信息,是在硬件上体现的。而OS是硬件的管理者,OS就要对硬件的“健康”负责。代码是你的进程里的代码,这些代码异常了导致硬件不健康,OS就会直接对你的进程发送信号,在合适的时候终止进程。

我们目前见到的所有异常都是OS对你的进程发信号导致的。语言层面的捕捉异常就是对信号的处理。

当进程崩溃退出的时候,最想知道什么?

无疑是崩溃的原因。我们可以用waitpid()中的status的次低7位获取到进程退出的信号,知道信号我们就知道了崩溃的原因。除此之外,我们还想知道进程是在哪一行崩溃的。

在linux中,当一个进程退出的时候,它的退出码和退出信号都会被设置(正常情况)。

当一个进程异常的时候,进程的退出信号会被设置,表明当前进程退出的原因。

如果必要,OS会设置退出信息中的core dump标志位,并将进程在内存中的数据转储到磁盘当中,方便我们后期调试。

可是对于以下崩溃的代码并没有看到任何的现象。默认情况,在linux云服务器core dump这项技术是被关掉的。 

如何证明core dump是被关掉的呢?

使用ulimit -a查看系统资源,我们发现第一个core file size这个是被关掉的。

我们自己手动设置下,让其允许core dump

此时我们在运行刚刚有/0错误的程序,发现多了一个core dumped,这就叫做我的进程代码被core dump了。

此时在当前路径下,我们发现多了一个core文件,这个4338就是刚刚崩溃进程的pid,打开这个文件发现是一堆乱码。这个文件将来是可以帮助我们调试的。

进行调试

首先将我们的Makefile 增加一个 -g选项,允许你的程序被gdb调试

开始调试 ,输入core-file core.pid,就可以看到该进程崩溃的原因,并且是在第几行崩溃了

我们先让程序出异常,然后在用gdb调试,直接用core-file 命令得到了错误的原因和错误的行数。这种方案我们称为事后调试。 同时我们也能充分理解core dump标志。进程如果异常的时候,被core dump,core dump该位置被设置成1。

不一定所有的信号都会形成core dump文件

证明如下:

先把程序改正确

我们发送2号信号,此时进程退出了,但是并没有收到core dump 

我们发送3号信号,发现是有的

我们发送9号信号,发现进程被杀死了,但是没有core dumo

所以不一定所有信号都有core dump 文件但是只要因为信号终止,那么该信号都会被设置,但有没有core dump文件是由core dump标志位决定。 

core dump位变成1,证明当前我们的子进程是会形成core dump文件的。 

3.通过系统调用接口,产生信号

kill系统调用 

 第一个参数传进程的pid,第二个参数传信号。功能就是我要给某个进程发送信号。

我们模拟实现一个kill命令。

我们用一个sleep 进程做测试

我启动了一个sleep进程。我用我自己的kill命令给这个sleep进程发送9号信号,那么这个sleep进程就被kill掉了。此时就叫做采用系统调用对目标进程发送信号。

raise调用

给自己发一个信号。

eg:给自己发送一个8号信号

abort函数

给自己发送一个确定的信号叫做abort信号。abort是6号信号。abort函数使当前进程接收到信号而异常终止。

eg: 我们对所有信号进行捕捉,看看abort是那个信号 

执行结果: 

 

证明abort确实是6号信号。

4.软件条件也能产生信号        

软件条件:通过某种软件(OS),来触发信号的发送,系统层面设置定时器,或者某种操作导致条件不就绪等这样的场景下,触发信号的发送。

进程间通信:当读端不光不读,而且关闭了读端的fd;写端一直在写,最终写进程会收到sigpipe(13),就是一种典型的软件条件触发的信号发送。OS发现此时不具备写入的条件直接终止。

alarm

设置一个计时器,会延迟的向我们发送一个信号。比如second设置成10,就代表着过10秒后给我们发送一个sigalrm信号也就是14号信号。 

这个函数的返回值是 0 或者是以前设定的闹钟时间还余下的秒数。打个比方 , 某人要小睡一觉 , 设定闹钟为 30 分钟之后响,20 分钟后被人吵醒了 , 还想多睡一会儿 , 于是重新设定闹钟为 15 分钟之后响 ,“ 以前设定的闹钟时间还余下的时间 就是10 分钟。如果 seconds 值为 0, 表示取消以前设定的闹钟 , 函数的返回值仍然是以前设定的闹钟时间还余下的秒数。

eg:证明alarm是14号信号 

eg:alarm的返回值是0的情况。 

执行结果:

eg:alarm的返回值是非0的情况。

eg:统计一下,1秒以后,我们的计算机能把数据count累加到多少 

 执行结果:统计到7万多 

我们稍微修改下代码,它的功能也是进行计数。

执行结果:

我们发现此次计数达到了5亿多次。效率翻了7000倍。

为什么第一种计数很慢呢?

根本原因就是有IO。根据体系结构,如果是第二种情况是不会进行体系访问的,计算都是纯CPU的最多和内存交互一点点,效率是非常高的。而第一种情况,加了printf,printf是IO函数,会向显示器上打印,而且消息传出是经过网络的,服务器在远端,你在本地,所以变的更慢了。 所以有IO和没有IO基本上是数量级上的差别。所以程序出现了大量的IO一定要考虑下效率问题。

信号产生的方式种类虽然非常多,但是无论产生信号的方式千差万别,但最终,一定是通过OS向目标进程发送的信号。换句话说,键盘产生的实际上不是键盘产生的,是键盘让OS产生的,比如crtl+c,是OS将你的组合键解释成信号发送给目标进程;操作异常了,并非是异常导致自己给自己发信号,而是因为异常引发了硬件错误,被OS知道,OS向目标进程发信号;系统调用就是OS提供的接口,底层就是OS在发信号;所谓的软件条件,都是OS检测到某种异常,发送信号。这些全都是OS所做的。

产生信号的方式,本质都是OS发送的!

如何理解OS给进程发送信号?

首先了解下进程保存信号的方式,信号的编号是有规律的[1,31],进程中,采用位图来表示该进程是否收到信号!

OS给进程发送信号->OS发送数据信号给task_struct->本质是OS向指定进程的task_struct中的信号位图写入比特位1,即完成信号的发送。信号的发送换一种说法就是信号的写入。 

四、信号产生中

信号在产生的时候不会立即进行处理,而是在合适的时候,在此信号就会被保存起来。现在我们着重讨论下信号保存的状态。

信号其他相关常见概念

  • 实际执行信号的处理动作称为信号递达(Delivery) 信号递达的凡是有三种:自定义捕捉,默认,忽略。
  • 信号从产生到递达之间的状态,称为信号未决(Pending)本质是这个信号被暂存在task_struct信号位图中,这就是未决状态。
  • 进程可以选择阻塞 (Block)某个信号。

阻塞的本质是OS允许进程暂时屏蔽指定的信号,屏蔽带来的结果有两种

  • 1.该信号依旧是未决的。
  • 2.该信号不会被递达,直到解除阻塞,方可递达。

忽略和阻塞有区别吗?

忽略是递达的一种方式。阻塞是没有递达,是一种独立状态。

被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。

注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

信号在内核中的表示

信号在内核中的表示示意图,共有三张表,block表,pending表,handler表

我们刚刚说的产生信号的本质是OS向指定进程的task_struct中的信号位图写入比特位1,即完成信号的发送->信号的写入。这里写入的进程的表称为信号的pending位图。所以确认一个进程是否收到信号我们称为pending。换句话说OS发送信号的本质就是修改目标进程的pending位图。

handler表叫做void(*handler[31])(int) ,它叫做函数指针数组,里面放的是默认,忽略,自定义的函数指针地址(这个函数指针数组有多少个看操作系统) 。handler根据信号标号去索引函数指针数组,就直接可以找到处理方法。SIG_DFL叫做处理该信号被默认,SIG_IGN就叫做处理该信号被忽略。

所以signal函数的原理就是根据信号编号索引这个数组下标,然后把handler函数的地址填到对应的下标内容处,此时对指定信号的处理方法就发生作用了。就是把函数地址传进去,特定信号的特定方法我们就有了。

我们在系统中查找下 SIG_DFL SIG_IGN

我们可以看到它俩就是被0和1做了一个__sighandler_t这个函数指针的强转。 所以如果在OS层面上发现这个地址值强转过来是0那就是默认,是1就是忽略。否则填的一定是具体函数地址,最终表明就是自定义捕捉。

block表:本质上也是位图结构。叫做uint32_t block;

比特位的位置,代表信号的编号。比特位的内容代表,代表信号是否被阻塞。1就代表被阻塞,0代表没有。阻塞位图也叫作信号屏蔽字

信号处理的过程

一个信号任何时候都可以被pending但是递达不递达由block说了算。

总结:识别一个信号采用三元组方式,是否被block是否被pendinghandler方法是什么,结合这三个信息我们就可以知道这个信号该被怎么处理,这三个信息合起来就叫做进程是可以识别信号的。

代码验证信号

不要只认为有接口才算是系统调用,也要意识到:OS也会给用户提供数据类型,配合系统调用来完成。

sigset_t

从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。


信号集操作函数

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的

#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
  • 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
  • 函数sigfifillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。
注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfifillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。

这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。

sigprocmask

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)修改的是进程的block表

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 
返回值:若成功则为0,若出错则为-1

set是输入性参数,oset是输出型参数。

如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果osetset都是非空指针,则先将原来的信号 屏蔽字备份到oset,然后根据sethow参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

如果调用 sigprocmask 解除了对当前若干个未决信号的阻塞 , 则在 sigprocmask 返回前 ,至少将其中一个信号递达。        

eg:我们屏蔽一个2号信号

 执行结果:2号信号是crtl+c,现在crtl+c与发送2号信号对该进程都是不起作用的。

  

eg: 现在我们在屏蔽下9号信号

执行结果:我们发现9号信号是无法被屏蔽的

9号信号被称之为管理员信号是不能被屏蔽和自定义捕捉的,必须永远遵守默认行为。所以不存在一个将所有信号都屏蔽的进程。

sigpending

返回值:若成功则为0,若出错则为-1。不对pending位图做修改,而只是单纯的获取进程的pending位图。这个参数就是一个输出型参数。pending位图是由OS进行修改的 

由以上函数我们可以做一个小实验:如果我的进程预先屏蔽掉2号信号(因为2号信号递达了默认动作会退出进程),不断的获取当前进程的pending位图,并打印显示000000000,然后手动发送2号信号,因为2号信号不会被递达,所以,不断获取当前进程的pending位图,并打印显示010000000

代码如下:我们先屏蔽2号信号,我们pending这个位图,不断获取当前进程的pending,最后在检测一下当前进程的pending的信号。

执行结果:

起初没有发送任何信号,一直是0000000,当我们发送2号信号的时候,变成了0100000000。我们直观的看到了信号被pending的状态。

观察恢复2号信号发生的情况。我们设置了一个计时器,20秒号,恢复2号信号。

 在20秒内发送一个2号信号,但是我们并没有观察到由0变1,在由1变0,而是在20秒的时候直接退出了,因为2号信号递达的默认动作就是终止进程,所以看不到现象。

我们想要看到现象只需要对2号信号进行自定义捕捉一下。

执行结果:我们观察到了我们预期的现象。而且是先把信号给处理了,再进场printf的打印

五、信号发送后

信号被发送后,不是被立即处理的,而是在合适的时候,这个合适是什么时候,为什么是合适的时候?

为什么是合适的时候?

因为信号的产生是异步的,它在任何时候都可能产生信号,在信号产生期间,我的进程可能一直都在运行,当前进程可能会在做着更重要的事情。所以我们会将信号做延时处理,这个取决于OS和进程。

什么是合适的时候(信号什么时候被处理)?

因为信号是被保存在进程的PCB中,pending位图里,所谓的处理一定要进行检测,递达(默认,忽略,自定义)。当进程从内核态返回到用户态的时候,进行上面的检测并且处理工作。

  • 用户态:就是用户代码和数据被访问或者执行的时候,所处的状态。我们自己写的代码全部都是在用户态执行的。
  • 内核态:执行OS的代码和数据时,计算机所处的状态就叫做内核态。OS的代码的执行全部是在内核态。

他们的主要区别:在于权限,内核态的权限远大于用户态,如果一个用户的进程出现越界,野指针的问题,OS可以直接将用户对应的进程通过进程杀死。如果OS内出现了越界,野指针的问题,OS就直接可能崩溃调了,因为内核和用户相比较,用户状态是被管控的状态,当用户出问题的时候,OS可以控制它。但如果是OS本身的问题,极有可能导致OS直接崩溃。我们实际上写代码的时候我们可能在不断的从用户态切入内核态,在从内核态切换用户态。最典型的表现就是调用系统调用。比如:open函数的实现是在OS里面,我们调用的是open系统调用接口,我们上层调用open得到返回值,打印这个返回值等待都是用户态代码,当我们陷入内核的时候,除了进入到系统函数本身,还要发生一个身份的变化,由用户变成了OS。

用户调用系统调用的时候除了进入函数,身份也会发生变化,用户身份变成内核身份 。

对内核态与用户态的理性认识       

以上是我们关于用户和内核态感性的认识,接下来是理性的认识。用户的身份是以进程为代表的。

用户的数据和代码一定要加载到内存,那么OS的数据和代码呢?

也是一定要加载到内存中。eg:电脑开机就是把OS的代码和数据加载到内存中。

OS的代码是怎么被执行到的呢?

假设只有一个CPU,我们之前学到的页表是用户级页表,每个进程都有一份,而在OS启动之后,也会有一个系统级别的页表。每个进程的进程地址空间都会有3G的用户空间和1G的内核空间,因为我们要保证每个进程看到的都是4G的空间,每个用户的页表是不同的,进而能映射到不同的位置来保证进程的独立性。而另外一张系统级页表也叫内核页表,而且整个系统只有一份,内核进程被所有进程共享!也就意味着每个进程可以将自己的整个内核空间的代码和数据经过内核页表映射到OS的物理内存的不同位置,从而能让这个进程能找到OS的代码和数据。这样就能保证每个进程既能看到它的代码又能看到OS的代码。在CPU中有个CR3寄存器,为0的时候代表OS,为3的时候代表普通用户,进程在执行的时候想要知道自己是用户态还是内核态就查这个CR3寄存器。

进程具有了地址空间是能看到用户和内核的所有内容的,不一定能访问。如果CR3为0就代表这个进程能访问1G的内核空间就是内核态。否则就只能访问3G的用户空间,是用户态。

CPU内有寄存器保存了当前进程的状态。用户态使用的是用户级页表只能访问用户数据和代码,内核态使用的是内核级页表只能访问内核级的数据和代码

进程间无论如何切换,我们能保证我们一定可以找到同一个OS,因为我们每个进程都有3~4G的地址空间,使用同一张内核页表。

所谓的系统调用:OS给你提供了一种方法,让你在你的代码里用接口,当你实际访问这个方法时,因为要系统调用,所以OS可以将你的身份变成内核级(通过又一种叫做中断实现的)虽然要系统调用,但系统调用的实现没有,起始地址有,然后进程切换成内核态就允许访问内核页表,然后我在系统调用通过内核页表找到OS的代码,然后在进程的上下文中执行系统调用。总结一下就是进程的身份转化成为内核态,然后根据内核页表找到系统函数,执行就行了。

在大部分情况下,实际上我们OS都是可以在进程的上下文中直接运行的,因为OS的代码被映射到了进程的地址空间。eg:进程切换,当时间片到了的时候我们立马把当前进程的状态改成内核态,然后执行内核态的进程切换的代码,就相当于OS把你的进程从CPU上拿下来,在把另一个进程放上去,而且并不影响OS,因为每个进程的进程地址空间(内核空间)是一样的。        

信号处理

程序有很多的方式进入内核,比如自身是死循环,或者调用了系统调用等,一定存在从用户到内核,再从内核到用户。

eg:我们的代码某一行里有系统调用,就要陷入内核,执行对应系统调用的代码,执行完毕之后在返回到用户态,这就完成了一次系统调用,而且完成了一次从用户态到内核态,内核态到用户态的状态切换。

实际上执行信号处理,在内核态执行完系统调用,返回到时候要对该进程进行信号检测,假设某个信号没有被block,但是被pending了,接下来就要处理它,一共有三种方案:默认,忽略,自定义:

  • 如果是默认,比如是终止进程,就直接把进程的相关资源释放掉就可以了,我现在就是内核态,如果是暂停,我把进程状态设置成stop,并把进程的PCB放到等待队列里就可以了;然后再直接返回用户态下一行。
  • 如果是忽略,将pending由1置0,直接返回用户态的下一行代码;
  • 如果是自定义捕捉,在用户态有我们实现的handler方法,我们就需要从内核态直接返回到用户态执行handler方法,当handler方法执行完,我们不能直接返回下一行代码,必须保证是从内核返回到下一行,因此我们执行完handler需要再次返回到内核,执行一个系统调用sys_sigreturn().返回用户态的下一行代码。

这就是信号处理的完全过程。

现在我们进行高度抽象

为什么一定要切换成用户态才能执行信号捕捉方法?

用户执行OS的代码是不可能的,因为用户身份不够,但是目前我是OS,我为什么不直接执行用户的代码呢,OS在理论上是可以执行用户态的代码的,因为OS权限高。但是OS不相信任何人!

  • 1.把自己保护好,用户只能调用OS提供的接口。
  • 2.轻易不执行用户的代码。eg:如果我的handler里封装了个rm -rf \ . 如果OS执行了,此时就相当于OS以它的身份,把整个系统干掉了。所以如果以内核身份执行用户代码,会存在用户会写一些恶意代码的情况。所以OS不能轻易执行他人代码。

sigaction函数

作用上是和signl一模一样的 

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

这个函数要比signl复杂,因为它考虑了实时信号,act是一个输入性参数就是说你想对这个信号执行什么动作,你可以把你的动作方法填入到这个结构体里,当信号就绪时执行;oact是一个输出型参数,你设置这个信号的老的方法是什么,它会带回老的信号的方法,不想要设为NULL。 

sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体:

将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。

sigaction结构体

我们主要关系这个两个,第一个就是函数方法。

eg:对2号信号做处理 

表handler方法设置成忽略SIG_IGN

 执行结果:

将handler方法设置成默认SIG_DFL

执行结果:

当某个信号的处理函数被调用时 , 内核自动将当前信号加入进程的信号屏蔽字 , 当信号处理函数返回时自动恢复原来的信号屏蔽字, 这样就保证了在处理某个信号时 , 如果这种信号再次产生 , 那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时, 除了当前信号被自动屏蔽之外 , 还希望自动屏蔽另外一些信号 , 则用 sa_mask 字段说明这些需要额外屏蔽的信号, 当信号处理函数返回时自动恢复原来的信号屏蔽字。

eg: 我们处理2号信号,屏蔽下3号;为了方便演示,我们将信号处理放法设置成死循环、PS:平时我们的handker方法不能设置成死循环,如果设置了也一定要设置退出。

 执行结果:在开始执行2号信号的方法后,再次执行2号信号,3号信号是没有反应的

如果我把2号信号屏蔽了,我给你的进程发送100个2号信号,此时你的进程只能记住一个,Linux对普通信号是可能丢失的(2个以上),因为记录信号的标志位只有一个比特位,如果把2号信号屏蔽了,发送100个,OS最终只记住一个(最新的那一个,发一次写一个)。不可能丢失的信号叫做实时信号,在内核中是以链表,队列的形式来把所有的实时信号链到PCB里面的,来一个链接一个。本质是底层数据结构的区别。 

六、可重入函数 

首先我们看下,带头结点的单链表的insert

我们之前写这个代码是没有任何问题的,是在单执行流下;但现在多了多执行流。多进程也有多执行流,但是进程具有独立性所以不会造成任何问题。当我们执行我们的进程代码逻辑时,因为系统调用,时间片到了进程切换等各种可能的因素陷入内核,当我从内核返回用户态,我要做信号检测,检测时可能要做信号捕捉,当我执行信号捕捉的时候就进入了另外一种执行流。如果我们在整个进程的生命执行流中没有收到任何信号,就说明信号捕捉函数决定没被调用。我们有可能只执行我们进程的代码,也有可能因为收到信号,执行信号捕捉流程。所以在单进程也可能存在多执行流的情况。它们两个是毫不相关的执行流。

现在我们看这个代码:我们要在main函数中执行头插insert,然后就是把node1插入到这个链表中,在insert函数中,第一步就是让node1这个节点指向head指向的第一个节点,但是刚刚做完这一步的时候 ,信号来了,恰好这个进程捕捉这个信号(代码在任何时候都有可能进行信号捕捉,从用户到内核态在任何时间都是可能发生的,因为进程有可能因为时间片到了进行进程切换,不一定是必须系统调用才可能发生),于是我就不执行下一步让head指向node1了,而是直接处理信号去了,处理信号的时候,发现这个信号捕捉函数是要进行插入node2,node2于是就插入完了,信号捕捉完了,就返回到上一次执行的语句继续向后执行,于是执行了head指向node1。此时head指向的是node2,但是回来以后head指向了node1。最终造成node2找不到了,也就是node2节点丢失,也称之为内存泄露问题。导致这个问题的根本原因就在于主执行流在执行插入的时候,因为信号到来,信号捕捉执行流也执行了插入。这种现象就成为insert函数被重复进入了。

insert函数一旦重入,有可能出现问题,该函数就叫做不可重入函数。

insert函数一旦重入,不会出现问题,该函数就叫做可重入函数。

所以可重入与不可重入是描述函数的概念。

我们学到的大部分函数,STL,boost库中的函数,大部分都是不可重入的。

如果一个函数符合以下条件之一则是不可重入的 :
  • 调用了mallocfree,因为malloc也是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

可重入函数是指在函数内部不使用任何共享变量或状态,每次调用函数时都能够获得相同的输出结果,并且多个线程可以同时调用这个函数,不需要加锁进行同步。

eg:

#include <string>

std::string reentrantFunction(const std::string& str)
{
    // 临界区代码
    // 可重入函数
    std::string result = str + str;

    return result;
}

在上面的例子中,这个可重入函数内部没有使用任何共享变量或状态,因此可以被多个线程同时调用而不会出现线程安全的问题。

在多线程环境中,如果可重入函数内部没有使用任何共享变量或状态,那么它就是线程安全的,可以被多个线程同时调用。这是因为每个线程在调用可重入函数时都会拥有自己的栈空间,函数内部的局部变量、参数等都会被复制到线程自己的栈空间中,从而保证线程之间不会互相干扰。

需要注意的是,如果可重入函数内部涉及到共享变量或状态,那么就需要考虑加锁的问题了,以保证线程安全。在实际应用中,需要根据具体情况来决定是否需要在可重入函数中使用锁。

七、volatile

这个关键字是C语言中重要的关键字。

首先我们看下这段代码

执行结果:完全符合预期 

但是有一个问题,刚刚我们的编译器是常规情况,看到的就是这种现象,但是我们的编译器是有各种优化的。gcc默认普通编译,可以让用户自己设置优化级别,存在O0-O4的优化级别的。

现在我们进行O3优化

        

我们发现在编译器经过优化后,对同一份代码展现了不同的优化结果。根本原因在于编译器优化不能甄别出你的代码存在多执行流情况。我们的编译器看来,认为main函数就是一个执行流,handler这访问了flag但是编译器是识别不到的。所以它只发现,在main函数这里,while只对flag做检测,意味着flag的值永远是0。编译器会将这个flag在编译器编译的时候优化到寄存器中,也就是说不需要在经历冗长的寻址。一般的flag是全局变量,是变量就应该在进程运行时在内存开辟空间,换句话说CPU识别flag,就一定要从内存里读flag,读到CPU内,而在CPU内做判断,判断完后再从CPU内读取flag,经过这样不断的读取检测flag。但是编译器发现没有任何人在main函数中对flag修改,所以就把flag优化到寄存器中,不再做内存级别的访问了,直接识别CPU寄存器相关的信息。

如何解决这个问题呢?

我们可以给这个flag加一个volatile.

执行结果:我们照样是O3的优化级别,但我们的结果又正常了。 

  

为什么是这种现象呢?

编译器是O3的优化级别。一般来说flag是全局变量,是变量就应该在进程运行时在内存开辟空间,换句话说CPU识别flag,就一定要从内存里读flag,读到CPU内,而在CPU内做判断,判断完后再从CPU内读取flag,经过这样不断的读取检测flag。但是编译器发现没有任何人在main函数中对flag修改,所以就把flag优化到寄存器中,不再做内存级别的访问了,直接识别CPU寄存器相关的信息,因为内存和CPU的效率差别是很大的。 我们的编译器看来,认为main函数就是一个执行流,handler访问了flag但是编译器是识别不到的,因为它改的并不是CPU寄存器的flag,而改的是内存当中的flag。所以在优化场景下,CPU和内存之间出现了寄存器缓存了一段数据而屏蔽了内存中数据的场景。所以它只发现,在main函数这里,while只对flag做检测,意味着flag的值永远是0。这个是优化后的结果,但并不是我们想要的。

volatile的作用:告诉编译器,不要对我这个变量做任何优化,读取数据必须贯穿式的读取内存,不要读取中间缓冲区寄存器中的数据!保存内存的可见性!ps:还有个作用是指令重排,了解下即可

八、SIGCHLD信号

多进程中我们可以用waitwaitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。

其实 , 子进程在终止时会给父进程发 SIGCHLD 信号 , 该信号的默认处理动作是忽略 , 父进程可以自 定义 SIGCHLD 信号的处理函数, 这样父进程只需专心处理自己的工作 , 不必关心子进程了 , 子进程 终止时会通知父进程 , 父进程在信号处理函数中调用wait 清理子进程即可。
eg:刚开始运行没有任何反应,但是子进程一旦退出,因为对SIGCHLD做捕捉了,所以父进程在while循环期间一定能收到一个SIGCHLD的信号,进而执行打印逻辑

 执行结果:我们借助一个监控脚本观察

while :; do ps axj | head -1 && ps axj | grep test |grep -v grep; sleep 1; echo "##################################"; done

我们确实证明子进程退出的时候会向父进程发送SIGCHLD信号。换言之如果我是可以直接在handler方法里调用waitpid,回收子进程的。此时父进程就不用主动等待子进程退出。

但是父进程就不想回收,压根就不关心这个子进程的退出码等信息,并且子进程退出的时候不形成僵尸进程,不要影响父进程。我们就可以显示设置忽略17号信号。

执行结果:此时子进程直接退出且没有形成僵尸进程。 

所以不是所有的场景都需要我们去等待,要按照场景去使用。这个方法只在linux下是有效的。

eg:使用信号的自定义捕捉进行等待

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig)
{
    pid_t id;
    while ((id = waitpid(-1, NULL, WNOHANG)) > 0)
    {
      printf("wait child success: %d\n", id);
    }
    printf("child is quit! %d\n", getpid());
}
int main()
{
    signal(SIGCHLD, handler);
    pid_t cid;
    if ((cid = fork()) == 0)
    { // child
      printf("child : %d\n", getpid());
      sleep(3);
      exit(1);
    }
    while (1)
    {
      printf("father proc is doing some thing!\n");
      sleep(1);
    }
    return 0;
}

 

我用的是while循环和WNOHANG(非阻塞等待),用循环的原因是为了满足各种子进程退出的情况,eg:我创建了10个子进程,10个子进程同时退出了,每个子进程都同时向父进程发送信号,可是pending位图只有一个比特位记录信号,如果只wait一次就只能wait一个子进程,剩下9个就wait不到。通过while循环我们就可以把所有的子进程都读到。

用非阻塞的原因:假如你是阻塞等待,有10个子进程,5个退出了,5个没退出。你循环读,也没有任何问题,但是当你读第6次的时候,子进程没退出,你就在信号捕捉函数这里卡住了。卡住之后你就永远回不来了(子进程不退出父进程不返回,这就叫做阻塞等待)。可是不是只有5个进程退出吗,为什么还要读第六次?因为刚刚我们是在上帝视角,我们知道有5个进程退出了,但是如果我们不知道呢?eg:你向你爸要零花钱,你第一天向你爸要了100,你爸给你了;第2天你又向你爸要了100,你爸给你了;当第三天的时候,你肯定还会问你爸要。同理,当你读取一个子进程,你就继续读,只有当你读取失败的时候,你才知道底层没有子进程退出了。所以这里要用非阻塞。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值