什么是信号
信号在我们的生活中非常常见;如红绿灯,下课铃,游戏团战信号,这些都是信号;信号用来提示接收信号者行动,但接收信号的人接收到信号会进行一系列的行为,完成某个动作;这就是信号;
接下来我们通过生活中的信号来辅助理解信号:
我们具备识别信号的能力,例如红绿灯,小孩本来不知道红绿灯的意思,大人们教会了小朋友红灯行绿灯行,小朋友们才能识别红绿灯信号;
1.操作系统可以识别我们的信号,执行一些列行为;
信号提示的行为我们可能不会立刻执行,例如下课铃响了,老师说再多讲几分钟,等会在下课,说明老师把下课的信号存储了,等课讲完了,才会处理信号提示的行为,让我们下课;
2.信号可以被暂时忽略,操作系统可以先执行它正在调度的工作,之后再去处理此信号;(至于什么时候去处理,就得看调度器的调度了)
信号的发出是随机的,就比如王者荣耀打团的时候,我们并不知道什么时候队友会发信号,我们什么时候该发起进攻;
3.信号产生是随机的,对于进程是异步的;
我们处理信号的行为是多样的;就比如在上课的时候,你的外卖到了,你可以选择忽略外卖到了的信号,继续听课,听完课之后再去拿;也可以直接去拿(因为你实在是太饿了);或者这个外卖不是你的你是帮别人拿的;这几种不同的行为也是计算机中处理信号的几种不同行为
4.信号的处理方式是多样的(下面详细讲解);
理解信号
信号是由谁发出的呢?信号是怎么产生的呢?我们先抛出这样的问题,然后慢慢理解;
通过场景来理解信号产生与执行信号的过程
我们带入这样一个场景,当我们写了一份死循环代码,我们使用ctrl+c组合键来终止进程;这个过程发生了什么?
初步的理解:
我们可以这么理解操作系统发出了终止信号,让进程终止了;
底层的理解:
1.首先我们在键盘上按了ctrl+c的按键,使得操作产生系统中断(键盘工作方式),使得操作系统接收到这个信息;
2.操作系统将此信息转换为信号(产生信号)发送给正在运行的进程,如何发送呢?进程有它的pcb结构体,pcb结构体中存储着保存信号的数据结构(位图),操作系统通过修改位图上的标志位,写信号给进程;信号发送成功就是进程pcb数据结构被修改成功;
3.在操作系统运行时会不断检查进程的信号,这个速度非常快(也是调度器来操作),当检查到某个进程的新为终止的时候,就会进行终止操作使得此进程被终止;
这就是信号产生与发送信号最后执行信号的过程;我们再来看看进程pcb中的位图可以存储哪些信号;
信号列表
通过kill -l查看信号列表
我们可以看到有62个信号(没有第32,33);
其中1至31是普通信号,我们现在学习的信号,而34至64是实时信号,他们的要求更高,当操作系统发送给进程时会立即执行此信号,场景(智能汽车的自动避障与刹车);
man 7手册
我们可以通过man 7 signal来查看这些信号的细节:
gpt:
man 7 这节主要包含了关于各种杂项的信息,比如惯例与协议、文件格式以及一些杂项的说明文档。
- man-pages(手册页的概述和说明)
- ascii(ASCII 字符集)
- environ(环境变量)
- filesystems(文件系统相关)
- ip(IP 协议相关)
- socket(套接字编程接口)
- signal(信号处理)
- standards(标准与规范)
产生信号方式
通过终端键盘产生信号
通过键盘产生信号ctrl+c实质是发送了SIGINT 2操作,ctrl+\是发送SIGQUIT 2操作,他们的默认功能都是终止进程;
先介绍一个接口signal
signal接口
这个系统接口可以捕捉我们的信号,对信号行为自定义;参数1是要捕捉的信号的宏例如SIGINT或者2;参数2传递的是一个回调函数,来自定义我们的信号行为,不再进行默认操作;
示例:
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<signal.h>
using namespace std;void catchsig(int signalnum)
{cout<<"catch signalnum is: "<<signalnum<<endl;exit(1);//读取信号后退出
}int main()
{signal(SIGINT,catchsig);signal(SIGQUIT,catchsig);while(1){cout<<getpid()<<": 我正在运行"<<endl;sleep(1);}return 0;
}
这是一份死循环;我们运行它并使用ctrl+c和ctrl+\终止进程;
所以ctrl+c与ctrl+\ 确实是分别产生了2和3信号;
另外我们还可以设置core文件的大小进行核心转储;
core dump核心转储
这实际上是一种debug的方式,通过接收信号默认行为为core的信号来将进程的debug数据存储到当前目录中;默认行为为core的信号一般都是进程产生了bug;而核心转储功能一般是在生产环境(就是编写代码的环境)下才会被打开的;如果我们发送默认行为为core的信号没有创建出core file文件的话就是核心转储功能没有被打开我们可以使用ulimit -a来查看;
使用ulimit -c (大小)设置core文件大小打开核心转储功能;
之后我们运行ctrl+\这是SIGQUIT 3命令默认行为是进行core dump会生成core file文件
我们在gdb中就可以查看此信息;
核心转储默认不打开,因为为了保护用户的数据安全,还有就是占用内存空间问题;
回顾一下
在我们前面进程等待那块waitpid的status参数:
通过系统调用产生信号
我们在bash上直接输入kill 命令是封装了系统调用发送信号的;
示例:
其实kill命令是封装了kill接口:
kill接口
这就是向进程pid发送sig号指令;
raise函数
向自己发送sig号指令;
abort函数
和exit函数相同,都是发送信号使得当前进程退出;发送6号SIGABRT信号给自己;
通过软件条件产生信号
软件产生信号,就是由于软件的某些行为产生某些条件从而发出信号;接下来我们举两个例子辅助理解;
匿名管道的13号信号
在我们前面学习匿名管道的时候,当读端关闭,写端还在写的时候,写端就会被终止;这种情况就是写端在读端关闭时,写端没读端发出的信号杀死了;
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <cassert>
#include <sys/wait.h>
using namespace std;int main()
{int pipefd[2];int ret_pipe = pipe(pipefd);assert(ret_pipe != -1);(void)ret_pipe;pid_t pid = fork();if (pid == 0) // 子进程{close(pipefd[0]);int i=0;while (true) // 死循环 永远不会退出{cout << getpid() << ": 我是子进程,我正在发消息" << i++ << endl;int tem = 1;write(pipefd[1], &tem, sizeof(tem));sleep(1);}exit(1);}close(pipefd[1]); // 关闭写管道for (int i = 0; i < 5; i++){cout << getpid() << ": 父进程进程正在运行" << endl;sleep(1);}close(pipefd[0]);cout << "父进程读管道关闭成功" << endl;for (int i = 0; i < 5; i++){cout << getpid() << ": 父进程正在运行" << endl;sleep(1);}wait(nullptr);return 0;
}
可以看到子进程本来在死循环一直在向管道中写数据,但是由于父进程的读管道被关闭了,所以子进程直接退出了;这就是软件上管道读端关闭条件满足,会发信号给写端的进程,使得写端退出;发的信号是13号信号;
我们改写一下代码捕捉13号信号;
void hander(int signum) 自定义行为
{cout<<"catch signal : "<<signum<<endl;
}int main()
{int pipefd[2];int ret_pipe = pipe(pipefd);assert(ret_pipe != -1);(void)ret_pipe;pid_t pid = fork();if (pid == 0) // 子进程{close(pipefd[0]);signal(13,hander); 增加一个捕捉信号。。。。。。}close(pipefd[1]); // 关闭写管道。。。。。
}
可以看到:
我们的程序变成了死循环代码,因为我们子进程一直在向管道中写数据,但是因为读端被关闭了,所以向写端发送了信号,而写端捕捉了这个信号,处理器在处理的时候发现了这个错误,但这个错误一直没有被修改所以这个信号就一直重复的发送;这就是软件上的发送信号;
我的思考:我认为这里的死循环其实也已经算是硬件异常产生的信号了,应该是操作系统检查到某个硬件的操作一直是错误的所以就会不断的向进程发送信号导致死循环;
alarm时钟信号
alarm接口可以在seconds秒后发送一个时钟信号给当前进程;
alarm的默认行为是忽略
就是说我们可以通过alarm设置一个闹钟,当seconds秒后会自动做一些事情,这常用来做一些自动的周期性的功能;比如我们手机的屏幕显示,当我们无操作30秒时,手机自动熄屏;用户端登录的自动退出;这些都可以使用alarm来进行周期性的工作;
下面我们通过代码来辅助理解:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <ctime>
#include <functional>
#include<vector>
using namespace std;// typedef void(*func)();
typedef function<void()> func;vector<func> task_table;void handler(int signum)
{cout << "catch signal: " << signum << endl;for(auto& func:task_table){func();}alarm(1);
}
void log()
{time_t curtime = time(nullptr);cout << asctime(localtime(&curtime)) << "打印日志信息" << endl;
}void exam()
{cout<<"检查一下程序"<<endl;
}void load()
{task_table.push_back(log);task_table.push_back(exam);
}int main()
{alarm(1);load();//加载任务列表signal(14, handler);int count=0;while (true){cout << getpid() << ": 正在运行中"<<count++ << endl;sleep(1);}return 0;
}
现象:
所以这就是软件层面上发送信号给进程从而完成某些任务;
拓展:
计算出一秒内cpu可以完成多少次++:
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;static int count=0;void handler(int signum)
{cout<<"count="<<count<<endl;alarm(1);
}int main()
{alarm(1);signal(14, handler);while (true){count++;}return 0;
}
通过硬件异常产生信号
什么是硬件异常呢?就是硬件发生了错误嘛,这个错误会被操作系统检查出来,检查出错误了的话,就会发送信号给我们的进程,告诉进程你这样的行为会导致我们的硬件出现错误,所以你得修改的你的代码;
我们直接据一个例子:除0错误
我们先写一份除0错误的代码并捕捉除0错误的信号:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <cassert>
#include <sys/wait.h>
#include<signal.h>
using namespace std;void handler (int signum)
{cout<<"catch signal : "<<signum<<endl;sleep(1);
}int main()
{pid_t pid = fork();assert(pid != -1);if (pid == 0) // 子进程{signal(SIGFPE,handler);int a = 10;a /= 0;}wait(nullptr);// int status=0;// waitpid(pid,&status,0);// cout<<WTERMSIG(status)<<endl;return 0;
}
我们可以看到我们的程序结果是进入了死循环:
这是为什么呢?其实前面在软件产生信号的管道发送信号方法的最后我们也说了;
因为处理我们数据的是硬件。这里进行除0的是我们的cpu,cpu进行除0操作后,我们的寄存器中存储了这次计算的状态;而这次除0的结果是错误的,状态也就被标识成了错误,所以操作系统对这个寄存器进行检查时就会发现这个错误,从而向进程发送信号;而我们的进程又捕捉了这个信号,此信号的行为被改变了,本应该退出进程变成了打印,并没有解决这个错误,所以进程没有退出,而进程没有退出继续运行,os在调度的时候不断检查进程信号位图,位图中依旧有这个信号,会继续运行信号的自定义行为,从而产生死循环;
梳理总结一下:
1.除0操作由cpu进行,cpu是硬件;
2.cpu除0后将除0后的数据放入cpu的寄存器中,而寄存器中有标记位供os来判断计算结果是否正确;
3.os检查出结果错误之后产生信号发送给进程;
4.进程没有解决硬件的异常错误使得进程进入死循环;
这就是由硬件异常而产生的信号;
我们可以通过一个坐标来说明信号的状态:
信号状态
接下来我们解释一下信号一些状态名词例如:
信号保存:信号产生后由操作系统发送给进程,操作系统将信号写入进程pcb的位图中;
信号未决:当信号被保存之后,信号还未抵达内核由操作系统处理的时候;
信号抵达:当进程中信号的数据结构进入内核时就是抵达,忽略,默认,捕捉是抵达后对信号的三种处理方式;
阻塞:当信号被保存后,通过阻塞使得信号无法抵达的操作
信号保存
信号到底是如何被保存的呢,我们前面只知道信号是被保存在进程pcb的位图中,但是具体是什么样的呢?
信号在pcb中的结构
这三个表又是一一对应的,pending信号集,block屏蔽字(也可以叫信号机),handler方法表;它们从1到31位置代表的就是我们31个普通信号的位置;
信号产生后由操作系统写入pending信号集中,再对block屏蔽字进行检查,如果此屏蔽字bit位为1则代表信号被屏蔽无需抵达,如果信号屏蔽字bit位为0即代表此信号没有被屏蔽需要检查handler方法表,如果方法表相应信号位置不为空即执行相应方法;
sigset_t封装类型
操作系统对我们的信号数据结构的类型进行了封装,将其封装为了sigset_t类型;
所以sigset_t pending 与 sigset_t block,它们是这样的数据类型;这样的封装,使得用户无法直接对pending与block进行位操作,如果相对其进行位操作,需要通过os给出的接口:
对sigset_t进行操作的接口:
sigemptyset:将set每个bit位置为0;
sigfillset:将set bit全置为1;
sigaddset:向set中添加signum号信号;
sigdelset:从set删除signum号信号;
sigismember:检查signum是否再set中存在;
sigprocmask
此接口的第一个参数是对屏蔽字block进行how操作,how选项就和我们再open函数中的设置选项一般由宏来控制:
这就是man手册中记载的how的三个选项的宏 ;
第二个参数sigset_t类型的set,这个set是由我们自己设置的,我们可以是用前面的接口来设置这个set;
而how的三个宏函数需要和我们的这个第二个参数配合:
SIG_BLOCK : block=block|set;
SIG_UNBLOCK : block=block&~set;
SIG_SETMASK : block=set;
形成这样的操作;
第三个参数是输出型参数,将原有的block屏蔽字输出到oset中;
返回值:操作正确返回0操作出现错误返回-1;
sigpending
这个接口是用来将pending信号集输出到我们的第二个参数set中;
返回值:操作正确返回0操作出现错误返回-1;
有了这些接口我们接下来就可以用代码来证明我们的信号的保存了:
实践证明信号的保存
我们写下面这份代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
using namespace std;void handler(int signum)
{cout<<getpid()<<": catch signal "<<signum<<endl;
}void handler1()
{sigset_t pending;sigpending(&pending);for (int i = 31; i > 0; i--){int ans = sigismember(&pending, i);cout << ans;}cout << endl;sleep(1);
}int main()
{sigset_t set;sigemptyset(&set); // 初始化将要替换block的set// for (int i = 31; i > 0; i--) // 打印一下初始化的set// {// int ans = sigismember(&set, i);// cout << ans;// }// cout << endl;for (int signum = 1; signum <= 31; signum++){// signal(signum,handler);//将1到31号信号全部捕捉发现9号信号无法被捕捉sigaddset(&set, signum); // 添加信号进入set// for (int i = 31; i > 0; i--) // 打印一下修改的set// {// int ans = sigismember(&set, i);// cout << ans;// }// cout << endl;sigprocmask(SIG_SETMASK, &set, nullptr); // 成功将信号屏蔽}while (true) // 让进程死循环防止退出{handler1();}return 0;
}
我们首先调用signal捕捉1到31全部的信号我们然后再运行这个bash脚本,对此进程发送1到31的信号;
i=1
id=$(pidof block)
while [ $i -le 31 ]
doif [ $i -eq 9 -o $i -eq 19 ]thenlet i++continuefikill -$i $idecho "kill -$i $id"let i++sleep 1
done
我们可以得出这样的效果:
同理:如果捕捉所有信号,进程也无法被终止,除9与19信号;9与19无法被捕捉和屏蔽
上面就是信号的保存内容;
信号处理方式
默认处理:
忽略:
信号抵达后(被os读取之后存储在某个地方),os暂时先不处理,os先处理其他任务;
自定义行为:
这就是我们上面进行了那么多次使用signal函数进行的catch捕捉信号;然后自定义函数,回调函数给signal,进行我们定义函数的行为;
信号处理的时间
我们知道了这些处理方式,我们下面详细的讲解一下它们;
我们知道信号要被处理,需要在合适的时候;那究竟什么时候是合适的时候呢?
当cpu执行状态从内核态返回用户态的时候;
那什么是用户态和内核态呢?我们先看这张图片:
我们知道了代码在内存中的结构,接下来听我好好分析;
我们进程中用户自己编写的代码数据被加载到内存中,而进程中的内核部分会被统一加载到操作系统进程中,这是为了方便操作系统进行管理;操作系统其实也是个进程的,cpu一次只能处理一条指令,那么它加载的进程也肯定只有一个;所以通过每个进程都拥有内核区,加载了操作系统的功能,那么每个进程都能被操作系统所管理;所以此时我们代码的执行会成为这个样子:
信号处理的过程
补充:
内核态与用户态的转换
1.自定义方法因为是我们用户所编写的所以一定会回到我们的内核态执行(内核态虽然能执行用户代码,但为了安全不会在内核态中执行),如果是在内核态执行我们的自定义方法,恶意方法中有指针会获取我们内核数据从而产生危害等行为;所以操作系统十分严格,用户的代码只能在用户态执行,那怎么我们是用户态还是内核态呢?
2.cpu中有一个不可见的寄存器CR3,这个寄存器中使用2个bit位来标识当前代码执行所处的状态,如果状态位内核态即可访问操作系统中的数据,如果为用户态及只能访问自己当前的进程空间;
3.由此我们也可以清楚的知道内核其实也是在所有进程的上下文数据中运行的;
信号一次只能处理一个
我们的信号在被处理时会屏蔽信号表设置屏蔽字,使得操作系统无法检查到其他信号,避免其他信号对当前信号处理的影响;
用下面这段代码证明:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
using namespace std;// 证明一个信号在被执行的时候它的信号集会被屏蔽接os无法去处理任何其他信号
void showpending()
{sigset_t pending;sigpending(&pending);cout << "信号集: ";for (int i = 31; i > 0; i--){int ans = sigismember(&pending, i);cout << ans;}cout << endl;
}void handler(int signum)
{cout << getpid() << ": catch signal " << signum << endl;for (int i = 0; i < 5; i++){showpending();sleep(1);}
}int main()
{sigset_t set;signal(2, handler);showpending();while (true){}return 0;
}
现象:
sigaction
它的第一个参数是用来传递信号的号码的;
第二个参数是一个结构体,用来设置这个信号的相关内容:
我们可以通过传递一个被我们自己改写好的sigaction对象来对signum信号内容进行处理;
第三个参数和sigprocmask的第三个参数一样都是输出型参数,用来输出我们旧的sigaction对象;
下面的内容是对线程的铺垫:
可重入函数
当发生这样的场景时:
上面的场景代表我们的函数不能多次重复进入,叫做不可重入函数;当某个函数被调用还没完全执行完成的时候,就被中断,调用其他函数时这就叫做重入;
只有当函数中的变量是局部的,不是在堆区这样的所有函数都可以访问的空间时,这样的函数才可以被重入,才不会发生混乱;
一般数据在堆区或者全局变量这样的情况是不可重入的;
volatile
在我们编写好代码后,由于效率问题,编译器一定会对我们的某些操作进行优化,而这些优化可能会影响我们原有的逻辑思维导致,出现错误,这类错误的发现也是最难的;就像我们c++之前将的拷贝构造函数的优化,会省略几步拷贝;这样的就是编译器的优化,不同编译器优化程度不同,编译器也可以自动的设置优化程度,而有时候我们为了避免编译器的优化对我们的代码逻辑产生影响就可以使用这个volatile关键字,告诉编译器不要优化这个数据的处理,保持内存可见性;
#include<iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;int count=0;void handler(int signum)
{cout<<getpid()<<": catch signal "<<signum<<" change count->1"<<endl;count=1;
}int main()
{signal(2,handler);while(count==0){}cout<<"成功退出"<<endl;return 0;
}
上面的逻辑是正常的;但是当我们提高编译优化级别:
g++加上 -o2选项;
优化级别:
gdp:
在 GCC(GNU Compiler Collection)中,除了 `-O2` 之外,还有更高级别的优化选项。这些选项用于告诉编译器在生成目标代码时进行更深层次的优化,以提高程序的性能或者减小生成的代码的大小。下面是一些常见的优化级别:
1. **-O1**:基本优化级别,会进行一些基本的优化,例如去除未使用的代码、简化表达式等。
2. **-O2**:更高级别的优化,会进行更多的优化,例如循环展开、函数内联等。这是默认的优化级别。
3. **-O3**:最高级别的优化,会进行更加激进的优化,例如向量化、更深层次的循环优化等。但是有时候 `-O3` 会导致编译时间增加,并且可能会引入一些不可预测的行为。
4. **-Os**:优化生成的代码大小。这个选项会尝试减小生成的目标代码的大小,以牺牲一些性能为代价。
5. **-Ofast**:在 `-O3` 的基础上进一步启用一些不严格的优化,例如允许忽略 IEEE 浮点数标准,以提高性能。但是由于牺牲了一些精度和安全性,使用 `-Ofast` 需要谨慎。
6. **-Og**:用于开发和调试阶段的优化级别,会生成容易调试的目标代码,同时保留大部分的优化。
这些优化级别可以根据具体的需求进行选择,通常在进行性能测试和调试时会尝试不同的优化级别来找到最优的性能和代码大小折中。
优化后:
原因:
将全局数据count增加啊volatile修饰,保持内存可见性;
volatile int count=0
SIGCHLD信号
这个信号一般出现在子进程运行结束后会向父进程发送这个信号告诉父进程,我们当前的进程运行完毕了;
我们可以使用这个特点来回收子进程:
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <signal.h>
using namespace std;void handler(int signum)
{pid_t pid;while ((pid = waitpid(-1, nullptr, WNOHANG)) > 0){cout <<"wait success : "<<pid << endl;}
}int main()
{signal(SIGCHLD, handler);// signal(17,handler);for (int i = 0; i < 5; i++) // 创建5个进程{pid_t pid = fork();if (pid == 0){sleep(10);exit(0);}}for (int i = 0; i < 12; i++){cout << "父进程正在运行 " << i << endl;sleep(1);}return 0;
}
回收成功:
还有一种linux特有的方式但在其他unix操作系统下不完全适用的方式直接:
signal(17,SIG_IGN)
直接使用此代码,这个忽略信号在被显示调用的时候会使得增加回收僵尸进程的功能;而相较于默认的忽略多了回收的功能;此方法为linux特有的方式但在其他unix操作系统下不完全适用;
以上就是本篇的全部内容;