作者主页:进击的1++
专栏链接:【1++的Linux】
我们在上一篇文章中讲述了信号的概念和信号的产生,并且我们知道了信号在发送给对应进程后,并不会被进程立即处理,而是进程会在合适的时间去进行处理。那么,在信号未处理的这段时间,信号在哪呢?这就是我们今天所要说的信号的保存。
我们将实际信号处理的动作叫做信号递达
将信号产生和递达之间的状态叫做信号未决
进程可以选择阻塞 (Block )某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
上述图中就是我们信号在内核中的表示示意图。
我们在上一篇中提到过,信号的数量是有限的,并且其本质是一些编号,因此我们可以用位图将其存储起来。我们的pending位图就是用来保存未决信号的。block位图是用来保存阻塞信号的,而handler中则存储的是,我们对应信号的处理方法的指针。我们的signal系统调用,实质上就是在该进程中,找到其handler表,若我们是在自定义二号信号的捕捉方法,则将handler[2]中的内容替换为我们自定义的处理方法的函数指针。(数组的下标就是我们要处理的信号) 。
信号的处理有默认,忽略,自定义,三种方式。当我们自定义处理信号时,进程先会判断handler[signum]==SIG_DFL?若等于则执行默认动作,若不等于,则判断handler[signum]==SIG_IGN?若等于则执行忽略,否则才会执行我们自定义的捕捉动作。
若signal()函数出错则返回SIG_ERR 。
当我们的一个信号产生后,其先是会成为未决信号,pending位图中的对应位将会被置为1 。接着进程会去检查block位图中该信号是否被阻塞,若被阻塞,则不会进行递达,直到对该信号的阻塞解除,若没有阻塞,则会直接进行递达。并将pending位图中对应信号的位置0 。
识别一个信号采用三元组方式,是否被block是否被pendinghandler方法是什么,结合这三个信息我们就可以知道这个信号该被怎么处理,这三个信息合起来就叫做进程是可以识别信号的。
下面我们演示信号位图的相关操作
sigpending ()获取当前调用进程的pending信号集。
sigismember()用来测试参数signum 代表的信号是否已加入至参数set信号集里。如果信号集里已有该信号则返回1, 否则返回0。
sigaddset()函数是允许您将一个指定的信号添加到一个自定义信号集中,也就是将该信号的标准位设为1,表示阻塞这个信号。当您需要创建或修改信号集,以便在信号处理、信号屏蔽等操作中使用时,可以使用此函数。
sigdelset函数允许您从一个自定义信号集中删除一个指定的信号,也就是将该信号的标准位设为0,不阻塞这个信号。当您需要调整信号集,以便在信号处理、信号屏蔽等操作中使用时,可以使用此函数。
sigfillset()函数初始化一个信号集,使其包含所有可接受的信号。sigempty()则相反。
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
下面我们来看一段代码:
#include
#include
#include
using namespace std;
void handler(int signum)
{
cout<<"正在处理"<<endl;
}
int main()
{
signal(2,handler);
sigset_t set;
//让二号命令阻塞
sigset_t bset,obset;
sigemptyset(&bset);
sigaddset(&bset,2);
sigprocmask(SIG_BLOCK,&bset,&obset);//将位图设置到进程中的block中去
while(true)
{
cout<<"我正在运行"<<endl;
sleep(1);
}
return 0;
}
那么要是我把所有的信号都进行阻塞了,或者说是将所有信号都进行自定义捕捉,那么是不是我们就写了一个用户杀不掉的进程呢?。我们接下来一 一进行验证。
void ShowPending(sigset_t& set)
{
for(int i=1;i<=31;i++)
{
if(sigismember(&set,i))
{
cout<<1;
}
else
{
cout<<0;
}
}
cout<<endl;
}
void Set_block(int sign)
{
sigset_t bset,obset;
sigemptyset(&bset);
sigaddset(&bset,sign);
sigprocmask(SIG_BLOCK,&bset,&obset);//将位图设置到进程中的block中去
}
int main()
{
sigset_t set;
cout<<"pid"<<getpid()<<endl;
for(int i=1;i<=31;i++) Set_block(i);
while(true)
{
sigpending(&set);
ShowPending(set);
sleep(1);
}
return 0;
}
我们发现到九号信号时,其无法被阻塞,直接就杀掉了该进程。
除了九号信号还有没有其他信号也能够无法被阻塞呢?
我们继续验证!
19号信号也无法被阻塞,它可以使得进程暂停。
void handler(int signum)
{
cout<<signum<<"号信号正在处理"<<endl;
}
int main()
{
cout<<"pid"<<getpid()<<endl;
for(int i=1;i<=31;i++)
{
signal(i,handler);
}
while(true)
{
sleep(1);
}
return 0;
}
同样9和19号信号也是无法被捕捉的,所以我们就可以回答前面的疑惑,是不会发生有杀不死的进程这样的情况的。
因为信号的产生是异步的,它在任何时候都可能产生信号,在信号产生期间,我的进程可能一直都在运行,当前进程可能会在做着更重要的事情。所以我们会将信号做延时处理,这个取决于OS和进程。
那么这个合适的时间是什么时候?
信号相关的数据段都是在进程的PCB中,属于内核的范畴。当该进程的执行流从内核态返回用户态的时候就会检测是否有信号需要处理并进行递达。
那么什么是内核态,什么是用户态呢?
用户态就是用户代码和数据被执行和访问时的状态,进程执行我们自己所写的代码就是处于用户态。
内核态就是OS执行自己的代码和数据,例如我们的系统调用。
那么他们还有什么区别呢?
内核态的权限是远大于用户态的,若是在用户态中出现野指针,除0这样的错误,OS是可以通过发送信号将该进程杀死的,而OS中若出现这样的问题,则会导致OS奔溃。因此用户态是被OS管制的一种状态。
我们的系统调用,中断,或者异常都会使得进程陷入内核态。我们以系统调用open为例,当我们调用open,进程会进入内核态去运行内核中open的实现代码,然后返回用户态将并带回了返回值。并且从用户态到内核态我们的身份也发生了改变:用户–》OS。
进入内核态后会执行系统自己的代码和数据。那么OS的代码是怎么被执行到的呢?
如图,是我们的进程地址空间。0-3G是我们的用户空间,3-4G是内核空间,我们之前所学到的在用户态执行时,我们是通过用户及页表去寻找存储在物理内存中的数据和代码,并且进程间是独立的,因此每个进程都有一个用户级的页表。我们的OS只有一个,因此其仅有一个内核级的页表,该页表可以被所有进程看到。在我们的CPU寄存器中,有个CR3寄存器,表示当前CPU执行的权限(即内核态还是用户态)。
当我们要执行系统调用时,进入内核态,通过内核级页表找到对应的系统调用代码,在进程的上下文当中执行。
进程在用户态运行时,遇到中断或者系统调用,进入内核内核态,处理完异常后在回到用户态之前会进行信号的检测和处理,若我们的信号捕捉动作是默认或者是忽略,则在内核态就可以完成相关的处理动作
如果是默认,比如是终止进程,就直接把进程的相关资源释放掉就可以了,如果是暂停,我把进程状态设置成stop,并把进程的PCB放到等待队列里就可以了;然后再直接返回用户态下一行。
如果是忽略,将pending由1置0,直接返回用户态的下一行代码;
若是自定义动作,则会回到用户态,执行信号的处理函数,处理完后会再次返回内核态(因为信号处理函数在用户态,其无法直到上次中断的地方在哪里)返回内核态后,再返回到用户态中上次中断的位置。
那么为什么内核不直接执行信号处理函数呢?是不能执行,还是不想要执行呢?
答案是不想要执行,OS的权限是很高的,其当然可以执行用户态的代码,但OS不相信任何人,它担心用户态中的代码有非法操作,因此它并不想执行用户级的代码。
下面我们用一幅对上述表的抽象图来进行总结:
一个橙色的圈代表一次状态切换。
sigaction函数
其作用与signal相同,只是用法不同。
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参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。
void handler(int signum)
{
cout<<signum<<"号信号正在处理"<<endl;
//sleep(3);
}
int main()
{
struct sigaction sigc,osigc;
sigc.sa_handler=handler;
sigaction(2,&sigc,&osigc);
while(true)
{
cout<<"我正在运行"<<endl;
sleep(1);
}
return 0;
}
当某个信号的处理函数被调用时 , 内核自动将当前信号加入进程的信号屏蔽字 , 当信号处理函数返回时自动恢复原来的信号屏蔽字, 这样就保证了在处理某个信号时 , 如果这种信号再次产生 , 那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时, 除了当前信号被自动屏蔽之外 , 还希望自动屏蔽另外一些信号 , 则用 sa_mask 字段说明这些需要额外屏蔽的信号, 当信号处理函数返回时自动恢复原来的信号屏蔽字。
sa_mask 本质是位图。
这也是为什么要有block的本质
如果我把2号信号屏蔽了,我给你的进程发送100个2号信号,此时你的进程只能记住一个,Linux对普通信号是可能丢失的(2个以上),因为记录信号的标志位只有一个比特位,如果把2号信号屏蔽了,发送100个,OS最终只记住一个(最新的那一个,发一次写一个)。
不可能丢失的信号叫做实时信号,在内核中是以链表,队列的形式来把所有的实时信号链到PCB里面的,来一个链接一个。本质是底层数据结构的区别。
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从 sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只 有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
我们学到的大部分函数,STL,boost库中的函数,大部分都是不可重入的。
如果一个函数符合以下条件之一则是不可重入的 :
调用了malloc或free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile关键字
我们来看下面这段代码:
int flag=0;
void handler(int signum)
{
cout<<signum<<"号信号正在处理"<<endl;
flag=1;
cout<<"flag"<<"--->"<<0<<"--->"<<flag<<endl;
}
int main()
{
struct sigaction sigc,osigc;
sigc.sa_handler=handler;
sigaction(2,&sigc,&osigc);
while(!flag);
cout<<"退出"<<endl;
return 0;
}
刚刚我们的编译器是常规情况,看到的就是这种现象,但是我们的编译器是有各种优化的。gcc/g++默认是普通编译,但也可以让用户自己设置优化级别,存在O0-O4的优化级别的。
下面我们进行优化后再次进行测试:
此时我们发现,flag变为了1却退出不了了。这是为什么呢?
while 循环检查的flag,并不是内存中最新的flag,这就存在了数据二异性的问题。 while 检测的flag其实已经因为优化,被放在了CPU寄存器当中。如何解决呢?很明显需要 volatile 。
加volatile后
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
SIGCHILD信号
多进程中我们可以用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
其实 , 子进程在终止时会给父进程发 SIGCHLD 信号 , 该信号的默认处理动作是忽略 , 父进程可以自 定义 SIGCHLD 信号的处理函数, 这样父进程只需专心处理自己的工作 , 不必关心子进程了 , 子进程 终止时会通知父进程 , 父进程在信号处理函数中调用wait 清理子进程即可。换言之如果我是可以直接在handler方法里调用waitpid,回收子进程的。此时父进程就不用主动等待子进程退出。
但是父进程就不想回收,压根就不关心这个子进程的退出码等信息,并且子进程退出的时候不形成僵尸进程,不要影响父进程。我们就可以显示设置忽略17号信号。此方法对于linux有用。
系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。
SIGCHILD信号
来看一段代码:
void handler(int signum)
{
while(waitpid(-1,nullptr,WNOHANG)>0)
{
cout<<"进程退出"<<endl;
}
cout<<"all quit"<<endl;
}
int main()
{
signal(17,handler);
int count=3;
while(count--)
{
pid_t pid=fork();
if(pid==0)
{
cout<<"pid"<<getpid()<<endl;
sleep(2);
exit(1);
}
}
while(true)
{
cout<<"father is doing"<<endl;
sleep(1);
}
}
我用的是while循环和WNOHANG(非阻塞等待),用循环的原因是为了满足各种子进程退出的情况,eg:我创建了10个子进程,10个子进程同时退出了,每个子进程都同时向父进程发送信号,可是pending位图只有一个比特位记录信号,如果只wait一次就只能wait一个子进程,剩下9个就wait不到。通过while循环我们就可以把所有的子进程都读到。
用非阻塞的原因:假如你是阻塞等待,有10个子进程,5个退出了,5个没退出。你循环读,也没有任何问题,但是当你读第6次的时候,子进程没退出,你就在信号捕捉函数这里卡住了。(子进程不退出父进程不返回,这就叫做阻塞等待)。当你读取一个子进程,只有当你读取失败的时候,你才知道底层没有子进程退出了。所以这里要用非阻塞。