前言
我们前面已经对进程已做了介绍!知道进程具有独立性,但在运行起来后可能会"放飞自我",即不受控制的执行,这就会导致系统崩溃等问题,非常不利于管理。因此OS需要一种机制来协调和控制进程的运行,而进程信号就是这样的重要机制!
目录
一、信号的基本概念
1、生活中信号的例子
2、什么是信号?
3、31个普通信号
4、自定义信号处理行为
二、信号的产生
1、 kill指令
2、键盘产生信号
3、函数的接口调用
• kill
• raise
• abort
4、软件条件
• 管道
• alarm
测试IO效率
5、异常
• 除 0 异常
• 野指针异常
三、其他问题
1、如何理解信号的发送?
• 信号的存储
• 信号的发送
2、核心转储
• Core和Trem的区别
• 核心转储的概念
• 核心转储的作用
• 云服务器默认关闭core dump的原因
• 解决历史遗留问题
一、信号的基本概念
1、生活中信号的例子
信号在我们的生活中是随处可见的!比如:
• 红绿灯 --> 红灯停,绿灯行
• 闹钟 --> 起床,该学习了
• 坤叫 --> 天要亮了
• 你考了30分你妈黑着脸 --> 你要被"奖励"了
• .......
当我们在生活中,看到这些信号时,我们会下意识的想到相关的动作,这是因为我们认识这些并能够处理信号! 其实,OS中的进程也是一样的!当进程收到一个信号后,至少要做到两点:1、认识信号 2、能处理信号
2、什么是信号?
信号是进程之间事件异步通知的一种方式!
这样直接说太过于官方了!不好理解!下面用直观的方式解释一下:
信号是Linux提供的一种向指定进程发送特定事件的方式!信号的产生是异步的,也就是收到信号的进程不知道具体什么时候就收到了,即信号和接收信号的进程是两个不相关的执行流!
OK,举个栗子:
比如说,你今天买了快递,但是你不知道他什么时候给你通知说到了!快递的发送是独立于你的生活的,当快递到了,快递员会通知你拿!这个栗子中,你就是进程,通知拿快递就是信号,你收到信号拿快递就是处理信号!你和通知你拿快递的消息是独立的,你也不知道啥时候会通知你,即异步的!
3、31个普通信号
Linux中有62个信号,我们可以用 kill -l 来查看:
其中, 1-31 是普通信号(收到信号后,选择合适的时间处理); 34-64 是实时信号(必须马上处理);
其中实时信号我们不介绍,他是用于一些实时的操作系统的,例如车载系统;而我们的一般开发用的操作系统都是分时操作系统,这也是普通信号的主要用武之地!
上面介绍了当进程收到信号后,需要对该信号做处理,处理的方式有三种:
1、忽略此信号
2、执行该信号的默认处理函数
3、执行自定义的处理函数,这种方式也称为信号的捕捉
忽略就是不管,当没看到!自定义后面解释!这里先介绍一下信号的默认处理动作:
可以用 man 7 signal 查看信号的默认动作:
其中Term
,Ign
,Core
,Stop
,Cont
都是信号处理的默认行为
Term
:默认操作是终止进程Ign
:默认操作是忽略信号Core
:默认操作是终止进程并转储核心Stop
:默认操作是暂停进程Cont
:默认操作是,如果该进程当前已暂停,则继续该进程
以上5种,其中Term 和 Core 都是终止进程,区别是Core会额外的进行 核心转储,这个后面介绍!我们再往下面翻,就会看到每个进程的描述:
4、自定义信号处理行为
默认行为了介绍了,忽略不管!那自定义行为如何处理呢?自定义信号的行为,是你当前的需求和系统默认的该信号的行为不符,此时你需要自定义该信号的行为!这个就和我们写的那个仿函数控制sort以及优先级队列的行为一样!
可以使用, signal 系统调用函数,来实现!
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
注意 typedef void (*sighandler_t)(int); 这是一个函数指针类型, 参数是一个int 类型的变量,其实就是收到的信号!
signal的第一个参数就是收到的信号,第二个就是该信号的行为函数,我们要想自定义该信号的行为,只需要自主实现handker函数即可!signal 的返回值也是sighandler_t类型,返回的是原先处理该信号函数的函数指针!
OK,我们用一个2号信号来演示:
2号信号SIGINT的默认行为是终止进程,和ctrl + c一样! 我们可以先来验证一下:
#include <iostream>
#include <unistd.h>
int main()
{
while (true)
{
std::cout << "pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
OK,下面我们不想让他按默认的终止进程来,我们想就让他打印一下他收到的信号是多少:
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int sig)
{
std::cout << "get sig: " << sig << std::endl;
}
int main()
{
signal(SIGINT, handler);//自定义2号信号的行为
while (true)
{
std::cout << "pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
我们发现此时无论是给该进程发送2号信号,还是直接ctrl + c 都是不能准直进程的!而且上面你ctrl + c显示的是收到的是2号信号!
这里你可能会想,普通信号就31个,那我全部捕捉了,都让他不终止那信号岂不是没用了!
哈哈是的,这个问题设计OS的人也想到了,人家避免上述的情况发生,于是将9号和19号信号设计成了不可自定义捕捉!
小tips : 其实上面的所有信号都是宏!所以既可以使用数字,也可以使用对应的大写字符串!
二、信号的产生
OK,简单的介绍了信号的概念和三种处理方式后,我们来看看Linux中信号产生的5种方式!
1、 kill指令
可以在命令行,向指定的进程发送指定的信号!例如,kill -9
2、键盘产生信号
例如,ctrl + c(2) ctrl + \(3)
int main()
{
while (true)
{
std::cout << "pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
3、函数的接口调用
• kill
作用:给指定进程发送指定的信号
参数
• pid : 收到信号进程的pid
• sig : 发送哪一个信号
返回值
成功,返回0; 失败,返回-1
OK,举个例子:让父进程休眠3秒,通过kill系统调用给子进程发送3号信号
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdio>
void handler(int sig)
{
std::cout << "get sig: " << sig << std::endl;
exit(1);
}
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
signal(3, handler);
while (true)
{
std::cout << "i am process" <<std::endl;
sleep(1);
}
}
//father
sleep(3);
kill(id, 3);
return 0;
}
果然在,3秒后收到了3号信号!这里你可能会想kill 指令和 kill系统调用是啥关系呢?其实kill指令的底层一定是调用了kill系统调用!
• raise
作用:给当前进程发指定的信号
参数
• sig : 要发送的信号
返回值
成功,返回0; 失败,返回非0
我们让我们的进程先跑5秒,然后给自己发送9号信号,让OS杀掉当前进程!
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdio>
void handler(int sig)
{
std::cout << "get sig: " << sig << std::endl;
exit(1);
}
int main()
{
signal(SIGKILL, handler);//捕捉一下
int cnt = 5;
while (cnt--)
{
std::cout << "hello world -> " << cnt << std::endl;
sleep(1);
}
raise(9);
return 0;
}
小tips:kill(getpid(), xxx) == raise(xxx);
• abort
作用:给自己发送6号信号(SIGABRT)
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdio>
#include <cstdlib>
void handler(int sig)
{
std::cout << "get sig: " << sig << std::endl;
exit(1);
}
int main()
{
signal(SIGKILL, handler);
int cnt = 5;
while (cnt--)
{
std::cout << "hello world -> " << cnt << std::endl;
sleep(1);
}
abort();
return 0;
}
OK,这里6号信号的作用好像也是终止进程!是的,但是他会形成core文件,即核心转储!
4、软件条件
这个看字面可能很抽象!但是,其实我们以前就提到过!例如管道!
• 管道
前面介绍管道的时候,介绍过管道的一个特性:当管道的读端关闭,写端还在写,此时OS会想写写进程发送13号信号!这是我们以前介绍的!这里不再验证了!
• alarm
作用:设定一个闹钟,一定时间后,给当前进程发送14( SIGALRM)号信号
参数
• seconds : 表示设定多少秒的闹钟
返回值
• 如果提前醒了,上一个闹钟还有剩余的时间,返回上一次闹钟的剩余秒数
• 否则,返回0
OK,我们先来演示一下,正常的闹钟:
int main()
{
alarm(5);
int cnt = 1;
while(true)
{
std::cout << "second = " << cnt << std::endl;
cnt++;
sleep(1);
}
return 0;
}
我们可以自定义捕捉看看是不是收到了14号信号:
void handler(int sig)
{
std::cout << "get sig: " << sig << std::endl;
exit(1);
}
int main()
{
signal(14, handler);//捕捉一下14号信号
alarm(5);//设置一个5秒的闹钟
int cnt = 1;
while(true)
{
std::cout << "second = " << cnt << std::endl;
cnt++;
sleep(1);
}
return 0;
}
下面我们来一个提前结束的:alarm(0)是取消闹钟
int main()
{
alarm(5);
sleep(1);
int n = alarm(0);// alarm(0) 提前取消闹钟,返回值是上一次闹钟的剩余时间
std::cout << "n = " << n << std::endl;
return 0;
}
这里本来设了5秒的闹钟,1秒后取消了;就剩下了4秒!
注意:alarm设的闹钟只有有效一次!如果想要多次有效就需要,每次重置(自举)
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdio>
#include <cstdlib>
void handler(int sig)
{
alarm(1);
std::cout << "get sig: " << sig << std::endl;
}
int main()
{
signal(14, handler);
alarm(1);
while (true)
{
std::cout << "hello world" << std::endl;
sleep(1);
}
return 0;
}
理解闹钟
我们一个进程可以定一个,也可以定好多的闹钟,同理OS中的其他进程也可以订个十个八个的,而OS中的进程很多,所以需要对闹钟进行做管理,如何做管理?先描述,在组织!
闹钟的描述可以用一个结构体来描述,例如这样的:
struct _alarm
{
time_t expired; // 未来要超时的时间 = seconds + 当前的时间
pid_t pid; // 哪一个进程设置的
func_t fun; // 默认行为
//....
};
组织的话,因为闹钟很多所以,可以用小根堆组织,到之后只需要判断当前时间和超时时间即可!
测试IO效率
这个以前从来没有测试过,我们以前只是说,I/O本是和外设打交道,外设的效率很慢,所以I/O的效率慢!理论是这样!但是没有一个量化的认识!我们这里借着闹钟演示一下:
先来看看一秒,有I\O可以执行多少次:
int cnt = 1;
void handler(int sig)
{
std::cout << "get sig: " << sig << std::endl;
std::cout << "cnt: " << cnt << std::endl;
exit(1);
}
int main()
{
signal(14, handler);
alarm(1);
while (true)
{
std::cout << "cnt: " << cnt << std::endl;
cnt++;
}
return 0;
}
我们发现是4万多次!再来看看没有I\O的:
int cnt = 1;
void handler(int sig)
{
std::cout << "get sig: " << sig << std::endl;
std::cout << "cnt: " << cnt << std::endl;
exit(1);
}
int main()
{
signal(14, handler);
alarm(1);
while (true)
{
cnt++;
}
return 0;
}
接近高了1W多倍!所以I/O是很费时间的!而我今天用的是云服务器,还要跨网络通信代价更大!
5、异常
最后一种产生的信号的方式是:异常,更准确说是硬件异常!例如,我们平时的代码遇到的:除0、野指针
• 除 0 异常
先来一段除0的代码看效果:
int main()
{
int n = 10;
std::cout << n / 0 << std::endl;
return 0;
}
这里报的错误是浮点数异常,一般这种错误就是除0导致的!这里进程终止了,说明他收到了信号!这里浮点数异常是收到了 8 号信号 SIGFPE
我们可以用前面的自定义捕捉,证明一下收到的就是8号信号:
void handler(int sig)
{
std::cout << "get sig: " << sig << std::endl;
}
int main()
{
signal(8, handler);
int n = 10;
std::cout << n / 0 << std::endl;
return 0;
}
确实验证了8号信号!但是我们看到这里一直死循环的打印!我只发生了一次除0错误啊!OK,我下面来解释一下原因:
我们所有的代码最后编译成二进制后,加载到内存都是由CPU来执行的(CPU执行又分为算术运算和逻辑运算)!所以上面的除0错误也是由CPU抛出的(CPU是硬件,也就是硬件异常)!现在的问题是CPU执行时他怎么知道当前的代码是正常还是异常呢?其实在CPU内部不只有正常运算的寄存器。还有一个状态寄存器eflag,在他内部有溢出标记位,当执行时发生异常时将溢出标记位设为1即异常的状态,OS是软硬件资源的管理者,当OS检测到异常时就要解决这种硬件问题保证系统的安全,如何处理?向指定的进程发送对应的信号让其终止!如果是正常的情况发送信号当前进程就结束了,但是我们这里给把对应的信号给自定义捕捉了,也就是打印一句话而不是进程退出。当OS给该进程发送信号后该进程就要被切换下来,前面介绍了进程切换时需要保存硬件的上下文数据!保存在哪里?当前进程的PCB里面!而当前进程把8号给自定义了有没有退出,接下来还会被调度,当再次调度时,他就会把上一次异常的数据又给CPU,CPU的溢出标记为又是1,OS又发送信号!这样就进入了死循环,也就是我们上面看到的一直打印!
• 野指针异常
野指针的异常包括越界、栈溢出等!
还是先来个例子看现象:
int main()
{
int* ptr = nullptr;
*ptr = 100;
return 0;
}
这里是段错误!其实他也是收到了信号,收到了11(SGISEGV) 号信号并终止了进程!我们也可以自己捕捉看看:
void handler(int sig)
{
std::cout << "get sig: " << sig << std::endl;
}
int main()
{
signal(11,handler);
int* ptr = nullptr;
*ptr = 100;
return 0;
}
这里看到了确实收到了11号信号!这里的一直循环的打印和上面的原理类似:
前面介绍过CPU在执行时全部使用的是虚拟地址,当他执行时根据页表转换成物理地址找到其数据和代码!如果CPU拿着虚拟地址去页表中依次查找的话有些慢。其实在CPU内部右两个专门负责虚拟地址转物理地址的寄存器:MMU和CR3,其中CR3存的是当前页表的起始地址,MMU是当前的虚拟地址,所以MMU+CR3 = 当前虚拟地址对应的物理地址。那CPU又如何知道我指针越界访问了呢?CPU内部还有一个和上面的状态寄存器作用相似的一个页故障线性地址寄存器CR2专门存储的是当前虚拟地址是否非法!如果当前的地址转换过程中发现非法,直接将该虚拟地址放到CR2,当OS检测到之后就会发送11号信号!这里一直循环打印的原因和上面的一样!
OK,介绍玩这里我们就可以回答下面三个问题了:
1、程序为什么会奔溃?
• 原因是当前程序中有代码进行了非法访问或非法操作,导致OS向该进程发送了信号!
2、程序奔溃后默认是退出,可以不退出吗?
• 可以!自定义捕捉异常。
3、为什么推荐异常收到信号后,终止进程?
• 为了释放进程的上下文数据,包括溢出标记为等其他异常数据!
三、其他问题
1、如何理解信号的发送?
我们前面都在说发送信号。但是如何理解这个发送信号呢?OK,要谈清楚发送信号,我们呢就得先介绍一下信号的存储!
• 信号的存储
我们分时OS用的是普通信号,有31个[1,31],没有0。你说巧不巧我们每个进程的PCB内部都会有一个字段(uint32_t),专门描述信号的!也就是一个整型,采用的是位图的思想!
• 信号的发送
当进程收到对应的信号后,OS就会把对应进程的PCB中的信号字段对应的位置1!所以发送信号的本质就是,将指定进程PCB中的描述信号位图中的特定位置修改为1;而PCB属于内核数据结构,只有OS有权利修改,所以,真正发送信号的是操作系统!!
2、核心转储
• Core和Trem的区别
前面就介绍了,Core和Trem最终都是异常终止进程!但是它两你有啥区别呢?
• Trem : 单纯的异常终止!
• Core : 在异常终止时,会额外生成一个Core文件,一般用于时候debug!
这个Core文件的内容就是,进程在收到信号异常终止时内存中核心的代码和数据!
OK ,先来一个验证一下:这里的11号信号就是Core
我们这里也没有形成Core文件啊!难道是环境的问题?这回还真是!我们当前的环境是云服务器!一般云服务器时把Core关掉的!
可以用ulimit -a 查看
我们可以用 ulimit -c size 打开
我们再来试一下:
这里面是乱码,就不再看了!如果你不想要了,你就把他关闭了!使用: ulimit -c 0
• 核心转储的概念
一些信号终止进程时,需要对该进程的核心数据进行在当前进程的目录下生成一个core.pid的文件,这个过程称作核心转储(Core Dump)
常见的信号有:3号 SIGQUIT
、4号 SIGILL
、5号 SIGTRAP
、6号 SIGABRT
、7号 SIGBUS
、8号 SIGFPE
、11号 SIGSEGV
、24号 SIGXCPU
、25号 SIGXFSZ
、31号 SIGSYS
都是可以产生核心转储文件的。
• 核心转储的作用
核心转储的主要作用是:调试!核心转储可以直接使用core-file corefile到出错的地方!也就是时候调试!
• 云服务器默认关闭core dump的原因
一般在云服务器上是部署项目的,一旦一个程序异常终止,就得立刻重启!当然这个工作是相关的监控脚本做的!如果默认打开了core dump,如果项目和上面的一样有问题,就会挂掉,但是自动脚本就会立即重启,这样就会生成大量的core文件,如果时间稍微一长可能磁盘就爆了!导致服务器宕机了!所以一般的服务器都是将core dump关掉的!
• 解决历史遗留问题
我们在进程控制中说过:子进程退出父进程要等待!其中status是获取子进程退出的信息的!他的低7位是退出信号,次低8位是退出码!但是我们当时留了一个问题就是,第8位,我们当时是说后面介绍!这里就可以介绍了:
其实第8位就是刚刚介绍的,是否核心转储,如果是核心转储,第8位就是1,否则就是0!
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
int* ptr = nullptr;
*ptr = 100;
}
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
{
printf("exit code: %d, exit sig: %d, core dump: %d\n", (status>>8)&0xff, (status&0x7f), (status >> 7)&1);
}
return 0;
}
注意:这里只有进程收到Core信号才会让core dump位,置1,Trem是不会让core dump位,置1的!
OK, 好兄弟本期分享就到这里,我是cp我们下期再见!
结束语:要使劲加油啊!