1.进程信号概念
信号是一个软件中断,通知进程某个事件发生了异步事件,打断进程当前的操作,去处理这个事件,信号是多种多样的,并且一个信号对应一个事件,这样才能做到进程收到一个信号后,知道是什么事件,应该如何去处理
1.1 信号的查看
使用 kill -l 命令查看信号种类
信号种类62种,其中,
1-31号信号都是非可靠信号(从unix借鉴而来,每个信号都有具体对应的系统事件,有可能会信号丢失)
34-64号信号 都是可靠信号(没有具体对应的事件,不会丢失信号)
这些信号各自在什么条件下产生, 默认的处理动作是什么, 可通过命令
man 7 signal 查看详细说明
1.2 信号的生命周期
信号产生->在进程中注册->在进程中注销->捕捉处理(从信号发送到信号处理函数执行完毕)
2. 信号的产生
我们平常在Linux系统下所写的程序遇到死循环等问题时,退不出来, 常用 ctrl+c / ctrl+z /ctrl + | / kill + pid 解决, 这里的这些组合键盘输入就是产生了一个信号,以死循环程序为例说明:
#include<iostream>
#include<unistd.h>
int main(){
pid_t pid = getpid(); // 获取当前进程pid
while(1){
std::cout << "死循环-" << pid << std::endl;
sleep(1);
}
return 0;
}
2.1 通过终端按键产生信号
- ctrl + c 产生的是 2号(SIGINT)终止信号,终止正在运行的前台进程,对后台进程无效。
前台进程在运行过程中用户随时可能按下 ctrl+c产生一个信号,该进程的用户空间代码执行到任何地方都有可能收到 SIGINT信号而终止, 所以信号对于进程来说是异步的
- ctrl + z 产生的是 20号(SIGTSTP)暂停信号,暂停正在运行的前台进程。 状态 T 表示是停止状态,可以通过发送 SIGCONT 信号让进程继续运行。(进程信息的查看可以参考这里)
2.2 通过调用系统函数产生信号
- kill函数可以给一个指定的进程发送指定的信号
int kill(pid_t pid, int sig);
kill + pid 默认发送15号信号。 本质上就是向一个进程发送了终止信号,进程收到这个信号并且处理才会退出。如果对于处在停止状态的进程, 进程没有在运行,意味着不会去处理这个信号, 所以 kill + pid 不能终止处于停止状态的进程(kiil -9 + pid 不在讨论范围)
- raise函数可以给当前进程发送指定信号
int raise(int sig);
- abort 函数给当前进程发送 SIGABRT(6号) 信号,使当前进程收到信号后异常终止, 通常用于异常通知
void abort();
2.3 通过软件条件产生信号
- alarm 函数,等待设置的时钟周期后, 向进程发送SIGALRM 信号, 该信号的默认处理是终止当前进程。
int alarm(int seconds)
2.4 硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
3. 信号的相关概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞(Block )某个信号。 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
(阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。)
3.1 信号在内核中的表示
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数处理。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。
3.2 信号在进程中的注册
为了让进程知道自己收到了某个信号,
- 在PCB中有一个未决信号 pending 集合,信号的注册就是指在这个 pending 集合中标记对应信号数值的二进制位为1
- 信号的注册不仅会修改位图, 还会为信号组织一个sigqueue 节点添加到PCB中的sigqueue链表中
PCB—> struct sigpending —> struct sigset_t
sigset_t 这个结构体中只有一个数组成员;这个数组成员用于实现一个位图------表示未决信号集合------表示收到了但是还没有被处理的信号集合
给进程一个信号,就会将这个位图对应位置置1,表示当前进程收到了这个信号;(由于位图只有0和1, 所以只能表示收到了这个信号, 但是无法表示收到了多少个这样的信号)
- 非可靠信号 注册一个信号最多只有一个节点
- 可靠信号的注册,同一个信号允许存在多个相同节点
- 位图只是表示一个信号有没有, 而节点可以反映一个信号有多少个
3.3 信号的销毁
为了保证一个信号只会被处理一次, 因此事先注销在处理;在PCB中删除当前信号信息
将 pending 位图置0, 删除要处理信号的 sigqueue 节点
- 若信号是非可靠信号, 则直接将位图置0(非可靠信号在没有被处理前只会注册一次)
- 若信号是可靠信号,则删除后,需要判断是否还有相同节点, 没有的话才会重置位图为0
4 信号的处理方式
信号的处理:信号表示一个事件的到来,处理事件就像是完成某个功能, 在C语言中完成一个功能的最小模块就是函数,也就是说每一个信号都有对应的处理函数,信号到来,去处理这个事件,也就是去执行对应的处理函数。
信号的处理方式:
- 忽略该信号----(什么也不做)
- 执行该信号的默认处理动作----(操作系统中针对该信号默认的处理方式)
- 提供一个信号处理函数, 内核在处理该信号时切换到用户态执行这个处理函数,也称为捕捉一个信号。----(信号的捕捉处理)
4.1 信号的捕捉处理
以具体代码来加深对信号的捕捉处理的理解
首先,学习相应是函数接口:
// 定义函数指针
typedef void (*sighandler_t)(int);
// 修改信号的回调函数
sighandler_t signal(int signum, sighandler_t handler)
handler:----用户自己定义的一个处理函数 没有返回值, 有一个 int 型参数的函数地址
SIG_DFL----默认处理方式
SIG_IGN----忽略处理方式
// 修改信号的整个处理动作
sigaction(int signum, struct sigaction *new, struct sigaction *old);
调用成功则返回0,出错返回-1;
signum是指定信号的编号;
new和old指向sigaction的结构体。
若new指针非空,则根据new修改该信号的处理动作;若old指针非空,则通过old传出该信号原来的处理动作;
示例代码:
/*
* 学习修改信号的处理方式
* sighandler_t signal(int signum, sighandler_t handler) 接口使用*/
#include<stdio.h>
#include<stdlib.h>
#include<signal.h>
#include<unistd.h>
void sigcb(int signum)
{
printf("recv a signal num : %d\n", signum);
}
int main()
{
// 1.将 SIGINT 处理方式修改为 SIG_IGN 忽略处理方式
//signal(SIGINT, SIG_IGN);
// 2.将 SIGINT 处理方式修改为 自定义 处理方式
// 只是修改了内核中信号的回调函数指针 ---当信号到来的时候才会调用这个回调函数,并且通过参数传入当前触发回调函数的信号值
signal(SIGINT, sigcb);
while(1){
printf("我好冷啊!!!\n");
sleep(10);
}
可以看出,信号会打断进程当前的操作
4.2 信号的捕捉处理流程
信号的处理是在程序运行从内核态切换回用户态之前, 默认/忽略处理 直接在内核中完成处理, 而用户自定义信号处理方式, 则需要返回用户态执行回调函数, 完成后返回内核态, 最终没有信号处理了, 再返回程序主控流程
1.当程序在用户态主控流程运行的时候, 会由于 系统调用 \ 异常 \ 中断 切换到内核态运行
2. 默认处理方式调用的函数与 忽略处理方式调用的函数都是系统中已经实现的-----内核中直接处理
信号的处理,是程序运行从内核态返回用户态之前处理的
用户态运行: 用户编写的程序或者库函数,运行在用户态.
内核态运行: 运行内核中的代码来完成某个内核功能
5 信号的阻塞
组织信号的递达, 信号依然可以注册,只是暂时不处理, 在PCB中有一个 blocked位图(阻塞信号集合),凡是添加到这个集合中的信号,都表示需要阻塞,暂时不处理
步骤:
- 将一些信号的处理函数自定义
- 将所有的信号都给阻塞
- 在解除阻塞之前, 向进程发送信号
- 解除阻塞,查看信号的处理情况
int sigprocmask(int how, sigset_t *set, sigset *old);----
how : 当前要对 block 集合进行的操作
SIG_BLOCK 将set集合中的信号添加到block进程阻塞信号集合中 block = block | set
表示阻塞set集合中的信号以及原有的阻塞信号, 并且将原有阻塞的信号返回到old集合中(便于还原)
SIG_UNBLOCK 将set集合中的信号从block集合中移除, 将set集合中的信号解除阻塞
block = block & (~set) 10110100 & ~00100000 11011111
SIG_SETMASK 直接将内核中的 block阻塞集合中的信号修改为set集合中的信号, block = set
set
// 清空set信号集合---使用一个变量的时候的初始化过程
int sigemptyset(sigset_t *set);
// 向set集合中添加指定信号
int sigaddset(sigset_t *set, int signum);
// 将所有信号添加到set集合中
int sigfillset(sigset_t *set);
// 从set集合中移除指定的信号
int sigdelset(sigset_t *set, int signum);
代码示例:
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<signal.h>
void sigcb(int signum){
printf("recv a signal: %d\n", signum);
}
int main(){
signal(SIGINT, sigcb);
signal(SIGRTMIN+4, sigcb);
sigset_t set;
sigemptyset(&set); // 清空集合, 防止未知数据造成影响
sigfillset(&set); // 向集合中添加所有信号
sigprocmask(SIG_BLOCK, &set, NULL); // 阻塞set集合中的所有信号
printf("press enter continue\n");
getchar(); // 等待一个回车, 如果不按回车就一直卡在这里
sigprocmask(SIG_UNBLOCK, &set, NULL); // 解除阻塞
while(1){
sleep(1);
printf("等待一秒\n");
}
return 0;
}
需要注意的是:
SIGKILL -9
SIGSTOP -19
在所有信号中, 这两个信号不可被阻塞, 不可被自定义修改处理方式, 也不可被忽略
联系之前总结的僵尸进程
SIGCHLD 信号(17号信号—非可靠信号):
僵尸进程: 子进程退出后, 操作系统发送 SIGCHLD 信号给父进程, 但是因为 SIGCHLD 信号的默认处理方式就是忽略, 因此在之前的程序中并没有感受到操作系统的通知, 因此只能固定的使用进程等待来避免产生僵尸进程, 但是在这个过程中父进程是一直阻塞的, 只能一直等待子进程退出
如何让程序感知到操作系统的通知?
在程序初始化阶段, 将SIGCHLD 信号的处理方式自定义, 并且在自定义函数重掉 waitpid ,这样的话就当子进程退出的时候, 则自动回调处理了, 父进程就不需要一直等待了
多个子进程同时退出, 都会向父进程发送 SIGCHLD 信号, 但是 SIDCHLD信号是非可靠信号, 有可能会丢失事件。
例如: 三个子进程同时退出, 但是信号只注册一次, 意味着 只会执行一次回调函数, 调用一次 waitpid , 只能处理一个僵尸进程
非可靠信号的丢失是无法避免的 因此只能在一次信号回调中处理完所有的僵尸进程