信号的处理
那么,从内核态返回用户态的时候,才会进行信号处理,也就是说很可能进行了系统调用或者是进程切换(进程切换需要进程切换到内核态,因为进程被切换的时候一定没有被执行完,放在运行或者是等待队列的时候一定就要切换到内核态,然后再继续调度下面代码的时候就要切换回用户态)
sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
pengding位图和block位图的统一类型就是sigset_t,是为了更方便用户,定义的用级数据结构的类型。
一般将block信号集叫做信号屏蔽字。
信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
对于信号集位图不要私自修改,要用对应的接口。
#include <signal.h>
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
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
第一个参数是下面这些选项。
第三个选项是重置信号屏蔽字。
第二个参数是你要修改的位图结构,也就是信号集。
第三个参数是第二个参数修改之前的信号集。(输出行参数)
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
sigpending
这个函数参数是一个输出型参数,在哪个进程调用就返回哪个进程的pengding位图。
返回成功0,失败-1。
对于信号保存更深入的理解
这里用起来上面介绍的接口,然后来写一段程序。
条件:
先屏蔽2号信号,发送一个信号2,在发生2号信号之前打印出pengding位图,发送之后再次打出pengding位图
#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/types.h> #include <cstdlib> 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要立马递达一个信号。(处理完一个信号,该比特位立刻清零)
sigaction
这个函数和signal函数差不多,第一个参数是对于该信号进行捕捉,第二个参数是一个结构体对象指针,传入的就是结构体的对象;
第一个成员是对于处理这个信号的方法。
第三个成员是信号集。
也就是说第二个参数是要对于该信号做一些列结构体中内容的设置的,是一个输入性参数。
第三个参数是一个输出型参数,获取对应信号老的处理方法。
成功返回0,失败返回-1。
#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/types.h> #include <cstdlib> 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库的很多实现都以不可重入的方式使用全局数据结构。
volatile
该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下。
#include <stdio.h> #include <unistd.h> #include <signal.h> #include <sys/types.h> 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 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
SIGCHLD信号
用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/types.h> #include <cstdlib> 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会识别,如果是手动设置的,就会修改未来创建子进程的时候的退出的属性等等。






















