信号:信号是进程之间事件异步通知的一种方式,是一个软中断
作用:操作系统通过信号告诉进程发生了某个事件,打断进程当前的操作,去处理这个事件
信号在我们生活中无处不在,例如交通上的红绿灯,学校上下课的铃声,而且肯定是一个信号对应一个事件,并且我们能够识别这个信号
在操作系统中的信号也是如此,在linux下我们可以通过kill -l
来查看信号的种类
但是我们会发现,没有32和33号信号,所以信号总数共有62种
1~31号信号是从unix借鉴而来的,每个信号都有具体对应的系统事件,但是它们是非可靠信号,也就是有可能会使信号丢失----事件丢失
34~64号信号是后期扩充的,它们都没有具体对应的事件,起的名字也比较粗糙,但是它们是可靠信号,也就是不会使信号丢失,肯定能传达到进程
信号的生命周期:产生->在进程中注册->在进程中注销->处理
信号的产生
信号的产生环境有两种,分别是硬件产生和软件产生
硬件产生
在linux上,我们可以使用ctrl+c
、ctrl+z
、ctrl+\
来产生硬件信号,操作系统再通过硬件信号转换成数字信号传递到进程,从而达到中断进程的作用
软件产生
kill -signum pid
命令,中间的signum就是我们的信号值,例如-9对应的事件是SIGKILL
,就是无论处于什么状态,都要去处理这个信号。例如我们的一个进程当处于暂停态时,如果不加上-9,默认是15号信号SIGTERM
终止进程,是杀不死这个进程的,因为处于暂停态,此时进程不会处理任何事情。
代码实现
接口kill(pid_t, int sig)
参数内容(进程id,信号值)给指定进程发送指定信号
接口raise(int sig)
给自己发送指定信号
接口abort()
给进程自身发送SIGABRT
信号,通常用于异常通知
接口alarm(int seconds)
seconds秒之后给进程自己发送SIGALRM
信号----俗称定时器
在进程中注册信号
在进程中注册信号,就是让进程知道自己收到了信号
在进程pcb中有struct sigpending
,里面有struct sigset_t
,在这个结构体中,只有一个数组成员,这个数组是用于实现一个位图,一共有64个元素。在没有收到信号之前,位图元素全部归0,000000...
当进程收到信号时,会在这个位图中对应的位置置1,例如给进程发送3号信号,位图变为000100...
,通过修改位图,从而注册信号。这个位图也称为是未决信号集合----收到但是没有处理的信号集合。位图数组中只有0/1,只能表示是否收到了某个信号,但是不能表示收到了多少个同样的信号;信号的注册其实不仅会修改位图,还会为信号组织一个sigqueue节点添加到pcb的sigqueue链表中。
1~31号非可靠信号的注册方式:若信号注册的时候,位图为0,则会创建一个sigqueue节点并修改位图为1,如果下次进来同样的信号,此时位图为1,此时就会将进来的信号丢掉
34~64号可靠信号的注册方式:不管位图当前是否为0,都会创建一个节点,添加到链表中,并修改位图置为1,此时加到链表就可以知道同样信号来的次数
在进程中注销信号
注销信号的原因是保证信号只会被处理一次,因此在处理信号之前要注销信号,在pcb中删除当前信号的信息。注销后会立即去处理刚注销的信号
1~31号非可靠信号的注销方式:因为非可靠信号只会有一个节点,因此删除节点后,位图直接置0
34~64号可靠信号的注销方式:因为可靠信号有可能同个信号被注册多次,有多个节点。因此删除节点后,需要判断是否存在相同节点,若没有相同节点才能将位图置0
信号的处理
信号表示一个事件的到来,处理事件就是完成功能,在c语言中完成一个功能的最小模块是函数。每个信号都会有对应自己事件的处理函数,当信号到来的时候,就会去执行这个处理函数,当处理函数执行完,也就表示该事件被处理完了
信号的处理方式
- 默认处理方式:操作系统中原定义好的每个信号的处理方式
- 忽略处理方式:处理方式就是忽略,不做任何处理
- 自定义处理方式:自己定义事件回调函数,修改内核中信号回调函数指针的指向,当信号到来就会调用我们自定义的回调函数了
通过pending位图中置为1的信号值,去handler信号处理数组中找对应的回调函数指针,然后进去执行这个指针指向的函数完成对事件的处理。
接口
//定义了一个名字叫sighandler_t的函数指针类型
typedef void (*sighandler_t)(int)
sighandler_t signal(int signum, sighandler_t handler)
参数内容(signum:信号值;handler :SIG_DFL
-默认处理方式;SIG_IGN
-忽略处理方式;用户自己定义一个没有返回值,有一个int型参数的函数地址
)
忽略处理方式演示
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
signal(SIGINT,SIG_IGN);
while (1)
{
printf("Hello WhiteShirtI\n");
sleep(10);
}
return 0;
}
将SIGINT
信号的处理方式改为忽略处理方式,当程序运行时按下ctrl+c
就无法终止程序
自定义处理函数演示
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
//自定义的处理函数
void sigcb(int signo)
{
printf(" recv a signal no:%d\n",signo);
}
int main()
{
signal(SIGINT,sigcb);
signal(SIGQUIT,sigcb);
while (1)
{
printf("Hello WhiteShirtI\n");
sleep(10);
}
return 0;
}
将SIGINT
信号的处理方式改为自定义处理方式,当程序运行时按下ctrl+c
就会去执行我们的信号处理函数。注意:只有当信号到来才会调用sigcb这个函数,并且通过参数传入当前触发回调函数的信号值。信号到来会打断当前进程的操作,当信号处理完后就继续执行进程,所以会出现每次发送信号处理完后都会打印一次数据
自定义处理方式的信号捕捉流程
信号的阻塞
信号的阻塞并不是不接受信号,信号可以正常注册,只是标识了哪些信号暂时不处理
在pcb中有一个block位图,也叫阻塞信号集合,如果存在这个位图中的信号来了,即使添加到了pending位图中,也会暂停处理
阻塞流程
- 自定义信号处理函数
- 将信号置为阻塞
- 在解除阻塞之前给进程发送信号
- 解除阻塞,查看信号的处理情况
如何阻塞一个信号
接口int sigprocmask(int how, sigset_t *set, sigset_t *old)
参数内容(how和set和old
:how有三个参数:SIG_BLOCK表示将set集合中的信号添加到内核中的block阻塞信号集合中,使用old保存原来的阻塞信息以便于还原,set | block;
SIG_UNBLOCK表示将set集合中的信号从内核中的block阻塞信号集合中移除,对set集合中的信号解除阻塞~set & blcok;
SIG_SETMASK表示将内核中的block信号集合内容设置为set集合中的信息。阻塞集合中的信号,block=set)
接口int sigemptyset(sigset_t *set)
清空set信号集合,初始化set集合
接口int sigaddset(sigset_t *set, int signum) 向set集合中添加指定的信号
接口int sigfillset(sigset_t *set)
将所有信号添加到set集合中
接口int sigdelset(sigset_t *set, int signum)
从set集合中移除指定的信号
接口int sigismember(const sigset_t *set, int signum)
判断指定信号是否在set集合中
代码测试:
mask.c
//mask.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
void sigcb(int signo)
{
printf("recv a signal:%d\n",signo);
}
int main()
{
signal(SIGINT, sigcb);
signal(SIGRTMIN+4, sigcb);
sigset_t set;
//清空集合,防止未知数据造成影响
sigemptyset(&set);
//向集合中添加所有信号
sigfillset(&set);
//阻塞set集合中的所有信号
sigprocmask(SIG_BLOCK, &set, NULL);
printf("press enter continue\n");
//等待一个回车,如果不按回车就一直卡在这里
getchar();
//对set集合中的信号解除阻塞
sigprocmask(SIG_UNBLOCK, &set, NULL);
while (1)
{
sleep(1);
}
return 0;
}
运行mask程序,我们给进程发送了SIGINT和SIGRTMIN+4都被阻塞了,ctrl+c
对应的信号也被阻塞了
当我们按下回车,解除信号,进程就会去执行我们发送的两个信号SIGINT和SIGRTMIN+4,我们会发现,可靠信号SIGRTMIN+4是收到多少信号就执行多少次处理函数,而非可靠信号是在阻塞时无论发送多少次,最后都只执行一次处理函数,发送了信号丢失事件。这就是非可靠信号和可靠信号的区别
但是我们要知道有两个特殊的信号是无法被阻塞的,SIGKILL-9
和SIGSTOP-19
这两个信号不可被阻塞,不可被
忽略,不可被自定义处理。
示例:
1、在命名管道中,如果管道所有读端被关闭,则继续写入会触发异常并退出,其实是触发了信号SIGPIPE-13
,如果我们不想让程序退出,我们可以将该信号对应的处理函数改变成我们自定义的处理函数,这样子进程就不会退出,而是进程写入会触发信号就可以执行我们信号对应的信号处理函数了
2、僵尸进程,在子进程退出后会向父进程发送SIGCHLD-17
信号通知父进程,让它知道子进程的状态改变了。但是因为SIGCHLD信号默认的处理方式是忽略处理,因此如果父进程不进行进程等待,信号就会被忽略处理,父进程就不知道子进程退出,子进程就成为僵尸进程。但是父进程进行进程等待,就会使父进程发生阻塞。如果我们想让父进程等待又不阻塞,我们就可以自定义SIGCHLD信号的处理方式,在自定义回调函数中调用waitpid,处理僵尸进程,只要当子进程退出时就会调用回调函数,这时父进程就不会发生阻塞了
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
void sigcb(int signo)
{
int pid = waitpid(-1, NULL, 0);
printf("child prcess %d exited\n", pid);
}
int main()
{
//发送SIGCHLD信号
signal(SIGCHLD, sigcb);
pid_t pid = fork();
if (pid == 0)
{
sleep(5);
exit(0);
}
while (1)
{
printf("Hello WhiteShirtI\n");
sleep(1);
}
return 0;
}
这样子父进程就没有阻塞了,但是SIGCHLD信号是一个非可靠信号,如果有多个子进程同时退出,有可能造成信号丢失。这时候我们可以在回调函数中进行循环非阻塞等待,将所有僵尸进程全部处理掉
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
void sigcb(int signo)
{
int pid;
//循环非阻塞等待
while ((pid = waitpid(-1, NULL, WNOHANG)) > 0)
{
printf("child prcess %d exited\n", pid);
}
printf("所有的僵尸进程都被处理完毕\n");
}
int main()
{
//发送SIGCHLD信号
signal(SIGCHLD, sigcb);
pid_t pid = fork();
if (pid == 0)
{
sleep(5);
exit(0);
}
pid = fork();
if (pid == 0)
{
sleep(5);
exit(0);
}
while (1)
{
printf("Hello WhiteShirtI\n");
sleep(1);
}
return 0;
}
知识扩展
关键字volatile
volatile作用:用于修饰一个变量,保持变量的内存可见性,防止编译器的过度优化
cpu处理一个数据的过程是从内存中将数据加载到寄存器上进行处理
在gcc编译器中,在编译程序的时候,如果使用了代码优化 -Olevel
选项,发现某个变量使用频率非常高,为了提高效率,则直接将变量的值设置为某个寄存器的值,以后访问的时候直接从寄存器访问,则减少了内存访问的过程,提高了访问效率
但是有时候这种优化有时候会造成代码的逻辑混乱,例如以下程序
我们想通过ctrl+c来改变循环的条件,让该循环结束
//volatile.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
long long a = 1;
void sigcb(int signo)
{
a = 0;
printf("a = %d\n", a);
}
int main()
{
//当按下ctrl+c时会调用自定义回调函数
signal(SIGINT, sigcb);
while (a){
};
printf("exited a = %d\n", a);
return 0;
}
以gcc -O2
优化编译volatile.c生成volatile程序,再执行volatile程序,当我们给进程发送信号,也就是按下ctrl+c去改变a的值时,循环并没有停下来,还继续运行中。
这时候volatile就是解决这种优化过度的问题,我们在定义变量a前添加关键字volatile,让cpu不管该变量的使用频率有多高,每次都重新到内存中获取数据
//volatile.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
volatile long long a = 1;
void sigcb(int signo)
{
a = 0;
printf("a = %d\n", a);
}
int main()
{
//当按下ctrl+c时会调用自定义回调函数
signal(SIGINT, sigcb);
while (a){
};
printf("exited a = %d\n", a);
return 0;
}
我们会发现,按下ctrl+c,a的值改变为0后,程序就跳出循环打印后边的数据,从而结束程序。
函数的可重入与不可重入
函数的重入:在多个执行流程中,同时进入一个函数并运行
例如下面程序,我们按下ctrl+c会去执行我们信号处理函数,主控流程也会去执行下一条语句,而这个执行的处理函数和我们主控流程要执行的函数是同一个函数,这就叫做函数的重入
#include <stdio.h>
#include <unistd.h>
int a = 1;
int b = 1;
int test()
{
a++;
b++;
return a + b;
}
void sigcn(int signo)
{
printf("signal sum:%d\n", test());
}
int main()
{
signal(SIGINT, sigcb);
printf("main sum:%d\n",test());
return 0;
}
函数的可重入:指的是函数重入之后,不会造成数据二义或者逻辑混乱
函数的不可重入:指的是函数重入之后,有可能造成数据二义或者逻辑混乱
让上面程序的test()函数睡上3秒
#include <stdio.h>
#include <unistd.h>
int a = 1;
int b = 1;
int test()
{
a++;
sleep(3);
b++;
return a + b;
}
void sigcn(int signo)
{
printf("signal sum:%d\n", test());
}
int main()
{
signal(SIGINT, sigcb);
printf("main sum:%d\n",test());
return 0;
}
运行rentry程序,不发送信号,也就是不按下ctrl+c,程序正确打印
运行rentry程序,发送信号,也就是按下ctrl+c,程序输出后的结果是混乱的,所以上面的test函数是不可重入函数。
函数是否可重入的判断基准
这个函数中是否对全局变量进行了非原子的操作
操作的原子性:操作一次完成,中间不会被打断
原子操作:要操作就直接一次性完成,如果不是一次性完成就不做
函数可重入
- 一个函数若没有对全局变量进行操作,则是可重入函数,因为每个函数的调用都会有自己独立的函数栈
- 一个函数若对全局变量进行操作,但是操作是原子性的,则是可重入函数