欢迎各位大佬光临本文章!!!
还请各位大佬提出宝贵的意见,如发现文章错误请联系冰冰,冰冰一定会虚心接受,及时改正。
本系列文章为冰冰学习编程的学习笔记,如果对您也有帮助,还请各位大佬、帅哥、美女点点支持,您的每一分关心都是我坚持的动力。
我的博客地址:bingbing~bang的博客_CSDN博客https://blog.csdn.net/bingbing_bang?type=blog
我的gitee:冰冰棒 (bingbingsupercool) - Gitee.comhttps://gitee.com/bingbingsurercool
系列文章推荐
冰冰学习笔记:《管道与共享内存》
冰冰学习笔记:《智能指针》
目录
系列文章推荐
前言
1.信号的概念
1.1什么是Linux信号
1.2Linux信号的产生
1.2.1键盘组合键产生信号
1.2.2系统调用接口产生
1.2.3软件条件产生
1.2.4硬件异常产生信号
2.信号的保存
2.1信号的存储结构
2.2sigset_t类型和信号操作函数
2.3信号的屏蔽测试
3.信号的捕捉
3.1信号的处理时机
3.2sigaction函数
4.可重入函数与volatile关键字
5.SIGCHLD信号
前言
在生活中我们能够接收到许许多多的信号,例如十字路口的红绿灯,我们能够知道并理解这些信号所表达的含义,并能对信号做出回应。在进程中也会产生一些信号来表达某些特定的含义,并让操作系统做出某些表达。那么究竟什么是Linux信号呢?
1.信号的概念
1.1什么是Linux信号
Linux信号本质是一种通知机制,用户或者操作系统通过发送一定的信号,通知进程,某些事件已经发生,你可以在后续进行处理。信号是进程之间事件异步通知的一种方式,属于软中断。
因此结合进程信号我们可以得出下列结论:
(1)进程需要处理信号,那么进程就必须具备信号识别的能力(看到+动作处理)
(2)进程怎么识别信号呢?通过程序员写定的代码
(3)信号产生是随机的,进程可能正在执行其他的动作,所以信号的处理可能并不是及时的
(4)既然进程需要后续处理信号,那么进程必然会临时记录下信号
(5)信号什么时候会被处理?“合适”的时候
(6)信号的产生相对于进程是异步的
那么常见的信号有哪些呢?
在我们之前的程序运行中,经常会遇到一些进程是死循环状态,进程一直在运行,我们可以通过ctrl+c的组合键将进程终止。这个过程其实就是向进程发送了2号信号。
进程在接受到2号信号后,进程会执行2号信号对应的终止进程的功能,操作系统将进程杀死。
2号信号对应的就是终止进程的功能,我们可以通过kill - l 命令查看所有的信号。
信号并非64个,其中没有0号,32号,33号信号,1-31是普通信号,34-36是实时信号。
我们还可以通过命令:man 7 signal 查看信号手册,获取每个信号的详细信息:
对于信号常见的处理方式有三种:
(1)默认:执行进程自带的逻辑,例如2号信号,进程终止。
(2)忽略:忽略此信号
(3)自定义捕捉:执行用户自己定义的逻辑
为什么组合键变成了信号呢?
键盘的工作方式是通过中断方式进行的,每个按键都会获取相应的执行结果,当然操作系统也能识别组合键。当操作系统获取到组合键时,将会对其进行解析含义,随后通过含义发送对应的信号。
如何理解信号被进程保存了呢?
首先进程需要知道是什么信号,该信号是否产生。这就需要进程必须具备保存信号的相关数据结构。保存信号的是通过位图进行保存的,1-32个信号通过一个“整形”的比特位进行存储映射,第几个比特位表示第几个信号,1表示信号产生,0表示信号未产生。而该位图结构保存在进程PCB内部。
如何理解信号发送的本质?
信号位图结构在PCB中,只有操作系统能够进行访问,因此信号发送的本质是OS向目标进程写信号,OS直接修改PCB中指定的位图结构,完成发送信号的过程。
所以ctrl+c被操作系统解释为2号信号,OS查找进程列表找到前台运行的进程,OS将2号信号写入到进程的位图结构中。
注意:
(1) Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程 结束就可以接受新的命令,启动新的进程。
(2) Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
(3)前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步 (Asynchronous)的。
1.2Linux信号的产生
信号是怎么产生的呢?前面我们通过组合键杀死进程就是产生信号的一种方式。
1.2.1键盘组合键产生信号
信号可以通过一些组合键进行产生,例如ctrl+c,ctrl+\ 等组合键都是产生信号,进程通过信号来获取对应信号的执行逻辑,并表现出来。
产生信号就需要对信号执行特定的处理动作,前面我们了解了信号处理的三种方式,默认、忽略、自定义捕捉。如果我们想自定义某个信号的特定执行动作就可以调用signal函数
该函数接受一个信号和一个函数指针,当该信号产生时,将会调用函数指针指向的函数进行信号的处理。signal函数,仅仅是修改进程对特定信号的后续处理动作,不是直接调用对应的处理动作。如果后续没有任何所捕捉的信号产生,那么传递的函数指针永远也不会被调用。
用下列代码进行测试:
void catchSig(int signum)
{
cout<<"捕捉信号: "<<signum<<" pid: "<<getpid()<<endl;
}
int main()
{
signal(SIGINT,catchSig);//对2号信号自定义捕捉
while(true)
{
cout<<"我是一个进程,pid:"<<getpid()<<endl;
sleep(1);
}
return 0;
}
当我们发送2号信号终止程序时,程序并不会终止,而是回去调用catchSig函数:
如果此时想要终止程序,可以使用快捷键ctrl+\向进程发送3号信号进行终止。SIGINT和SIGQUIT虽然都是将进程终止,但是SIGQUIT的默认处理动作是终止进程并且Core Dump。
那么什么是Core Dump(核心转储)呢?
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。主要是用于程序调试的。一般而言,云服务器的核心转储功能是被关闭的。原因在于云服务器一般是长时间运行的,因此一旦打开核心转储功能,遇到信号就会产生文件,会将磁盘慢慢侵蚀。所以云服务器一般都会关闭。
上面的例子中,我们发送3号(SIGQUIT)信号,进程终止,但是并没有生成core文件,说明此时核心转储功能被关闭。
我们可以使用命令:ulimit -a 进行查看,ulimit -c 10240 打开核心转储功能(创建的文件大小为10240k) ulimit -c 0 关闭核心转储。
此时我们使用3号信号终止进程时就会产生core文件:
core dump其实就是标记一个是否在进程终止时产生数据文件的标记位。我们在进程等待时讲过,waitpid函数需要传入一个输出型参数status,status参数的次低8位代表进程的退出码,最低7位代表信号,而第8位代表的就是是否发生了核心转储。
此时我们可以自己进行验证core dump是否被设置:
void testCoreDump()
{
pid_t id = fork();
if(id == 0)
{
sleep(1);
int a = 100;
a /= 0;//除零错误,发送8号信号
exit(0);
}
int status = 0;
waitpid(id, &status, 0);
cout << "父进程:" << getpid() << " 子进程:" << id << \
" exit sig: " << (status & 0x7F) << " is core: " << ((status >> 7) & 1) << endl;
}
1.2.2系统调用接口产生
通过一些系统接口的调用,我们也能向进程发送信号。
(1)kill函数:给pid发送sig信号
(2)raise函数:给自己发送sig信号
(3)abort函数:自己给自己发送6号信号,通常用于终止进程
如何理解系统调用接口呢?
用户调用系统接口,实际上是执行系统对应的调用代码,系统提取参数,或者设置特定的数值向目标进程写信号,修改对应进程的信号标记位,进程后续在处理信号。
1.2.3软件条件产生
SIGPIPE信号是一种由软件条件产生的信号,在管道进行通信时,读端不再进行读写并且关闭文件操作符后,此时操作系统会自动终止正在进行的写入端程序,而实现这一行为就是通过发送13号信号的方式。
我们可以通过下列代码进行测试:
void testPipeSig()
{
int pipefd[2]={0};
int n= pipe(pipefd);
pid_t id =fork();
if(id==0)//子进程
{
close(pipefd[0]);
string message="我是子进程,我正在给父进程发消息";
int count=0;
char send_buffer[1024];
while(true)
{
snprintf(send_buffer,sizeof(send_buffer),"%s[%d]:%d",message.c_str(),getpid(),count++);
ssize_t m=write(pipefd[1],send_buffer,strlen(send_buffer));
//if(count==3) break;
sleep(1);
}
close(pipefd[1]);
exit(105);
}
//父进程--读取
//关闭写入端
close(pipefd[1]);
char buffer[1024*8];
int count2=0;
while(1)
{
sleep(1);
ssize_t s= read(pipefd[0],buffer,sizeof(buffer)-1);
count2++;
if(count2==5)
{
cout<<"读端关闭"<<endl;
break;
}
if(s>0)//读取成功
{
buffer[s]=0;
cout<<"father get a message["<<getpid()<<"]"<<"Father#"<<buffer<<endl;
}
else
break;
}
close(pipefd[0]);
int status=0;
pid_t ret=waitpid(id,&status,0);
cout<<"子进程退出码: "<<((status>>8)&0xFF)<<" core dump: "<<((status>>7)&1)<<" 退出信号: "<< (status&0x7F)<<endl;
assert(ret>0);
(void)ret;
}
当父进程读取5秒后,读取端关闭,此时子进程的写入端也会关闭,并且接收到13号信号。
SIGALRM信号也是一种软件产生的信号,调用alarm函数就会产生该信号,alarm函数的功能类似于闹钟,当设定的时间到达时,函数将会触发,并向进程发送SIGALRM信号,该信号的默认处理方式是终止进程。
1.2.4硬件异常产生信号
硬件异常被硬件以某种方式检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
我们对SIGFPE信号进行自定义捕捉,此时会发现当进程出现除零错误时,自定义捕捉到信号后会一直出现循环。
void handler(int signum)
{
cout<<"捕捉信号: "<<signum<<" 进程pid: "<<getpid()<<endl;
}
void test()
{
signal(SIGFPE,handler);
int a=100;
a/=0;
while(true) sleep(1);
}
怎么理解除零错误?
我们执行计算时都是通过CPU这个硬件进行执行的,CPU内部存在状态寄存器,有对应的状态标记位,当出现除零错误时,溢出标记位就会被设置,OS会自动进行计算完毕后的检查,如果溢出标记位是1,OS识别到有问题,立即就会找到当前是哪个进程正在运行,进程会在合适的时机处理这个异常错误。通常情况下进程会将这种除零错误的处理方式定义为退出进程,即便不退出,也什么都做不了。当我们自己捕捉8号信号后,寄存器中的异常一直没有被处理,进程又在持续的执行,此时CPU只要一调度该进程,就会发现寄存器中出现异常,就会发送信号,从而实现了死循环。
怎么理解野指针或者越界问题?
我们通过地址访问内存或者指针,此时需要将虚拟地址转换成物理地址进行实际位置的访问,虚拟地址会通过页表和MMU(memory manager unit)进行转换,MMU是一个硬件,如果指针为野指针,或者存在越界行为,那么MMU在转化过程中就会出现报错,从而向系统发送信号。
所有的信号都有它的来源,但最终全部都是被OS识别,解释,并发送的。
2.信号的保存
前文主要介绍了信号的产生情况,当信号产生后,信号并不能被及时的处理,信号需要先保存在进程中,进程在合适的时机进行信号的处理工作。那么信号保存在哪里,是怎么保存的呢?
前文说信号是保存在进程的task_struct结构体中,使用的是位图结构。其实信号的存储并非是一个简单的“整形”结构。
我们先了解以下信号处理过程的概念:
(1)实际执行信号的处理动作称为信号递达(Delivery)
(2)信号从产生到递达之间的状态,称为信号未决(Pending)。
(3)进程可以选择阻塞 (Block )某个信号。
(4)被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
也就是说信号产生后并不是直接去执行对应的处理动作,而是会经过一些检测之后,满足条件才回去执行。
2.1信号的存储结构
实际上,task_struct结构体中对于信号的存储使用了三个结构,pending,handler,block。
其中pending和block是相同的数据结构,都是通过比特位来标记信号的状态。一个信号是否产生,pending位图中对应的位就会被设置为1,0。而block位图中则记录该信号是否处于阻塞状态,如果block位图中的位为1,那么代表该信号处于阻塞状态,那么信号产生后就不会被递达执行后续的处理动作。相反,如果位为0,就意味着信号不会被阻塞,信号产生后会执行相应的处理动作。
handler为一个函数指针数组,里面存储的就是对应下标位置信号的处理方式。在执行处理动作时,信号对应的处理方式会先进性强转检测,即 (int)handler[signal]==0 ?(int)handler[signal]==1?如果等于0则执行默认的处理动作,如果等于1则执行忽略动作。
因此一个信号被处理,首先OS会将信号写入到进程task_struct结构体中pending位图的对应位置,然后会去检测block位图中对应信号的位置是否被阻塞,如果阻塞则不进行信号递达,如果未阻塞则进行信号递达,并调用handler数组中信号的执行方式。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,Linux系统中对于常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。
2.2sigset_t类型和信号操作函数
那么存储信号的位图结构究竟是什么样的呢?
系统给我们提供的存储信号的数据类型为sigset_t,称之为信号集。这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
sigset_t类型我们可以直接使用该类型创建变量,但是不允许用户自己进行位操作,而是给我们提供了一系列的函数来进行操作。
头文件:#include<signal.h>
(1)int sigemptyset(sigset_t *set);
函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
(2)int sigfillset(sigset_t *set);
函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
(3)int sigaddset (sigset_t *set, int signo);
函数sigaddset添加某个信号到set所指向的信号集中,使其对应的bit置为1。
(4)int sigdelset(sigset_t *set, int signo);
函数sigdelset删除某个信号到set所指向的信号集中,使其对应的bit置为0。
这4个函数的返回值为成功返回0,失败返回-1。
(5)int sigismember(const sigset_t *set, int signo);
sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
注意:在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。
除了对sigset_t类型进行操作的函数外,还有获取当前进程信号集的函数sigpending
头文件:#include<signal.h>
函数:int sigpending(sigset_t *set);
参数:sigset_t *set,输出型参数,将当前进程的信号集写入到set中
返回值:调用成功返回0,出错返回-1。
读取或更改进程的信号屏蔽字(阻塞信号集)的函数sigprocmask
头文件:#include<signal.h>
函数:int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
参数:int how,可选参数,参数和对应功能如下
const sigset_t *set,我们希望添加到当前信号屏蔽字的信号。
sigset_t *oset,如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出
返回值:若成功则为0,若出错则为-1
注意:如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
我们并没有接口用来设置pending位图,信号的写入是操作系统执行的。
2.3信号的屏蔽测试
现在我们了解了信号能够被阻塞,能够被自定义捕捉,那么如果我们将所有的信号都进行自定义捕捉或者将所有的信号都屏蔽,那么进程是否就会被一直运行,而无法终止呢?
我们通过以下代码进行测试:
//将所有信号自定义捕捉
void handler(int signum)
{
cout<<"捕捉信号: "<<signum<<endl;
}
int main()
{
for(int i=1;i<32;i++)
{
signal(i,handler);
}
while(true) sleep(1);
return 0;
}
#发送1-31信号的脚本
#!/bin/bash
i=1
id=$(pidof mysignal)
while [ $i -le 31 ]
do
kill -$i $id
echo "kill -$i $id"
let i++
sleep 1
done
我们发现程序并没有一直运行,在发送9号信号后,程序被杀掉了!
下面将所有信号都进行屏蔽:
void blockSig(int sig)
{
sigset_t bset;
sigemptyset(&bset);//初始化
sigaddset(&bset,sig);//添加sig信号到信号集
int n = sigprocmask(SIG_BLOCK,&bset,nullptr);//将sig信号屏蔽
assert(n==0);
(void)n;
}
void showPending(sigset_t &pending)
{
for (int sig = 1; sig <= 31; sig++)
{
if (sigismember(&pending, sig))
std::cout << "1";
else
std::cout << "0";
}
std::cout << std::endl;
}
void testsig()
{
for(int i=1;i<32;i++)
{
blockSig(i);
}
sigset_t pending;
while(true)
{
sigpending(&pending);//获取当前进程信号集
showPending(pending);//打印信号集
sleep(1);
}
}
我们发现9号信号也不能被屏蔽,现在跳过9号信号的发送,继续观测。
i=1
id=$(pidof mysignal)
while [ $i -le 31 ]
do
if [ $i -eq 9 ];then
let i++
continue
fi
kill -$i $id
echo "kill -$i $id"
let i++
sleep 1
done
19号信号发送后,信号无法被屏蔽,进程并没有被杀死而是处于暂停状态。不仅如此,20信号也无法被屏蔽。
从这里我们就看出,系统管理者设置了一些信号是无法被屏蔽或者无法被捕捉的,这些信号可以杀掉或停止进程。9号信号无法捕捉无法屏蔽。
3.信号的捕捉
信号产生之后,信号并不会被立即执行,而是在合适的时候,那么信号究竟在什么时候进行处理呢?
3.1信号的处理时机
由于信号的相关数据字段是存储在进程的PCB结构中,而进程的PCB结构属于内核范畴,如果想要访问内核数据,那我们就得从用户态切换到内核态。因此,信号实际上是在内核态中进行处理,在内核态返回用户态时,进行信号的处理和检测。而我们之所以会进入到内核态,实际上是因为我们进行了系统调用,或者出现异常等错误时才会进入。
用户态是一个受管控的状态,内核态是一个操作系统执行自己代码的状态,具备非常高的优先级。那么一个进程是如何跳转进入操作系统呢?
实际上,每个进程都有自己独立的进程地址空间,其中1~3G是用户级空间,3~4G则是内核级空间。用户级空间上的数据会通过每个进程自己的用户级页表映射到物理内存中。而操作系统只有一份,因此每个进程共享一份内核级页表,将物理空间上存储的OS数据映射到进程的内核空间中,此时进程想要访问OS的数据,子需要跳转到自己虚拟地址空间的内核空间中找到对应的数据然后通过内核级页表进行映射访问即可。所以说,内核也是在所有进程的地址空间上下文中运行的。
但是我们凭什么能够访问OS的代码呢?这就需要确保我们是否具备权力,CPU中存在一套寄存器CR3,CR3表示当前CPU的执行权限,即内核态,用户态。当我们调用open函数时,open函数内部会存在中断80命令,将CR3的用户态更改为内核态,然后open跳转到进程的内核空间中,通过内核级页表进行映射,执行对应的OS代码。
内核的代码数据执行完毕,需要切回用户态。当从内核态切换成用户态时,操作系统会顺手处理信号,所以信号的处理过程如下所示:
内核态切换为用户态时,会先进行信号的检测,查看pending中是否存在未被处理的信号,如果存在信号,那么就去查看block中信号是否被阻塞,如果信号为阻塞状态,那么就不会被递达,从而直接返回用户态,执行后续代码。如果此时block中信号未被阻塞,那么此时会去调用handler表中信号对应的处理方式,默认处理方式一般是终止进程,会去调用相应的终止逻辑,忽略处理方式是将pending中信号位置为0。如果是用户自定义的捕捉方式,此时会切换状态,切换为用户态去调用信号处理方法进行处理,处理结束后,会再次切换状态,切换为内核态回到OS中,找到代码执行流被打断的地方进行进行恢复,恢复到上下文时,内核态会再次切换为用户态。
在内核态中OS也能去调用用户层的处理方法,但是操作系统为了确保安全不会去调用。经过4次的状态切换,最终OS处理完了进程中的信号。
3.2sigaction函数
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回 -1。sigaction函数含有3个参数,其函数原型如下:
头文件:#include<signal.h>
函数:int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
返回值:调用成功则返回0,出错则返回- 1
signo参数时指定信号的编号,即要处理的是哪个信号。act和oact是struct sigaction类型的变量,若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。
sigaction结构体如下图所示:
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,通常将其sa_flags设为0,sa_sigaction是实时信号的处理函数。
4.可重入函数与volatile关键字
函数重入的问题也是信号处理过程中经常出现的一种bug。例如下面的例子,main函数想将node1节点头插到链表中,因此调用了insert函数,头插过程分为两步,首先将p->next=head,然后再将head=p。可是在执行完第一步后,因为某些系统调用,进程切入到了内核态。执行完系统调用后,再切回用户态时会进行信号的处理,如果此时有信号产生,系统会切换成用户态执行信号的处理函数。在信号处理函数中,我们再次调用了头插函数,将node2进行头插。信号处理完成后,会切回内核态,并从内核态切到用户态回到切走时的数据段中。此时第一次调用的insert函数将会执行第二步操作,将head指向node1。
经过此番操作我们发现,node2丢失了,造成了内存泄漏的问题,这就是函数重入所引发的问题。而insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
如果一个函数符合以下条件之一则是不可重入的:
(1)调用了malloc或free,因为malloc也是用全局链表来管理堆的。
(2)调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
不仅如此,一些编译器不同的优化机制也会带来某些全局变量的错误。当编译器不带-O2的优化选项时,flag在发送2号信号后,处理之后就会变为1,进程终止。优化后,由于flag仅仅作为判断使用,频繁的访问内存会降低效率,因此会被放入寄存器中,此后flag在更改,内存中变化了,寄存器中并没有。所以会出现死循环。
int flag=0;
void handler(int sig)
{
cout<<"flag:"<<flag;
flag = 1;
cout<<" -> "<<flag<<endl;
}
int main()
{
signal(2, handler);
while(!flag)
{}
printf("进程结束\n");
return 0;
}
解决这种问题就得需要volatile关键字修饰,volatile 作用是保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量 的任何操作,都必须在真实的内存中进行操作。
增加之后的现象:
5.SIGCHLD信号
SIGCHLD信号是子进程在退出时向父进程发送的信号。进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
实际上子进程退出时并不是什么都不做,而是会向父进程发送SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
下图为子进程退出时向父进程发送的信号,我们没有处理子进程,子进程依旧是僵尸进程。
在捕捉信号后进行进程等待工作,就不会产生僵尸进程:
void handler(int signum)
{
cout<<"子进程退出: "<<signum<<"father: "<<getpid()<<endl;
waitpid(-1,NULL,WNOHANG);//等待子进程
}
void test()
{
signal(SIGCHLD,handler);
if(fork()==0)
{
//子进程
int count=0;
while(true)
{
count++;
cout<<"子进程在运行"<<endl;
sleep(1);
if(count==10)
break;
}
cout<<"子进程退出"<<endl;
exit(0);
}
while(true)
{
cout<<"父进程在运行"<<endl;
sleep(1);
}
}
实际上Linux系统中要想不产生僵尸进程,我们可以将SIGCHLD信号的处理方式手动设置为忽略(SIG_IGN)此时进程退出后会自动清理,不会参数僵尸进程,也不会通知父进程。虽然SIGCHLD信号系统默认的就是忽略处理形式,但是并不会自己释放僵尸进程,但是我们主动设置后就可以释放进程。
void test()
{
signal(SIGCHLD,SIG_IGN);//主动设置
if(fork()==0)
{
//子进程
int count=0;
while(true)
{
count++;
cout<<"子进程在运行"<<endl;
sleep(1);
if(count==10)
break;
}
cout<<"子进程退出"<<endl;
exit(0);
}
while(true)
{
cout<<"父进程在运行"<<endl;
sleep(1);
}
}