1、信号概述
(1) 信号是什么?
信号是在软件层次上对中断机制的一种模拟,是一种异步通信方式。
- 软件层次对于中断机制的模拟
说中断,一般针对的是cpu。就是说cpu正在处理一个进程,通过发送中断请求,可以让cpu先暂时停止手中的活儿,来处理更紧急的事情。而说信号是对中断的模拟是指,正在运行的程序,被发来的信号打断,取执行别的更紧急的事情。因此,信号操作的(针对的)对象是进程。 - 异步通信方式
异步指的是一个进程不必通过任何操作来等待信号的到达,事实上,进程也不知道信号到底什么时候到达。
(2)信号用来干什么?
上面说了,信号是一种通信方式,那它的作用一定是用来通信的。信号可以用来进行进程之间的通信,因为我们知道每个进程的内存空间是独立的,那么进程之间想要通信(相互发送信息),那么就需要一种机制来实现。在linux中有好几种这样的机制,其中信号机制就是其中的一种。信号的具体作用是:
- 实现用户空间和用户空间进程之间的通信。(比如:进程A向进程B发送一个信号,这个信号的目的是将进程B给终止了。)
- 实现内核空间和用户空间之间的通信。(比如:通知用户进程发生了某个系统事件。)
2、信号的分类
信号可以分为可靠信号(实时信号)和不可靠信号(非实时)。
(1)那么什么是可靠信号,什么是不可靠信号呢?
见名便可知其义。比如进程A和进程B进行通信。
- 那么可靠信号指的就是,A发送了一个信号,B一定能够接收到,并且记着我有这么一个信号等着我处理,A再给B发同一个信号,B能知道A给B发了两次(这两次信号被记录在一个队列里)。并且能处理两次。这种通信就是可靠的。
- 而不可靠信号就是,进程A发送了一个信号给进程B,说我需要X资源一个,还没等B处理这个事儿,过了一会进程A又发送了一个信号给进程B,又发送了我需要X资源一个。那么进程B会忘记A第一次发送的资源X,它只会给A一个资源X(不可靠信号没有队列来记录信号发送了几次。大于一次只会按一次处理)。这个时候就产生了信号的丢失。这种通信就是不可靠的。
(2)既然可靠信号这么好,为啥还要不可靠信号呢?
这其实是一个历史遗留问题,Linux信号机制基本上是从Unix系统中继承过来的。早期Unix系统中的信号机制比较简单和原始,信号机制是不可靠信号机制。随着时间发展,又发明了新的可靠的信号机制。为了和原来的机制兼容,老的和新的都存在与系统中。
(3)怎么分辨老的信号(不可靠信号)和新的信号呢(可靠信号)呢?
在linux上的体现是:信号值位于SIGRTMIN和SIGRTMAX之间的信号都是可靠信号。也就是linux定义的信号的个数是有限的,而且每个信号只是一个数值,这些数值都定义了一个名字,如下:
3、处理信号的方式
当某个进程接收到一个信号时,有三种处理方式可选:
- 方式一:是类似中断的处理程序,对于需要处理的信号,进程可以指定处理函数,由该函数来处理。
- 方式二:忽略某个信号,对该信号不做任何处理,就象未发生过一样。
- 方式三:对该信号的处理保留系统的默认值,这种缺省操作,对大部分的信号的缺省操作是使得进程终止。进程通过系统调用signal来指定进程对某个信号的处理行为。
怎样选择如何处理一个信号的方式?
这属于信号的部署,也就是我们在写一个程序时,就要按照我们的需求,提前定义好当程序接受到某个信号时,我们以什么样的方式处理。
3、信号在操作系统中的表示
我们知道,内核以一个task_struct 结构变量来标识一个进程。而我们的信号在操作系统中的体现,也记录在这个结构体中。
(1)解读上述图片
如上图,task_struct 表示一个进程表。我们可以认为,这这个进程表中,有一个字段为一个表格。我们可以认为这个表格有三列,分贝记录了我们是否有等待处理的信号(pending 列),以及这个信号是否被阻塞着(block 列),以及处理这个信号的方式是什么(handler)(这是一个函数指针),每个信号都有这三个标志位。
信号产生时,内核在进程控制块中设置该信号的未决标志(表示某信号是否发生过),直到信号处理完毕才清除该标志。在上图的例子中:
(2)表格的第三列:信号的三种处理方式
- SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能投递(暂时不能处理)。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。 信号未决。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集。这个类型可以表示每个信号的“有效”或“无效”状态。在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞。而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。(有关信号集的概念后面还有补充。)
(3)在上面的图片中,记录了三个信号的状态信息:
-
第一行,记录着SIGHUP信号的信息,即:
现在还没有收到这个信号,如果收到的话,信号不会被阻塞,而是马上处理,处理这个信号的方式是采用SIG_DFL,即默认的方式处理。默认的方式就是系统预定义的方式。 -
第二行,记录着SIGINT信号的信息,即:
现在已经收到这个信号,但是信号被阻塞着,像这种状态下的信号,我们给它起了一个名字叫信号未决。如果信号解除阻塞的话,处理这个信号的方式是:SIG_IGN。即忽略这个信号。 -
第三行,记录着SIGQUIT信号的信息,即:
现在还没有收到这个信号,如果收到信号被阻塞着。如果收到这样的信号,信号将被阻塞。如果信号解除阻塞的话,处理这个信号的方式是:采用我们定义的函数处理。(这种方式就需要我们采用安装信号的函数设置处理的函数。)
(4)表格中pending列的背后
实际上,进程结构体中,存储的不像我们上述说的那么简单,在pending列的背后,还关联着一些别的信息。
实际上,一个进程中所有pending 被置位(未决信号)的信号被记录在一个结构中(相当于表格中的一整列,这一列不仅记录了信号是否是未决信号,还绑定着别的信息。)。个结构如下:
struct sigpending{
struct sigqueue *head, *tail;
sigset_t signal;
};
结构的第三个成员,是进程中所有未决信号集,我们在假设的表格中看到的整个pending列,就是把signal 变量按位一个一个向下展开的内容。第一、第二个成员分别指向一个sigqueue类型的结构链(称之为"未决信号信息链")的首尾,信息链中的每个sigqueue结构刻画一个特定信号所携带的信息,并指向下一个sigqueue结构:
struct sigqueue{
struct sigqueue *next;
siginfo_t info;
}
信号在进程中注册(信号产生后,内核自动帮我们注册。)指的就是信号值加入到进程的未决信号集:sigset_t signal(每个信号占用一位)中,并且信号所携带的信息被保留到未决信号信息链的某个sigqueue结构中。只要信号在进程的未决信号集中,表明进程已经知道这些信号的存在,但还没来得及处理,或者该信号被进程阻塞。
当一个可靠信号发送给一个进程时,不管该信号是否已经在进程中注册,都会被再注册一次,因此,信号不会丢失。这意味着同一个可靠信号可以在同一个进程的未决信号信息链中占有多个sigqueue结构(进程每收到一个可靠信号,都会为它分配一个结构来登记该信号信息,并把该结构添加在未决信号链尾,即所有诞生的可靠信号都会在目标进程中注册)。
当一个不可靠信号发送给一个进程时,如果该信号已经在进程中注册(通过sigset_t signal指示),则该信号将被丢弃,造成信号丢失。这意味着同一个非实时信号在进程的未决信号信息链中,至多占有一个sigqueue结构。
4、信号集概念
信号集是一种数据结构,它表示多个信号的集合。
typedef struct {
unsigned long sig[_NSIG_WORDS];
} sigset_t
定义这个数据的类型的目的是方便告诉内核哪些某一类信号不应该发生。信号集中,每个信号占用一位。有关信号集的处理函数如下:
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum)
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
sigemptyset(sigset_t *set)初始化由set指定的信号集,信号集里面的所有信号被清空;
sigfillset(sigset_t *set)调用该函数后,set指向的信号集中将包含linux支持的64种信号;
sigaddset(sigset_t *set, int signum)在set指向的信号集中加入signum信号;
sigdelset(sigset_t *set, int signum)在set指向的信号集中删除signum信号;
sigismember(const sigset_t *set, int signum)判定信号signum是否在set指向的信号集中。
5、使用信号机制通信的流程
(1)信号的部署
信号的部署是指,设置未决信号的处理方式:
- 信号发生了怎么处理(阻塞、忽略、系统默认、用户自定义)
注意:
如果发送给一个处于可运行状态的进程,则只置相应的域即可。如果信号发送给一个正在睡眠的进程,如果进程睡眠在可被信号中断的优先级上,则会因为信号临时唤醒进程;否则仅设置进程表中信号域相应的位,而不唤醒进程。
信号的部署(disposition)信息是per-process的,这意味这对于多线程的进程,共享同一份信号部署。
如果将处理方式设置为执行用户的自定义函数,那么则需要安装信号函数来指定信号产生时执行的函数。Linux在支持新版本的信号安装函数sigation()以及信号发送函数sigqueue()的同时,仍然支持早期的signal()信号安装函数,支持信号发送函数kill()。新版本的信号安装函数是针对新的信号写的函数,但是它兼容旧信号。
1)旧版本信号安装函数
#include <signal.h>
void (*signal(int signum, void (*handler))(int)))(int);
-
函数功能:
部署指定信号的处理方式 -
函数参数:
参数1: signum:
指定信号的signum
参数2: handler:信号的处理方式
SIG_IGN:忽略该信号
SIG_DFL:采用系统默认方式处理
指向自定义信号处理函数的函数指针 -
函数返回:
成功:返回之前信号处理函数的地址
失败:SIG_ERR
2)新版本的信号安装函数
#include <signal.h>
int sigaction(int signum,const struct sigaction *act,struct sigaction *oldact));
-
函数功能:部署指定信号的处理方式
-
函数参数:
参数1:为信号的值,可以为除SIGKILL及SIGSTOP外的任何一个特定有效的信号(为这两个信号定义自己的处理函数,将导致信号安装错误)
参数2:是指向结构sigaction的一个实例的指针,在结构sigaction的实例中,指定了对特定信号的处理,可以为空,进程会以缺省方式对信号处理。
参数3:oldact指向的对象用来保存返回的原来对相应信号的处理,可指定oldact为NULL
-
函数返回:
注意:第二个参数最为重要,其中包含了对指定信号的处理、信号所传递的信息、信号处理函数执行过程中应屏蔽掉哪些信号等等。如果把第二、第三个参数都设为NULL,那么该函数可用于检查信号的有效性。
(2)信号的产生
信号事件的发生主要有两个来源(信号产生的方式):
1)硬件来源
- 用户在终端按下某些键时,终端驱动程序会发送信号给前台进程,例如ctr+c产生SIGINT,该信号会终止进程。 ctr + 产生SIGQUI信号,和Ctrl+C类似,ctr + z产生SIGTSTP,暂停进程并放到后台,即挂起。
- 硬件异常产生信号,这些条件由硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元(ALU)会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给当前进程 (产生段错误)。
2)软件来源
- 系统调用:一个进程调用int kill(pid_t pid,int sig)函数可以给另一个进程发送信号、raise函数等
- 终端命令:可以用kill命令给某个进程发送信号,如果不明确指定信号则发送SIGTERM信号,该信号的默认处理动作是终止进程。
- 当内核检测到某种软件条件发生时也可以通过信号通知进程,例如子进程结束,内核给父进程发送SIGCHILD;闹钟超时产生SIGALRM信号(内核设定闹钟);向读端已关闭的管道写数据时产生SIGPIPE信号。
(3)信号的发送
发送信号的主要函数有:kill()、raise()、 alarm()等,下面介绍几个常用的发送信号的函数。
第一个函数:kill()
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid,int signo)
-
函数功能:
向指定的进程或进程组发送信号 -
函数返回值:
成功:返回0
失败:返回-1 -
函数参数:
参数1: pid:
pid > 0 : 将该信号发送给进程ID为pid的进程
Pid == 0:将该信号发送给发送进程所属进程组的所有进程(不包括内核进程和init进程)
pid == -1:将该信号发送给发送进程所有有权限发送的进程(不包括内核进程和init进程)
Pid < 0:将该信号发送给器进程组ID等于pid绝对值,而且发送进程具有权限向其发送信号的进程。参数2:sig:
需要发送的信号的signum(信号的种类如图所示)
第2个函数:raise函数
#include <signal.h>
int raise(int signo)
-
函数功能:
向发送信号的进程或线程本身发送信号,等价于kill(getpid(), sig)或者是pthread_kill(pthread_self(), sig);(向线程发送信号) -
函数返回值:
成功:返回0
失败:返回非0 -
函数参数:
参数1:sig,需要发送的信号的signum
第3个函数:alarm函数
#include <unistd.h>
unsigned int alarm(unsigned int seconds)
-
函数功能:
alarm()也称为闹钟函数,它可以在进程中设置一个定时器。当定时器指定的时间到时,内核就向进程发送SIGALARM信号。 -
函数参数:
参数1: senconds:指定的秒数
-
函数返回值:
成功:如果调用alarm()之前,进程中已经设置了闹钟时间,则返回上一个闹钟剩余的时间,如果原来没有设置闹钟,则返回0
失败: -1
注意:
- 如果不忽略或者不捕捉此信号,默认动作是终止该进程
- 经过指定秒后,信号由内核产生,由于进程调度的延迟,进程得到控制能够处理该信号还需一段时间,所以该设置的时间不会非常准确。
- 每个进程只能有一个闹钟,新闹钟会替代老闹钟。
- 如果参数seconds为0,则之前设置的闹钟会被取消 – 实践中常常会使用,防止多余的闹钟又来干扰。
第4个函数:pause函数
#include <unistd.h>
int pause(void);
- 函数功能:
用于将调用进程挂起直到收到信号为止。
注意:
- 只有执行了一个信号处理程序并从其返回时,pause才返回;如果我们没有提供处理函数,pause缺省会终止进程,就不会返回了
- 对指定为忽略的信号,pause()不会返回。只有执行了一个信号处理函数,并从其返回,puase()才返回-1,并将errno设为EINTR
(4)注册信号
当由于软件或者硬件原因向进程发送一个信号后,内核会帮我们将该进程中,与信号相关的表的pengding列置位,这就是注册了信号。
(5)信号的处理
内核处理一个进程收到的软中断信号是在该进程的上下文中,因此,进程必须处于运行状态。当其由于被信号唤醒或者正常调度重新获得CPU时,在其从内核空间返回到用户空间时会检测是否有信号等待处理。如果存在未决信号等待处理且该信号没有被进程阻塞,则在运行相应的信号处理函数前,进程会把信号在未决信号链中占有的结构卸掉。(即:当进程由内核态切换到用户态之前,会检查未处理的信号,符合处理条件就进行相应的处理。(默认方式、忽略、或者用户自定义函数。))
(6)信号的注销
- 对于不可靠时信号来说,由于在未决信号信息链中最多只占用一个sigqueue结构,因此该结构被释放后,应该把信号在进程未决信号集中删除(信号注销完毕);
- 而对于可靠信号来说,可能在未决信号信息链中占用多个sigqueue结构,因此应该针对占用sigqueue结构的数目区别对待:如果只占用一个sigqueue结构(进程只收到该信号一次),则执行完相应的处理函数后应该把信号在进程的未决信号集中删除(信号注销完毕)。否则待该信号的所有sigqueue处理完毕后再在进程的未决信号集中删除该信号。
当所有未被屏蔽的信号都处理完毕后,即可返回用户空间。对于被屏蔽的信号,当取消屏蔽后,在返回到用户空间时会再次执行上述检查处理的一套流程。
内核处理一个进程收到的信号的时机是在一个进程从内核态返回用户态时。所以,当一个进程在内核态下运行时,软中断信号并不立即起作用,要等到将返回用户态时才处理。进程只有处理完信号才会返回用户态,进程在用户态下不会有未处理完的信号。
6、总结使用信号机制进行通信的示意图
信号的处理流程如下述:
- 在执行主程序时,由于某种原因陷入到内核态。(中断,被动的进入内核态。系统调用,主动的进入内核态。)
- 在内核态执行玩代码后,返回用户态之前,内核会检车是否有等待处理的信号
- 如果有,并且处理的方式是用户自定义的函数,那么且回到用户态,执行信号处理函数,并把信号在未决信号结构中注销(删除)掉。
- 处理完后,自动回到内核态(由于特殊的系统调用)
- 内核再试图切换到用户态,检查是否有等待处理的信号
- 如果没有,那么回到第一次被信号打断的地方继续执行。
注意:
如果进程一致处于用户态,那么它不会检查是否有信号产生。只有进入内核态,并且由内核态返回的时候才会检查。 (如果我们没有通过系统调用主动进入内核态,但是进程还是会由于别的原因进入内核态的。)