之前的博文已经介绍了信号的一些基本概念(https://blog.csdn.net/tangduobutian/article/details/79812540),这篇博文主要介绍一下捕捉信号、可重入函数、竞态条件等。
1.捕捉信号
1)内核如何实现信号的捕捉
如果信号的处理动作是用户自定义的函数,在信号递达时就会调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,我们通过一个例子来了解一下这个过程:用户程序注册了SIGQUIT信号的处理函数sighandler,当前正在执行main函数,这时发生中断或异常切换到内核态,在中断处理完毕之后要返回用户态的main函数之前检查到有信号SIGQUIT递达,内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler函数和main函数使用不同的堆栈空间,它们之间不存在调用与被调用的关系,是两个独立的控制流程,sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态,如果没有新的信号递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
2)sigaction
#include <signal.h> int sigation(int signo,const struct sigaction *act,struct sigaction *oact);
· sigaction函数可以读取和修改与指定信号相关联的处理动作,成功返回0,失败返回-1。signo是指定信号的编号,若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact指针传出该信号原来的处理动作。act和oact指向sigaction结构体。
·当某个信号的处理函数被调用时,内核自动将当前信号加入进程信号的屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
·如果在调用信号处理函数时,出了当前信号被自动屏蔽外还希望自动屏蔽另外一些信号,则用samask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动回复原来的信号屏蔽字。
3.pause
#include <unist.h> int pause(void);
pause函数使调用进程挂起直到有信号递达。如果信号的处理动作是终止进程,则进程终止,pause函数没有机会返回;如果信号的处理动作是忽略,则进程继续处于挂起状态;如果信号的处理动作是捕捉,则调用了信号处理函数之后pause返回-1,error设置为EINTR,所以pause只有出错返回值。
下面用pause和alarm实现mysleep函数:
#include <stdio.h> #include <unistd.h> #include <signal.h> void sig_alrm(int signo) { } unsigned int mysleep(unsigned int nsecs) { struct sigaction new,old; unsigned int unslept=0; new.sa_handler=sig_alrm; sigemptyset(&new.sa_mask); new.sa_flags=0; sigaction(SIGALRM,&new,&old); //注册信号处理函数 alarm(nsecs); //设置闹钟 pause(); unslept=alarm(0); //清空闹钟 sigaction(SIGALRM,&old,NULL); //恢复默认信号处理动作 return unslept; } int main() { while(1){ mysleep(5); printf("5 seconds passed\n"); } return 0; }
测试结果:
分析一下上述函数的执行过程:
1.main函数调用mysleep函数,后者调用sigaction注册了SIGALRM信号的处理函数sig_alrm
2.调用alarm(nsecs)设定闹钟
3.调用pause等待,内核切换到别的进程运行
4.nsecs秒之后,闹钟超时,内核发SIGALRM给这个进程
5.从内核态返回这个进程的用户态之前处理未决信号,发现有SIGALRM信号,其处理函数是sig_alrm
6.切换到用户态执行sig_alrm函数,进入sig_alrm函数时SIGALRM信号被自动屏蔽,从sig_alrm函数返回时SIGALRM信号自动解除屏蔽。然后自动执行系统调用sigreturn再次进入内核,再返回用户态继续执行进程的主控制流程
7.pause函数返回-1,然后调用alarm(0)取消闹钟,调用sigaction恢复SIGALRM信号以前的处理动作
2.可重入函数
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head插入结点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步,结果是main函数和sighandler先后向链表中插入两个节点,而真正只有一个节点插入链表之中。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入。insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数。反之,如果一个函数只访问自己的局部变量或参数,则称为可重入函数。
如果一个函数符合以下条件之一则是不可重入的:
·调用了malloc或free,因为malloc也是用全局链表来管理堆的
·调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
竞态条件与sigsuspend函数
现在重新审视“mysleep”程序,设想这样的时序:
1.注册SIGALRM信号的处理函数
2.调用alarm(nsecs)设定闹钟
3.内核调度优先级更高的进程能够取代当前进程执行,并且优先级更高的进程有很多个,每个都要执行很长时间
4.nsecs秒后闹钟超时了,内核发送SIGALRM信号给这个进程,处于未决状态
5.优先级更高的进程执行完了,内核要调度回这个进程执行,SIGALRM信号递达,执行处理函数sig_alrm之后再次进入内核
6.返回这个进程的主控制流程,alarm(nsecs)返回,调用pause()挂起等待
7.可是SIGALRM信号已经处理完了,还等待什么呢?
出现这个问题的根本原因是系统运行的时序并不像我们写程序时所设想的那样。虽然alarm(nsecs)紧接着下一行就是pause(),但是无法保证pause()一定会在调用alarm(nsecs)之后的nsecs秒之内被调用。由于异步事件(及优先级更高的进程)在任何时候都有可能发生,如果我们写程序时考虑不周密,就可能由于时序问题而导致错误,这叫做竞态条件。
那么,如何解决这个问题呢?我们可能会想到在调用pause()之前屏蔽SIGALRM信号使它不再提前递达就可以了,看看以下方法可行吗?
1.屏蔽SIGALRM信号
2.alarm(nsec)
3.解除对SIGALRM信号的屏蔽
4.pause()
从解除信号屏蔽到调用puase()之间存在间隙,SIGALRM仍有可能在这个间隙递达,要消除这个间隙,把解除屏蔽移到pause()后面可以吗?
1.屏蔽SIGALRM信号
2.alarm(nsec)
3.pause()
4.解除对SIGALRM信号的屏蔽pause()
这样更不行了,还没有解除屏蔽就调用pause(),pause()根本不可能等到SIGALRM信号,要是“解除信号屏蔽”和“挂起等待信号”这两步能合并成一个原子操作就好了,这正是sigsuspend函数的功能。sigsuspend包含了pause的挂起等到功能,同时解决了竞态条件的问题,在对时序要求严格的场合下都应该调用sigsuspend而不是pause。
#include <signal.h> int sigsuspend(const sigset_t *sigmask)和pause一样,sigsuspend没有成功返回值,只有执行了一个信号处理函数之后sigsuspend才返回,返回值为-1,errno设置为EINTR。调用sigsuspend函数时,进程的信号屏蔽字由sigmask参数指定,可以通过指定sigmask来临时解除对某个信号的屏蔽,然后挂起等待,当sigsuspend返回时,进程的信号屏蔽字恢复为原来的值,如果原来对该信号是屏蔽的,从sigsuspend返回后仍然是屏蔽的。
以下用sigsuspend重新实现mysleep函数:
#include <stdio.h> #include <signal.h> #include <unistd.h> void sig_alrm(int signo) { } unsigned int mysleep(unsigned int nsecs) { struct sigaction newact,oldact; sigset_t newmask,oldmask,suspmask; unsigned int unslept; newact.sa_handler=sig_alrm; sigemptyset(&newact.sa_mask); newact.sa_flags=0; sigaction(SIGALRM,&newact,&oldact); sigemptyset(&newmask); sigaddset(&newmask,SIGALRM); sigprocmask(SIG_BLOCK,&newmask,&oldmask); alarm(nsecs); suspmask=oldmask; sigdelset(&suspmask,SIGALRM); sigsuspend(&suspmask); unslept=alarm(0); sigaction(SIGALRM,&oldact,NULL); sigprocmask(SIG_SETMASK,&oldmask,NULL); } int main() { while(1){ mysleep(4); printf("4 seconds has passed!\n"); } return 0; }
测试结果:
SIGCHLD信号
我们已经知道用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞的查询是否有子进程结束等待清理。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方法,父进程在处理自己工作的同时还要记得轮询一下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的动作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
要想不产生僵尸进程还有一种方法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程,系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的(此方法是一个特例,在Linux系统可用)。以下程序验证这样做不会产生僵尸进程:
#include <stdio.h> #include <signal.h> #include <stdlib.h> void handler(int signo) { sleep(3); printf("father:pid:%d,ppid:%d,child is quit,father get a sig:%d\n",getpid(),ge tppid(),signo); int status=0; while(1){ pid_t ret=waitpid(-1,&status,WNOHANG); if(ret>0){ printf("sig:%d,exit code:%d\n",status &0x7f,(status>>8) &0xff); } else if(ret==0){ break; } } } int main() { signal(SIGCHLD,handler); if(fork()==0){ printf("I am child,pid:%d,ppid:%d\n",getpid(),getppid()); sleep(10); exit(12); } while(1); return 0; }
测试结果: