一.信号的基本概念
1.理解信号是什么?
- 用户输入命令,在shell下启动一个前台进程
- 用户按下
ctrl +c
,键盘输入产生一个硬件中断 - 如果CPU当前正在执行这个进程的代码,则该进程的用户空间代码暂停执行,CPU从用户态转换到内核态处理硬件中断
- 终端驱动程序将
ctrl+c
解释成一个SIGINT
,既2号信号,然后发给该进程 - 当某个时刻要从内核返回到该进程的用户空间代码继续执行之前,首先处理PCB中记录的信号,发现有一个SIGINT信号待处理,而这个信号的默认处理动作是终止进程,所以直接终止进程而不再返回它的用户空间执行剩下的代码
注意:
ctrl+c
产生的信号只能发给前台进程,运行时在后边加上&
表示放到后台运行- shell可以同时运行一个前台进程或任意多个后台进程
- 前台进程在任意时刻都可以接受到
ctrl+c
产生的信号,也就是说进程的用户代码执行到任何地方都可能收到SIGINT
信号,所以信号对于进程的控制流程是异步的 - 1.处理信号的前提是要认识信号
- 2.信号一旦产生,可能不会立即处理它,等待合适的时间才去处理
- 3.信号事件的产生对进程而言是异步的
- 4.信号如果无法对它进行处理,就先将它记录下来
- 5.处理信号的三种方式
执行确认/默认动作
、忽略信号
、自定义的动作(捕捉)
2.信号列表及分类
查看系统自定义的信号列表:kill -l
这些是Linux操作系统定义的62
个信号,每个信号都有对应的一个编号和宏定义名称,这些宏定义可以在signal.h
中找到。其中1-31
号信号为普通信号
,34-64
号信号为实时信号
,一旦发出信号则立即去处理。
二.信号的产生
1.产生信号的概述
a.终端驱动程序向前台发送信号
ctrl+c
产生SIGINT
信号ctrl+\
产生SIGOUT
信号ctrl+z
产生SIGTSTP
信号
b.硬件异常产生信号
除零操作,CPU运算单元产生异常
发送SIGFPE(8号)
信号野指针问题,访问非法内存,MMU产生异常
发送SIGSEGV
c.某种软件条件发生时通过信号通知进程
SIGTERE
默认处理动作为终止进程SIGALRM
闹钟超时产生信号SIGPIPE向读端已经关闭的管道写数据时产生
2.各种产生信号方式详解
a.通过终端按键产生信号
SIGINT
默认处理动作是终止进程SIGOUIT
默认处理动作是终止进程并且Core Dump
Core Dump:
- 当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是
core
,这个就叫做Core Dump
。 - 进程异常终止通常是因为程序有Bug(例如:非法内存访问),我们可以在程序崩溃掉以后,根据
core
文件来调试,这称作事后调试
- 一个进程允许产生多大的core文件取决于进程的
Resource Limit
。默认是不允许产生core
文件的,因为core
文件可能会包含用户密码等敏感信息。但是在开发调试阶段可以用ulimit
命令改变限制,产生core
文件
$ ulimit -a //查看用户资源限制
$ ulimit -c 1024 //修改core文件允许产生最大1024k
注:线上服务器不能允许产生core
文件,debug调试才可以
事后调试:
- 我们可以先写一个死循环程序
#include<stdio.h>
#include<unistd.h>
int main()
{
int arr[10];
int i;
for(i=0;i<=11;i++)
{
arr[i]=0;
}
printf("pid is %d\n",getpid());
return 0;
}
我们将这个程序运行起来,发现程序崩溃了。然后按下ctrl + \
将这个程序终止掉,我们在当前目录上可以发现一个core.xxx(xxx为进程的pid)
文件。
下一步我们就可以用gdb调试程序了,直接把core文件引到gdb中即可。core-file core.xxx
我们可以发现core文件直接帮我们定位出来错误的地方。
b.调用系统函数向进程发信号
首先,我们可以将上边非法内存访问的例子运行起来,然后在给这个进程发送SIGSEGV(11号信号)
。
- kill函数给指定进程发送信号
$ kill -SIGSEGV 进程pid
$ kill -11 进程pid
- raise函数
//功能:给当前进程发送指定信号(自己给自己发)
#include<signal.h>
int kill(pid_t pid,int signo);//kill命令的调用kill函数实现的
int raise(int signo);
返回值:成功返回0,失败返回-1
例如:
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
int main()
{
int arr[10];
int i;
raise(11);
for(i=0;i<=11;i++)
{
arr[i]=0;
}
printf("pid is %d\n",getpid());
return 0;
}
运行结果为:
- abort函数
//功能:abort()函数首先解除进程对SIGABRT信号的阻止,然后向调用进程发送该信号。
//abort()函数会导致进程的异常终止除非SIGABRT信号被捕捉。
#include<stdlib.h>
void abort(void);
例如:
int main()
{
printf("call abort\n");
abort();
return 0;
}
软件条件产生信号
-
SIGPIPE信号
:当读端将自己的文件描述符关闭,os会向写端发送一个SIGPIPE信号
来关闭写端 -
alarm函数
#include<unistd.h>
unsigned int alarm(unsigned int seconds);
功能:设置一个闹钟,告诉内核在seconds秒后给当前进程发送SIGALRM信号,该信号的默认动作是终止进程
例如:
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
int main()
{
alarm(5);
while(1)
{
printf("pid is %d\n",getpid());
sleep(1);
}
return 0;
}
运行结果为:输出5次,然后进程终止。
- signal函数
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
参数说明:signum是信号编号,指的是对那个信号操作
handler是一个函数指针,这个函数指针指向的是对默认处理替换的方法函数
上边说过我们处理信号的的方式有三种:
- 采用默认方法处理
( signal的第二个参数为SIG_DFL )
- 忽略
(signal的第二个参数为SIG_ING)
- 采用自定义方式处理
(signal的第二个参数为自己实现的函数)
例如:
void handler(int signum)
{
printf("我是%d号信号\n",signum);
}
int main()
{
signal(2,handler);
while(1)
{;}
return 0;
}
三.阻塞信号
1.信号的常见相关概念
- 实际执行信号的处理动作称为递达
- 信号从产生到递达之间的状态叫做未决
- 进程可以选择阻塞某个信号(该信号还未被处理)
- 被阻塞的信号产生保持在未决状态,直到进程解除对此信号的阻塞,才执行抵达的动作
- 阻塞和忽略不相同,只要信号被阻塞就不会抵达,而忽略是在抵达之后可选的一种处理动作
2.在内核中的表示
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理的动作
- 信号产生时,内核在进程控制块中的未决标志直到信号递达才清除该标志
- 如果进程解除对某信号的阻塞之前这种信号产生多次,只按一次计算。
3.sigset_t信号集
- 每个信号只有一个比特的未决信号,阻塞也是一样。所以,它们两个可以用同样的数据结构
sigset_t
来存储,这个类型可以表示每个信号的有效
和无效
- 阻塞信号集中的有效无效代表该信号是否被阻塞,而未决信号集中的有效和无效代表信号是否处于未决状态。
- 阻塞信号集也就当前进程的信号屏蔽字。
4.信号集操作函数
我们可以用一下的操作函数来操作信号集,但是直接打印sigset_t
类型的变量没有任何意义。
#include<signal.h>
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);//用来判断一个信号集的有效信号中是否包含某种信号,包含返回1,不包含返回0
- sigemptyset初始化set所指向信号集,使得其中的所有信号的对应bit清零,表示该信号集不包含任何有效信号
- sigfillset对set所指向的信号集置位,表示该信号集的有效信号包括系统支持的所有信号
- 在使用
sigset_t
变量之前,一定要调用sigemptyset
或者sigfillset
做初始化。
5.sigprocmask函数
#include<signal.h>
int sigprocmask(int how,const sigset_t *set,sigset_t *oset);
//返回值:成功返回0,失败返回-1
//功能:读取或者更改进程的信号屏蔽字(阻塞信号集)
参数说明:
how代表如何更改
set代表新的信号屏蔽字
oset代表老的信号屏蔽字
-
oset为非空指针,则读取当前信号屏蔽字通过该参数传出
-
set为非空指针,根据参数how更改进程的信号屏蔽字
-
两个都不为空,将原来的信号屏蔽字备份到oset里,在根据how和set更改当前的信号屏蔽字
how的可选值: -
SIG_BLOCK
:set包含了我们希望添加到当前信号的信号屏蔽字的信号,mask=maks|set
-
SIG_UNBLOCK
:set包含了希望从当前信号屏蔽字中解除堵塞的信号,mask=mask&~set
-
SIG_SETMASK
:设置当前的信号屏蔽字为set指向的值,mask=set
-
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在函数返回之前,至少将其中一个信号递达
6.sigpending信号
#include<signal.h>
int sigpending(sigset_t *set);
//功能:读取当前进程的未决信号集,通过set参数传出,出错返回-1
例如:用上边两个函数做个测试
#include<stdio.h>
#include<signal.h>
#include<unistd.h>
void printsigset(sigset_t *set)
{
int i=0;
for(;i<32;i++)
{
if(sigismember(set,i)){//判断指定信号是否在目标集合中
putchar('1');
}
else{
putchar('0');
}
}
puts("");
}
int main()
{
sigset_t s,p;
sigemptyset(&s);//初始化信号集
sigaddset(&s,SIGINT);
sigprocmask(SIG_BLOCK,&s,NULL);//设置阻塞信号集,阻塞SIGINT信号
while(1)
{
sigpending(&p);//获取未决信号集
printsigset(&p);
sleep(1);
}
return 0;
}
输出结果为:
当我们输入Ctrl+c
,也就是2号信号时,由于该信号被阻塞,所以它会一直处于未决状态,不被处理。但是按ctrl+\
可以结束,因为SIGOUIT
没有被阻塞。
7.捕捉信号
7.1 信号捕捉内核是如何实现的?
如果信号的处理动作是用户自定义函数,在信号递达的时候就是调用这个函数,这就是信号的捕捉。信号处理函数是在用户空间的,假设用户程序捕捉了SIGOQIT
信号的处理函数,当前正在执行main函数时,这时因为中断、异常、系统调用
从用户态切换到内核态。在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGOUIT
递达。内核会返回到用户态去执行信号SIGOUIT
的自定义函数hander
。由于hander
和main
函数使用的是不同的堆栈空间,并且不存在调用的关系。所以它们是两个不同的执行流。hander
函数返回之后会自动执行函数sigreturn
再次进入内核态。如果没有新的信号抵达,此时从内核态切换到用户态继续执行剩下的代码。
7.2 sigaction捕捉函数
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
//参数说明:signo是指定信号的编号,act为修改信号的处理动作,oldact为一个输出型参数,用来传出该信号原来的处理动作
//结构体类型
struct sigaction {
void(*sa_handler)(int); //自定义处理动作
void(*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void(*sa_restorer)(void);
};
函数作用:
sigaction
函数可以读取和修改与指定信号相关联的处理动作。调用成功返回0,失败返回-1。- 结构体中字段
sa_handler
若为SIGIGN
表示忽略信号;若为常数SIG_DFL
表示系统的默认动作;如果赋值一个函数指针表示自定义的捕捉信号,或者说向内核注册了一个信号处理函数。该处理函数返回值类型为void,参数类型为int,表示信号的编号
。 - 当某个信号的处理函数被调用时,内核自动将当前信号加入到进程的信号屏蔽字中,当信号处理函数返回时自动恢复到原来的信号屏蔽字。这样可以保证在处理某个信号时,如果该信号再次产生,它会被阻塞到当前处理结束为止。如果调用信号处理函数时,除了当前的信号被自动屏蔽之外,还还可以屏蔽另外的信号。
sa_mask用来说明这些额外需要屏蔽的信号当信号处理函数返回时自动恢复到原来的信号屏蔽字
。sa_flag
参数可以默认为0
。
7.3 pause函数
#include <signal.h>
int pause(void);
函数作用及参数说明:
- pause函数使得调用进程挂起直到信号抵达。如果信号的处理动作是
终止进程,那么进程终止,pause函数没有机会返回,所以说pause函数只有失败的返回值,没有成功的返回值
。如果信号的处理动作是忽略
,则进程继续处于挂起状态
,pause不返回
。如果信号的处理动作为捕捉
,则调用信号处理函数之后pause返回-1
,errno设置为EINTR
,表示信号被中断。
7.4 使用alarm和pause实现sleep函数
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
// handler只需要捕捉它不需要使用它实现功能
void handler(int no)
{}
int MySleep(unsigned int second)
{
struct sigaction act,oact;
act.sa_handler =handler;
sigemptyset(&act.sa_mask);
sigaction(SIGALRM,&act,&oact);
//调用函数设置闹钟,second秒后会给自己发送SIGALRM信号
alarm(second);
//使进程挂起,直到信号SIGALRM递达从而pause返回,因为处理动作是捕捉
pause();
// 因为前边调用了自定义处理动作,但是如果后边再次使用alarm函数
// 它的处理动作却是别人自定义的,所以这个调用完成之后要将信号改回到原来的默认处理动作
sigaction(SIGALRM, &oact, NULL);
// 在second秒内可能被提前收到SIGALRM信号被唤醒,唤醒之后过几秒会再次收到alarm函数发送的SIGALRM信号从而导致进程终止
int ret = alarm(0); //取消闹钟,返回值为上一个闹钟的剩余的秒数
//ret大于0说明是收到其他进程发送的SIGALRM信号
//ret等于0说明等待成功
return ret;
}
int main()
{
while(1)
{
MySleep(5);
cout << "wake up" << endl;
}
return 0;
}
//输出结果为:间隔5秒中会输出wake up。
8.可重入函数
可重入函数指的是一个函数可以同时被多个执行流执行,但是不会引起任何问题
。可重入函数只能访问自己的局部变量或参数
。而一个函数同时被多个执行流执行,如果会引起问题,则叫不可重入函数
,不可重入函数是因为访问一些全局的东西从而导致错乱的现象
。
一个链表的例子说明可重入函数:
假设现在存在一条链表,要想这条链表头插一个结点
。首先,mian函数调用insert
函数向链表中插入结点node1
,插入操作分为两步p->next=head,head->p
,当函数执行完第一步p->next=head
的时候,因为硬件中断使得进程切换到内核,回到用户态之前发现有信号待处理,于是切换到自定义函数handler
去处理函数,但是在handler
函数也调用了insert
函数向同一个全局链表中插入结点node2,
插入操作两步都进行完之后从handler
函数返回内核态,再次回到用户态就直接从main
函数中断的地方继续执行,现在执行插入函数的第二步,最后的结果是本来要向链表中插入两个结点,现在只剩一个结点了,因为head->node1
了。
为什么两个不同的控制流调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
因为每个函数都有自己的函数栈帧,局部变量存在于栈帧之中,出了函数局部变量就已经销毁。
符合下边两个条件的函数一定是不可重入函数:
- 调用了
malloc或free
函数,因为malloc
也是用全局链表
来管理堆的。 - 调用了
标准I/O函数
。标准I/O库
的很多实现都是以不可重入
的方式使用全局数据结构
。
9.在谈volatile关键字
单独总结于我的另一篇博客:
https://blog.youkuaiyun.com/hansionz/article/details/83895484
10.竞态条件
竞态条件是由于时序的从而导致代码产生的一些问题。我们可以在重新的思考一下上边写的MySleep
函数有没有问题呢?事实上,上边的代码是存在问题的:
-
首先,我们捕捉
SIGALRM
信号的处理函数,调用alarm
设定闹钟 -
然后内核调度优先级更高的进程取代当前进程执行,并且优先级更高的进程有很多,每个都要执行很长的时间
-
很有可能这个时间超过了
alarm
函数设定的时间,所以内核此时向该进程发送SIGALRM
信号,该信号处于未决状态 -
当优先级高的进程执行完,内核调度会改进程继续执行。此时,
SIGALRM
信号递达,回到用户态执行处理函数handler
继续进入内核 -
然后在由内核态直接返回到用户态继续执行main函数的执行流,
alarm()
函数已经调用结束,然后继续调用pause()
使程序挂起,但是此时SIGALRM信号已经处理完了
,pause
函数继续等待下去已经没有意义了。这就是由于时序导致的一些问题,就称作竞态条件。上述问题产生的原因是由于异步时间在任何时候都有可能发生(指的是优先级更高的进程)
那么对上述的问题,有什么解决办法?我们可以这样做: -
1.首先把
SIGALRM
信号屏蔽 -
2.设置闹钟
alarm(s)
-
3.解除对
SIGALRM
的屏蔽 -
4.最后调用
pause
挂起
上边的解决办法在3和4
之间依旧存在时间间隙,SIGALRM
信号依旧可以在这时候递达,我们要解决这个问题就必须要保证3和4
两个操作的原子性,既想办法把它们合并成一个操作。这就必须借助sigsuspend
函数来解决这个问题,它既可以实现对SIGALRM
信号解除屏蔽,也可以将调用pause
挂起进程,这个函数可以很好的解决竞态条件的问题,所以在对时序严格的场合下都应该调用sigsuspend
函数。
#include <signal.h>
int sigsuspend(const sigset_t *mask);
//没有成功时的返回值,只有执行了一个信号处理函数之后才会返回,返回值-1,errno为EINTR(表示被信号中断)。
函数说明: 调用sigsuspend
函数时,进程的信号屏蔽字
由sigmask
参数指定,可以通过指定sigmask
来临时的解除对某个信号的屏蔽
,然后将进程挂起等待。当sigsuspend
返回时,进程的信号屏蔽字
恢复为原来的值,如果原来对该信号是屏蔽
的,从sigsuspend
返回后仍然是屏蔽
的。
我们现在可以改根据这个函数修改一下MySleep函数:
- 调用
sigprocmask(SIG_BLOCK,&newmask,&oldmask)
,屏蔽SIGALRM
信号 - 调用
sigsuspend(&suspmask)
,解除对SIGALRM
信号的屏蔽,然后将进程挂起等待 SIGALRM
递达后suspend
返回,自动恢复原来的信号屏蔽字(原来的信号SIGALRM信号依然被屏蔽)
- 再次调用
sigprocmask(SIG_BLOCK,&oldmask,NULL)
解除对信号SIGALRM
的屏蔽
代码位于我的我的github上:
https://github.com/hansionz/Linux_Code/tree/master/MySleep
11.SIGCHLD信号
我们知道使用wait
和waitpid
函数清理僵尸进程
的时候,父进程可以以阻塞的方式
来等待子进程结束,也可以非阻塞式(轮询)的方式
进行等待。采用阻塞
的方式,父进程阻塞就不能处理自己
的工作,如果采用非阻塞
,父进程在处理字节集工作的时候要时不时的轮询检查
是否存在退出的子进程。事实上,子进程在终止时会向父进程发送SIGCHLD
信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD
信号的处理,这样父进程只需要关心自己的工作不必担心子进程,子进程终止时通知父进程,父进程在调用信号处理函数中调用wait
清理子进程即可。
#include <iostream>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
void handler(int sign)
{
pid_t id;
while((id = waitpid(-1, NULL, WNOHANG)) > 0)
{
std::cout << "wait child success:" << id << std::endl;
}
std::cout << "child is quit! pid is:" << getpid() << std::endl;
}
int main()
{
pid_t id;
id = fork(); // frok子进程
if(id < 0){
exit(1);
}
else if(id == 0){//child
std::cout << "i am child, my pid is:" << getpid() << std::endl;
sleep(3); // 子进程3秒后退出
}
else{//parent
signal(SIGCHLD, handler); //父进程捕捉SIGCHLD信号
while(1)
{
std::cout << "parent proc is doing thing" << std::endl;
sleep(1);//父进程不用wait,只需要在handler里wait即可,父进程可以做自己的事
}
}
return 0;
}
下边是运行结果: