生活中的信号:红绿灯,手机的来电通知等。
为什么这些是信号呢?因为我们知道这些信号的意义代表着什么。
例如:红绿灯
有人教育过我们,让我们的大脑记住了红绿灯属性对应的行为。
但是,我们就算知道这个信号,也不一定要立刻去处理,因为可能正在做另一间更重要的事情。
所以我们也会有对应的三个动作:
默认动作(看到红灯停),自定义动作(看到红灯不是立刻停下,而而是后退一步或者是其他操作),忽略动作(看到红灯不停)。
首先要清楚一点,信号是OS发给进程的。
例如:kill -9 进程的pid
那么进程是如何识别信号的呢?
认识+动作。
进程本身就是被程序员编写出来的。
当进程收到某个信号的时候,它可能无法第一时间作出处理,有可能在执行更重要的代码。这也就说明进程对于信号要有保存的能力。
进程对于处理信号有三种动作:默认,自定义,忽略。这里有一个专业名词,叫做信号被捕捉。
那么信号是保存在了哪里呢?
是保存在了进程的PCB中。里面用的是位图结构,假如说有32个比特位,那么就可以保存32种信号。0表示没收到,1表示没有。
也就是说,给进程发送信号的本质其实就是修改PCB中的信号位图而已。
我们还能得出一个结论,一个进程的PCB是内核数据结构对象,PCB是的管理者是OS,也之后OS有权利去修改PCB中的位图结构。
结论:信号发送的各种方式,都是通过OS给进程发送信号,那么OS必须提供发送信号处理信号的相关系统调用。
再来看看之前见过的信号:
一个程序在运行的时候,如果用ctrl+c,进程就立刻终止了,这里其实就是相当于给进程发送了一个信号。
其实这个本质就是像这个进程发送了2号信号,这里用kill -l来查看所有信号。
上面说过,每个信号都有对应的动作,那么如何查看2号信号的对应动作呢?
man 7 signal
这个默认行为就是终止进程。
如果我想看到是如何向这个进程发送2号信号怎么办呢?
这里的参数第一个是对于当前进程几号信号进行捕捉,第二个参数是一个函数指针,这个相对应的函数内容是对于当前进程自定义动作。(自己实现)
#include
#include
#include
using namespace std;
void handler(int signo)//参数是对应信号的编号
{
cout << "进程捕捉到了要给信号,信号编号是:" << signo << endl;
}
int main()
{
//这里是signal函数调用,不是handler函数调用
//这里只是设置对于2号信号会进行捕捉而已,只有收到对应的信号才会执行handler函数中对应的内容
signal(2,handler);
while(true)
{
cout << "进程" << getpid() << endl;
sleep(1);
}
return 0;
}
这个时候ctrl+c和kill -2 pid都不会使这个进程停止下来。
如果想退出可以用kill -9 pid或者ctrl+\(也是默认终止当前进程)。
信号发送第一种方法是通过键盘发送,上面的组合键就是。
第二种方法是系统调用向目标进程发送信号。
这个接口就是向目标进程发送信号。
首先要清楚,OS才有权力向进程发送信号,对用户提供向进程发送信号的服务要通过系统调用才可以。
第一个参数是要向哪个进程发送pid,第二个参数是要向该进程发送几号信号。
成功返回0,失败返回-1。
我们可以利用这个系统接口来实现一个对另外进程发送信号的进程。
可以向任意进程发送任意信号。
#include
#include
#include
#include
using namespace std;
int main(int argc, char *argv[])
{
int count = 0;
while(true)
{
if(count == 5)
{
cout << "向进程发送2号信号" << endl;
raise(2);
}
cout << "进程计数:" << count << endl;
count++;
}
return 0;
}
这个接口是给自己发送指定的信号。(STGABRT,6号信号)
这里我们来说一下如何理解信号处理的行为:
有很多的情况,进程收到大部分的信号默认动作都是终止进程。
信号的意义:如果进程有异常,遇到异常终止了进程,那么是因为什么种类的异常停止了呢?这个时候就需要发送一个信号来判断是什么异常。
第三种方式,硬件异常产生的信号。
浮点数错误。
那么为什么除0就会终止进程呢?
因为当前进程会收到OS的信号。
然后我们来捕捉一下8号信号,并且略微改动一下代码:
我们发现,这里一直在打印接受到8号信号,可是我们这里只除了一次0,为什么一直在发送呢?
下面说一说对于这段代码的理解:
CPU在计算的时候会有很多个寄存器,其中有一个是状态寄存器,这个是用来衡量这一次计算的结果,如果发现数据计算异常,比如说除0,等于除无穷大,这个时候状态寄存器中的数据溢出的位置就会由0置为1。
CPU运算发生了异常,OS就会知道,所以OS立刻就知道是当前进程出问题了,立刻向这个进程发送8号信号。
所以,收到信号不一定就会退出,如果没退出,有可能还会被调度。
CPU的寄存器只有一份,但是寄存器中的内容属于当前进程的上下文。
我们也无法将CPU中的状态寄存器修改,当进程被进行切换的时候,就有无数次状态寄存器就有被保存和恢复的过程。
所以每一次恢复的时候,OS就会识别到CPU内控部的状态寄存器溢出标志位。
同理,野指针也硬件异常,我们访问地址是先去虚拟地址空间然后通过页表映射到物理内存,一旦发生野指针,页表就会拦截,OS也会注意到,然后直接向当前进程发送信号。
第四种,软件也可以产生信号:
比如说之前的管道,读端关闭,写端也会关闭,然后导致这个软件触发条件,发生信号。
在Linux下有一个叫定时器的软件,可以设定一个闹钟,如果时间到了,会给当前进程发送编号为14的信号。(闹钟只会响一次)
参数是按照秒为单位设置一个信号。
#include
#include
#include
#include
#include
using namespace std;
int main(int argc, char *argv[])
{
int count = 0;
alarm(1);
while(true)
{
count++;
cout << count << endl;
}
return 0;
}
这段代码的功能是统计1S左右能让我们的计算机数据累加多少次。
其实正常来说CPU不会这么慢,可以改进一下代码:
#include
#include
#include
#include
#include
using namespace std;
int count = 0;
void catchSig(int sig)
{
cout << count <<endl;
exit(1);
}
int main()
{
signal(14, catchSig);
alarm(1);
while(true)
{
count++;
}
return 0;
}
那么为什么差距这么大呢?
因为打印是一种外设输出,访问外设的时候是很慢的,需要大量的时间,第一段代码一直在通过外设进行打印,所以很慢,第二段之后结束的时候才会通过外设打印。
如果是服务器还要经过网络IO,会更慢。
”闹钟“其实就是用软件实现的:
任何一个进程都可以通过alarm系统调用在内核中设计闹钟,OS内可能会存在很多的闹钟,OS也一定要管理这些闹钟,先描述再组织。
用struct alarm类型的对象去描述各个进程的闹钟数据:
struct alarm
{
uint64_t when;//未来的超时时间
int type;//闹钟类型,一次性的还是周期性的
tasl_struct *p;//和哪个进程相关
struct alarm *next;
}
然后OS用特定堆的数据结构方式管理,struct alarm *head
OS会周期性的检测这些闹钟,如果发现超时了OS就会给对应的进程发SIGALARM信号。
上面所说的所有信号的产生,都是由OS来执行,但是信号不一定立即处理,那么是什么时候被处理的呢?
先来看一段代码:
#include
#include
#include
#include
#include
using namespace std;
int main()
{
while(true)
{
int arr[10];
arr[100] = 106;//这里数组是越界的
}
return 0;
}
这里并没有显示越界的报错。
改成一千也没报错,但是i改成一万就报错了
这里是什么情况呢?因为开辟的栈区是合法的,只有到了为开辟的栈区才会进行报错。
像这种,Term这种是正常退出,而Core是退出之后还要做其他工作。
在云服务器上,默认如果进程是core退出的暂时看不到现象,想看到需要打开一个选项:
第一个core file size是0,这是云服务器默认的。
这里设置一下。
然后再次运行上面的段错误的代码:
并且还多出来了一个文件。
第一个后面多出来的core dumped就是核心转储操作,多出来的文件就是核心转储的内容。
多出来的文件.后缀是引起core问题进程的pid。
核心转储:当进程出现异常是hi后,我们将进程的对应时刻,在内存中的有效数据转储到磁盘中。(二进制临时文件)
作用就是为了更方便调试:
这里直接就帮助我们找到了问题。(这里叫做事后调试)
core-file core.xxx
有一个问题,如果所有信号都被捕捉了,那么这个信号是不是就无法停下来了呢?
#include
#include
#include
#include
#include
using namespace std;
void catchSig(int signo)
{
cout << "信号拦截:" << signo << endl;
}
int main()
{
for(int signo = 1; signo <= 31; signo++)
{
signal(signo,catchSig);
}
while(true)
{
cout << "运行中" << getpid() << endl;
sleep(1);
}
return 0;
}
最后用了kill -9才将这个进程杀掉。
OS中9号信号是无法进行捕捉的。
实际执行信号处理的动作称为信号递达。
信号从生产到递达之间的状态称为信号未决(Pending)。
进程可以选择阻塞(Block)某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞才执行递达的动作。
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
并且,PCB中还有一个信号的函数指针数组,里面都是处理信号的方法。
我们使用的信号捕捉也只是将该数组中对应信号的方法给替换了,也就是替换了函数地址。
也就是说,如果要给信号产生,不妨碍他可以先被阻塞。
之前说信号只会在合适的时候才会被处理,不然就一直被保存在pending位图中。
从内核态返回用户态的时候,进行信号的处理。
我们平时是用户态,但是难免会去通过OS访问系统自身的资源和硬件资源,这个时候就要去进行系统调用才能完成:
也就是说,系统调用还要进行身份切换,会比调用用户层本身的方法慢。
所以避免频繁的使用系统调用。
那么,一个进程怎么跑到OS中执行方法呢?
因为进程的独立性,所以每个进程都有一个用户级页表。
在开机的时候,操作系统要加载到内存中,因为操作系统只有一份,在内存中也只有一份,相对应的内核级页表也只有一份就够了。
CPU中也会有一个寄存器储存内核级页表,每个进程都会通过内核空间访问内核页表,然后去找到物理内存中的操作系统的代码和数据。
也就是说,进程要访问OS的接口,其实只需要在自己的地址空间上进行跳转就可以了。
如果想访问内核级数据,CPU的CR3要变成0才有权限。
那么是怎么进行切换的呢?是系统调用接口的起始位置会帮助我们进行切换。
也就会说前半段代码可能是用户态跑的,但是这里突然就变成内核态跑。
在Linux中,有一个叫Int 80 —— 陷入内核。
这个是汇编指令,这个就是修改当前进程在寄存器中CR3的身份状态。
那么,从内核态返回用户态的时候,才会进行信号处理,也就是说很可能进行了系统调用或者是进程切换(进程切换需要进程切换到内核态,因为进程被切换的时候一定没有被执行完,放在运行或者是等待队列的时候一定就要切换到内核态,然后再继续调度下面代码的时候就要切换回用户态)
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
pengding位图和block位图的统一类型就是sigset_t,是为了更方便用户,定义的用级数据结构的类型。
一般将block信号集叫做信号屏蔽字。
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
对于信号集位图不要私自修改,要用对应的接口。
#include
int sigemptyset(sigset_t *set);//清空位图中的所有位置,全都变成0
int sigfillset(sigset_t *set);//位图全都置为1
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。
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
第一个参数是下面这些选项。
第三个选项是重置信号屏蔽字。
第二个参数是你要修改的位图结构,也就是信号集。
第三个参数是第二个参数修改之前的信号集。(输出行参数)
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
这个函数参数是一个输出型参数,在哪个进程调用就返回哪个进程的pengding位图。
返回成功0,失败-1。
这里用起来上面介绍的接口,然后来写一段程序。
条件:
先屏蔽2号信号,发送一个信号2,在发生2号信号之前打印出pengding位图,发送之后再次打出pengding位图
#include
#include
#include
#include
#include
using namespace std;
#define BLOCK_STGNAL 2
void show_pending(const sigset_t& pengding)
{
for(int i = 31; i >= 1; i--)
{
if(sigismember(&pengding, i))
cout << "1";
else
cout << "0";
}
cout << "\n";
}
int main()
{
//1.屏蔽指定信号
sigset_t block, oblock, pengding;
//初始化
sigemptyset(&block);
sigemptyset(&oblock);
sigemptyset(&pengding);
//屏蔽
sigaddset(&block, BLOCK_STGNAL);//添加屏蔽的信号
sigprocmask(SIG_BLOCK, &block, &oblock);//正式屏蔽,这里才是真正通过OS设置进当前进程的PCB中
//2.打印pengding信号集
int count = 5;
while(true)
{
sigpending(&pengding);//获取他
show_pending(pengding);
sleep(1);
//3.解除信号的屏蔽
if(count-- == 0)
{
sigprocmask(SIG_SETMASK, &oblock, &block);
cout << "不屏蔽信号" << endl;
}
}
return 0;
}
我们发现,如果一旦解除信号屏蔽,进程立刻就会退出,后续的代码不会被执行。
因为一旦信号屏蔽解除,一般OS要立马递达一个信号。(处理完一个信号,该比特位立刻清零)
这个函数和signal函数差不多,第一个参数是对于该信号进行捕捉,第二个参数是一个结构体对象指针,传入的就是结构体的对象;
第一个成员是对于处理这个信号的方法。
第三个成员是信号集。
也就是说第二个参数是要对于该信号做一些列结构体中内容的设置的,是一个输入性参数。
第三个参数是一个输出型参数,获取对应信号老的处理方法。
成功返回0,失败返回-1。
#include
#include
#include
#include
#include
using namespace std;
void handler(int sig)
{
cout << "get a signo" << sig << endl;
sleep(10);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaction(SIGINT, &act, &oact);
while(true) sleep(1);
return 0;
}
第一次立刻打印,第二次和第三次只打印了一次,两次我一起按的,但是打印出来的结果只有一个,这是为什么呢?
当我们进行正在递达第一个信号期间,同类型信号无法被递达,因为当前信号正在被捕捉,系统会自动将当前信号加入到该进程的信号屏蔽字。
当信号完成捕捉动作时,OS又会自动解除对该信号的屏蔽。
上面的现象可以这样解释,2号比特位被第一次置为1的时候,相对应的block位图2号也被置为了1,那么处理这个2号信号的时候,pengding位图对应的比特位又被置为0了,但是紧接着又来了一个2号信号,该比特位又变成了1,最后又来了一个2号信号,这个时候就不会再让pengding为途中2号信号中的比特位继续改变了,因为已经没有能力保存了。
在一个信号被解除屏蔽的时候,会自动递达当前屏蔽信号,没有就不做任何动作。
也就是说我们进程处理信号的原则是串行的处理同类型的信号,不允许递归。
那么,刚才这段代码这里:
当我们正在处理某一种信号的时候,我们也想顺便屏蔽其他信号,就可以添加到这个sa_mask中。
让上面的也屏蔽3号信号试一下。
这里退出的原因是什么呢?
因为是同时屏蔽2,3信号,第一次发送的也是2号信号,在处理2号信号的时候会同时屏蔽2号和3号信号,所以3号不会被立刻递达,因为是先发的2号信号,3号信号先不会处理,处理完前面两个2号信号之后才会解除对2号和3号的屏蔽,因为3号默认动作是退出,所以3号递达程序也就退出了。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
这就会导致一个结果,node2就会数据丢失。
1.一般来说,mina执行流和信号捕捉执行流是两个执行流。
2.如果在main中和handler中,该函数被重复进入,出问题,insert函数就是不可重入函数。
3.如果在main中和handler中,该函数被重复进入,没出问题,insert函数就是可重入函数。
上面的例子,insert就是不可重入函数。
其实大部分函数都是不可重入的,这是一个特性。
如果一个函数符合以下条件之一则是不可重入的:
调用了malloc或free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下。
#include
#include
#include
#include
int quit = 0;
void handler(int signo)
{
printf("信号捕捉成功->\n");
printf("quit:%d\n",quit);
quit = 1;
printf("quit变化之后->%d\n",quit);
}
int main()
{
signal(2, handler);
while(!quit);
printf("正常退出\n");
return 0;
}
在gcc编译器有个优化的选项是O3,再来看一下优化之后的效果:
这里进程并没有正常退出,这是为什么呢?
这里和优化是有关系的:
在循环这里,CPU从内存当中拿数据进行分析,但是并没有写回去。
上面说过,mian执行流和信号捕捉执行流是两个执行流,在没有进行优化的时候,捕捉到信号执行信号的动作就到了捕捉信号的执行流,将quit变成1之后返回到了main的执行流。然后CPU做出处理判断循环条件为假就跳出了循环。
那么优化之后,因为quit在main执行流没有被改变,所以编译器就认为quit没必要进行后续的判断,所以就将quit的值放进了编译器的内存里面,也就是说它的值已经无法被用户去改变了。所以这里判断的是CPU中寄存器最开始储存的那个值,就算信号捕捉执行流去改变,但是也不会影响CPU中寄存器的值。
那么这个时候怎么办呢?又想优化又不想出现这种情况,这个时候就需要加volatile关键字了。
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
#include
#include
#include
#include
#include
using namespace std;
void handler(int sig)
{
cout << "捕捉到信号" << endl;
//以下是伪代码
/*while(1)
{
pid_t ret = waitpid(-1, NULL, WNOHANG);//这里不能阻塞,万一只有一部分子进程退出就不好办了,这就是阻塞式调用了
if(ret == 0) break;
}*/
}
int main()
{
signal(SIGCHLD, handler);
cout << "父进程:" << getpid() << endl;
pid_t id = fork();
if(id == 0)
{
cout << "子进程:" << getpid() << "父进程:" << getppid() <<endl;
sleep(5);
exit(1);
}
while(true) sleep(1);
return 0;
}
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
这里子进程退出也没留下任何痕迹。
还有一个细节:
明明对于17号信号处理就是”忽略“嘛?
但其实我们默认设置和手动设置的是不一样的。
因为OS会识别,如果是手动设置的,就会修改未来创建子进程的时候的退出的属性等等。