写在前面
下面我们来学习Linux中第三个模块,进程信号.今天我们将正式解释kill指令为何可以杀掉进程,这里存在很多小的知识点,
信号
在谈正式的内容之前,我们需要想一下我们之前用过信号吗?有的,这里和大家再简单的回顾一下.
#include <unistd.h>
int main()
{
while (1)
{
sleep(1);
}
return 0;
}
这里的9号就是一个信号.那么我们这里就有点问题了,什么信号?
我们这里需要从生活出发.我们在现实生活中中有哪些可以可以称之为信号.例如,红绿灯,下课铃声,信号枪,烽火台,旗语...那么我们是如何知道这些信号的含义呢?我们是如何知道红灯停,绿灯行,这些都是有人交过我们的,所以我们知道这些信号的含义.同样,Linux中的信号也是一个也定成俗的规则,当对应的信号的产生时造作系统一眼就知道要做什么,即便这个信号还没有产生,就像我们知道上课时知道下课铃声就是休息的意思.只有我们可以识别信号,并且知道处理这些信号方法,我们就拥有了处理信号的能力.
那么我们又有问题了,我们是如何处理这些信号呢?准确说是在信号还没有产生之前,我们是如何知道正确处理它呢?例如红绿灯,这是我们规定的,同样的对于操作系统而言,信号是给进程发的,进程可以识别并处理这些信号,这些都是我们程序员规定好的,是早就设计好的
- 该能力一定预先存在了
- 进程具有识别信号的能力
- 进程存在处理信号的相应方法
说了半天,我们这里下一个结论,信号是给进程发送的,即便信号还没产生,我们进程已经具有识别和处理信号的能力了,就像我们知道9号信号可以杀掉进程一样.
前台进程 & 后台进程
前面我们已经谈过这两个的区别了,这里回顾一下.
前台进程
这里占据的指令的输入行,我们前面几乎都是前台进程吗,可以被ctr+c杀死.
int main()
{
while (1)
{
cout << "hello bit" << endl;
sleep(1);
}
return 0;
}
后台进程
后台进程 ./myproc & 这个就是后台进程 ctrl + c 杀不掉 当然 kill 是可以的.后台进程是不会能占据我们命令行的输入的,也就是我们可以正常使用shell,本质上后台进程和前台进程的不是一个文件.
int main()
{
while (1)
{
}
return 0;
}
我们如何把后台转为前台, jobs查看后台指令 ,fg 1(这个1是任务编号)就可以了
通理前台->后台 ctrl+z -> jobs -> bg(任务编号),这里就可以了.
这个时候我们发现即使后台进程往显示器上打印数据,也不会影响我们指令的输入,最多就是看着有点乱序,这里本质上他们不是一个文件,我们把这个现象称之为回显.
信号
上面的操作不是问题,一操作就可以了,真正的问题是ctr+c为何可以杀掉进程,这里现在还没有办法谈,我们放在后面一点.这里我们知道一个事实,我们键盘是硬件,也就是OS会把硬件的某些操作会把它变成信号.
我还想和大家谈一个观点,对于一个进程而言,信号是任何时候都可以产生的.例如你点了一个外卖,你把手机一关,你不会知道外卖小哥是在取餐还是在配送,你也不关心,,直到外卖员敲你们的时候你才会明白外卖已经到了,对于进程也是如此,它们是异步的.
这里有两个问题,这里的信号量,你看这这些信号量熟不熟悉,这不就是宏吗.那么这些宏究竟有多少个?我们一看,这个不是编号吗,64个,我们信誓旦旦的说到?那么真的是64个吗?你去好好看看,这里是62个,记住,不要记错了.
[bit@Qkj 11_18]$ kill -l
注意,它们没有0号信号.我们这里只谈1到31号信号,像后面34 到64是实时信号,不知道大家听过实时操作系统,这个不太不主流,但是有一定应用场景,它们对这些信号的处理是非常快的,只要任务到来了,必须立马处理,还要处理的快,最关键的是只能一个一个处理 ,比如车载系统.
我们现在主流分时操作系统, 信号来了我们不一定要及时处理. 例如我点了个外卖 ,我也不知道他什么时候来,所以开了一吧游戏,外卖到了,我也不想去拿,告诉外卖小哥等一下.或者同学叫我去吃饭 ,当是我正在处理非常重要的事情,我叫朋友等一等.这里面外卖到了和同学叫我们吃饭,就是信号到了,但是我不一定立即处理,为何我不一定立即处理这些事,信号不是来了吗?因为这个世界不是围绕外卖小哥和同学转的 ,我是一个难缠的客户,或许我正在忙着处理重要的事,所以你们都等一等吧.进程也是如此,当信号来的的时候,我们正在经行IO,或者竞争资源,该进程可能不会立即处理这些信号.<font color = red>所以我们产生一个重要的结论,进程可能不会立即处理这些信号.</font>
异步
这里我们回答前面谈到的异步,比如你正在上网课,此时你非常饿,你想去吃饭,老师说你去吧,我们和其他同学都等你,这里是同步,要是说你去吃饭吧,等下回来补录屏,此时就是异步.
处理信号
这里就存在一个问题,我打完了游戏,我怎么直到外面有一个外卖还在门口等着,这里我们在心里记住了外卖已经到了,或者同学在等我一起吃饭,我们现在要处理这些事情.此时我们就知道了进程不是不处理这些信号,而是让这些进程稍微等一等,我们一定会处理的.那么此时进程是是如何记住信号到来,并且识别它们呢?就像我们知道外面是外卖到了,而不是同学叫我吃饭.进程需要关注两个方面的内容.
- 有信号吗
- 记住什么信号
再来谈一个问题,对于我们拿到外卖之后,有极大的可能是直接直接吃,这里是默认动作.这里还有一个问题,要是你和你的女朋友吵架了,你的女朋友为了先给你道歉,给你买了一个外卖,此时你还是不解气,你对外卖小哥说,你把外买扔在外边吧,此时你也处理了,因为你把它扔在外边了,这里叫做忽略.还有一种场景,你的外卖到了,你不太想吃,你有个弟弟或者妹妹,你把外卖给他了这里是自定义,就像我们过绿灯,别人是正常走过去的,你非要蹦着过去.这三种情况也适合进程,进程对信号的处理封为三种.
- 默认动作
- 忽略
- 自定义
位图
前面我们已经谈过了进程会记住信号,那么是如何记住的呢?我们这里只谈前31个.首先我们需要知道我们需要记住什么?记住的是有没有信号产生以及产生的信号是什么?我们需要在进程的PCB中记住这一个信号.那么我们PCG是如何记住这个信号呢?此时位图又出现了.PCB里面保存了一个32位的整数. 此时我们01表示信号的有无,信号所在的比特位所在的位置表示是哪一个信号.
这里又存在一个问题,task_struct是不是一个内核数据结构?是的,那么只有OS有这个权利修改这个结构,因为OS是进程的管理者,我们有关进程的所有内容和设置都是OS来完成的.
产生信号
我们这里需要手动产生信号,先来看看不同的信号产生方式.
通过键盘产生信号
我们前面已经谈过了ctr+c可以杀掉信号,那么我们这里需要好好分析一下为何可以产生信号,有产生了什么信号?
我们先来看一下函数.这个函数可以帮助我们捕捉信号.前面我们已经谈过了,进程对信号的处理存在三个方式,Linux一般是默认的.这里的忽略或者自定义都可以使用这个函数.我们需要看看这里面的第二个参数.
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
注意,这里的sighandler_t是一个函数指针,我们这里面需要来几个小代码来暂时验证一下键盘是可以产生信号的,同时也来使用一下这个函数.我这里知道ctr+c是产生二号信号,我们这里验证一下.
void handler(int signo)
{
cout << "我是一个进程,刚刚获取了一个信号: " << signo << endl;
}
int main()
{
ssignal(SIGINT, handler);
sleep(3);
cout << "进程已经设置完了捕捉" << endl;
sleep(3);
while (true)
{
cout << "我是一个正在运行的进程 pid : " << getpid() << endl;
sleep(1);
}
return 0;
}
这里防止大家产生一个误区,我们开始的ssignal只是简单的设置了一个捕捉函数,只有当我们使用ctr+c的时候才会调用这个ssignal函数.
上面我们就明白了,我们一直ctrl+c是给进程发送一个2号信号,原本的默认的处理是杀掉接受信号的进程,但是此时我们自定义的设置了处理2号信号只是简单的打印出一句话罢了.
我们这个补充一下,ctr+\是一个3号信号,这里我们测试一下.
void handler(int signo)
{
cout << "我是一个进程,刚刚获取了一个信号: " << signo << endl;
}
int main()
{
signal(SIGINT, handler);
signal(SIGQUIT, handler);
sleep(3);
cout << "进程已经设置完了捕捉" << endl;
sleep(3);
while (true)
{
cout << "我是一个正在运行的进程 pid : " << getpid() << endl;
sleep(1);
}
return 0;
}
这里我们证明了ctr+\确实是产生3号信号,但是我们该如何把这个进程给杀掉呢?这里我们使用9好信号.
此时我们脑洞大开,我们想把9号信号也设置成自定义捕捉,或者更加绝一点,我们把31个信号都设置成自定义的,这里是不是会产生一个bug呢?试一下.
int main()
{
for (size_t i = 1; i <= 31; i++)
{
signal(i, handler);
}
sleep(3);
cout << "进程已经设置完了捕捉" << endl;
sleep(3);
while (true)
{
cout << "我是一个正在运行的进程 pid : " << getpid() << endl;
sleep(1);
}
return 0;
}
这里我们就知道了,你能想到的大佬都能想到,我们对31个普通信号都可以做自定义捕捉,但是对于9号信号,即使我们自定义处理方法,但是9号仍旧是杀掉进程,也就是说9号信号可以杀死有所有的进程.
键盘产生信号原理
这里我们需要重点说一下,键盘是可以产生信号的,注意是产生.那么是谁给进程发送发送信号呢?前面我们已经谈过了,只有OS有权利修改PCB.这里是OS发送的.那么我们疑惑的是键盘不是一个硬件吗,它是如何产生信号的.这里给大家补充一个名词中断.前面我们在谈冯诺依曼体系中,我们说硬件是不会和CPU进行交互的,那么真的是无法交互吗?不是的,前面我们一直谈数据层面.站在控制信号层面上,是可以的,CPU上面有着许多针脚,这些针脚可以接受硬件的电脉冲,这里的关心非常复杂,大家不用关心.例如我们平时按键盘,这里是如何被OS感知到的,这里都是中断相关的内容,这里我们就明白了,我们按键后会产生某种数据,例如电信号,被某些设备捕捉到,把数据给到COU上面的针脚,数据进入CPU,CPU会优先处理这些信号的.
我们谈了那么多,还是没有谈键盘是如何产生的.OS内具有默认的映射表,里面映射按键和数据.我们当我们按了一下ctr+c,此时映射出来一个信号,此时信号就出来了.那么OS是如何发送信号的呢?我们不想谈OS是发送信号,与其谈发送,不如谈修改,OS可以看到所有的进程,并且可以修改进程的中的位图,这样就是给进程发送信号了.
系统接口产生信号
我们出了硬件产生信号,还可以通过系统接口给进程发送信号.这里谈三个接口
kill
kill除了是指令之外,还是一个系统接口,它可以对指定进程发送信号,我们的指令kill底层调用的就是它.成功了就是0,失败了-1.
int kill(pid_t pid, int sig);
这里我们模拟实现一个kill命令.我们看看吧.
这里测试一下.
raise
如果说kill可以给任意进程发信号,那么raise是给当前的进程自己发送信号.
int raise(int sig);
我们测试一下.
void handler(int signo)
{
cout << "我是一个进程,刚刚获取了一个信号: " << signo << endl;
}
int main()
{
signal(2, handler); // 这里只是 设置
while (1)
{
raise(2);
sleep(1);
}
return 0;
}
abort
这个和上面两个有不一样,这个给自己进程发送SIGABRT信号.这个信号可以被捕捉,即使我们自定义了动作,但是这里还是会退出进程.有的项目会替换我们之前的exit.
void handler(int signo)
{
cout << "我是一个进程,刚刚获取了一个信号: " << signo << endl;
}
int main()
{
signal(SIGABRT, handler); // 这里只是 设置
while (1)
{
sleep(1);
abort();
}
return 0;
}
软件条件产生信号
这里我们再来谈第三种产生信号的做法.这个理解起来还是比较费解的.前面我们已经谈过,进程也会等待软件资源,今天我们让他等待信号.
alarm
这个是定时功能,这个就像我们让自己的老爸定一个闹钟,让老爸早上起来叫我.这里的老爸是一个软件,闹钟是一个硬件.我们知道操作系统是一个死循环,他负责很多东西,像管理文件,进程等等,那么是谁推动操作系统,是硬件.这里我们有点问题,OS不是管理硬件吗?那么为何又会被硬件来管.这里很好理解,OS就像一个公司的老板,他是公司最大,但是他的秘书也会给老板安排每天的行程行程.在一定程度管理这老板,此时的秘书就是硬件.在众多的硬件中,存在时钟硬件,他记录这个从开机到现在有多少时间我们现在要谈的就是一定定时器.我们设置一个时间.
void handler(int signo)
{
cout << "我是一个进程,刚刚获取了一个信号: " << signo << endl;
}
int main()
{
alarm(1); // 一秒之后 产生一个信号 OS会发送给当前进程 一个SIGALRM
int cnt = 0;
while (1)
{
cout << "hello : cnt " << cnt << endl;
cnt++;
}
return 0;
}
前面我们已经证明了alarm作用,这里我们提出了另外一个问题,请问我们这个程序究竟是干什么?这里是计算cnt可以++多少次.这里只有2w次,要知道我们只是简单的++,而且我的云服务的配置不算是非常低,感觉这个频率有点低,那么真的是这样吗?我们再换一个代码.
int cnt = 0;
void handler(int signo)
{
cout << "我是一个进程,刚刚获取了一个信号: " << signo << endl;
cout << "cnt : " << cnt << endl;
exit(1);
}
int main()
{
alarm(1); // 一秒之后 产生一个信号 会发送 一个SIGALRM
signal(SIGALRM, handler); // 这里只是 设置
while (1)
{
cnt++;
}
return 0;
}
此时你就会发现IO是非常慢的,这也是前面缓冲区的必要性.
有硬件异常产生的信号
这个是非常关键的一个概念,我们这里将真正的理解访问野指针为何会导致程序崩溃等问题.
int main()
{
int a = 10;
a /= 0;
return 0;
}
这里我们非常明白我们除零了,崩溃是非常正确的,我们也明白越界访问,野指针都会导致程序崩溃,这里我们要和大家说一下所谓的崩溃就是进程被杀死了,也就是我们的错误产生了某种信号,那么究竟是不是呢?
void handler(int signo)
{
cout << "我是一个进程 pid " << getpid() <<",刚刚获取了一个信号: " << signo << endl;
exit(1); // 这里捕捉完直接退出
}
int main()
{
for(int i = 0; i < 31;i++)
signal(i+1, handler);
int a = 10;
a /= 0;
return 0;
}
也就是说前面我们谈的程序崩溃了,在本质上就是进程退出了,以后需要我们更正一下自己的说法.
下面我们要讨论了的是我们代码有问题,为何会崩溃呢?是由于我们进程收到了信号,那么进程为何会收到信号?这里我们以除零错误为例子.我们知道程序进行数据的计算,需要把数据加载到CPU中.CPU有很多寄存器,其中有一个叫做状态寄存器,他来记录本次计算是不是出现问题,一旦出现问题,这个状态寄存器会改变标志位,也就是会报错,也就是浮点数错误.这里就是除零的原理.此时这个状态寄存器是一个硬件,OS注意到报错了报错之后,这里就需要分析谁报的错,报的什么错,这个时候OS在分析过后就会发送信号给进程,一般而言,进程对于报错的处理就是退出.
我们再来看看访问越界和野指针.
我们已经知道了除零错误,那么这里的越界和野指针又是什么原因呢?前面我们已经谈过了,这里的地址都是虚拟地址,虚拟地址会被转化为真实的地址,到物理内存,才会得到数据.那么虚拟地址是如何转化为物理地址的,这里是一个硬件MMU.一旦我们MMU转化出现错误.OS注意到报错,这里就需要分析谁报的错,报的什么错.这就是他们的原理.
try catch
这里又有一个问题,程序出现错误,进程一定会崩溃吗?不会的,上面的我们都是默认行为,这里我们捕捉一下.
void handler(int signo)
{
cout << "我是一个进程 pid " << getpid() << ",刚刚获取了一个信号: " << signo << endl;
}
int main()
{
for (int i = 0; i < 31; i++)
signal(i + 1, handler);
int a = 10;
a /= 0;
return 0;
}
这里崩溃了吗?没有,但是我们清楚的看见它在一直在刷屏.这里刷屏的原因是OS告知你了,这里程序有问题,你要处理,可是你一直不解决,所以OS会一直告知你.
C++语言里面我们学习过异常,这个也可以帮助我们解决这个问题,我们简单的用一下.
int main()
{
try
{
while (1)
{
int a = 10;
int b = 0;
if (b == 0)
{
throw "除零错误";
}
a /= b;
}
}
catch (const char *&e)
{
std::cerr << e << '\n';
}
printf("---------------\n");
return 0;
}
core dump
前面进程等待的时候我们有一个东西没有谈,高8位是进程退出码,低7位是退出信号.这里第8位我们一直没有谈,它叫core dump.那么什么是core dump呢?我们先来看一下.
int main()
{
pid_t id = fork();
if (id == 0)
{
int *p = nullptr;
*p = 20; // 野指针
exit(1);
}
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0)
{
cout << "推出码 " << ((status >> 8) & 0xFF) << endl;
cout << "core dump " << ((status >> 7) & 0x1) << endl;
cout << "退出信号 " << ((status)&0x7F) << endl;
}
return 0;
}
我们继续测试,来个除零错误.
我简绍一个东西,man 7是查看更加详细的手册.
[bit@Qkj 11_18]$ man 7 signal
也就是我们上面标记的几个信号都会Core.我们以8号信号为例子.8号信号在退出的时候会设置core dump,所谓的设置core dump可以理解为产生一个文件.可是我们好象没有看到啊,这是由于我用的是云服务器,这里有点限制.
这里我们需要把它打开,设置大一点,用过后记得关了就可以了,等下我们解释为何云服务器是关住的.
[bit@Qkj 11_18]$ ulimit -c 10000
我们继续除零错误,此时的core dump被设置成1了.
int main()
{
pid_t id = fork();
if (id == 0)
{
int a = 0;
a/=0;
exit(1);
}
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0)
{
cout << "推出码 " << ((status >> 8) & 0xFF) << endl;
cout << "core dump " << ((status >> 7) & 0x1) << endl;
cout << "退出信号 " << ((status)&0x7F) << endl;
}
return 0;
}
这里还有一个更加关键的,我们发现当前目录下出现了一个文件,叫做core.5773.我们打卡文件发现是一堆乱码.
首先我们回答一些问题,进程收到某些信号时,core dump会被置为1,此时会出现一个文件,这个文件叫做core.pid.这个pid就是引起错误的那个进程的pid.这个就是核心转储.core dump就是把进程在运行的异常的上下文转存到磁盘上.我们这里面多运行几遍,每一遍都会产生这个文件.
那么这个文件有什么用呢?这里我们编译时用一下-g选项,我们用下一下字就可以定位问题,这里可以快速的让我们找到问题.我们把问题看到了,定位了,最后在通过逻辑分析问题,这个叫做事后调试.
我们在想,为何我们线上的环境(生产环境)默认不打开它呢?也就是不能创建这个core文件,他不是挺香的吗?首先他一定要配合-g选项也就是gdp使用,生产环境是release模式.这个调试没有意义.对于生产环境挂掉的话我们要做的一定是重启,而不是调试,这个过程自动的.要是你的代码写的有问题,一运行就挂,你不断重启,每一次重启都会出现大量的core文件,此时磁盘空间可能会被打满,就会出现问题.我们尽量关掉.
信号递达
前面我们谈的都是信号产生前.现在开始第二个阶段信号产生中.这个是关于信号在内核中是如何被处理的.
信号递达
我们把对信号处理的动作称之为信号递达.关于信号递达的三种动作我们都已经谈过了.
- 默认动作
- 忽略
- 自定义
信号未决
当信号产生后,进程在做更重要的事情,进程会稍后处理这个信号,也就是信号产生到信号被处理的这一段我们称之为信号未决
阻塞
信号已经产生了,处于未决态,我们可以随时递达,但是我们也可以阻塞这个信号.被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
内核中信号的表示
我们需要看一下内核中关于信号的数据结构.这里有三张表.
我们来解释一下上面的表.首先先看一下pending表,这是一个32位的位图,它的存在就是记录OS给当前进程发送了什么信号,上面我们说的OS会修改进程的位图说的就是这个.第三张表handler表就是我们对信号的处理动作i,它里面保存的是函数指针,一般都是默认的,前面我们得到signal就是就是修改的这张表.
说一下block表,有的程序不想处理2号信号,但是拦不住别人给你发,如果没有block表,我们必须处理到来的信号.我们管不住别人,但是我们可以管住自己,你随意给我发,但是我给你阻塞不就行了. 和pending表是以一样的01表示是否阻塞(拦截),比特位的位置 代表对应的信号.
阻塞 VS 忽略
我们需要分析一下阻塞和忽略.注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作.
我举一个布置作业的例子帮助我们更好的理解上面的内容.张三老师上完了课,要给我们布置作业,说要去写一篇博客等等我害怕给忘了,布置作业就是信号的到来.所以我拿出来一张纸,我们布置的作业题目给记了下来,其他的同学也是如此,这个动作就是信号被记录了下来.这个时候不同的同学对待作业有不同的做法.有的人以看老师布置作业了,我立马去写,这也是我们应该去去写的,所以这叫做默认动作.有的人一看,不想写,就把作业扔在了一边,这叫做忽略,还有的人不想写,但是有想交,所以找人代打,这叫做定义.但是不是所有的同学都喜欢张三老师,比如我特别讨厌他,凡是它的作业我都把拦截了,老子一个不碰,但是我还是记了下来,突然有一天我觉得老师还不错,就开始把记下来的作业写了.
信号集
现在我们谈我们对上面的三张表的操作.我们已经谈过第三张表的如何修改了,就是signal函数.这里需要把前面的两张表更爱一下名字,为了后面的更好的理解.我们把pending表称之为未决信号集,把block表称之为**阻塞信号集.**下面我们所有的操作都是关于他的.
sigset_t
不同的系统对底层的实现是不一样的,我们Linux用的是位图,不代表其他的系统也是,所以这里Linux封装了一个类型,我们把这个类型称之为信号集.
不同的实现是不一样的,我们不能使用位运算.我们需要接口,内核也是给我们提供了这些接口.这些接口非常简单,我们先来认识一下.
- int sigemptyset(sigset_t *set);
- int sigfillset(sigset_t *set);
- int sigaddset (sigset_t *set, int signo);
- int sigdelset(sigset_t *set, int signo);
- int sigpending(const sigset_t *set, int signo);
sigpending
这个函数是查看当前未决信号集的,我们直接使用一下.其中为何更好的打印出效果,sigpending函数是查看当前信号是不是在当前的信号集中.
void showPending(const sigset_t &pendings)
{
// 如何显式
for (size_t i = 1; i <= 31; i++)
{
if (sigismember(&pendings, i) == 1)
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
int main()
{
sigset_t pendings;
while (true)
{
sigemptyset(&pendings); // 清空当前信号集
if (sigpending(&pendings) == 0)
{
showPending(pendings);
}
sleep(1);
}
return 0;
}
sigprocmask
我们想看到信号产生后,想把未决信号集给打印一下,不想让他直接退出,此时我们就需要把这个信号给阻塞一下.这个函数就是修改我们当前进程的阻塞信号集.
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
其中set是我们想要的阻塞信号集,oset是一个我们之前的阻塞信号集,how是一个宏,他代表不同的操作.
我们先把2号信号给屏蔽一下.
int main()
{
cout << "pid : " << getpid() << std::endl;
sigset_t bsig, obsig;
sigemptyset(&bsig); // 清空当前信号集
sigemptyset(&obsig); // 清空当前信号集
// 添加 2号信号到 信号集
sigaddset(&bsig, 2);
// 把信号集 替换到内核中
sigprocmask(SIG_SETMASK, &bsig, &obsig);
sigset_t pendings;
while (true)
{
sigemptyset(&pendings); // 清空当前信号集
if (sigpending(&pendings) == 0)
{
showPending(pendings);
}
sleep(1);
}
return 0;
}
此时我们就可以发现当我们设置了阻塞信号集,我们给他2号信号,他也不会退出.我们过分一点,把那所有的信号都给屏蔽了
int main()
{
cout << "pid : " << getpid() << std::endl;
sigset_t bsig, obsig;
sigemptyset(&bsig); // 清空当前信号集
sigemptyset(&obsig); // 清空当前信号集
for (int i = 1; i <= 31; i++)
{
sigaddset(&bsig, i);
}
sigprocmask(SIG_SETMASK, &bsig, &obsig);
sigset_t pendings;
while (true)
{
sigemptyset(&pendings); // 清空当前信号集
if (sigpending(&pendings) == 0)
{
showPending(pendings);
}
sleep(1);
}
return 0;
}
这个时候我们无论发任何信号,我们发现都不可以杀掉该进程,是不是我们程序出bug了?不是的,我们发送9号信号,这个信号一直会坚守自己的岗位.
假设我们想要一次性处理被阻塞的信号,此时我们就就需要把阻塞队列给公开.
void handler(int signo)
{
cout << "我是一个进程 pid " << getpid() << ",刚刚获取了一个信号: " << signo << endl;
//exit(1);
}
int main()
{
cout << "pid : " << getpid() << std::endl;
sigset_t bsig, obsig;
sigemptyset(&bsig); // 清空当前信号集
sigemptyset(&obsig); // 清空当前信号集
for (int i = 1; i <= 31; i++)
{
sigaddset(&bsig, i);
signal(i, handler);
}
sigprocmask(SIG_SETMASK, &bsig, &obsig);
sigset_t pendings;
int cnt = 0;
while (true)
{
sigemptyset(&pendings); // 清空当前信号集
if (sigpending(&pendings) == 0)
{
showPending(pendings);
}
sleep(1);
cnt++;
if(cnt == 10)
{
sigprocmask(SIG_SETMASK, &obsig, nullptr);
break;
}
}
return 0;
}
那么如果我们只想把2后信号的屏蔽给打开呢?这里也是有方法的.
int main()
{
cout << "pid : " << getpid() << std::endl;
sigset_t bsig, obsig;
sigemptyset(&bsig); // 清空当前信号集
sigemptyset(&obsig); // 清空当前信号集
for (int i = 1; i <= 31; i++)
{
sigaddset(&bsig, i);
signal(i, handler);
}
sigprocmask(SIG_SETMASK, &bsig, &obsig);
sigset_t pendings;
int cnt = 0;
while (true)
{
sigemptyset(&pendings); // 清空当前信号集
if (sigpending(&pendings) == 0)
{
showPending(pendings);
}
sleep(1);
cnt++;
if (cnt == 15)
{
sigset_t s;
sigemptyset(&s);
sigaddset(&s, 2);
sigprocmask(SIG_UNBLOCK, &s, nullptr);
}
}
return 0;
}
捕捉信号
我们想问的信号什么时候被递达?前面我们一直说信号的产生和处理是异步的,也就是当信号产生后,进程在做更重要的事情,进程会稍后处理这个信号.可是上面我们都是遇到信号都是立即处理,我们这个模块就是谈的这个情况.
用户态&内核态
那么什么时候处理这个信号呢?<font color = red>当前进程从内核态转变为用户态的时候,进行信号的检测和处理.</font>那么什么是用户态,什么是内核态?我们之前从来没有正式的谈过这个内容,先和大家说一下.我们进程地址空间那里和大家看过,那里我们说了一句进程地址空间的最上层属于内核态.
用户级页表&内核级页表
上面我们说了用户态,我们知道用户态是存在一个页表的,它可以把虚拟地址映射到实际的物理内存,这个页表是每一个进程都存在一个.我们把这个页表称之为用户级页表.但是这里面又存在另外一个页表,这个页表属于进程的内核态,他也是映射到物理内存的一块区间.不同的是我们所有的进程的用户态共用这个页表,也就是这个页表只有一份.我们这个个页表称之内核级页表
那么他们有什么区别呢?用户级页表只能访问我们子进程的数据,但是内核级页表可以访问所有进程的代码和数据.
身份切换
我想问的是OS会不会被加载到内存中呢?会的,那么我们知道无论我们进程如何切换,我们的OS都可以看到所有进程的代码和数据,只要我们有这个权利.我们可以可能到所有进程和代码的页表就是我们上面说的内核态页表.那么请问我们如何访问内核态页表,此时我们就需要身份切换,那么我们是如何知道当前进程是是用户态还是内核态呢?OS存在一个寄存器,叫做Cr3,它有几个数字代表不同的状态其中0就是内核态,3就是用户态,想要仔细了解的可以去看看博客,这理我们认识就可以了.
可以从用户态陷入到内核态的几种情况
- 时间片到了,进程之间进行切换
- 系统调用
- 其他
捕捉信号
上面我们说了这么多就是我们在已经明白了我们程序在执行的时候,会有无数次的身份切换在暗地里进行,只不过哪个时候我们没有谈罢了.我们说不对啊,下面的的代码也是会发生身份切换吗?是的.如果当前进程的时间片到了,OS会把这个进程给切换下来,此时就会陷入内核.
int main()
{
while(1)
{
}
return 0;
}
上面我们说了对信号的处理发生在内核态切换成用户态的时候,这里我们举一个例子.我们正在用户态执行的不错,此时我们有一个系统调用,此事我们进程就会陷入到内核态.当前我们系统调用结束后,他会顺便检查当前进程的pcb,他会检查单腔pcb的是否收到信号?是否处理,这就是信号捕捉的原理.
我们用一个例子来说一下信号捕捉的流程.我们程序正在用户态运行的好好的,直到时间片到了或者我们调用系统接口,我们从用户态陷入内核态,假如我们调用的是open接口,当我们执行完open函数的大部分流程后,我们先把返回值放在一个寄存器中,由于我们此时是内核态,也就是看到的是内核级页表,此时我们完全有权利看到当前进程pcb的未决信号集,我们观察都有信号出现,此时观察未决信号级,看他是不是被阻塞,阻塞什么都不会做,拿着open返回值直接返回用户态,如果没有被阻塞,我们就看我们捕捉的信号的动作是什么,.是默认,忽略还是自定义这些都是我们上面的谈过的.我们这里主要关注自定义.此时如果我们要执行代码,我们需要从内核态转变为用户态,在这里执行,当我们执行完自定义的的操作之后,我们还需要返回发到内核态,因为我们需要拿到open的返回值然后重新返回到用户态.
上就是我们的流程图,我们可以简化一下.
这里有一个问题,为何我们执行自定义处理信号时需要从内核态变成用户态,我们在内核桃不是可以看到所有的代码和数据吗?是的.内核有这个能力吗,有的,而且权限高的多. 4g空间随便用 ,这就证明了内核可以直接切成用户页表.它可以操纵所有的事.但是这里不能用内核身份.我们就纳闷了,OS顺手执行一下不是挺好的吗?原因是非常简单的,就是因为这个代码是用户写的.如果用户写的是删除所有文件,要知道OS什么都可以做,这里面OS就被人恶意利用了.OS是可以,但是他不会相信任何人,所以用用户身份.所以这是内核保护自己.
sigaction
前面我们一直说处理函数的三种动作,那个时候我们的函数捕捉代码时signal函数,这里又存在在另外一个,它的基本功能一样的,用法上面还是有点困难的,我们这里简单的人认识一下.
[bit@Qkj 12_18]$ man sigaction
我们说一下他们的参数,第一个是我们要捕捉的信号,后面两个是一个结构体,里面保存这很多的信息,其中一个就是我们的处理动作,我们看看这个结构体,我们要关注的就是其中两个内容,mask先不谈.
我们先来用一下,是在是太简单了.
void handler(int signo)
{
std::cout << "我捕获了一个信号 : " << signo << std::endl;
}
int main()
{
struct sigaction ac;
struct sigaction oldac;
memset(&ac, 0, sizeof(struct sigaction));
memset(&oldac, 0, sizeof(struct sigaction));
ac.sa_handler = handler; // 自定义信号动作
sigemptyset(&ac.sa_mask); // 置空
sigaction(2, &ac, &oldac); // 捕捉2号信号
while (1)
{
sleep(1);
}
return 0;
}
我们发现我们已经对2号信号做了自定义的捕捉,这一点我们之前没有什么区别,此时我们需要在和大家说一一个理论.当我们OS正在处理莫某一个信号时,我们会把它给阻塞掉,知道处理结束后我们才会变化为原来的信号屏蔽字.这句话有很大是意思.我们在之前ctr+c干掉进程时我们多次按了键盘,但是只有一个被处理,后面的被阻塞了.我们演示一下.
void handler(int signo)
{
cout << "获取到一个信号,信号的编号是: " << signo << endl;
sigset_t pending;
//增加handler信号的时间,永远都会正在处理2号信号!
while (true)
{
cout << "." << endl;
sigpending(&pending);
for(int i = 1; i <=31; i++){
if(sigismember(&pending, i)) cout << '1';
else cout << '0';
}
cout << endl;
sleep(1);
}
}
int main()
{
struct sigaction ac;
struct sigaction oldac;
memset(&ac, 0, sizeof(struct sigaction));
memset(&oldac, 0, sizeof(struct sigaction));
ac.sa_handler = handler; // 自定义信号动作
sigemptyset(&ac.sa_mask); // 置空
sigaction(2, &ac, &oldac); // 捕捉2号信号
while (1)
{
sleep(1);
}
return 0;
}
我们已经知道了多次发送同一个信号会被阻塞,但是我们发现如果我们发送3号信号就会被立即处理假设我们也想让3号信号被阻塞,这里就是mask的作用.我们向mask中添加我们想要被阻塞的信号.
int main()
{
struct sigaction ac;
struct sigaction oldac;
memset(&ac, 0, sizeof(struct sigaction));
memset(&oldac, 0, sizeof(struct sigaction));
ac.sa_handler = handler; // 自定义信号动作
sigemptyset(&ac.sa_mask); // 置空
sigaddset(&ac.sa_mask, 3); // 添加 3号信号被阻塞
sigaction(2, &ac, &oldac); // 捕捉2号信号
while (1)
{
sleep(1);
}
return 0;
}
可重入函数
我们在数据结构中学习过链表的相关概念,也知道链表是如何完成头插的,我们这里给大家演示一下由于insert函数被多次进入而导致的问题,注意这不是代码的问题,之前没有谈是涉及到OS.
此时我们发现insert被重复进入了,由于我们重复进入insert导致insert函数不安全,此时我们就是它是不可重入函数,如果函数被重复复进入,我们说它是可重入函数.
volatile
我们再来学习一下C语言中的宇哥关键字,由于和编译器的优化有关,我们在VS中不太好号演出来效果.这个关键字我们可以称之为内存可间关键字,我们来看一下现象,这里我们用C语言进行演示.
mytest:mytest.c
gcc -std=c99 -o $@ $^
.PHONY:clean
clean:
rm -f mytest
int flags = 0;
void handler(int signo)
{
flags = 1;
printf("更改flags: 0->1\n");
}
int main()
{
// 可以同时修饰一个变量吗??
// 含义冲突吗,该变量代表什么含义?
signal(2, handler);
while (!flags);
printf("进程是正常退出的!\n");
return 0;
}
上面的现象和我们心中一样的,那么如果我们只是修改一下编译器的优化程度,我们让编译器更加聪明一点,此时你就会发现不一样的现象.
mytest:mytest.c
gcc -std=c99 -o $@ $^ -O2
.PHONY:clean
clean:
rm -f mytest
这是由于我们while循环做条件判断的时候,我们也是需要从内存中读取数据进入CPU,由于编译器优化程度更高,他认为既然我们只是做了一个判断,不如把他的的值放在一个寄存器中,我们每一次都去寄存器中去拿,这就是我们为何不结束进程的原因,是因为我们修改的是内存中的值,不影响寄存器的.而我们的volatile关键字就是不把它的值放在寄存器中,我们强制编译器从内存中读取.
volatile int flags = 0;
void handler(int signo)
{
flags = 1;
printf("更改flags: 0->1\n");
}
int main()
{
// 可以同时修饰一个变量吗??
// 含义冲突吗,该变量代表什么含义?
signal(2, handler);
while (!flags);
printf("进程是正常退出的!\n");
return 0;
}
SIGCHL
我们来信号的最后一一个话题,这个还是比较重要的.
进程退出
我们在谈进程的相关的概念的时候我们说了父进程需要等待子进程,如果不等待,当子进程结束后他会变成僵尸进程,那么父进程是如何知道子进程结束的呢?总不能每隔一段时间进行询问子进程,问他死了没死,这有点麻烦你.程序员是这样这设计的,我们父进程不关心子进程的运行状况,子进程要是死了,你给父进程发一个信号就可以了,这里就是SIGCHL信号,它的默认动作就是忽略.
那么我们如何证明呢?这了很简单.
void handler(int signo)
{
cout << "子进程确实退出了 sig "
<< signo << " 我是父进程 " << getpid() << endl;
}
int main()
{
pid_t id = fork();
if (id == 0)
{
while (true)
{
/* code */
cout << "我是子进程 pid " << getpid() << endl;
sleep(1);
}
cout << "我是子进程 我要推出了" << endl;
return 1;
}
signal(17, handler);
while (true)
{
cout << "我是父进程 pid " << getpid() << endl;
sleep(1);
}
return 0;
}
暂停/苏醒进程
除此之外子进程暂停或者苏醒都会他发送17号信号,暂停进程我们发送19号信号,苏醒进程发送18号信号.
作用
那么我想问问这个信号有什么用呢?这里我想和大家写一个代码.我们先回忆下原来的知识.
int main()
{
pid_t id = fork();
if (id == 0)
{
int cnt = 10;
while (cnt)
{
cout << "我是子进程 pid " << getpid()
<< " cnt " << cnt << endl;
sleep(1);
cnt--;
}
cout << "我是子进程 进入僵尸" << endl;
return 1;
}
// signal(17, handler);
if (waitpid(id, nullptr, 0) >= 0)
{
cout << "父进程等待成功" << endl;
}
return 0;
}
我们还要父进程自己等待,既然子进程退出时会发17号信号,我们在自定义函数那里等待.
void handler(int signo)
{
assert(signo == 17);
cout << "子进程确实退出了 sig "
<< signo << " 我是父进程 " << getpid() << endl;
pid_t id = waitpid(-1, nullptr, 0) >= 0; // -1 等待任意进程
if (id > 0)
{
cout << "父进程等待成功 child id " << id << endl;
}
}
int main()
{
pid_t id = fork();
if (id == 0)
{
int cnt = 10;
while (cnt)
{
cout << "我是子进程 pid " << getpid()
<< " cnt " << cnt << endl;
sleep(1);
cnt--;
}
cout << "我是子进程 进入僵尸" << endl;
return 1;
}
signal(17, handler);
while (true)
{
cout << "我是父进程 pid " << getpid() << endl;
sleep(1);
}
return 0;
}
上面是思想很不错,但是这里存在很多的问题,上面只能等待一个进程,多个进程时就会出错.
int main()
{
for (size_t i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
int cnt = 10;
while (cnt)
{
cout << "我是子进程 pid " << getpid()
<< " cnt " << cnt << endl;
sleep(1);
cnt--;
}
cout << "我是子进程 进入僵尸" << endl;
return 1;
}
}
signal(17, handler);
while (true)
{
cout << "我是父进程 pid " << getpid() << endl;
sleep(1);
}
return 0;
}
这里我们上面说过,当有多个一样的信号信号被递达时,我们会把其他的block住,总有不会被等待的,所以里出现了僵尸进程.那么我们应该如何做呢?此时我们需要while死循环等待.
void handler(int signo)
{
assert(signo == 17);
while (true)
{
pid_t id = waitpid(-1, nullptr, 0); // -1 等待任意进程
if (id > 0)
{
cout << "父进程等待成功 child id " << id << endl;
}
else
{
break;
}
}
}
这里还有一个问题,一当我们等待一个子进程的时候,由于我们\是阻塞等待,这里父进程就不会继续工作,这里由于团片上显式的不太清楚,我说一下就可以了,我们这里要非阻塞等待.
void handler(int signo)
{
assert(signo == 17);
while (true)
{
pid_t id = waitpid(-1, nullptr, WNOHANG); // -1 等待任意进程
if (id > 0)
{
cout << "父进程等待成功 child id " << id << endl;
}
else
{
break;
}
}
}
除此之外我们这个信号如果我们手动的捕捉,我们把它定义成忽略,自己就不用关心子进程推出了,他会被 OS释放掉资源,也就是我们不需要担心子进程会变成 僵尸进程.这是非常方便的.
int main()标签:cout,int,handler,信号,进程,我们 From: https://blog.51cto.com/byte/5966283
{
signal(SIGCHLD, SIG_IGN);
pid_t id = fork();
if (id == 0)
{
int cnt = 10;
while (cnt)
{
cout << "我是子进程 pid " << getpid()
<< " cnt " << cnt << endl;
sleep(1);
cnt--;
}
cout << "我是子进程 进入僵尸" << endl;
return 1;
}
while (true)
{
cout << "我是父进程 pid " << getpid() << endl;
sleep(1);
}
return 0;
}