在讨论进程间的信号之前,我们可以先讨论一下出现在生活中的信号。
闹铃,红绿灯,钓鱼的浮标甚至老师的脸色都是信号,而我们面对信号的处理方式是多样的,当信号一出现我们立马执行行动,或是先记住信号然后在合适的时间去执行相应的活动,抑或是对信号置之不理。
但是不管如何,信号所对应的行为都已经提前被我们记录在大脑中,比如听到闹钟就起床,红灯停绿灯行,浮标晃动说明鱼已上勾,看到老师的脸色不在课堂上说话等等。等待信号的过程,于你而言是异步的,在此之前你完全可以做自己的事情。
信号是为了让进程具有处理突发事件的能力
为理解信号,先从我们熟悉的场景说起:
[sjl@VM-16-6-centos signal]$ cat signal.c
#include
int main()
{
while(1)
{
printf("i am waiting\n");
sleep(1);
}
return 0;
}
[sjl@VM-16-6-centos signal]$ ./signal
i am waiting
i am waiting
i am waiting
i am waiting
^C
[sjl@VM-16-6-centos signal]$
⚠ 注意:ctrl
+c
/z
/\
所产生的信号只能发给前台,而一个命令的结尾加上 &
可以放到后台运行,这样shell就不必等待进程结束就可以接收新的命令,启动新进程。
shell可以同时运行一个前台程序和多个后台程序,只有前台进程才能接受像ctrl+c这种键盘产生的信号。前台进程运行中用户随时可能按下ctrl+c而产生一个信号,所以信号相对于进程的控制来说是异步(Asynchronous)的。
使用 jobs
指令查看后台的所有进程,再使用 fg+任务号
让其运行成为前台进程。(如果后台只有一个进程可以省略任务号),bg+任务号
命令用于将后台暂停的作业开始运行,使前台可以执行其他任务。该命令的运行效果与在指令后面添加符号&的效果是相同的,都是将其放到系统后台执行。
kill -l
命令查看系统定义信号列表每个信号都有一个编号和宏定义名称,编号从1开始到31为普通信号,
信号是如何记录的?
实际上,当一个进程接收到某种信号后,该信号是被记录在该进程的进程控制块当中的。我们都知道进程控制块本质上就是一个结构体变量,而对于信号来说我们主要就是记录某种信号是否产生,因此,我们可以用一个32位的位图来记录信号是否产生。
34及以上为实时信号,本篇只讨论编号34以下的信号。
在路径 /usr/include/bits/signum.h
中查看信号的宏定义。
之前谈过进程需要对信号有所准备,所以进程要为信号配置信号处理函数,当某个信号发生的时候,就默认执行这个函数即可,通过 man 7 signal
可以查看各个信号的处理方法和具体含义。
Signal Value Action Comment
──────────────────────────────────────────────────────────────────────
SIGHUP 1 Term Hangup detected on controlling terminal
or death of controlling process
SIGINT 2 Term Interrupt from keyboard
SIGQUIT 3 Core Quit from keyboard
SIGILL 4 Core Illegal Instruction
SIGABRT 6 Core Abort signal from abort(3)
SIGFPE 8 Core Floating point exception
SIGKILL 9 Term Kill signal
SIGSEGV 11 Core Invalid memory reference
SIGPIPE 13 Term Broken pipe: write to pipe with no
readers
SIGALRM 14 Term Timer signal from alarm(2)
SIGTERM 15 Term Termination signal
SIGUSR1 30,10,16 Term User-defined signal 1
SIGUSR2 31,12,17 Term User-defined signal 2
SIGCHLD 20,17,18 Ign Child stopped or terminated
SIGCONT 19,18,25 Cont Continue if stopped
SIGSTOP 17,19,23 Stop Stop process
SIGTSTP 18,20,24 Stop Stop typed at terminal
SIGTTIN 21,21,26 Stop Terminal input for background process
SIGTTOU 22,22,27 Stop Terminal output for background process
……
第一列是信号的宏名称,第二列为信号编号,第三列为默认处理动作,最后一列介绍了什么条件下产生该信号。
用户在终端按下某些键时:ctrl+c
(SIGKILL),ctrl+z
(SIGSTP),ctrl+/
(SIGQUIT)
硬件异常通知内核,内核向进程发送相应信号。
例如进程执行了除以0的指令,CPU的运算单元会产生异常,内核将此异常解释为SIGFPE信号,再发送给进程。
又如进程访问了非法内存地址,MMU产生异常,内核将异常解释为SIGSEGV(段错误)信号发送给进程。
一个系统调用 kill(2) 函数可以发送信号给另一个进程。
可以用kill(1)命令发送信号给某个进程, kill(1)命令也是调用kill(2)函数实现的,如果不明确指定信号则发送SIGTERM信号,该信号的默认处理动作是终止进程。
当内核检测到某种软件条件发生时也可以通过信号通知进程,例如闹钟超时产生SIGALRM信号,向读端已关闭的管道写数据时产生SIGPIPE信号。
所有的信号归根结底都是OS发向进程的。
执行默认操作。
操作 | 含义 |
---|---|
Term | 终止进程 |
Core | 终止当前进程并且Core Dump(之后介绍) |
Ign | 忽略信号 |
Stop | 中断进程 |
Cont | 若进程中断,则继续进程 |
自定义处理信号行为 (捕捉信号)
捕捉信号后,我们可以为信号自定义信号处理函数。
忽略信号
当我们不希望处理某些信号时,就可以忽略不做任何处理。
注意: 有两个信号是无法捕捉(自定义)和忽略的,SIGKILL
和 SIGSTOP
,他们用于在任何时候结束或中断进程。
要为一个信号自定义处理函数,可使用signal系统调用
#include
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数
返回值
成功则返回前一次调用signal函数时传入的函数指针。或者信号signum对应的默认处理函数指针SIG_DEF。
失败返回SIG_ERR,并设置errno。
示例代码
我们对信号 SIGINT 进行自定义行为:
#include
#include
#include
void handle(int signum)//对信号SIGINT的自定义行为
{
printf("get signal : %d\n",signum);
}
int main()
{
signal(SIGINT,handle);//捕捉SIGINT(写编号:2也可以)
while(1)
{
printf("waiting a signal\n");
sleep(1);
}
return 0;
}
从终端按键(ctrl+c)和kill指令来验证信号自定义行为:
(之后会讲)
之前谈过 ctrl+c
是给前台进程发送信号:SIGINT
这里再介绍一个终端按键信号 ctrl+\
发送的信号 SIGQUIT
SIGQUIT 的默认处理动作是终止进程并且 Core Dump
。
我们在进程篇的waitpid函数中介绍的status结构理包含了Core Dump,他是status的从低到高的第7个比特位(从0计数)。
现在对Core Dump 做出解释:
当一个进程要异常终止时,意味着程序中存在bug,为了方便用户事后可以debug,那可以选择把进程用户空间的内存数据全部保存在磁盘上,文件名通常是 core
,这称为 Core Dump (核心转储
),此时status的Core Dump会由0置为1。
进程的异常退出,事后我们使用调试器(gdb)检查core文件以查清错误原因,这叫做 Post-mortem Debug
。
一个进程允许产生多大的core文件取决于进程的Resource Limit(该信息保存于PCB中)。
我使用的是云服务器,现在通过指令 ulimit -a
查看资源限制:
可以看到我们的core文件的资源被限制为0,那意味着目前我们的核心转储功能默认是被关闭的。
默认不允许产生core文件,因为core文件中可能包含用户密码等敏感信息,而这并不安全。
在开发调试阶段可以使用ulimit命令更改Resource Limit,允许产生core文件。在我的云服务器中允许core文件最大为1024K:
ulimit -c 1024
我们写一个死循环的小程序,分别使用SIGINT和SIGQUIT来终止它,看一下谁会产生的core文件:
//signal.c
#include
#include
#include
int main()
{
while(1)
{
printf("waiting a signal\n");
sleep(1);
}
return 0;
}
可以看到当我们键入 ctrl+\ 终止进程后会提示 (core dumped)
,表明核心转储成功,文件夹中也多了一个core.pid的文件,后面的pid则是发生这次核心转储的进程PID。
注意到core文件还是不小的:
du
指令 查看磁盘占用空间
当我们服务端程序为提供不间断服务而设定为进程异常不断重启时,那可能就会在磁盘中产生大量core文件,所以core dump在线上默认是关闭的。
既然了解核心转储文件,那我们的目的就是为了分析进程异常退出的原因,于是接下来便是对 core 文件进行调试,定位问题。
有两种调试方法
gdb 执行文件 core.pid
gdb 执行文件
+ core-file core.pid
这次我们改写一下程序,使其发生除0错误,此时将会产生SIGFPE信号,同样会core dump。
//signal.c
int main()
{
while(1)
{
printf("waiting a signal\n");
sleep(5);
int a=1/0;
}
return 0;
}
该步骤gdb命令调用过程如下:
gdb 可执行程序 core文件
此时就能看到在哪一行出现的异常了
还有一种调试方法,首先进入 gdb signal
然后键入
core-file core.pid
我们将这个方法来检测一下段错误(SIGSEGV)的程序
//signal.c
int main()
{
while(1)
{
printf("waiting a signal\n");
sleep(5);
int *p=NULL;
*p=10;
}
return 0;
}
成功定位错误位置:
如果进程是异常退出的那么,status中的0~6位存储的是进程异常的信号,第7位core dump 由0置为1,那么我们来验证一下。
实验代码依旧用除0的错误代码演示,不过这会发生在子进程中,我们使用父进程waitpid来获取子进程的status:
#include
#include
#include
#include
#include
#include
int main()
{
if(fork()==0)
{
printf("waiting a signal\n");
sleep(5);
int a=1/0;//异常位置
exit(0);
}
int status;
waitpid(-1,&status,0);
printf("exit code :%d,Core Dump:%d,signal:%d\n",
(status>>8)& 0xFF,(status>>7)&1,status & 0x7F);
//依次输出退出码,CoreDump和信号。
return 0;
}
之前谈过使用键盘按键只能对前台进程发送信号,无法发送至后台的进程。
如果要对后台进程发送信号,可以使用shell指令:kill -信号编号 进程PID
我们对一个死循环的后台程序,使用kill指令向其发送SIGSEGV信号。
11是SIGSEGV的信号编号,也写成 kill -11 19722
。
kill命令是调用kill函数实现的。kill函数可以给一个指定进程发送指定的信号
#include
#include
int kill(pid_t pid, int sig);
我们可以写一段程序将kill函数包装成一个kill的shell指令:
//mykill.c
#include
#include
#include
#include
void Usage(const char* proc)
{
printf("Usage:%s signo PID\n",proc);
}
int main(int argc,const char* argv[])
{
if(argc!=3)
{
Usage(argv[0]);
return 0;
}
int signo=atoi(argv[1]);
pid_t pid=atoi(argv[2]);
kill(pid,signo);
return 0;
}
我们写的mykill程序使用起来和kill指令一样:mykill signo PID
为了便于使用,我们将mykill执行程序添加进系统指令的搜索路径(PATH)下,从而运行时可以不加路径。
我们可以写一个死循环输出的程序来试试:
进程自己可以给自身发送函数
#include
int raise(int sig);
在一个循环中给自己发送SIGINT信号,这里我们同时捕捉SIGINT信号进行输出:
#include
#include
#include
void handle(int signum)
{
printf("get a signal : %d\n",signum);
}
int main()
{
signal(2,handle);
while(1)
{
printf("Hello,pid:%d\n",getpid());
sleep(1);
raise(2);
}
return 0;
}
进程给自己发送SIGABRT信号
#include
void abort(void);
abort函数总是会成功的,所以没有返回值。
延用上面的代码,不过我们改为对6号信号(SIGABRT)进行捕捉:
#include
#include
#include
void handle(int signum)
{
printf("get a signal : %d\n",signum);
}
int main()
{
signal(6,handle);
while(1)
{
printf("Hello,pid:%d\n",getpid());
sleep(1);
abort();
}
return 0;
}
可以看到虽然我们捕捉并自定义了6号信号,但是操作系统还是为我们终止了进程。
在进程间通信篇讨论管道时,我们谈过当管道的读端关闭的时候,操作系统会给写端发送SIGPIPE信号,于是写端进程会被终止,这便是有软件产生信号的一种方式。
这里再介绍一种软件产生信号的方式:alarm函数和SIGALRM信号。
#include
unsigned alarm(unsigned seconds);
当seconds的时间流尽后,系统发送SIGALRM信号终止进程。
初次设定alarm函数时,返回值为0 。之后再次设定alarm函数,返回值为上一个alarm函数的剩余时间,并且会取最新的alarm函数的seconds重置发送SIGALRM的时间。
#include
#include
#include
#include
int main()
{
printf("set alarm : 50 sec\n");
int remain=alarm(50);
printf("remain:%d\n",remain);
printf("wait 5 sec\n");
sleep(5);
printf("reset alarm : 3 sec\n");
remain=alarm(3);
printf("remain:%d\n",remain);
while(1)
{
printf("waiting...\n");
sleep(1);
}
return 0;
}
测试自己的服务器在1秒钟可以累加多少
int count=0;
int main()
{
alarm(1);
while(1)
{
count++;
printf("count:%d\n",count);
}
return 0;
}
由于我们每进行一次累加就进行了一次打印操作,而申请IO操作的效率很低,其次,网络传输也会折损许多时间,因此最终显示的结果要比实际一秒内可累加的次数小得多。
为得到真实数据,避免IO和网络的损失,我们可以自定义SIGALRM信号处理函数,时间结束发送SIGALRM信号时在自定义函数中打印即可。
void handle(int signum)
{
printf("count:%d\n",count);
exit(1);
}
int main()
{
signal(14,handle);
alarm(1);
while(1)
{
count++;
}
}
可见IO的速度是相当慢的。
打个比方:在备忘录上记录回家作业意味着收到信号,等到真正写作业的时候是递达状态,而从记录回家作业到开始写作业的这段时间是未决状态,如果到家后你妈妈叫你先吃完饭再写,那么写作业这件事情就会阻塞,直到吃完饭后阻塞就被解除,可以递达。而忽略意味着,当你开始写作业后发现作业太难不想做了,便不去完成此作业了。
我们从横向解读一个信号的状态:
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),他们都以位图作为数据结构,下标表示信号的编号,内容为1表示触发,0为没有触发。最后一列为一个函数指针表示我们递达信号时的处理动作,SIG_DFL为默认处理动作,SIG_IGN为忽略信号,自定义的函数就是signal函数参数的函数指针。
在上图的例子中:
⚠注意:如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?
从上图看来,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志亦然。
因此,未决和阻塞标志可以用相同的数据类型 sigset_t
来存储,sigset_t 称为信号集。
这个类型可以表示每个信号的有效或无效的状态。
阻塞信号集也称为当前进程的信号屏蔽字(Signal Mask),这里的屏蔽不可理解为忽略,因为压根就没递达。
sigset_t 使用比特位来表示有效和无效的状态,但是我们不能通过按位操作来设定其值。该类型如何存储bit位依赖于操作系统的实现。
我们应该也只能使用下列函数来操作sigset_t变量,而不应该对它的内部结构进行处理,而且使用 printf 打印 sigset_t 是没有意义的。
#include
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
sigemptyset
初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。sigfillset
初始化set所指向的信号集,使其中的所有信号的对应bit置为1,表示系统支持的所有信号在此信号集中全部有效。注意:在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。
sigaddset
或 sigdelset
函数在该信号集中添加或删除某种有效信号。注意:上面四个函数都是成功返回0,出错返回-1。
sigismember
函数用于判断一个信号集的有效信号中是否包含某种信号,若包含返回1,不包含返回0,出错返回-1。#include
void PrintSet(const sigset_t* s)
{
int i;
for(i=1;i<=31;++i)
{
printf("%d ",sigismember(s,i));
}
printf("\n");
}
int main()
{
sigset_t s;
printf("sigemptyset\n");
sigemptyset(&s);
PrintSet(&s);
printf("sigaddset 1 3 5\n");
sigaddset(&s,1);
sigaddset(&s,3);
sigaddset(&s,5);
PrintSet(&s);
printf("sigfillset\n");
sigfillset(&s);
PrintSet(&s);
printf("sigdelset 1 3 5\n");
sigdelset(&s,1);
sigdelset(&s,3);
sigdelset(&s,5);
PrintSet(&s);
return 0;
}
注意:上面的信号集函数只是单纯地针对我们自己定义的sigset_t的变量进行操作,而这个值只是在我们的用户空间的栈上,并没有设置到进程相关的PCB内,所以不会影响到进程的任何行为。所以我们需要通过系统调用函数将设置好的 sigset_t 参数配置进操作系统中。
调用 sigprocmask
可以读取或者更改进程的信号屏蔽字(阻塞信号集,block)
#include
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
返回值:成功返回0,失败返回-1;
oset,set都是非空指针,则可以先通过输出型参数oset来备份原先的信号屏蔽字,然后根据set和how参数更改信号屏蔽字。
选项 | 含义 |
---|---|
SIG_BLOCK | set是我们希望添加到当前信号屏蔽字的信号,相当于mask=mask l set |
SIG_UNBLOCK | set是我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set |
SIG_SETMASK | 把进程当前的信号屏蔽字设为set,相当于mask=set |
如果调用了sigprocmask 解除了对当前若未决干信号的阻塞,则在函数返回前,至少将其中一个信号递达。
#include
int sigpending(sigset_t *set);
sigpending 通过输出型参数set,来读取当前进程的未决信号集。调用成功返回0,出错返回-1。
#include
#include
#include
void PrintPending(const sigset_t* p)
{
int i=1;
for(;i<32;++i)
{
if(sigismember(p,i))
{
printf("1 ");
}
else
{
printf("0 ");
}
}
printf("\n");
}
void handler(int signo)
{
printf("catch %d\n",signo);
}
int main()
{
signal(SIGINT,handler);//捕捉2号信号,执行自定义函数handler
sigset_t s,oset,p;
sigemptyset(&s);//初始化s
sigemptyset(&oset);
sigaddset(&s,SIGINT);//在s的位图上将SIGINT的bit位置为1
sigprocmask(SIG_BLOCK,&s,&oset);//让进程的2号信号阻塞
int count=0;
while(1)
{
sigemptyset(&p);
sigpending(&p);//获取当前进程的pending位图
PrintPending(&p);
sleep(1);
count++;
if(count==10)
{
//10秒后恢复信号屏蔽字
sigprocmask(SIG_SETMASK,&oset,NULL);
//sigprocmask(SIG_UNBLOCK,&s,NULL); //或者取消set的阻塞,也可获得同样效果
}
}
return 0;
}
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
每个进程有自己的进程地址空间,其中有内核空间和用户空间。
内核页表是全局的,每个进程的内核空间中的代码和数据是一样的。
当用户需要执行操作系统的相关代码时(如系统调用),就必须要从用户态进入内核态。
内核态和用户态各有优势:运行在内核态的程序可以访问的资源多,但可靠性、安全性要求高,维护管理都较复杂;用户态程序访问的资源受限,但可靠性、安全性要求低,自然编写维护起来都较简单。一个程序到底应该运行在内核态还是用户态取决于其对资源和效率的需求。
用户态与内核态的切换情况:
所有的用户程序都运行在用户态,但是有时候程序确实需要做一些内核态的事情,比如在屏幕打印文字,读取硬盘中的文件等。于是程序就需要从用户态切换到内核态,再由内核执行相应的请求。这种机制为系统调用。
在CPU执行用户态程序时,突然发生如硬件异常等突发的异常事件或者进程时间终止,此时会触发从用户态切换为内核态执行相关处理。
当硬件完成内核的请求操作后,会向CPU发出中断信号,此时CPU会暂停执行下一条即将执行的指令,转而执行中断信号对应的处理程序。如果先前的程序是在用户态下,就自然发生用户态到内核态的切换。
注意:系统切换的本质也是一种中断,相对于外设的硬中断,这种称为软中断。从本质上看这三种切换方式都相当于执行了一个中断相应的过程,当然系统调用是主动请求切换的,而异常与硬中断是被动的。
信号的处理动作可分为三种:默认(SIG_DFL),忽略(SIG_IGN)和自定义函数。
如果信号的处理动作是自定义的,那么在信号递达时就调用这个函数,这称为捕捉信号,由于信号处理函数的代码处于用户空间,处理流程略显复杂,举例如下:
用户自定义了部分信号的处理函数sighandler。
当前正在执行用户的main函数,此时发生中断或异常,切换到内核态。
在中断处理完毕后要返回main函数之前,先检查pending位图和block位图。如果有信号此时没有被阻塞且又处于未决状态,就让信号递达。
如果处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达且进程没有被终止,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。
如果信号是自定义的处理函数,内核决定返回用户态去执行sighandler函数,而非恢复main函数的上下文继续执行。
注意:main函数和sighandler使用不同的堆栈空间,他们之间不存在调用和被调用的关系,是两个独立的进程。
注意:内核态的权限更高,故无法以内核态的身份执行用户自定义的sighandler函数(可能存在漏洞),内核执行操作系统之外的代码可能会造成系统崩溃,故须先切换为权限更低的用户态去执行自定义函数可保证安全。
sighandler函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态,并清除对应的pending标志位。
如果没有新的信号要递达,这次返回用户态恢复main函数的上下文继续执行用户程序。
当信号有自定义处理函数的时候记忆起来有些困难,于是我们针对此将上图简化:
#include
int sigaction(int signo,const struct sigaction *act,struct sigaction *oldact);
返回值
成功返回0,出错返回-1
参数
struct sigaction
struct sigaction
{
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
sa_handler : 自定义函数(和signal函数的handler一样),也可以定义为SIG_DFL或SIG_IGN 。
sa_sigaction :是实时信号的处理函数本篇不做讨论。
sa_mask : 设置该信号处理时的信号屏蔽字。
注意:
sa_flags :用来设置信号处理的相关操作,平时设为0即可,若有要求可以设置如:
sa_restorer :是一个废弃的数据域不要使用。
要求:我们给SIGINT自定义信号处理函数,在执行SIGINT信号的同时还阻塞住SIGQUIT信号,最后将SIGINT信号还原。
#include
#include
#include
#include
struct sigaction newact,oldact;
void handler_INT(int signo)//SIGINT的自定义函数
{
printf("catch %d\n",signo);
printf("SIGQUIT is blocked in 10 sec! try me\n");
sleep(10);//这10s中,SIGQUIT是阻塞状态。
sigaction(signo,&oldact,NULL);//10秒后恢复SIGINT的默认信号处理
}
void handler_QUIT(int signo)//SIGQUIT的自定义函数
{
printf("catch %d\n",signo);
printf("SIGQUIT is unblocked now!\n");
}
int main()
{
newact.sa_handler=handler_INT;//为SIGINT信号设置自定义函数
sigemptyset(&newact.sa_mask);
sigaddset(&newact.sa_mask,SIGQUIT);//添加信号屏蔽字,阻塞SIGQUIT
sigaction(SIGINT,&newact,&oldact);//newact赋予新行为,oldact备份
signal(SIGQUIT,handler_QUIT);
while(1)
{
printf("hello\n");
sleep(1);
}
return 0;
}
#include
int pause(void);
EINTR
,所以pause只有出错的返回值。EINTR
表示 “被信号中断” 。使用 alarm 和 pause 实现 sleep 函数,称为 mysleep 。
#include
#include
#include
void sig_alrm(int signo)
{
printf("bell's ringing\n");
}
unsigned int mysleep(unsigned int nsecs)
{
struct sigaction newact,oldact;
unsigned int unslept;
newact.sa_handler=sig_alrm;
sigemptyset(&newact.sa_mask);
newact.sa_flags=0;
sigaction(SIGALRM,&newact,&oldact);
alarm(nsecs);
pause();
unslept=alarm(0);
sigaction(SIGALRM,&oldact,NULL);
return unslept;
}
int main()
{
while(1)
{
mysleep(2);
printf("Two seconds passed\n");
}
return 0;
}
当捕捉到信号时,不论主程序进行到哪里,都会跳到信号处理函数执行,从信号处理函数返回后再继续执行主程序。
信号处理函数是一个单独的流程,因为他和主程序时异步的,二者不存在调用和被调用的关系,并且使用的是不同的堆栈空间。引入信号处理函数使得一个进程具有多个控制流程,如果这些控制流程访问相同的全局资源(全局变量、硬件资源等),就可能会出现冲突,如下例子:
main函数调用insert函数向一个链表head中插入节点node1,刚做完 ① 的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数。
sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步 ② ③ 都做完之后从sighandler返回内核态。
再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第 ① 步之后被打断,现在继续做完第 ④ 步。
结果是,main函数和sighandler先后向链表中插入两个节点,而只有node1真正插入链表中了,node2因为再也找不到从而造成内存泄漏。
注意:
像上例这样, insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入, insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant)函数。
如果一个函数符合以下条件之一则是不可重入的:
在上面的例子中,main和sighandler都调用了insert函数则有可能出现链表头插错乱的情况,其根本原因在于insert的函数操作要分两步完成,不是一个原子操作。
如果这两步操作必定会一起做完,中间不可能被打断,就不会出现错乱了。在之后的线程篇中会讲到如何保证一个代码段以原子操作完成。
如果对全局数据的访问只有一行代码,是不是就是原子操作呢?
如main函数和sighandler函数都对一个全局变量赋值可不可能出现错乱情况?
long long a;
int main(void)
{
a=5;
return 0;
}
现调试进入汇编:
a=5;
8048352: c7 05 50 95 04 08 05 movl $0x5,0x8049550
8048359: 00 00 00
804835c: c7 05 54 95 04 08 00 movl $0x0,0x8049554
8048363: 00 00 00
虽然C代码只有一行,但是在32位机上对一个64位的long long变量赋值需要两条指令完成,因此,它并不是一个原子操作。同样地,读取这个变量到寄存器需要两个32位寄存器才放得下,也需要两条指令,不是原子操作。可以设想一种时序, main和sighandler都对这个变量a赋值,最后变量a的值发生错乱。
如果在64位机上编译,可以用一条指令完成,则是原子操作。同理,如果a是32位的int变量,在32位机上是原子操作,但是16位机上就不是了。
如果程序需要一个变量,保证其读写都是原子操作,为了解决平台的相关问题,C标准定义了一个类型 sig_atomic_t
,在不同的平台下会取不同的类型,32位机上定义sig_atomic_t为int类型。
注意:
使用sig_atomic_t的类型并不代表着万事大吉,此时还需注意另外一个情况:
#include
sig_atomic_t flag=0;
void handler(int signo)
{
printf("change flag to 1\n");
flag=1;
}
int main()
{
signal(SIGINT,handler);
while(!flag);//等待信号抵达
printf("process quit\n");
return 0;
}
在main函数中首先注册某个信号的处理函数sighandler,然后在一个while死循环中等待信号发生,如果信号递达执行sighandler,从而改变flag,这样再次回到main函数时就可以退出while循环。
可是当编译器优化的级别比较高时,代码的执行结果可能并不如我们所设想的那样。注意:使用gcc编译时,选项 -O3
使得编译器的优化级别最高。
发现在发送SIGINT信号后,执行了信号处理函数,flag也被我们改变成了1,然而死循环并没有被制止,这是为什么呢??
是编译器优化的失误吗?并非如此,目前用户程序只有单一的执行流程,在我们在主程序中没有改变flag的值,那么flag的值就没有理由会变,没有必要反复去内存中读取,所以编译器就把flag存到了寄存器中。
之所以程序中存在多个执行流程,是因为调用了特定平台的特定库函数,比如signal,sigaction,这些不是C语言本身的规范,编译器自然也无法识别程序中存在多个执行流程。现在内存中的flag被信号处理函数修改为1,但是寄存器里的flag依旧为0。
C语言提供了 volatile
限定符,如果用volatile修饰,可以确保本条指令不会因编译器的优化而省略,且要求每次直接读值。编译器将始终从内存中读取flag的值。
什么样的内存单元会具有这样的特性呢?肯定不是普通的内存,而是映射到内存地址空间的硬件寄存器,例如串口的接收寄存器属于上述第一种情况,而发送寄存器属于上述第二种情况。
sig_atomic_t类型的变量应该总是加上volatile限定符,因为要使用sig_atomic_t类型的理由(原子读取)也正是要加volatile限定符的理由。
在进程篇中讲过使用wait和waitpid函数来清理僵尸进程,父进程可以采用阻塞的方式等待子进程,也可以非阻塞的查询子进程是否退出(即轮询方式)。采用前者,父进程阻塞就不能处理自己的工作了,采用后者,父进程在处理自己工作的同时轮询子进程是否退出,降低了父进程的工作效率。
事实上,子进程在终止时会向父进程发送 SIGCHLD
信号,该信号的默认处理动作是忽略,作为父进程可以自定义该信号的处理函数,这样父进程便可以专心处理自己的工作而不必在意子进程,直到收到 SIGCHLD 信号,在信号处理函数中调用wait/waitpid清理子进程即可。
我们现在就编写程序实现如下功能,父进程fork子进程,子进程调用exit(2)终止,父进程自定义SIGCHLD信号的处理函数,在其中调用waitpid获得子进程的退出状态并打印。
#include
#include
#include
#include
#include
void handler(int signo)
{
printf("catch signal:%d\n",signo);
int ret=0;
while((ret=waitpid(-1,NULL,WNOHANG))>0)
{
printf("wait child(%d) success\n",ret);
}
}
int main()
{
signal(SIGCHLD,handler);
pid_t id;
id=fork();
if(id==0)
{//child
printf("child process:%d\n",getpid());
sleep(3);
exit(2);
}
//father
while(1);
return 0;
}
注意:
这样父进程就只需关心自己的工作即可,收到信号会自动清理子进程。
⭐tips:
显式的用signal或sigaction函数将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户自定义的忽略通常没有区别,此为特例,且对于Linux有用。
-end-
青山不改 绿水长流