文章目录
- 前言
- 1.信号初识前置知识
- 2.信号产生的方式
- 1.键盘产生信号
- 2.系统调用产生信号
- 3.软件条件产生的信号
- 4.硬件异常
- 3.信号的保存
- 4.信号的处理
- 1.用户态和内核态
- 2.用户态和内核态的切换方式
- 3.内核中信号的捕捉流程
- 4.volatile关键字
前言
本文主要是对Liunx中进程信息进行讲解介绍。对信号递达和阻塞的概念,原理,以及信号的处理都会所有介绍。
1.信号初识前置知识
关于信号,我们可以先联系一下日常生活中的信号。比如:红绿灯,闹钟之类的,这些东西之所以被称为信号,有个关键点就是,当我们看见或听到这些东西时,后续会产生与之匹配的动作。比如我们过马路看见红灯就不能走了需要等绿灯。那么为什么,我们知道看见红灯就停下呢?这是因为我们能正确识别一个信号,并知道后续的处理动作。同样的,站在进程的视角上,进程在收到一个信号后,它就知道这个信号该怎么处理了。进程被程序员设计的时候就已经设计好了进程对信号的识别能力。关于进程信号,我们之前就使用过kill指令对一个进程发送信号了。联系日常和我们使用kill指令,我们知道信号是可以随时产生的具有不确定性,当进程在处理一个优先级更高的事情时,可能不会马上处理这个信号,后续会在合适的时机去处理这个信号。由此我们也可推断出进程需要有保存信号的能力。
既然要保存信号,就要为其创建对应的结构将其管理起来,还是那句话先描述再组织,每个进程pcb结构体中有个位图结构的子段用来保存管理信号。
说了这么多我们先来看看Liunx的下的进程信号吧。
我们可以通过kill -l指令去查看信号,每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到。
这些信号本质就是规定好的宏,编号34以上的是实时信号,本文不讨论实时信号,
这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明,使用指令 man 7 signal可进行查看。
这里的默认处理方式是系统规定的处理方式,当然也可以使用系统调用接口对信号进行捕捉更改信息默认处理方式,比如我们默认听到闹钟就起床,当然我们也可以选择听到闹钟就唱歌。这里介绍一个系统调用接口
这里singal第一个参数是信号编号使用对应的宏或者数字编号都行,第二个参数是一个函数指针,也就是说当进程收到某个信号的时候不会按照默认的方式去处理这个信号,而是调用这个函数去处理。
代码演示
include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
void handler(int signal)
{
cout<<"hello"<<endl;
}
int main()
{
signal(2,handler);
while(1)
{
cout<<"我是一个进程,我的pid是"<<getpid()<<endl;
sleep(1);
}
}
2号信号的默认处理方式是终止进程,当我们调用signal对2号信号进行捕捉后,如果我们向该进程发送2号进程后,该进程对2号进程的处理动作就是调用这个handler函数。
之前提到了进程pcb结构体中会有位图字段来保存对应的信号,当某个进程接收到某种信号后,对应位置的位图结构的比特位就会由0置1,
由此所谓的发送信号本质来说就是写入信号,更改对应的位图结构,pcb是内核数据结构,无论有多少种产生信号的方式最后都是由操作系统发送信号。
信号会随时产生,且有时候不会马上处理这个信号,因此信号的产生对进程来说是异步的,同步是指调用方在发出请求后,必须等待被调用方返回结果才能继续执行。异步是指调用方在发出请求后,不必等待被调用方返回结果,而是通过状态或回调函数来通知调用方。
所谓异步和同步简单举个例子同步 就像舔狗等别人回微信 等不到就一直等,异步就是你不回我消息,我就先玩会游戏,过一会我再看看微信 ,如果还没有回我 ,我就在玩会别的 ,然后再看看微信 直到等到回复。
从以上代码来看信号从处理方式大致就以下几种:默认处理方式,用户自定义处理,忽略信号。这个signal系统调用并不是对所有的信号都可以进行捕捉自定义处理。9号信号就不可以。
我们看到这个2号信号和4号信号都会调用handler打印hello,无法终止这个进程,当我们发送9号信号的时候还可以终止掉进程。
这里介绍第二个系统调用接口kill,向指定进程发送指定信号。
第一个参数是进程pid,第二个参数是信号编号。我们利用这个系统调来实现一个简单的kill指令。
以上就关于信号的前置知识了,下面介绍的是信号产生的方式。
2.信号产生的方式
信号的产生方式大概是如下几种方式:
1.通过终端按键产生信号,也就是通过按键盘产生信号。2. 调用系统函数向进程发信号(指令也是属于系统调用)。3.软件条件产生信号。4.硬件异常。
我们来分别看看这几种产生信号的方式。
1.键盘产生信号
当我们按下Ctrl+C时这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程前台进程因为收到信号,进而引起进程退出。
从上图可以看出,当我按下Ctrl+C后打印出了hello2,2就是对应的信号编号。这里前台进程在进程状态提到过一次,Liunx下允许终端命令行中存在一个前台进程,后台进程没有限制,我们平时输入指令 ./运行自己写的程序都是前台进程,前台进程用Ctrl+c就可以终止进程,后台进程不行,后台进程需要使用kill指令,当我们运行程序加上&就是后台进程了。
后台进程启动后我们依然可以输入指令,并且能成功运行,但是前台进程启动后就不行了。当我们按下键盘后,cpu通过一些硬件单元发送中断信号知道了我们按下了哪个设备,cpu的针脚会在从对应的中断向量表中去读取键盘的数据,从而知道我们按下了键盘的哪些键,最后在由操作系统将其转化为进程信号发送给对应的进程,这个过程是通过软硬件配合协同的。
前台进程在运行过程中用户随时可能按下 Ctrl+C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步的。
2.系统调用产生信号
系统调用产生信号,我们之前通过kill指令给进程发送信号也是属于系统调用产生信号。这里在介绍一个接口raise,这个系统调用的作用是谁调用这个函数就给谁发送信号。
我们来看看代码演示的效果
我们看到这个mysingal中调用了raise,程序运行起来后就这个接口给自己发送了2号信号,进程就终止了。我们再来认识一个C语言函数abort,这个函数是C语言封装好的函数,也是用来终止进程的。
这个函数调用后会给进程发送6号信号,并且终止进程,当我们对该信号进行捕捉后,也会终止进程,毕竟这是C库封装的函数不是系统调用。
3.软件条件产生的信号
在管道那里,我们就知道当我们关闭管道的读端后,管道的写端进程就会被操作系统给直接杀掉,这就是一种软件条件产生的信号。这里就介绍一个系统接口alarm。
这个系统调用可以设置软件条件信号,设置几秒后给调用该函数的进程发送SIGALRM信号。这个信号的默认处理动作也是终止进程。它参数就是设置多少秒以后触发这个软件条件发送信号。这个函数就像我们平时设置的闹钟一样,到时间了就发送信号。
10秒后就设置好的闹钟就响了触发了软件条件信号发送了14号信号。这个闹钟设置了一次就只响一次。我们也可以通过自举的方式设置闹钟。
所谓自举,当我们设置的闹钟触发了软件条件产生信号,调用handler函数,这个函数中又设置了一个10秒的闹钟,之后10秒后这个闹钟又会发送信号,再次设置闹钟周而复始。在图中我们提前发送了14号信号,相当等于闹钟提前响了,这个时候这个函数的返回值是以前设定的闹钟时间还余下的秒数,如果是正常响了就返回0。理论上我们可以设置任意个闹钟,而且这些闹钟发送信号本质还是操作系统来处理,操作系统为了管理好这些闹钟,同样的也会为这些闹钟创建对应的数据结构进行管理。
4.硬件异常
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。我们先看看这样一段代码。
这段代码有个除0操作,在编译时发出了告警,我们将其运行后,操作系统就会向该进程发送8号信号终止进程。这就出现了硬件异常。
cpu中有个状态寄存器,当cpu处理数据除0时就会溢出,状态寄存器的标志位就会由0置1被操作系统检测到发送信号终止进程。空指针或野指针也是硬件异常,我们来看看如下代码。
当我们使用空指针的时候就会引发段错误,这也是一种硬件异常。
当由于是空指针触发的硬件异常,这个时候操作系统向该进程发送11号信号,终止进程。
我们看到这个很多信号都是用来终止进程的,为啥要搞出这么多相同作用的信号呢?其实这个发送信号不重要,重要的是进程因为什么原因收到就信号,这样才能有助于我们分析程序出现的问题,从而更快的解决问题bug。
3.信号的保存
以上种种产生信号的方式最后都是要借助于操作系统之手向对应的进程的pcb的信号位图结构中写入信号。在具体介绍信号的保存之前,我们再来看看开头的关于介绍信号的图片。
带Coer和待Term的信号都是用来终止进程的,这两者有啥区别呢?Term信号默认就是终止进程没有多余的动作,但是Core信号会进行核心转储,核心存储就是进程在出现异常时终止前将内存中核心代码数据部分dump到磁盘上形成一个core.pid的文件。云服务器上默认是关闭这个功能的,我们可以通过指令查看也可以将这个功能给打开。
这里默认是关闭的,我们输入指令
ulimit -c size
就可以打开核心转储的功能并且设置好core文件的大小。
我们来验证一下这个核心存储功能吧。
这里因为空指针问题操作系统会向该进程发送11号信号从而终止进程,我们看到确实生成这个核心转储文件,
这个核心存储文件有什么用呢?有了核心转储文件我们可以通过gdb直接定位到问题,便于事后调试。
因为我们默认使用g++编译的是发布模式的程序,我们更改一下Makefile中g++编译选项加上-g就是debug模式下发布的程序了。我们使用gdb对程序进行调试,在gdb中输入core后跟对应的核心转储对应的文件,即可快速定位到问题所在。
这样看起来核心转储还是挺好用的,为啥服务器默认都是关闭的呢?其实云服务器是线上生产环境,如果我们部署在上面的程序有个触发频率很高的bug,每次运行后,就会产生一个core文件,一般真实项目都会有自启动的功能,当项目挂掉后会重新起来,这样一来运行一次就是产生一次core文件,如果是在深夜重出现问题,很可能第二天之后整个机器的磁盘上都被写满了core文件,从而造成整个服务器崩溃。
我们使用ulimit -c 0指令将核心转储文件大小设置为0就可以关闭这个功能了。
关于核心转储还有一点补充,在进程等待那里提到了,waitpid的参数是输出型参数,次低8位表示进程退出码,0到第7位表示进程收到信号,第八位之前没有讲,这个第八位就是表示核心转储的标志位。
这个标志位表示是否发生核心转储,我们通过代码验证一下。
当我们开启核心转储后,并且向进程发送的是带core的信号,我们可以看到这个标志位就是1.
这里再补充几个信号的常见概念:
实际执行信号的处理动作称为信号递达(Delivery)。信号从产生到递达之间的状态,称为信号未决(Pending)。进程可以选择阻塞 (Block )某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。我们来看看操作系统中对这几个概念的产生的结构。
这里需要强调一点,
阻塞屏蔽进程是可以接收到信号的,但是进程是无法对信号递达的,也就是也一直处于未决状态,对信号迟迟做不了处理。
如果进程pending表中信号对应位置处为1,说明该信号一直处与信号未决的状态,如果pending表对应位置处为0,要么是该信号已经被处理了,要么是该信号还没有产生
我们来看看这样一段代码。
这个SIG_DEF就是设置2号信号是默认处理动作,也就是相当于保存原样啥也没变。
这个SIG_IGN是用来忽略2号信号,当我们按下Ctrl+C没有反应,终止不了进程。
用户可能对进程的信号有自己的处理方式,系统中提供的一种自定义数据类型sigset_t。这种类型本质上也是位图结构。这种数据类型一般称为信号集。常用来作为进程的阻塞信号集也就是说通过相关系统调用和该数据类型可以让更改进程的block,阻塞一些信号或者解除一些信号的阻塞。
信号集操作函数
函数名 | 作用 |
---|---|
int sigemptyset(sigset_t *set); | sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit位清零,表示该信号集不包含任何有效信号。 |
int sigfillset(sigset_t *set); | 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。 |
int sigaddset (sigset_t *set, int signo); | 将某个信号设为有效信号,就是信号集合中添加信号 |
int sigdelset(sigset_t *set, int signo); | 将某个信号设为无效信号,就是将集合中的某个信号删除 |
int sigismember(const sigset_t *set, int signo); | 检查指定信号是否存在; |
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。
其实上述这些函数都是针对sigset_t这个类型所设计的一些更改位图中比特位的函数,说白了就是对这个sigste_t类型的变量进行一些特定的位运算而已,我们如果想更改进行中的block表,需要另一批系统调用接口,将sigset_t类型的数据添加或者更改到对应的内核数结构据中去,这样才能实现对进程信号相关位图表的修改。
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 返回值:若成功则为0,若出错则为-1
.
第一个参数是选择什么样方式来更改进程的阻塞信号集,系统提供了3个选项。
选项 | 作用 |
---|---|
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |
第二个参数就是我们设置好的set阻塞信号集,第三个参数是输出型参数,先将原来的信号屏蔽字备份到oset中,如果不需要原来的信号屏蔽字就直接将oset设置为空。
sigpending函数,
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1
代码示例
#include <iostream>
#include <cassert>
#include <cstring>
#include <unistd.h>
#include <signal.h>
using namespace std;
void PrintPending(const sigset_t &pending)
{
cout << "当前进程的pending位图: ";
for(int signo = 1; signo <= 31; signo++)
{
if(sigismember(&pending, signo))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << "\n";
}
void handler(int signo)
{
cout << "对特定信号:"<< signo << "执行捕捉动作" << endl;
}
int main()
{
// 设置对2号信号的的自定义捕捉
signal(2, handler);
int cnt = 0;
sigset_t set, oset;
// 初始化
sigemptyset(&set);
sigemptyset(&oset);
// 将2号信号添加到set中
sigaddset(&set, 2);
// 将新的信号屏蔽字设置进程
sigprocmask(SIG_BLOCK, &set, &oset);
// while获取进程的pending信号集合,并打印
while(true)
{
// 先获取pending信号集
sigset_t pending;
sigemptyset(&pending);
int n = sigpending(&pending);
assert(n == 0);
PrintPending(pending);
sleep(1);
// 10s之后,恢复对所有信号的block动作
if(cnt++ == 10)
{
cout << "解除对2号信号的屏蔽" << endl;
sigprocmask(SIG_SETMASK, &oset, nullptr);
}
}
}
我们通过实验验证了猜想,信号产生是保存在对应的位图结构中的,那么信号是怎么捕捉的呢?我们接着往下看。这里再补充一个函数。
捕捉信号除了用前面用过的signal函数之外 我们还可以使用sigaction函数数对信号进行捕捉。
返回值说明:调用成功返回0 失败返回-1
参数 | 说明 |
---|---|
signum | 代表指定信号的编号 |
act | act指针非空,则根据act修改该信号的处理动作 |
oldact | oldact指针非空,则通过oldact传出该信号原来的处理动作 |
参数act和oldact都是结构体指针变量 该结构体的定义如下:
struct sigaction {
void (*sa_handler)(int);
void(*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
这也是系统提供的一种数据类型,结构体的成员介绍如下。
成员 | 说明 |
---|---|
sa_handler | 将sa_handler赋值为常数SIG_IGN传给sigaction函数,表示忽略信号;将sa_handler赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作;将sa_handler赋值为一个函数指针,表示用自定义函数捕捉信号或者说向内核注册了一个信号处理函数 |
sa_sigaction | 实时信号的处理函数 我们不必理会置空即可 |
sa_mask | 当某个信号的处理函数被调用,内核自动将当前信号加入进程的信号屏蔽字 ;当信号处理函数返回时自动恢复原来的信号屏蔽字; 这样就保证了在处理某个信号时,如果这种信号再次产生, 那么它会被阻塞到当前处理结束为止;如果在调用信号处理函数时,除了当前信号被自动屏蔽之外还希望自动屏蔽另外一些信号 ,则用sa_mask字段说明这些需要额外屏蔽的信号, 当信号处理函数返回时自动恢复原来的信号屏蔽字 |
sa_flags | 该成员我们不关心,默认设置为0 |
sa_restorer | 该成员我们这里也不关心。 |
代码示例
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <signal.h>
using namespace std;
struct sigaction act;
struct sigaction oact;
void handler(int sig)
{
cout << "对特定信号:"<< sig << "执行捕捉动作" << endl;
sigaction(2,&oact,nullptr);//恢复之前对2号信号的处理方式
}
int main()
{
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact));
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaction(2, &act, &oact);
while (1)
{
printf("我是一个进程正在运行!\n");
sleep(1);
}
return 0;
}
我们看到开始对2号信号更改了默认处理方式,之后恢复了默认处理方式,也就说第一次自定义处理,第二次恢复默认处理。
4.信号的处理
阻塞状态字(block)是一个位图结构,用来表示进程对每个信号的阻塞状态,1代表阻塞,0代表不阻塞。信号处理函数(handler)是一个函数指针,用来表示进程对每个信号的处理动作,可以是系统默认的动作,也可以是忽略或者自定义的动作。如果一个信号的pending位为0,block位为0,handler不是忽略,那么说明该信号被处理了。
我们之前提到了进程会在合适的时机去处理产生的信号,那什么时候是合适的呢?就是进程由内核态返回用户态的时候对信号作检测和处理。
1.用户态和内核态
在进程地址空间中,从3G到4G是1G大小的内核空间,通过内核级页表和内存中存储的操作系统的代码的和数据的物理地址建立映射关系。不同的进程用户空间中的内容是不同的,但是内核空间中的内容都是一样的,都是指向操作系统的代码和数据。当进程处在内核态时,就可以访问或执行操作系统系统的代码或数据,但是无法执行用户级的数据和代码的。
这是为了保护系统的稳定性和安全性,
当进程有一些特定的需求就会在内核态和用户态之间进行切换。进程在内核态和用户态之间进行切换的时候是还需要通过通过硬件的协同,cpu中有个特定的状态寄存器,会进行检测,进程在当陷入内核态的时候该寄存器中的比特位序列就位0,处于用户态的时候就是3。进进程状态的切换是通过软硬件结合的方式来实现的。
进程用户态和内核态的切换,是指进程在执行过程中,根据不同的需要,从一种模式切换到另一种模式。进程在用户态运行时,只能访问有限的资源,不能执行特权操作。进程在内核态运行时,可以访问所有的资源,可以执行特权操作。
进程在用户态和内核态之间切换,并不是为了让操作系统间接管理它,而是为了让操作系统提供一些服务给它。进程在用户态运行时,操作系统也是在管理它的,只是不会干涉它的运行,除非有必要。进程在内核态运行时,操作系统也是在管理它的,只是会更加密切地监控和控制它的运行,以保证资源的有效利用和系统的稳定性。所以说,进程在任何时候都是直接被操作系统管理的,只不过在用户态和内核态之间有不同的管理策略和手段。
2.用户态和内核态的切换方式
进程从用户态切换到内核态有三种方式:
系统调用
:当进程需要请求操作系统提供一些服务时,比如打开文件、读写数据、申请内存等,就需要通过系统调用陷入内核态,并执行相应的内核函数。
异常
:当进程在执行过程中发生一些错误或异常时,比如除零错误、缺页异常、非法指令等,就需`要通过异常陷入内核态,并执行相应的异常处理程序。
中断
:当进程在执行过程中被外部事件打断时,比如键盘输入、鼠标点击、定时器到期等,就需要通过中断陷入内核态,并执行相应的中断处理程序。
进程从内核态切换到用户态的方式有两种方式
中断返回
:当进程在内核态处理一个外部中断后,比如键盘输入、鼠标点击、定时器到期等,就需要通过中断返回指令(iret)将处理器从内核态切换回用户态,并恢复进程的上下文。
系统调用返回
:当进程在内核态执行一个系统调用后,比如打开文件、读写数据、申请内存等,就需要通过系统调用返回指令(sysret)将处理器从内核态切换回用户态,并返回系统调用的结果给进程。
其实调度的最后一步是从内核态切换到用户态
,但是这个切换是伴随着进程切换的,也就是说,切换前后的进程不是同一个进程。而之前说的从内核态切换到用户态的方式,是指在同一个进程内部发生的切换,也就是说,切换前后的进程是同一个进程。这两种切换的目的和过程都不一样,所以不能混为一谈。
系统调用本质上是由操作系统来执行的,进程只是发出相关的请求,并提供一些参数和返回值。系统调用是一种软件中断,它会触发一个中断处理程序,将进程从用户态切换到内核态,并执行相应的内核函数。执行完毕后,再将进程从内核态切换回用户态,并返回结果给进程。这个过程涉及到很多细节和步骤,比如保存和恢复寄存器、设置和清除标志位、检查和转换参数、分配和释放内核栈等.
3.内核中信号的捕捉流程
内核如何实现信号的捕捉呢?
以2号信号为例: 在代码中编写了自定义处理2号信号(SIGINT)的handler函数,当该进程接收到2号信号时,这个信号捕捉的流程大概是这样的:
当按下Ctrl+C键,键盘驱动程序会向内核发送一个中断号,内核会根据中断向量表找到对应的中断处理函数。中断处理函数会读取键盘输入,并将其转换为2号信号,然后向前台进程发送该信号。前台进程在收到信号后,会检查其信号屏蔽字(block)和信号未决字(pending),如果该信号没有被阻塞,也没有处于未决状态,那么就会执行信号处理函数(handler)。信号处理函数(handler)是自定义的函数,它会在进程的正常执行流程中被调用,并执行指定的操作。
这时,由用户态切换到内核态。handler函数是虽然自己写的,但是它是在内核的帮助下被调用的。当进程收到信号时,内核会检查进程的信号处理函数,并将其地址放入进程的栈空间中,然后将程序计数器(PC)指向一个内核函数,该函数会从栈中取出handler函数的地址,并跳转到该地址执行。这样,handler函数就在内核态下被执行了。执行完毕后,内核会恢复进程的原来的栈和PC,并切换回用户态。进程会继续执行原来的流程。
如果多次向该进程发送2号信号,这个处理流程是怎么样的呢
如果多次向该进程发送2号信号,这个处理流程大致如下:
当第一个2号信号到达时,进程会按照之前的流程执行handler函数,并在执行过程中阻塞2号信号,即将信号屏蔽字(block)中的2号位设为1。
当第二个2号信号到达时,进程会检查信号屏蔽字(block),发现2号信号被阻塞了,就不会立即执行handler函数,而是将信号未决字(pending)中的2号位设为1,表示有一个2号信号处于未决状态。
当第三个或更多的2号信号到达时,进程会发现信号屏蔽字(block)和信号未决字(pending)中的2号位都为1,就不会做任何操作,相当于忽略了这些信号。
当第一个handler函数执行完毕后,进程会解除对2号信号的阻塞,即将信号屏蔽字(block)中的2号位设为0。然后,进程会检查信号未决字(pending),发现有一个2号信号处于未决状态,就会再次执行handler函数,并在执行过程中重新阻塞2号信号。
当第二个handler函数执行完毕后,进程会再次解除对2号信号的阻塞,并检查信号未决字(pending),发现没有任何2号信号处于未决状态,就会继续执行原来的流程。
也就说多次发送2号信号,但是实际上只会执行两次handler函数。这是因为信号未决(pending)只能记录一个信号的未决状态,无法记录多个信号的数量。所以,如果在一个信号被阻塞的期间,有多个同样的信号到达,只有一个信号会被记录为未决状态,其他的信号会被丢弃。
4.volatile关键字
在C/C++中volatile关键字是用来
保持内存的可见性。告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
我们看这样的一段代码
#include<iostream>
#include<unistd.h>
#include<signal.h>
int quit =0;
void handler(int signao)
{
printf("change quit from 0 to 1\n");
quit=1;
}
int main()
{
signal(2,handler);
while(!quit);
printf("main quit 正常\n");
return 0;
}
while循环主要的作用就是用来检测qiut的值,当程序运行起来的时候,会一直卡在while循环处。当我们发送2号信号就会把这个qiut置为1,这样就会跳出循环,打印出main quit正常。看上去代码没啥问题,我们可以给G++编译器加上-O2优化选项,让编译器优化一下上述代码。
从打印结果来看,我们发现while循环是一直没有退出的,为啥呢?
当我们加上-O2选项让编译器对代码的优化的时候,代码中while循环的是对quit检测,对于这种频繁大量的使用的且无变化的变量,编译器对其优化的时候,会汇编指令优化,每都让cpu都从其缓存中读取数据,而不是从真实的内存地址中读取数据,前者的速度无疑是更快的,这样就优化了代码的执行效率。但是cpu缓存中一直存储的都是0,这样我们哪怕将quit改为1,但是cpu每次读取都是从缓存中读取,这样我们看到的代码执行效果就是while循环一直没退。
那么我们怎么解决呢?可以使用volatile关键字,这样让cpu每次读取数据都是从真实的内存中读取数据,保证内存的可见性。
quit变量被volatile关键字修饰时,尽管在Makefile中给g++带上优化-O2选项,当进程收到2号信号,执行信号处理函数将内存中的quit变量从0置1时,main函数的执行流也能够检测到内存中的flag变量的变化,从而跳出死循环进程退出。
以上内容如有问题欢迎指正,谢谢!