目录
1.信号
1.1.知识引入
生活中我们常常都在接收着信号,信号产生时到信号对应的事件被我们处理,可以存在一段时间差,也就是信号产生和处理是支持异步的。怎么理解这一句话呢?
我们正在处理一个程序的bug时,外卖小哥打电话给你说你的外卖到了,这时我们接收到“外卖到了”这一个信号,那么我们有两个选择,直接下去拿或者让他放在外卖柜等你解决了这个bug你再去拿这两种行为,对于后者就形成了“异步”,而外卖小哥放完外卖就可以离开了。
这里对应着:信号产生了,我们不一定要立即处理,我们可以选择合适的时机处理。但前提是我们需要有暂时保存信号的能力,和能够知道如何处理这个信号,信号的产生是异步的。
信号处理的常见方式:
- 忽略此信号。
- 执行该信号的默认处理动作。
- 自定义自己的处理方式
那什么是信号呢?
信号是一种向目标进程发送通知信息的一种机制,也称为进程之间事件异步通知的一种方式,属于软中断。
【OS学习笔记】十一 实模式:中断-软中断和硬中断基本原理_实模式中断-CSDN博客
这里转载了一篇关于软件中断和硬件中断的博客,在信号篇章中我们主要讲一下软中断的现象……
同步和异步:
同步操作:在发出一个调用后,调用者会等待这个调用返回结果后,再继续执行后续的代码。同步操作要求调用者和被调用者之间保持一种紧密的协调,调用者必须等待被调用者完成操作后才能继续执行。这种等待可能导致调用者的线程或进程被阻塞,直到结果返回
我们在之前学习过管道通信,这种通信方式就是同步的,当数据没有被写入时,当读端在读取信息之前,一定要等待写端写入数据,才能进行读取。
异步操作:调用者在发出调用后,不必等待被调用者完成操作,即可继续执行后续的代码。被调用者在完成操作后,会通过某种机制(如回调函数、事件、Promise等)通知调用者。异步操作使得调用者能够在等待期间执行其他任务,从而提高了程序的并发性和效率。
而共享内存通信、消息队列我们就不需要进行强制等待,只要我们需要接收,随时都可以接收,这样的数据传输就是异步的。
1.2.信号的产生
产生信号的方式多种多样,但是给直接进程发信号的只有操作系统!!!
如图:为Linux内置的信号集合,通过kill -l查看系统中的信号列表,本质为宏替换
- 信号1-31称为普通信号,34-64称为实时信号
- 每一个进程都有一张自己的函数指针数组,数组的下标和信号编号强相关
而这个强相关的函数指针数组,提供给接收到信号的进程,在处于合适的时机调用对应信号操作的函数方法,来实现对信号的响应。
我们知道当进程接收到信号时,可以存在时间差来进行信号的处理,那么这就需要进程对收到的信号进行存储并且如何表示是否收到了某种信号?这时我们就很顺畅的想到了位图!
struct task_struct
{
// 信号位图
uint32_t signmap;
}
进程中维护一个位图,通过一个int整型32个比特位,比特位的位置来对应信号编号,比特位的01表示是否收到信号,而信号的产生就是操作系统向进程中发送(写)信号,即操作系统对进程的task_struct的位图对应位置由0置1的操作。
无论通过什么方式产生的信号,本质上都是与操作系统进行交互后,操作系统对进程进行信号的写入,而完成信号的写入后,就是通过下标索引调用对应的函数指针数组中的函数。
接下来我们就学习一下,常见的信号产生方式 ……
1.2.1.通过键盘产生信号
我们在讲解什么是信号时,提到了信号是一种软中断,这句话的意思是:信号的本质是用软件来模拟、实现中断的行为。在我们运行程序时,我们可以通过Ctrl + C来终止一个进程,也可以通过kill指令来终止,本质我们向键盘中输入指令,操作系统通过软硬件结合来向shell发送信号,当shell接收到这个信号后会将这个进程给关闭。
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
#include<stdlib.h>
using namespace std;
// 自定义一个信号处理的方式
void handler(int signal_id)
{
cout << "调用该命令时产生了一个:" << signal_id << " 信号" << endl;
exit(1);
}
int main()
{
// 向signal函数中 传入系统中定义的信号名、自定义的处理方式
signal(2, handler);
while (1)
{
cout << "process is running, pid: " << getpid() << endl;
sleep(2);
}
}
在代码中我们结合了上面提到的自定义处理信号,这里我们也验证了从键盘中输入可以产生信号,这里产生的是信号2。
这里我们介绍一下signal函数
typedef void (*sighandler_t)(int);
// 需要传入信号编号、可调用函数对象
sighandler_t signal(int signum, sighandler_t handler);
我们通过自己编写的handler函数可以实现信号编号对应函数体的改变,例如我们上面的地址接收到2号信号时,可以顺便打印,当然我们去掉exit后,就能够把这个2号编号对应的函数,把他的关闭进程功能给除去 ,也就是可以随意diy信号编号的函数。
不过编号为9的函数体强制杀掉进程不支持改变!
1.2.2.通过系统调用产生信号
// 给任意进程发送信号(可以被signal捕捉)
int kill(pid_t pid, int sig);
// 给自身进程发送信号(可以被signal捕捉)
int raise(int sig);
// abort接收6号信号,这个函数运行直接关闭进程(即使被signal函数捕捉)
void abort(void);
接下来我们用一个demo来实现以下Linux的kill命令
#include <iostream>
#include<sys/types.h>
#include<signal.h>
using namespace std;
void Usage()
{
cout << "Correct Usage is: ./process_name -signal_num process_pid" << endl;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage();
exit(0);
}
int signal_id = stoi(argv[1] + 1);
int process_id = stoi(argv[2]);
kill(process_id, signal_id);
}
1.2.3.硬件异常产生信号
访问空指针问题:
int *p = nullptr;
*p = 100;
这段代码中我们访问了空指针,当Cpu进行调度时,因为无法实现虚拟地址到物理地址的转化,导致非法内存访问,出现异常,Cpu将异常情况反馈给操作系统,而操作系统会根据异常情况往task_struct中的signal_map写入信号,进而调用对应的默认处理方法杀掉该进程,恢复硬件的正常运作。
当我们在bash上运行时会发生段错误,Segmentation fault。对应我们信号编号表上的11,翻译一下:非法的内存访问
进行除0错误:
int a = 10;
a = a / 0;
我们知道C语言、C++、Java等语言存在除0时,程序能够通过编译但是运行时会出现异常,这时因为语言级别上除0并没有造成编译器的异常,而是因为Cpu出现数据溢出导致异常,进而通过操作系统写入信号、杀掉该进程!
当我们在bash上运行时会发生浮点数异常,Floating point exception。对应我们的8号信号。
我们可以通过singal函数捕捉这个信号,并且在自定义函数中只是打印函数,并不终止进程.
void handler(int signal_id)
{
cout << "调用该命令时产生了一个:" << signal_id << " 信号" << endl;
sleep(1);
}
int main()
{
// 向signal函数中 传入系统中定义的信号名、自定义的处理方式
signal(8, handler);
cout << 10 / 0 << endl;
}
我们通过这个函数再结合我们的异常代码,会出现不断循环的现象!
我们会疑惑:明明没有循坏的语句,而这个进程为什么会循环打印!这里是:因为当CPU发送给操作系统异常情况后,操作系统通过kill函数发送信号给该进程,准备将这个进程给杀掉,但是我们在自定义handler中并没有杀掉这个进程。那么在下一个时间片中CPU再次调度到这个进程,也就再次抛出异常!进而不断循环
这里我们结合一下: 当我们在vs中进行访问野指针和除0错误时,vs会崩溃。本质就是Windows接收到了CPU发送的异常情况,进而Windows将你正在运行的程序直接杀掉,导致了vs的崩溃。
1.2.4.软件异常产生信号
当我们在管道文件中不断写入,并且关闭读端,因为数据不断写入而不被读取,操作系统就会获取软件信息上的异常,进而向该进程写入信号,杀掉这个进程。这时称为软件异常产生信号……
另外,我们常常应用于网络编程中的超时检测alarm闹钟,当闹钟设定的时长到达设定值时,就会给操作系统发出信号SIGALRM给该进程,进而进程退出,这也是软件异常产生信号
1.3.信号的保存
在学习信号如何保存之前,我们要先知道信号需要保存什么,也就是描述信号的量有什么……
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending),大部分场景时某个信号被阻塞,但是操作系统向该进程发送这个信号。
- 进程可以选择阻塞 (Block )某个信号。
如图所示:即为进程PCB中维护的信号的模块 。我们通过对block位图0,1表示是否需要进行对该信号的阻塞,对应的比特位表示是哪一个信号需要进行阻塞,同理pending也是,最终我们就能够通过信号处理的三种方式来实现进程对系统信号发出的响应。
操作系统操作信号时,本质上就是对这三张表进行修改
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号 产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
那么假设有这一个场景:如果某个信号在被处理之前,不断产生,那么这时如何保存这一连串的通过信号呢?
在Linux中,对于普通的信号连续产生时,只保存一次。而对于实时信号,则会把他们插入进实时信号队列来存放(产生多少个保存多少个)
1.4.信号集操作
1.4.1.sigset_t
我们已经知道信号是通过位图来进行保存的,为了更好的统一标准,操作系统直接在自己内部实现了一个信号集类型,本质上就是位图。操作系统中的源代码:
/* A `sigset_t' has a bit for each signal. */
# define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
typedef __sigset_t sigset_t;
1.4.2.信号集操作函数
初始化信号集函数
// 将位图上所有比特位置为0
int sigemptyset(sigset_t *set);
// 将位图上所有比特位置为1
int sigfillset(sigset_t *set);
这两个函数的作用是,将信号集对象设置为没有信号和接收到所有信号两种状态。另外,在使用信号集类型的变量之前,一定要调用初始信号集函数来进行初始化,使信号集处于确定的状态。
信号集中操作某个信号的函数
// 往信号集中增加信号编号为 signo 的信号
int sigaddset (sigset_t *set, int signo);
// 往信号集中删去信号编号为 signo 的信号
int sigdelset(sigset_t *set, int signo);
// 判断信号集中有无信号编号为 signo 的信号
int sigismember(const sigset_t *set, int signo);
这三个函数的底层,就是对位图对应信号编号下标来进行比特位的修改、或判断。
阻塞信号集函数 sigprocmask
// 传入参数:如何操作当前信号集、传入当前信号集、保存操作前的当前信号集
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
如图为:传入的how方法对应的宏参数
- SIG_BLOCK:将传入的set中对应比特位为1,也就是保留的信号置为阻塞态。并且添加到当前进程的信号屏蔽字(阻塞信号集)。
- SIG_UNBLOCK:解除set中保留信号的阻塞态,并且从信号屏蔽字(阻塞信号集)移除
- SIG_SETMAXK:set 中指定的信号集合替换当前进程的整个信号屏蔽字。也就是用set这个集合作为当前的信号屏蔽字(阻塞信号集)
void handler(int signal_id)
{
cout << "调用该命令时产生了一个:" << signal_id << " 信号" << endl;
sleep(2);
exit(1);
}
// 进行信号屏蔽
int main()
{
signal(2, handler);
sigset_t block, oblock;
// 初始化信号集
sigemptyset(&block);
sigemptyset(&oblock);
// 在栈上对临时变量block对2号对应的block表进行由0置1
sigaddset(&block, 2);
// 把这个block表应用到系统层面上,实现将2信号添加进信号屏蔽字中
sigprocmask(SIG_BLOCK, &block, &oblock);
int count = 10;
while (1)
{
cout << "process is running, pid is: " << getpid() << endl;
sleep(1);
count--;
if (count == 0)
break;
}
cout << "取消对2号信号的屏蔽" << endl;
// 当取消屏蔽后,如果操作系统发送了2号信号,2号信号此时会调用
sigprocmask(SIG_UNBLOCK, &block, &oblock);
// 这一部分代码为了验证:如果发送了2号信号,就会退出而不会进来这一部分
while (1)
{
cout << "你好啊,pid:" << getpid() << endl;
sleep(1);
}
}
这段代码我们通过信号集,将某个信号插入进信号屏蔽集后,又将他解屏蔽,在测试过程中,如果我们在屏蔽时,操作系统给这个进程发送了该信号,那么这个信号会置于未决状态插入,未决信号集中,当我们解除屏蔽后,这个未决信号就收到进程的处理,进行信号的自定义处理方法。
读取未决信号集函数sigpending
// 传入参数:待查看的信号集
int sigpending(const sigset_t *set)
sigpending本质也是把传入的信号集对应位置置为1
void handler(int signal_id)
{
cout << "调用该命令时产生了一个:" << signal_id << " 信号" << endl;
sleep(2);
}
void PrintSet(const sigset_t &set)
{
// 通过二进制形式打印
for (int i = 32; i > 0; i--)
{
// 判断这个set中有没有这下标对应的信号
if (sigismember(&set, i) == 1)
cout << "1";
else
cout << "0";
}
cout << endl;
sleep(2);
}
int main()
{
signal(2, handler);
sigset_t block;
sigset_t oblock;
// 初始化信号集
sigemptyset(&block);
sigemptyset(&oblock);
// 在栈上对临时变量block对2号对应的block表进行由0置1
sigaddset(&block, 2);
// 把这个block表应用到系统层面上,实现将2信号添加进信号屏蔽字中
sigprocmask(SIG_BLOCK, &block, &oblock);
cout << "process is running, pid is: " << getpid() << endl;
sleep(2);
int count = 5;
while(1)
{
sigset_t pending;
sigpending(&pending);
// 通过二进制形式打印待处理集合
PrintSet(pending);
if(count-- == 0)
{
cout<<"关闭对信号的屏蔽"<<endl;
// 将屏蔽字设置为全空
sigprocmask(SIG_SETMASK, &oblock, nullptr);
}
}
}
在这段代码中,当我们进入while循环时,操作系统在10s内传入2号信号,因为2号信号被屏蔽了,处于未决状态,所以此时penging对象再通过打印时对应比特位会置为1,接着取消屏蔽后,2号信号被接受,退出未决状态,那么通过sigpending函数后对应比特位就位0了。
1.5.信号的处理
信号处理的时间:进程从内核态返回到用户态时,进行信号的检测和信号的处理。
我们在代码中调用操作系统提供的信号集函数的本质就是:从用户态(用户)访问内核态(操作系统内部数据)。如图为signal函数信号捕捉的示意图
观察这个图:我们发现对信号的处理就是内核态和用户态,进行若干次状态切换,在不同的状态下调用各自的方法……
sigaction函数调用
sigaction函数是一个用于设置和获取信号处理程序的系统调用,它的功能和signal函数类似,但是它的功能更多
// 传入参数为信号编号 信号操作结构体 保存原有结构体
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
接着我们来看一下这个信号操作结构体:
struct sigaction
{
__sighandler_t sa_handler;
/* Additional set of signals to be blocked. */
__sigset_t sa_mask;
/* Special flags. */
int sa_flags;
/* Restore handler. */
void (*sa_restorer) (void);
};
在这个结构体中,我们可以通过sa_handler修改信号编号对应的函数指针表,这就是signal的功能,另外我们可以通过sa_mask这个屏蔽信号集对象来实现信号的屏蔽。
void handler()
{
// 自定义的信号操作函数
}
int main()
{
struct sigaction act;
struct sigaction oact;
// sa_mask为存储阻塞的信号集
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 1);
sigaddset(&act.sa_mask, 3);
act.sa_handler = handler;
// 修改handler表
// 填入信号编号,实现和signal一样的功能
sigaction(2, &act, &oact);
// 当进行sigaction后,1,3信号也被屏蔽了
}
那为什么需要这个函数呢?
在Linux中,当进程接收到某一个信号时,如果该信号正在递达,并没有结束那么如果再次接收时该信号时,操作系统会将其屏蔽,直至结束,也就是进程对应的Block表对应该信号编号下标的比特位置为1,但是这时,其他信号可以对这个进程进行操作,那么如果接收到其他信号时,会不会使得原信号在处理时收到影响呢?
2.信号的应用
2.1.基于信号实现对子进程的回收
我们在之前的fork函数学习时,提及了子进程退出时会给父进程返回一个退出的信号,并且子进程退出时如果父进程不进行进程等待,那么子进程会处于僵尸状态,具有内存泄漏的危险。一般情况下,我们会在父进程的代码模块进行进程等待,但是实际场景中,父进程往往是while(1)的死循环不断调度,这时我们就可以通过自定义的处理信号来进行对子进程的回收,而不在父进程的模块进行回收。
void handler_child(int signo)
{
// 如果进程退出能够进入该函数则验证:子进程退出会返回信号
cout << "get a signo: " << signo << endl;
pid_t id = 0;
// 循环等待
while ((id = waitpid(-1, nullptr, WNOHANG)))
{
if (id <= 0)
break;
cout << "回收进程:" << id << endl;
}
}
// 基于信号对子进程进行回收
int main()
{
signal(SIGCHLD, handler_child);
// signal(SIGCHLD, SIG_IGN); // Linux支持手动忽略SIGCHLD, 所有的子进程都不要父进程进行等待了!!退出自动回收Z
// 模拟多个子进程的创建
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
cout << "child is running" << endl;
sleep(5);
exit(1);
}
}
// 单个子进程的回收
// pid_t id = fork();
// if (id == 0)
// {
// cout << "child is running" << endl;
// sleep(5);
// exit(10);
// }
while(1)
{
// 父进程代码模块
sleep(1);
}
}
原理我们已经理解了,可是为什么我们在自定义的处理函数需要循环回收呢?这里的原因是:实际场景中,前一个子进程退出时,信号还在处理,然后又有一个(多个)子进程退出,那么这时就会有一连串相同的信号发出,这时操作系统只会记录一个!那么若干个子进程就只会有一个子进程会被接收信号处理,那么僵尸问题就得不到解决了。所以我们需要循环等待。
另外:普通的循环等待也不行,我们需要知道什么时候,所有的子进程都退出了,那么我们就不需要继续循环下去了,那么就需要对waitpid的返回值做合理化判断……