信号的引入
生活中我们有很多和信号有关的例子,比如说,你在家点了个外卖,当外卖小哥到你家门口的时候,你正在打游戏,快递小哥给你敲门,相当于向你传递了一个信号,而你本身就是具有处理这种信号的能力的,但你不一定要立刻去拿,这就是异步性,因为此时的你正在打游戏,很忙,抽不开身,所以收到这个信号会暂时不处理,而当你拿到外卖后,又可以有三种选择:1.执行默认动作,把外卖吃掉 2.忽略,继续打游戏 3.执行自定义行为,把外卖送给别人吃。
操作系统层面的信号
信号是给进程发送的,而进程具有处理信号的能力。进程不仅能够识别对应的信号,也能根据信号做出相应的行为
进程具有异步性,进程是以不可预知的速度进行推进的,所以当进程收到一个信号时,可能此时正在处理很重要的事情,不能马上处理信号,但是会先记录下这个信号,等做完该做的事情了再进行处理,而在处理时有三种选择:1. 执行默认行为 2.忽略 3. 执行自定义行为
进程是如何记住信号的呢?
进程的PCB中有一个 uint_32 sig;的数据,本质上是一个32位的位图,每个比特位对应有没有信号产生,而比特位的位置是对应信号的编号。
而只有操作系统有权利对该数据结构进行修改,也就是说无论信号怎么产生,都是操作系统帮我们进行设置的。
产生信号的方式
- 键盘产生,操作系统进行发送信号
- 系统调用
- 软件条件
- 硬件异常产生信号
kill -l 可以查看所有的信号
signal函数可以用于进行将对应的信号执行自定义行为
下面做一个实验:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
void handler(int signo)
{
cout << "我收到了一个信号 :" << signo << endl;
}
int main()
{
for(int sig = 1;sig<=31;sig++)
{
signal(sig,handler);
}
while(1)
{
cout << "这是一个正在运行的进程:" << getpid() << endl;
sleep(5);
}
return 0;
}
执行下面的代码
*我们依次按下 ctrl+c ctrl+z ctrl+*
可以发现分别对应的信号是 SIGINT SIGTSTP SIGQUIT
那么问题来了,我们让所有信号都有了自己的自定义行为,那么岂不是任何信号都无法终止该进程了,该进程永远存在了?
我们输入 kill -9 4550
可以看到进程被杀掉了,说明该信号是不能被捕获并忽略的,是强制杀人手段,除非该进程在D状态!
下面介绍另一个接口,kill函数
传入指定的pid,和信号,可以对指定进程发送对应的信号
我们可以自己写一个kill 命令
#include<iostream>
using namespace std;
#include<signal.h>
#include<sys/types.h>
void Usage()
{
cout << "使用方法: ./mykill 信号编号 pid" << endl;
}
int main(int argc,char* argv[])
{
if(argc != 3)
{
Usage();
exit(0);
}
pid_t id = static_cast<pid_t>(atoi(argv[2]));
kill(id,atoi(argv[1]));
return 0;
}
时钟中断信号
alarm函数传入一个剩余时间,(单位:秒) 时间到了像进程发送 SIGALARM信号(默认行为是终止进程)。
我们可以写一个程序来比较IO和CPU的速度
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
void handler(int signo)
{
cout << "我收到了一个信号 :" << signo << endl;
exit(0);
}
int main()
{
signal(SIGALRM,handler);
alarm(1);
int cnt = 1;
while(1)
{
cnt ++;
cout << cnt << endl;
}
//看看一秒内cnt能++多少次
return 0;
}
1秒内加加 75713次左右
如果我们把cout去掉,只放到handler里面呢?
![#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
int cnt = 1;
void handler(int signo)
{
cout << "我收到了一个信号 :" << signo <<"cnt:"<< cnt <<endl;
exit(0);
}
int main()
{
signal(SIGALRM,handler);
alarm(1);
while(1)
{
cnt ++;
}
//看看一秒内cnt能++多少次
return 0;
}
4亿多次,可以看出cpu的速度比IO速度快多少了!
程序崩溃的本质
进程崩溃的本质是收到了异常的信号,从而导致程序的强制结束,我们拿除零错误举例:
当进程执行时发现除零错误时:CPU内的状态寄存器会被设置被有报错,浮点数越界,os会识别CPU的寄存器中的状态发现错误,像对应的进程发送信号。
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
void handler(int signo)
{
cout << "我收到了一个信号 :" << signo <<endl;
exit(0);
}
int main()
{
for(int sig = 1;sig<=31;sig++)
{
signal(sig,handler);
}
int *p = nullptr;
*p = 100;
return 0;
}
这是一个野指针的错误,我们运行
会发现是11号信号,也就是SIGSEGV 天天见的段错误
我们再试试除零
8号信号,也就是SIGFPE 浮点数越界
abort函数,像进程发送 SIGABRT信号 6号信号
阻塞信号
相关概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)
- 进程可以选择阻塞(Block)某个信号
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达动作
- 阻塞与忽略不同,忽略是在递达之后可选的一种处理动作
在内核中的表示
可以看到在task_struct结构体中,有两个标志位block和pending,他们都是位图结构,block中0表示该比特位对应的信号没有被阻塞,而1表示该比特位对应的信号被阻塞了,pending中0表示该比特位的信号没有产生或者已经处理完,而handler是一个函数指针数组,对应每种信号的处理方式。
拿SIGINT举例子,pending为1 说明SIGINT已经产生,但是还是未决状态,没有递达,而此时block对应的是1,也就是说该信号会被阻塞,暂时不能递达,直到解除阻塞。
sigset_t
未决和阻塞状态都可以用相同的数据类型sigset_t来存储,sigset_t也被称为信号集,这个类型可以表示每个信号的状态。
信号集操作函数
sigprocmask
用于读取或更改进程的信号屏蔽字(阻塞信号集)
如果oldset参数不为空,则读取进程当前的阻塞信号集,并从oldset传出,如果set不为空,则更改当前进程的阻塞信号集,更改方法由how决定。
sigpending
读取当前的信号屏蔽集,通过set传出。
#include<iostream>
using namespace std;
#include<unistd.h>
#include<signal.h>
void handler(int signo)
{
cout << "我收到了一个信号 :" << signo <<endl;
exit(0);
}
void showPending(sigset_t* pendings)
{
for(int sig = 1;sig <= 31;sig ++)
{
if(sigismember(pendings,sig))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
int main()
{
sigset_t bsignal,oldbsignal;
sigemptyset(&bsignal);
sigemptyset(&oldbsignal);
for(int sig = 1;sig<=31;sig++)
{
sigaddset(&bsignal,sig);
signal(sig,handler);
}
sigprocmask(SIG_SETMASK,&bsignal,&oldbsignal);
sigset_t pendings;
sigemptyset(&pendings);
int cnt = 0;
while(1)
{
sigemptyset(&pendings);
sigpending(&pendings);
showPending(&pendings);
sleep(2);
cnt ++ ;
if(cnt == 10)
{
cout << "开始解除对所有信号的block..." << endl;
sigset_t sigs;
sigemptyset(&sigs);
for(int i = 1;i<=31;i++)
{
sigaddset(&sigs,i);
}
sigprocmask(SIG_UNBLOCK,&sigs,nullptr);
showPending(&pendings);
}
}
return 0;
}
core dump
core dump就是所谓的 信息转储,当进程在运行过程中发生异常而终止,会把上下文数据core dump到磁盘上,便于调试。 也就是在进程等待waitpid接口中,status参数的低16为中的低八位的第八位
但是一般在云服务器上面 core dump会被关掉。
ulimit -c 可以查看是否打开了 core dump
ulimit -c unlimited可以打开core dump
ulimit -c 0 可以关闭core dump
我们写一个异常导致coredump
处理信号的过程
sigaction 函数
结构体的第二个 第四个 第五个成员我们可以不用管,和实时信号有关(sa_flags设置为0)
sa_sigaction:如果设置了SA_SIGINFO标志位,则会使用sa_sigaction处理函数,否则使用sa_handler处理函数。
sa_mask:定义一组信号,在调用由sa_handler所定义的处理器程序时将阻塞该组信号,不允许它们中断此处理器程序的执行,只有在调用handler方法的时候才会阻塞。
sa_flags:位掩码,指定用于控制信号处理过程的各种选项。
SA_NODEFER:捕获该信号时,不会在执行处理器程序时将该信号自动添加到进程掩码中。
SA_ONSTACK:针对此信号调用处理器函数时,使用了由sigaltstack()安装的备选栈。
SA_RESETHAND:当捕获该信号时,会在调用处理器函数之前将信号处置重置为默认值(即SIG_IGN)。
SA_SIGINFO:调用信号处理器程序时携带了额外参数,其中提供了关于信号的深入信息
SA_RESTART:执行信号处理后自动重启动先前中断的系统调用
#include<iostream>
using namespace std;
#include<signal.h>
#include<sys/types.h>
#include<unistd.h>
void handler(int signo)
{
cout << "我是一个进程,我收到了一个信号:" << signo << endl;
sleep(20);
}
int main()
{
//signal(2,handler);
// signal(3,handler);
//signal(4,handler);
//signal(5,handler);
struct sigaction act ,oact;
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask,3);
sigaction(2,&act,&oact);
while(1)
{
cout << "running" << endl;
sleep(4);
}
return 0;
}
#include<iostream>
using namespace std;
#include<signal.h>
#include<sys/types.h>
#include<unistd.h>
void handler(int signo)
{
cout << "我是一个进程,我收到了一个信号:" << signo << endl;
sigset_t pendings;
while(true)
{
cout << "." << endl;
sigpending(&pendings);
for(int i = 1;i<=31;i++)
{
if(sigismember(&pendings,i))
{
cout << "1" ;
}
else
{
cout << "0";
}
}
cout << endl;
sleep(3);
}
}
int main()
{
//signal(2,handler);
// signal(3,handler);
//signal(4,handler);
//signal(5,handler);
struct sigaction act ,oact;
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask,3);
sigaction(2,&act,&oact);
while(1)
{
cout << "running" << endl;
sleep(4);
}
return 0;
}
观察上述代码,运行会发现,我们如果多次按ctrl+c 也就是发送二号信号的时候,会自动阻塞二号信号,直到当前二号信号的handler函数处理完毕,但不阻塞其他信号
可重入函数
考虑这样一个场景,当我们在进行链表头插时,调用insert函数进行头插,而此时进程突然收到一个信号,并且该信号被捕捉,调用处理信号的自定义方法,而在自定义方法中又有一个链表的insert方法被调用,此时又头插了一个node2,信号处理结束,回到原进程继续insert node1,此时问题出现了,node2变成了野指针。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称
为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
volatile
#include<iostream>
#include<signal.h>
using namespace std;
#include<unistd.h>
int flags = 0;
void handler(int signo)
{
flags = 1;
cout << "收到信号,并更改flags:"<< signo << endl;
}
int main()
{
signal(2,handler);
while(!flags);
printf("进程exit\n");
return 0;
}
观察上述代码,按理来说程序被运行起来会卡在while循环处,因为flags的值一直为0,而收到2号信号后,flags值改变,循环结束,可以结束进程,但如果我们编译的时候把优化等级提高会怎样呢?
发送二号信号但是进程却无法结束,这是为什么呢???
原因是:当优化等级提升后,编译器为了优化,会“自作聪明”,不会每次都去内存中取flags的值,而是直接把flags优化到了cpu的寄存器中,这样即使收到信号后,在内存中更改了flags的值,cpu也不会察觉了,这时候 volatile关键字就派上用场了,volatile可以让cpu强制从内存中取数据
子进程退出会给父进程发送信号
子进程退出时会给父进程发送17号信号,代码验证:
#include<iostream>
#include<signal.h>
using namespace std;
#include<unistd.h>
void handler(int signo)
{
cout << "我是父进程,我确实收到了子进程给我的信号:" << signo << endl;
}
int main()
{
signal(SIGCHLD,handler);
pid_t id = fork();
if(id == 0)
{
while(1)
{
cout << "我是子进程:" << getpid() << endl;
sleep(2);
}
exit(0);
}
else
{
while(true)
{
cout << "我是父进程" << getpid() << endl;
sleep(2);
}
}
return 0;
}