目录
三、阻塞信号
3.1 信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
注意:信号阻塞与进程阻塞没有任何关系,就像老婆饼和老婆的关系一样
3.2 信号在内核中的表示
前面简单提过,信号是由位图保存的,下面详解信号在内核中的表示
信号在内核中的表示示意图(一)如下:
信号在内核中有三种结构,在进程的 task_struct 里面表示,一个是 pending位图,一个是 block位图, 另一个是 handler,handler一个函数指针表示对信号处理动作,每个信号都有两个标志位分别表示阻塞(block) 和 未决(pending)
- 在 pending位图中,比特位的位置代表某一个信号,比特位的内容代表是否收到该信号(信号是否未决),内容为1收到信号,为0表示没有收到信号。第一个比特位位置代表 1号信号,以此类推(位图结构)
- 在 block位图中,比特位的位置代表某一个信号,比特位的内容代表该信号是否被阻塞,内容为1表示对该信号进行阻塞,为0表示没有对该信号进行阻塞。第一个比特位位置代表 1号信号,以此类推(位图结构)
- handler表本质上是一个函数指针数组,数组的下标代表某一个信号(需要加1或者不用,看具体实现),数组的内容代表该信号递达时的处理动作,处理动作包括默认、忽略以及自定义
- block、pending和handler这三张表的每一个位置是一一对应的
信号在内核中的表示示意图(二)如下:
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。
- block、pending和handler这三张表的每一个位置是一一对应的
- 信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,1号信号SIGHUP 未阻塞也未产生过(pending位图对应信号位置标志位为0,block位图对应信号位置标志位为0),当它递达时执行默认处理动作(SIG_DFL)。
- 上图中,对于2号信号SIGINT,该信号产生过(pending位图对应信号位置标志位为1,处于未决),但正在被阻塞(block位图对应信号位置标志位为1),所以暂时不能递达。虽然它的处理动作是忽略(SIG_IGN),但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- 上图中,3号信号SIGQUIT 未产生过(pending位图对应信号位置标志位为0),该信号正在被阻塞(block位图对应信号位置标志位为1),一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
注意:一个信号没有被产生,但是不影响对该信号进行阻塞,比如上面的3号信号例子
进程为何能识别信号??
因为每个进程的PCB结构体(task_struct)都设置有 block 、pending 和 handler 这三种结构
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?
POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号(普通信号)在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里,这里不讨论实时信号,只讨论普通信号
3.3 sigset_t
由于 block 和 pending位图是属于内核的,用户不能直接修改,想要修改就要通过系统调用,OS也不能提供十几个参数的函数给你用吧,所以就有了 sigset_t
从内核的block和pending结构来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储(在用户层),sigset_t 称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。
- 在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞
- 而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的 “屏蔽” 应该理解为阻塞而不是忽略
3.4 信号集操作函数
sigset_t 类型对于每种信号用一个bit表示 “有效”或“无效” 状态,至于这个类型内部如何存储这些 bit 则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作 sigset_ t 变量,而不应该对它的内部数据做任何解释,比如用 printf 直接打印 sigset_t 变量是没有意义的
信号集操作函数有如下5个,都是位图的操作函数
头文件都是:#include<signal.h>intsigemptyset(sigset_t*set); intsigfillset(sigset_t*set); intsigaddset(sigset_t*set, intsignum); intsigdelset(sigset_t*set, intsignum); intsigismember(constsigset_t*set, intsignum);
- sigemptyset函数:初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号;
- sigfillset函数:初始化 set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号;
- sigaddset函数:在set所指向的信号集中添加某种有效信号;
- sigdelset函数:在set所指向的信号集中删除某种有效信号;
- sigismember函数:判断在set所指向的信号集中是否包含某种信号。
sigemptyset、sigfillset、sigaddset和sigdelset函数都是成功返回0,出错返回-1
sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1
注意:在使用 sigset_ t 类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用 sigaddset 和 sigdelset 在该信号集中添加或删除某种有效信
这些信号集一般配合以下两个函数使用
3.5 sigprocmask函数
调用 sigprocmask函数可以读取或更改进程的信号屏蔽字(阻塞信号集)
man 2 sigprocmask 查看:
函数:sigprocmask头文件函数原型intsigprocmask(inthow, constsigset_t*set, sigset_t*oldset); 参数第一个参数how可选选项有三个:SIG_BLOCK、SIG_UNBLOCK、SIG_SETMASK第二个参数set是信号集定义的变量,它是一个输入型参数,如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改第三个参数oldset是信号集定义的变量,它是一个输出型参数,如果oldset是非空指针,则读取进程当前的信号屏蔽字通过oset参数传出如果oldset和set都是非空指针,则先将原来的信号屏蔽字备份到oldset里,然后根据set和how参数更改信号屏蔽字返回值sigprocmask函数调用成功返回0,出错返回-1
第一个参数how选项介绍如下:
SIG_BLOCK、SIG_UNBLOCK是在原有的基础上做处理,SIG_SETMASK相当于重置原有的再设置
注意:如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达
3.6 sigpending函数
sigpending函数可以用于读取进程的未决信号集
man 2 sigpending 查看一下
函数:sigpending头文件函数原型intsigpending(sigset_t*set); 参数:set是信号集定义的一个变量返回值该函数调用成功返回0,出错返回-1
sigpending函数读取当前进程的未决信号集,并通过 set 参数传出
3.7 信号集实验
- 操作阻塞信号集的系统调用:sigprocmask
- 操作未决信号集的系统调用:sigpending
- 操作handler(默认、忽略、自定义)的系统调用:signal
操作 block、pending、handler的系统调用都有了,我们下面进行信号集实验
注意:默认情况下,所有信号是不会被阻塞的;如果一个信号被屏蔽了,该信号不会被递达
实验步骤如下:
- 进行屏蔽指定的信号
- 打印输出当前的pending信号集
- 通过kill命令或快捷键的方式给进程发送信号
- 观察pending信号集的变化
测试代码:测试屏蔽的是2号信号)
usingnamespacestd; staticvector<int>sigarr= {2}; staticvoidshow_pending(constsigset_t&pending) { for(intsigno=MAX_SIGNUM; signo>=1; signo--) { if(sigismember(&pending, signo)) { cout<<"1"; } else { cout<<"0"; } } cout<<endl; } intmain() { //1.屏蔽指定信号sigset_tblock, oldblock, pending; //1.1 初始化sigemptyset(&block); sigemptyset(&oldblock); //1.2 添加要屏蔽的信号for(auto&sig : sigarr) { sigaddset(&block, sig); } //1.3 开始屏蔽,真正开始更改PCB里面的数据sigprocmask(SIG_SETMASK, &block, &oldblock); //2.遍历打印pending信号集while(true) { // 2.1 初始化sigemptyset(&pending); // 2.2 获取当前pending信号集sigpending(&pending); // 2.3 打印信号集show_pending(pending); sleep(2); } return0; }
运行测试
从运行结果可以看到,程序刚刚运行时,因为没有收到任何信号,所以此时该进程的pending信号集一直是全0,而当我们使用 快捷键Ctrl+C 向进程发送2号信号后,由于2号信号是阻塞的,因此2号信号一直处于未决状态,所以我们看到pending信号集中的第二个数字一直是1,最后 由 Ctrl+\ 向进程发送3号信号,终止进程
注意:9号信号是无法被屏蔽的,这是OS管理员信号
修改一下代码,恢复对信号的屏蔽,不屏蔽任何信号,当解除2号信号的屏蔽后,2号信号就会立即递达,程序就直接终止了
代码如下:
usingnamespacestd; staticvector<int>sigarr= {2}; staticvoidshow_pending(constsigset_t&pending) { for(intsigno=MAX_SIGNUM; signo>=1; signo--) { if(sigismember(&pending, signo)) { cout<<"1"; } else { cout<<"0"; } } cout<<endl; } intmain() { //1.屏蔽指定信号sigset_tblock, oldblock, pending; //1.1 初始化sigemptyset(&block); sigemptyset(&oldblock); //1.2 添加要屏蔽的信号for(auto&sig : sigarr) { sigaddset(&block, sig); } //1.3 开始屏蔽,真正开始更改PCB里面的数据sigprocmask(SIG_SETMASK, &block, &oldblock); //2.遍历打印pending信号集intcnt=10; while(true) { // 2.1 初始化sigemptyset(&pending); // 2.2 获取当前pending信号集sigpending(&pending); // 2.3 打印信号集show_pending(pending); sleep(1); if(cnt--==0)//10秒后,恢复 { sigprocmask(SIG_SETMASK, &oldblock, &block);//一旦对特定信号进行解除屏蔽,一般OS要至少立马递达一个信号!cout<<"恢复对信号的屏蔽,不屏蔽任何信号"<<endl; } } return0; }
运行测试:
下面的测试是把递达的2号信号进行捕捉,执行自定义动作。开始运行,进程收到2号信号后,该信号在一段时间内处于未决状态,当解除2号信号的屏蔽后,2号信号就会立即递达,执行我们所给的自定义动作,而此时的pending表也变回了全0,这时我们就可以观察到现象了
修改代码如下:
usingnamespacestd; staticvector<int>sigarr= {2}; staticvoidshow_pending(constsigset_t&pending) { for(intsigno=MAX_SIGNUM; signo>=1; signo--) { if(sigismember(&pending, signo)) { cout<<"1"; } else { cout<<"0"; } } cout<<endl; } staticvoidmyhandler(intsigno) { cout<<signo<<" 号信号已经被递达!!"<<endl; } intmain() { for(auto&sig : sigarr) { signal(sig, myhandler); } //1.屏蔽指定信号sigset_tblock, oldblock, pending; //1.1 初始化sigemptyset(&block); sigemptyset(&oldblock); //1.2 添加要屏蔽的信号for(auto&sig : sigarr) { sigaddset(&block, sig); } //1.3 开始屏蔽,真正开始更改PCB里面的数据sigprocmask(SIG_SETMASK, &block, &oldblock); //2.遍历打印pending信号集intcnt=10; while(true) { // 2.1 初始化sigemptyset(&pending); // 2.2 获取当前pending信号集sigpending(&pending); // 2.3 打印信号集show_pending(pending); sleep(1); if(cnt--==0)//10秒后,恢复 { sigprocmask(SIG_SETMASK, &oldblock, &block);//一旦对特定信号进行解除屏蔽,一般OS要至少立马递达一个信号!cout<<"恢复对信号的屏蔽,不屏蔽任何信号"<<endl; } } return0; }
运行测试:
四、深入理解捕捉信号
4.1 进程地址空间二次理解(内核空间与用户空间)
进程地址空间在进程概念已经说过一部分,点击穿越
这里的内容对进程地址空间二次理解,重点:内核空间与用户空间
前面已经说过:每一个进程都有自己的进程地址空间,该进程地址空间由内核空间和用户空间组成(下面都是以32位平台为例)
用户所写的代码和数据位于用户空间,操作系统(OS)的代码和数据都位于内核空间
用户空间大小:[0, 3]GB,内核空间大小:[3, 4]GB
之前谈论的页表都指的是用户级页表,并没有涉及到内核空间,而内核空间也有属于自己的页表,这张页表叫做内核级页表
注意:每个进程都有一份用户级页表,这张页表是独立的,而内核级页表只有一份,所有进程共享
- 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系
- 内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系
- 每个进程都有自己的地址空间,用户空间(0 ~ 3GB)每个进程独占,内核空间被映射到了每一个进程的3 ~ 4GB,即每个进程看到的内核空间都是一样的
结合这些知识可以再次理解进程切换,在进程切换的时候:
- 在当前进程的进程地址空间中的内核空间,找到操作系统的代码和数据;
- 执行操作系统的代码,将当前进程的代码和数据剥离下来,并换上另一个进程的代码和数据
注意:这里涉及用户态和内核态切换的问题,下面解释
4.2 用户态和内核态
首先简单介绍一下CPU
CPU内肯定存在大量的寄存器,寄存器分为两类:
- 可见寄存器
- 不可见寄存器
这些寄存器凡是和当前进程强相关的,这些寄存器内的数据都是该进程的上下文数据
其中,就有一个寄存器直接指向当前进程的 task_struct,还有一个寄存器也是直接指向当前进程的用户级页表,所以CPU可以直接找到当前进程的 task_struct 和用户级页表,又因为页表的MMU(内存管理单元)集成在CPU里面,所以在CPU这里虚拟地址到物理地址的转换就直接完成了
其中,CPU内还有一个寄存器:CR3,这个寄存器用于表示当前进程的运行级别,0表示进程处于内核态级别,3表示处于用户态级别
什么是用户态和内核态??
- 内核态通常用来执行操作系统的代码,是一种权限非常高的状态
- 用户态是一种用来执行普通用户代码的状态,是一种受监管的普通状态
我们平时所写的代码都是属于用户态的,但是我们的代码可能要访问操作系统自身的资源(比如getpid、waitpid等等)或者访问硬件资源(比如printf、read、write等等)
操作系统的资源和硬件资源都是在操作系统或操作系统之下的,所以我们要使用这些资源,就必须使用操作系统提供的系统调用接口
系统调用接口是操作系统提供的,也就是说这些代码是属于操作系统,是在内核态里面,而我们写的代码是用户态的,普通用户无法以用户态的身份执行内存态的代码,必须进行身份切换,把身份从用户态切换到内核态才能执行内核态的相关代码
所以实际执行系统调用的 “人” 是 “进程”,但身份其实是内核
系统调用比普通调用所花费的时间多一些,成本高一些,因为系统调用要对身份来回切换(从用户态变成内核态,从内核态变回用户态),普通函数调用则没有,所以要尽量避免频繁调用系统调用
------------- 分割线 -----------
进程想要访问OS的系统调用接口,如何访问??
其实只需要在进程自己的地址空间上进行跳转就可以了(结果地址空间进行理解)
当进程执行到系统调用接口了,进程就会跳转到内核执行系统调用的代码,在跳转的过程中,用户态的身份会切换到内核态的身份,系统调用的代码执行完成了,就又会跳转回当前进程执行的代码,跳转的过程中身份由内核态切换会用户态,再以用户态的身份继续执行用户代码
这些操作在进程的上下文中就可以完成
注意:每个进程都有 [3 ~ 4]GB 的内核空间,每个进程都会共享一个内核空间和一个内核级页表,所以无论怎么切换,都不会改变 [3 ~ 4]GB的内核空间
从用户态切换为内核态通常有如下几种情况:
- 需要进行系统调用时。
- 当前进程的时间片到了,导致进程切换。
- 产生异常、中断、陷阱等。
与之相对应,从内核态切换为用户态有如下几种情况:
- 系统调用返回时。
- 进程切换完毕。
- 异常、中断、陷阱等处理完毕。
其中,由用户态切换为内核态我们称之为陷入内核。每当我们需要陷入内核的时,本质上是因为我们需要执行操作系统的代码,比如系统调用函数是由操作系统实现的,我们要进行系统调用就必须先由用户态切换为内核态
有了这些知识储备之后,就可以理解内核是怎么进行信号捕捉的了
4.3 内核中信号的捕捉流程
进程收到信号之后,并不是立即处理信号,而是在合适的时候,这里所说的合适的时候实际上就是指,从内核态切换回用户态的时候,进行处理
从内核态切回用户态就代表了我一定是先进入了内核态
因为某些情况,进程执行代码过程中陷入内核,当内核处理完毕准备返回用户态时,就需要带回去一些东西(都进入内核了,不带点东西回去怎么行,因为陷入内核的成本比较高),这时就会进行对信号的检查(此时仍处于内核态,有权力直接修改或查看当前进程的 task_struct)
在检查信号的时候,首先查看 block位图,查看对应信号是否阻塞,该信号没有阻塞就需要查看 pending位图,pending位图对应该信号的内容为1,需要对该信号进行处理,执行默认、忽略或者自定义行为,简称递达,递达之后,清除对应的pending位图标志位,完成之后如果没有新的信号要递达,就直接返回用户态
下图是执行默认或忽略动作的:
但如果待处理信号是自定义捕捉的,即该信号的处理动作是由用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理动作,执行完后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码
当待处理信号是自定义捕捉时的情况比较复杂,可以借助下图进行记忆:数学上的无穷
该图形与直线有几个交点就代表在这期间有几次身份状态切换,过程:1 -> 2 -> 3 -> 4 -> 1 ,其中的交点是信号检测的全部过程,这样简单明了
识别到信号的处理动作是自定义时,能直接在内核态执行用户空间的代码??
答案是不能,操作系统不相信任何人,因为内核态的权限比较高,如果以内核态的身份去执行用户态的代码,假设用户态的代码是恶意代码,就会导致非常不好的后果,所以绝对不允许以内核态的身份去执行用户态的代码
4.4 sigaction函数
sigaction函数的功能与 signal函数的功能一样,都是捕捉信号,执行自定义方法,但是 sigaction函数多了一些东西
man 2 sigaction 查看
函数:sigaction头文件:#include<signal.h>函数原型intsigaction(intsignum, conststructsigaction*act, structsigaction*oldact); 参数:第一个参数signum代表指定信号的编号第二个参数act是一个结构体指针变量,输入型参数,若act指针非空,则根据act修改该信号的处理动作第三个参数oldact也是一个结构体指针变量,输出型参数,若oldact指针非空,则通过oldact传出该信号原来的处理动作返回值调用成功返回0,失败返回-1,错误码被设置
sigaction 本身就是一个结构体,它的定义如下:
structsigaction { void(*sa_handler)(int); void(*sa_sigaction)(int, siginfo_t*, void*); sigset_tsa_mask; intsa_flags; void(*sa_restorer)(void); };
(1)结构体的第一个成员变量是 sa_handler
- 将sa_handler赋值为常数SIG_IGN传给sigaction函数,表示忽略信号。
- 将sa_handler赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作。
- 将saa_handler赋值为一个函数指针,表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数
注意:该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用
(2)结构体的第二个成员变量是 sa_sigaction
sa_sigaction是实时信号的处理函数,我们不使用,这里我们只谈普通信号,直接设置为空即可或者不理会
(3)结构体的第四个成员变量是 sa_flags 和 第五个成员变量是 sa_restorer
sa_flags 这里不考虑使用,直接设置为0即可,sa_restorer也是不使用,设置为空即可或者不理会
(4) 结构体的第三个成员变量是sa_mask
sa_mask 是信号集,按照上面的进行使用即可
注意:如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,可以把需要屏蔽的信号加入到 sa_mask中,当信号处理函数返回时,自动恢复原来的信号屏蔽字
所以,在这个结构体里面,只需关心 sa_handler 和 sa_mask 即可,其他无需关心
下面进行测试
使用sigaction函数对 2号信号进行了捕捉,将 2号信号的处理动作改为了自定义动作
usingnamespacestd; //倒计时计数器voidCount(intcnt) { while(cnt) { printf("cnt: %2d\r", cnt); fflush(stdout); cnt--; sleep(1); } printf("\n"); } voidhandler(intsigno) { cout<<"get a signo: "<<signo<<", 该信号正在处理中..."<<endl; Count(20); } intmain() { //定义sigaction结构体变量structsigactionact, oldact; act.sa_handler=handler; act.sa_flags=0; //初始化 sa_masksigemptyset(&act.sa_mask); //sigaddset(&act.sa_mask, 3);// 当我们正在处理某一种信号的时候,我们也想顺便屏蔽其他信号,就可以添加到这个sa_mask中//开始屏蔽sigaction(2, &act, &oldact); while(true) sleep(1); return0; }
测试运行
从测试结果看出,我们正在递达某个信号期间,同类信号无法被递达。因为当当前信号正在被递达,OS自动会把当前信号加入信号屏蔽字中(block)。当前信号完成递达动作,OS又会自动解除对该信号的屏蔽
进程处理信号的原则是串行的处理同类型的信号,不允许递归
这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止
当信号递达了,pending位图对应信号相应的内容就会由1置0,如果这时又检测到了信号,pending位图对应信号相应的内容就会由0置1,当上一个递达动作完成,OS会自动解除对该信号的屏蔽,也就是说该信号被解除屏蔽后,OS会自动递达当前屏蔽的信号
所以在上面的测试中发送了多次2号信号,第一次信号处理动作完成后,2号信号还会被捕捉一次并递达。如果没有检测到信号,pending位图对应信号相应的内容依旧是0,是0就不做任何动作
注意:9号信号不允许被屏蔽,前面已经提过了
五、可重入函数
假设有一个带头单链表进行头插,在主函数主函数中调用insert函数向链表中插入结点node,注意:在某信号处理函数中也调用了insert函数向链表中插入结点node2
简单写一下伪代码
接下来,进行分析
(1) 首先,main函数调用 insert 函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步(p->next = head)的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,节点插入示意图如下:
(2) 切换到sighandler函数后,sighandler也调用 insert函数向同一个链表head中插入节点node2,sighandler函数调用的insert函数也完成了插入节点的第一步,(p->next = head)
(3)sighandler函数调用的 insert函数插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态
(4) 返回到用户态之后,从main函数调用的 insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。
结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了,而 node2结点就再也找不到了,造成了内存泄漏
像上例这样,insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,
insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
一般认为,main执行流,和信号捕捉执行流是两个执行流
- 如果在main中,和在handler中,该函数被重复进入,出现问题,该函数是不可重入函数
- 如果在main中,和在handler中,该函数被重复进入,没有出现问题,该函数是可重入函数
如果一个函数符合以下条件之一则是不可重入的:
- 调用了 malloc或 free,因为 malloc也是用全局链表来管理堆的
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构
注意:可重入函数不是一个问题,可重入是一个特性,是一个中性词
六、再次理解C语言关键字volatile
volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性
下面以信号的角度进行理解它
比如,对2号信号进行捕捉,当该进程收到2号信号时会将全局变量 quit 由0置1。也就是说,在进程收到2号信号之前,该进程会一直处于死循环状态,直到收到2号信号时将 quit 由0置1才能够正常退出
intquit=0; voidhandler(intsigno) { printf("%d号信号,正在被捕捉!\n", signo); printf("quit:%d", quit); quit=1; printf(" -> %d\n", quit); } intmain() { signal(2, handler); while(!quit); printf("注意,我是正常退出\n"); return0; }
测试运行,发送2号信号,正常退出
接下来,尝试把编译代码的优化级别变高
使用的是C语言,man gcc 查看
其中 -o3是最高的优化等级,上面的代码使用 -O3 进行编译
再次运行该程序v
优化情况下,运行发现,handler执行流执行完成,quit由0置1了,返回main执行流,按理说应该退出循环,程序正常结束,但是为什么仍然陷入死循环??这是为什么??
在编译器优化级别较高的时候,编译器可能会把quit直接设置进寄存器里面,这个寄存器保存的只是临时的数据。由于编译器优化,while循环检查quit的值,直接看寄存器内的值,并不会到内存里面查看
也就是说, while循环检查的quit,并不是内存中最新的quit。从 handler执行流返回main执行流时,内存中的 quit 值已经更新(由0置1),但是寄存器内的值并没有更新。因为寄存器的值只是临时数据,改了没有意义,只是把quit更新到内存。
结果就出现了存在数据二异性的问题,while循环检查的是寄存器里面的值,寄存器里面的值依旧是0,没有更新,所程序依旧死循环不退出
由于寄存器的存在,遮盖了内存中的更新后数据,所以while循环眼里只有寄存器,没有内存,这是编译器优化产生的问题
而C语言的关键字 volatile 的作用就是:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
给 quit变量加上关键字 volatile:
再次用 -O3 最高优化级别进行编译,运行结果如下,程序正常退出
七、SIGCHLD信号
前面控制讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发 17号信号SIGCHLD,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了。子进程终止时会通知父进程,父进程在信号处理函数中调用 wait 清理子进程即可
测试代码,对 SIGCHLD 信号进行捕捉,执行自定义动作,自定义处理函数中调用了waitpid函数对子进程进行了回收清理
voidhandler(intsig) { pid_tid; while ((id=waitpid(-1, NULL, WNOHANG)) >0)// WNOHANG非阻塞式等待 { printf("wait child success, pid: %d\n", id); } printf("child is quit! %d\n", getpid()); } intmain() { signal(SIGCHLD, handler); pid_tcid; if ((cid=fork()) ==0) { // childprintf("child pid: %d\n", getpid()); sleep(3);//3秒后子进程退出exit(1); } // parentprintf("parent pid: %d\n", getpid()); while (1) { printf("father proc is doing some thing!\n"); sleep(1); } return0; }
测试运行
说明:
- SIGCHLD属于普通信号,记录该信号的pending位只有一个,如果在同一时刻有多个子进程同时退出,那么在handler函数当中实际上只清理了一个子进程,因此在使用waitpid函数清理子进程时需要使用while不断进行清理
- 使用waitpid函数时,需要设置 WNOHANG 选项,即非阻塞式等待,否则当所有子进程都已经清理完毕时,由于while循环,会再次调用waitpid函数,此时就会在这里进行阻塞
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 sigaction将 SIGCHLD的处理动作置为 SIG_IGN,这样 fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。
系统默认的忽略动作和用户用 sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用
注意:这里手动传的SIG_IGN,与系统中的默认SIG_IGN不一样,系统中的默认SIG_IGN与Term、Core的流程一样,手动传的SIG_IGN的特性是:子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程
下面进行测试
intmain() { signal(SIGCHLD, SIG_IGN);//显示的设置对SIGCHLD进行忽略pid_tcid; if ((cid=fork()) ==0) { // childprintf("child pid: %d\n", getpid()); sleep(3);//3秒后子进程退出exit(1); } // parentprintf("parent pid: %d\n", getpid()); while (1) { printf("father proc is doing some thing!\n"); sleep(1); } return0; }
运行结果,子进程在退出时会自动被清理掉,不会产生僵尸进程
信号到这里全部完成,下一篇进入多线程
----------------我是分割线---------------
文章就到这里,下篇即将更新!!!