一:信号简介
(1)CTRL+C
我们都知道,按下ctrl+c
后将会结束当前运行的一个前台进程,当我们按下ctrl+c后,会被操作系统获取,而这个动作或者这个快捷键组合已经被赋予了结束进程的含义(就像你从小就被告知看见红灯就要停下来),它被解释为信号,发送给目标前台进程,而进程由于收到了信号所以退出
其中2号信号KILLINT发挥的作用就是ctrl+c
,如下可以一个进程不断地向屏幕打印文字,然后使用2号信号结束
(2)注意
ctrl+c
产生的信号只能发送给前台进程,一个命令后面加入&
可以将进程放在后台运行,这样shell就不必等待进程结束就可以接受新的命令从而启动新的进程- shell可以同时运行一个前台进程和多个后台进程,但是只有前台进程才能接收到类似于
ctrl+c
这样的控制键产生的信号 - 前台进程在运行过程中用户随时可能按下
ctrl+c
而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能受到SIGINT
信号而终止,所以信号相对于进程的控制流是异步的
(3)信号列表
使用kill -l命令可以查看系统定义的信号列表
每一个信号都有一个编号和一个宏定义名称,编号34及以上的属于实时信号,我们在这里只讨论的是1-31的非实时信号
(4)处理信号-signal函数
进程获得一个信号后,有如下三种处理方式
- 忽略此信号(
SIGIGN
) - 执行该信号的默认动作(
SIG_DFL
) - 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉一个信号
signal函数是用来让进程处理信号的,它的函数原型是(大家先不要管它的返回值)
sighandler_t signal(int signum,sighandler_t handler)
其中第一个参数就是我们要处理的信号(可以输入编号或者名称);第二个参数就是我们处理的方式,处理的方式分别就对应上面的三条
举例1:ctrl+c
对应的是2号信号,如果使用signal函数,第一个参数设置为2,表示处理2号信号,第二个参数设置为SIG_IGN
,然后在signal
函数后面循环打印文字
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
int main()
{
signal(2,SIG_IGN);//忽略此信号
while(1)
{
printf("I'm running now!\n");
sleep(1);
}
return 0;
}
由于我们动作是忽略2号信号,所以大家可以发现即便我疯狂的按下ctrl+c
,也无法终端进程(按下ctrl+\
结束进程)
举例2: 如果将第二个参数设置为SIG_DFL
呢,那么它就表示默认,这个就相当于不写这个函数一样,所以按下Ctrl+c
就能正常终止,这里就不演示了
举例3: 除了前两种处理方式外,我们说过第三种方式就是用户自定义一个函数,称这种方式为捕捉到一个信号,当捕捉到信号后,进程就会执行你所定义的函数的内容(所以第二个参数是一个函数指针)
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int sig)
{
printf("catch a sin : %d\n",sig);
}
int main()
{
signal(2,handler);//一旦捕捉到2号信号,将会执行handler函数内的操作
while(1)
{
printf("I Am runnng now...\n");
sleep(1);
}
return 0;
}
效果如下,大家可以看到,一旦信号被捕捉后,将会执行所指定函数内的内容,所以这里的2号信号被捕捉后也无法结束进程了。其中9号进程无法被捕捉,因为9号进程一旦被捕捉,操作系统就无法终止一些恶意进程了
在这里我们就可以具体说一说这个函数的形参,返回值了,因为它的真正的原型是这样子的,是不是感觉很懵?
typedef void(*sighandler_t)(int);
sighandler_t signal(int signum,sighandler_t handler);
我们首先要明白的就是这里typedef
的作用,typedef的作用实则就是定义一个新类型,比如说typedef int Myint
,这样的话我定义整形就可以用Myint a=10
了,那么在这里先不要看typedef,先看后面的,void(*sighandler)(int)这个明显是一个函数指针,sighandler指向一个一个形参为int返回值为void的函数,当加上typedef后,sighandler_t就是一个新的类型,就可以像int一样用它,只不过int声明的是一个整形变量,而sighandler声明的是一个函数指针,而且这个函数指针指向的函数接收一个整型参数并返回一个void。
你可能会问,这样的写法有什么作用呢?其实如果不这样写,那么你看见的signal函数将会是下面的这样子
void (*signal(int signum,void (*handler)(int)))(int);
是不是感觉更加不懂了。所以现在我们去剖析一下这个函数,想要弄清楚,你必须明白下面的两个简单的例子
void (*p)(int)
:这个肯定很简单,自然是一个函数指针p,p指向的函数是一个形参为int,且返回值为void的函数void (*fun())(int)
:这里,fun是一个函数,所以可以看成是fun这个函数执行完毕之后,它的返回值是一个函数指针,指向了一个形参为int,返回值为void的函数
了解完毕后,就可以结合上面的例子解释一下它的运行过程了
二:产生信号
为了方便后面的讲解,我们先要了解一下如何捕捉信号,使用signal
函数可以捕捉到发送给这个进程的信号,该函数第一个形参设定要捕捉的信号,第二个形参实则是一个函数指针,指向了一个函数,表明当捕捉到要捕捉的信号时,所要进行的操作
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int sig)
{
printf("catch a sin : %d\n",sig);
}
int main()
{
signal(2,handler);//一旦捕捉到2号信号,将会执行handler函数内的操作
while(1)
{
printf("I Am runnng now...\n");
sleep(1);
}
return 0;
}
效果如下,大家可以看到,一旦信号被捕捉后,将会执行所指定函数内的内容,所以这里的2号信号被捕捉后也无法结束进程了。其中9号进程无法被捕捉,因为9号进程一旦被捕捉,操作系统就无法终止一些恶意进程了
(1)通过按键产生信号-Core Dump
通过按键产生信号,这里就不多强调了。本节重点介绍一下Core Dump
Core Dump是什么?在VS中如果我们写上这样的代码,那肯定会报出相应的错误的
int main()
{
int arr[10]={0};
for(int i=0;i<=100;i++)
{
arr[i]=i;
}
printf("运行到了这里\n");
}
但是在Linux中不是图形化界面,如果要给出这样的报错信息的话,就需要到Core Dump。一旦程序异常终止后,就会在磁盘上转存一份以core开头的文件,后面跟上的数字是此进程的pid。
如下,这个进程运行时出现了发生了段错误
但是这里没有相应的文件,是因为core file size
被设置为了0(使用ulimit -a
查看)
可以使用ulimit -c设置core的大小
再次运行后,出现了相应的文件
这个文件就记录了出错的信息,有了这样的文件,使用gdb调试的时候,键入core-file
【那个文件名】后,就可以列出十分详细的错误信息
(2)调用系统函数向进程发送信号
A:kill
我们使用到的最多也就是kill 命令了,kill命令是调用kill函数实现的,kill函数可以给一个指定的进程发送指定的信号
#include <signal.h>
int kill(pid_t pid,int signo);
B:raise
#include <signal.h>
int raise(int signo);
raise函数可以给当前进程发送指定的信号
int main(int argc,char* argv[])
{
if(argc==2)//保证传入参数正确
{
raise(atoi(argv[1]));//将信号值传入
}
while(1)
{
printf("I Am runnng now...\n");
sleep(1);
}
return 0;
}
C:abort
abort函数使进程接收到信号而异常终止,和exit函数一样,abort函数总能被调用成功
#include <stdlib.h>
void abort(void);
abort对应的信号就是SIGABRT,编号是6
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
void handler(int sig)
{
printf("catch a sin : %d\n",sig);
}
int main(int argc,char* argv[])
{
signal(6,handler);//捕捉6号信号
abort();//异常终止
while(1)
{
printf("I Am runnng now...\n");
sleep(1);
}
return 0;
}
(3)由软件条件产生信号
前面咋们在讲管道的特性四时说到过:如果读端关闭文件描述符,那么写端就会被操作系统终结掉,因为操作系统发现此时的写端是一个无用,浪费资源的进程,并且发送的型号是SIGPIPE,这其实就是一种由软件条件产生的信号。这种类型信号不同于之前的硬件,系统调用接口那种方式,而是一种满足了一定条件就发出信号,就像水满了就溢出的感觉
今天主要介绍alarm
函数和SIGALRM
信号,它的作用是设定一个时间进行倒计时,当倒计时完成之后结束进程
比如下面的死循环中,首先设定alarm(1),这表示进程进行1s就终止,然后再while循环中不断打印
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
int cout=0;
void handler(int sig)
{
printf("catch a sin : %d\n",sig);
printf("cout:%d\n",cout);
exit(1);
}
int main()
{
alarm(1);//1s后结束
while(1)
{
cout++;
printf("cout:%d\n",cout);
}
return 0;
}
可以发现1s后,被SIGALRM信号给终止
(4)硬件异常产生信号
除0,空指针,野指针这些操作是不被允许的,所以操作系统一旦监测这样的进程,就会发送响应的信号终止
比如说空指针
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
void handler(int sig)
{
printf("catch a sin : %d\n",sig);
exit(1);
}
int main()
{
signal(11,handler);
sleep(1);
int* p=NULL;
*p=10;//错误
return 0;
}
其实在C++中使用到的捕获异常,在系统层面,就是在处理信号
总结:
1:从上面的描述中大家可以看到,不管是哪一种产生信号的方式,背后总有一个角色在默默支撑着,信号的发送依靠的是操作系统,因为操作系统是进程的管理者
2:以core dump为例,大家可能也看到了,即便发生了段错误,但是后面的语句也被执行了,这表明进程在接受到信号时并不一定会立即处理,中间可能会存在一定的空档期
3:操作系统给进程发送信号,其中发送二字显得尤为专业,总让感觉这一定是一个很复杂的过程。实则不然,与其说发送,倒不如用写入二字更为贴切。每个进程都有其task_struct
,所以可以在它里面搞一个位图,32位(就像文件系统中的inode
和bitmap
),一旦操作系统发送某个信号,就把某一位置为1,比如发送8号信号,就把它的第八位的0置1
三:阻塞信号
前面说过,操作系统发出信号之后,对于进程有可能不是立马就处理的,所以如果不是立即处理,那么在这个空档期间进程究竟对信号做了怎样的处理呢?
(1)信号相关术语
为了表示清楚,这里总结关于信号的一些术语
- 递达(Delivery):进程执行信号的处理动作
- 信号未决(Pending):信号从产生到递达之间的状态
- 阻塞(Block):进程可以选择对信号进行阻塞,被阻塞的信号产生时将保持在未决状态,知道进程解除对此信号的阻塞,才会执行递达动作
需要注意区分阻塞和忽略,递达有三种可选动作——忽略,执行默认,自定义捕捉,所以忽略是也就是递达了,但是阻塞是保持在了未决
(2)信号在内核中的表示
我们一切的叙述都是围绕进程来展开的,管理进程对应的数据结构式task_struct
,而task_struct
中又会涉及到各种各样的结构(比如之前的files struct
)。
前面说过操作系统对进程发送信号的动作实际应该表述为写入,其实每个信号都有两个标志位分别表示阻塞和未决,还有一个函数指针来表示处理的动作,如下
大家可以看到,从1号信号(SIGHUP
)开始,每个信号都对应block
位图,pending
位图和handler
数组(它是一个函数指针数组,每个函数指针指向一个函数,表示处理的动作)的一位或下标。一旦信号产生,操作系统会在task_struct
中设置该信号的未决状态,也就是说如果操作系统给某个进程发送了3号信号,那么就会把这个进程的task_struct
中的pending
位图中的第三个位置为1,此时信号处于未决状态还没有递达,一旦信号递达,操作系统就会将该标志位置为0。而block
位图中,一旦把第n位设置为了1,表示n号信号被阻塞,需要注意的是无论这个信号是否产生,它都可以被阻塞。
SIGHUP
信号产生过,也未被阻塞,所以当此信号递达时将会执行默认动作SIGINT
信号产生过,但是正在被阻塞,所以暂时无法递达,它的处理动作是忽略,但是在没有接触阻塞之前是不能忽略这个信号的,因为进程仍然有机会改变处理动作再接触阻塞SIGQUIT
信号没有产生过,一旦产生就会被阻塞,它的处理动作是用户自定义的一个函数。
四:信号集操作函数
(1)sigset_t
前面说过,未决和阻塞分别用位图来表示,于是我们把保存位图这样的数据类型称为sigset_t
,sigset_t
称为信号集,于是他们分别称为阻塞信号集和未决信号集
sigset_t
这种类型可以表示每个信号的有效和无效的状态(阻塞信号集的有效和无效的含义是该信号是否被阻塞,未决信号集则是该信号是否处于未决状态),其中阻塞信号集也叫做当前进程的信号屏蔽字(SignaL Mask
)
(2)信号集操作函数
sigset既然是一个保存位图的数据类型,那么是否直接修改它对应数据的比特位就能达到屏蔽信号,产生信号的目的呢?答案是可以的,但是由于这个类型内部如何存储这些位图要依赖于系统实现,简单来说不同平台的存储方式是不一样的,所以我们不能直接操作比特位,我们只能调用一下函数来操作sigset_t
变量
(注意以下函数仅在操作变量,它并没有深入到内核中改变对应的位图,就像ftok函数生成key的作用一样)
#include <signal.h>
int sigemptyset(sigset_t* set);
int sigfillset(sigset_t* set);
//注意,使用sigset_t类型的变量前,一定使用他们其中的一个做初始化,
//让信号集处于一种确定的状态
int sigaddset(sigset_t* set,int signo)
int sigdelset(sigset_t* set,int signo)
int sigismember(const sigset_t* set,int signo)
int sigpending(sigset_t* set);
sigemptyset
是初始化set所指向的信号集,所有比特位清零sigfillset
是初始化set所指向的信号集,所有比特位置为1sigaddset
是对set所指信号集添加信号signosigdelset
是从set所指信号集中删除信号signosigismember
用于判断一个信号集的有效信号是否包含signo这个信号sigpending
用于读取当前进程的未决信号集
所以结合上述例子,我们就可以写下面这样一段小程序,查看一下未决信号集,
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void print_pending(sigset_t* pending)
{
int i=1;
for(i=1;i<=31;i++)
{
if(sigismember(pending,i))
{
printf("1");//只要i信号存在,就打印1
}
else
{
printf("0");//不存在这个信号就打印0
}
}
printf("\n");
}
int main()
{
sigset_t pending;//定义信号集变量
while(1)
{
sigemptyset(&pending);//初始化信号集
sigpending(&pending);//读取未决信号集,传入pending
print_pending(&pending);//定义一个函数,打印未决信号集
sleep(1);
}
}
运行效果如下,由于没有传入信号,所以未决信号集全部为0
那么下一个问题就是用户如何去控制阻塞信号集(信号屏蔽字),为什么不控制未决信号集呢?因为产生信号无非就是咋们上面说过的4种方式之一嘛。
前面说过你不能直接操作比特位,所以我们要使用到一个函数:sigprocmask
#include <signal.h>
int sigprocmask(int how,const sigset_t* set,sigset_t* oset);
参数how指示了如何更改,当选定参数how后,set的意思如下
how | set |
---|---|
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号 |
SIG_UNBLOCK | set包含了我们希望添加到当前信号屏蔽字中解除阻塞的信号 |
SIG_SETMASK(常用) | 设置当前信号屏蔽字为set所指的值 |
参数oset如果不设置为NULL,由于此函数会更改当前的信号屏蔽字,所以会在更改之前将此时的信号屏蔽字备份一份到oset所指的变量中
所以现在在上面的例子的基础上,添加block
和oblock
变量,再使用sigaddset
函数添加2号信号11号信号,此时如果发送2号信号和11号信号,就会将信号添加到阻塞信号集也就是block中,然后使用sigprocmask
将屏蔽关键字设置为block
的值,并将原先的屏蔽关键字备份到oblock
中。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void print_pending(sigset_t* pending)
{
int i=1;
for(i=1;i<=31;i++)
{
if(sigismember(pending,i))
{
printf("1");//只要i信号存在,就打印1
}
else
{
printf("0");//不存在这个信号就打印0
}
}
printf("\n");
}
int main()
{
sigset_t pending;//定义信号集变量
sigset_t block,oblock;//定义阻塞信号集变量
sigemptyset(&block);
sigemptyset(&oblock);//初始化阻塞信号集
sigaddset(&block,2);//将2号信号添加的信号集
sigaddset(&block,11);
sigprocmask(SIG_SETMASK,&block,&oblock);//设置屏蔽关键字
while(1)
{
sigemptyset(&pending);//初始化信号集
sigpending(&pending);//读取未决信号集,传入pending
print_pending(&pending);//定义一个函数,打印未决信号集
sleep(1);
}
}
效果如下,大家可以发现当我发送2号信号时(之前发送2号信号进程立即终止,是因为没有阻塞它)而现在发送2号信号大家可以看到未决信号集对应的第2号位置变为了1,进程也没有终止,因为此时在阻塞状态,没有递达
可以发现由于信号一直被阻塞,没有递达,所以本该结束进程的命令也将“失效”。要使其生效,就必须接触阻塞状态,所以再次修改上面的案例,由于之前用oblock备份了屏蔽关键字(都是0),所以在10s之后,用该函数再次设置屏蔽关键字为oblock,以此接触阻塞,由于接触阻塞后,进程会终止,所以这里用signal
捕捉信号,以免结束进程,便于观察
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int sig)
{
printf("获得信号:%d\n",sig);
}
void print_pending(sigset_t* pending)
{
int i=1;
for(i=1;i<=31;i++)
{
if(sigismember(pending,i))
{
printf("1");//只要i信号存在,就打印1
}
else
{
printf("0");//不存在这个信号就打印0
}
}
printf("\n");
}
int main()
{
signal(2,handler);//捕捉
sigset_t pending;//定义信号集变量
sigset_t block,oblock;//定义阻塞信号集变量
sigemptyset(&block);
sigemptyset(&oblock);//初始化阻塞信号集
sigaddset(&block,2);//将2号信号添加的信号集
sigprocmask(SIG_SETMASK,&block,&oblock);//设置屏蔽关键字
int cout=0;
while(1)
{
sigemptyset(&pending);//初始化信号集
sigpending(&pending);//读取未决信号集,传入pending
print_pending(&pending);//定义一个函数,打印未决信号集
sleep(1);
cout++;
if(cout==10)//10s后解除阻塞
{
printf("解除阻塞\n");
sigprocmask(SIG_SETMASK,&oblock,NULL);
}
}
}
效果如下
五:信号的捕捉过程
(1)用户态和内核态
我们说过,每个Linux进程有4GB的地址空间
其中0-3G是用户空间,由用户页表负责映射到物理内存,剩余的1G存放的是内核及其维护的数据,由内核页表负责映射。
一个非常简单的C语言程序如下
#include <stdio.h>
int main()
{
int a=10;
printf("%d\n",a);
return 0;
}
这段程序中有一个printf函数作用是向屏幕打印内容,根据对操作系统理解,我们知道printf底层肯定是调用了系统调用接口write完成了对应的功能
也就是说这段程序中像int a=10
这种语句是属于进程的,也就是属于用户的,而执行printf的时候却需要深入到内核完成。
内核态
当一个进程执行系统调用(比如上面的printf,本质是系统调用)而陷入内核代码中运行时,我们就称为该进程处于内核运行状态。
用户态
当进程执行用户自己的代码时(比如上面的int a=10
),则称其处于用户状态
用户态核内核态下工作的程序有很多的差别,但是最主要的差别就在于其权限的不同,运行在内核态下的程序可以直接方位用户态的代码和数据(但是禁止这样做)
所以这样的一个简单的程序反映的却是进程在用户态和内核态中的不断切换的过程
(2)用户态和内核态的切换
当在系统中执行一个程序时,大部分时间都是运行在用户态下的,在需要操作系统帮助完成一些用户态没有能力完成的操作时就会切换到内核态(比如printf函数)
用户态切换到内核态主要有三种方式
- 系统调用:除了前面的printf外,我们之前说过的fork()函数也是一个典型的例子
- 异常:当CPU在执行运行在用户态下的程序时,,发生了一些没有预知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关进程中
- 外围设备的终端:这个其实就是咋们上面说过的信号
(3)内核是如何实现信号的捕捉
信号是什么时候处理,以及什么时候被捕捉的呢?是在从内核态切换到用户态的时候
- 首先在用户态的程序由于中断,系统调用或者是异常将会进入内核态
- 进入内核态,处理完需求,准备返回用户态
- 在返回用户态之前检测信号。如果没有信号继续返回;如果有信号,但是被阻塞了,由于无法处理,所以也返回;最后就是有信号,但是没有被阻塞,那么信号就应该递达。当递达后如果是默认动作,那么一般就是终止进程,而此时在内核态它是有权利终止进程的,如果是忽略,那么不要忘记将pending对应位置置为0,最后一种就是自定义的函数了
- 如果是自定义函数,将会再次返回用户态,处理完成之后,借助相关系统调用再次进入内核
- 最后再通过返回到上次中断的地方继续向下执行
所以上述过程可以用下面的这一张图记忆
如果信号的处理动作是用户自定义函数,所以内核决定返回用户态时就会去指定自定义函数,而这个自定义函数和原先的main函数使用的是不同的堆栈空间,所以他们之间不存在调用和被调用的关系,是两个独立的控制流程,而一个进程可以有多个控制流程——线程
至此我们可以解释上面Core Dump中为什么下标已经越界了,但是后面的代码还是可以输出
int main()
{
int arr[10]={0};
for(int i=0;i<=100;i++)
{
arr[i]=i;
}
printf("运行到了这里\n");
}
这是因为信号是在从内核态切换到用户态的时候处理的,所以上面的for循环的代码始终运行在用户态,当涉及到printf的时候,将会陷入内核态完成调用,然后返回时检测信号,此时检测到了11号信号,所以进行处理
(4)sigaction
sigaction
这个函数和signal
作用基本一致,但是前者功能要比后者丰富一点
#include <signal.h>
int sigaction(int signo,const struct sigaction* act,struct sigaction* oact);
其中act
和oact
分贝是struct sigaction
的结构体指针,act
表示捕捉后你做的操作,oact
是备份一下捕捉前的操作
其中struct sigaction
结构体如下
struct sigaction
{
void (*sa_handler)(int);//捕捉后你要做的操作
void (*sa_sigaction)(int,siginfo*,void*);//处理实时信号
sigset_t sa_mask;//设置为0
int sa_flags;//设置为0
void (*sa_restorer)(void)//处理实时信号
}
- sa_mask的作用:大家可以想象这样一个场景,上节展示的捕捉过程中,如果处理完2号信号的自定义捕捉动作时,再来了一个2号信号怎么办,这样就会导致无法从内核态返回至用户态,会造成很严重的后果,于是sa_mask也是一个信号集,表示当你在处理某个信号时想要把哪些信号加入屏蔽。在默认状态下,当前处理的信号是默认加入进去的,也就是操作系统可以处理很多信号的自定义捕捉动作,但是每一个信号的自定义捕捉动作一次只能处理一个
六:其他概念
(1)可重入函数
如下是一个不带头结点的单链表的头插操作
- 上述过程是这样的:main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件终端使进程切换到内核态,再次返回用户态时做信号检测,由于是自定义用户操作,所以切换到sighandler函数,而sinhanderl函数也调用了insert函数向同一个链表中插入了结点node2,插入操作的两步都做完之后从sighandler返回至内核态,接着再次返回用户态就从main函数调用的insert函数中继续向下执行,完成了之前由于中断而未完成的第二步。结果就是最终只有一个有效的结点插入链表中了,剩余的一个结点由于没有指针指向,造成了内存泄漏
像上面这样,iinsert函数被不同的控制流程调用,有可能在第一次调用还没有返回的时候就再次进入了该函数,我们称之为重入。很明显,insert函数是不可以被重入的,因为会造成混乱,所以像insert这样的函数称之为不可重入函数。
(2)volatile关键字
A:背景知识
由于内存的访问速度远不及CPU的处理速度,所以为了提高机器的整体性能,在硬件上引入了高速缓冲Cache,加速对内存的访问。除了硬件级别的优化外,编译器也通常会进行优化,比如说将内存变量缓冲到寄存器或调整指令顺序充分利用CPU指令流水线
B:产生的问题
其实这样的优化会产生一定的问题,因为访问寄存器要比访问内存单元快的多,所以编译器一般都会作减少存取内存的优化,也就是读取数据时更加倾向于读取寄存器中的数据,这就有可能读取到脏数据
结合前面信号所讲过的相关知识可以做一个案例:
在下面的这段代码中,首先定义一个全局变量flag
并初始化为0,接着在main函数中捕捉2号信号,一旦发送2号信号就执行自定义的捕捉动作——将flag
设置为1,signal
函数下面是一个while
循环,在默认状态下,while
将会不断检测flag
的值,如果此时编译器不做任何优化,那么flag
默认处于内存中,那么while
将会不断读取内存中flag
的值做判断。
#include <stdio.h>
#include <signal.h>
int flag=0;
void handler(int sig)
{
flag=1;
printf("flag被设置为了1\n");
}
int main()
{
signal(2,handler);
while(!flag);
printf("程序运行到了这里\n");
}
效果如下,由于发送了2号信号,于是flag
被设置为1,因此while
循环没有成为死循环,最后的一条语句也被成功打印了,这的确是我们预料的结果
上述编译过程中编译器其实没有做优化,而gcc其实可以携带一定的优化选项,也就是-O
选项,分别是-O0
,-O1
,-O2
,-O3
和-Os
。所以这一次我们让编译器进行优化,因为前面我们说过编译器一旦进行优化,将会把变量放在寄存器里面
于是效果如下,大家可以发现这里似乎产生了一定的矛盾:flag
的值已经被设置为了1,按理说while
循环一旦对flag
取反,肯定是不会产生死循环的,但实际情况是它还在死循环当中
C:volatile关键字
其实上面矛盾的现象反映也正是volatile的关键字,volatile将保持内存的关键字,一个变量一旦被volatile修饰,那么系统总是会从内存中读取数据,而不是从寄存器 。因此刚才那个例子中,由于编译器做了一定的优化,将flag
放置到了寄存器中,同时因为这是两个不同的执行流程,而handler
函数修改时修改的仍然是内存中的那个数据,这样就导致内存中的flag
已经为1了,但是while
循环读取的确实寄存器中的数据,而寄存器里的flag
值仍然是0,因此会造成死循环
所以既然编译器已经做了优化,而我们又要达到正常的效果,就可以使用volatile修饰flag
,这样while
读取flag
时将会被强制从内存中读取
#include <stdio.h>
#include <signal.h>
volatile int flag=0;
void handler(int sig)
{
flag=1;
printf("flag被设置为了1\n");
}
int main()
{
signal(2,handler);
while(!flag);
printf("程序运行到了这里\n");
}
(3)SIGHLD信号
A:复习僵尸进程
在linux进程那一节我们提及了僵尸进程这个概念。僵尸进程就是子进程已经退出了,父进程还在运行当中,由于父进程没有读取到子进程的状态,所以子进程就会进入僵尸状态
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
// printf("还没执行fork函数时的本进程为:%d\n",getpid());
pid_t ret=fork();//其返回值类型是pid_t型的
sleep(1);
if(ret>0)//父进程返回的是子进程ID
{
while(1)
{
printf("----------------------------------------------------\n");
printf("父进程一直在运行\n");
sleep(1);
}
}
else if(ret==0)//子进程fork返回是0
{
int count=0;
while(count<=10)
{
printf("子进程已经运行了%d秒\n",count+=1);
sleep(1);
}
exit(0);//让子进程运行10s
}
else
printf("进程创建失败\n");
sleep(1);
return 0;
}
B:清理僵尸状态的新方法-SIGCHLD
在Linux进程控制那一节我们讲了可以使用wait和waitpid清理僵尸进程。而且父进程可以以阻塞或非阻塞的方式等待子进程结束,但是这两种方式都有一个很大的缺点就是:父进程除了忙于自己的工作外,还要时不时的关心子进程怎么样了,尤其在子进程的返回状态不是那么重要的情况下,这样的操作就显得有点多余了
其实,子进程在终止时会给父进程发送SIGCHLD信号,该信号的默认动作是忽略,当然父进程也可以自定义SIGCHLD信号的处理函数,这样父进程就只需要关心自己的工作,当子进程终止时,会向父进程发送信号,而父进程则利用信号捕捉自动处理子进程
于是上面的案例中在父进程中加入
signal(SIGCHLD,SIG_IGN);//忽略