👀樊梓慕:个人主页****
** 🎥个人专栏:《C语言》《数据结构》《蓝桥杯试题》《LeetCode刷题笔记》《实训项目》《C++》《Linux》《算法》**********
🌝每一个不曾起舞的日子,都是对生命的辜负
前言
信号在我们的生活中无处不在,比如红绿灯给你递达了一个红灯停绿灯行的信号,收到快递取件码给你抵达了一个快递到了的信号等等,那么在计算机中也有信号的概念,对只不过接收信号的个体从人变成了进程,本篇文章我们会以信号的产生、信号的保存以及信号的处理来介绍信号的概念。
*欢迎大家📂收藏*📂以便未来做题时可以快速找到思路,巧妙的方法可以事半功倍。 **
=========================================================================
GITEE相关代码:****🌟樊飞 (fanfei_c) - Gitee.com🌟
=========================================================================
1.认识信号
信号是Linux系统提供的让用户(进程)给其他进程发送异步信息的一种方式。
异步:一个任务的执行不会阻塞其他任务的执行,即一个任务无需等待另一个任务完成即可开始。
那么根据我们在现实生活中对于信号的概念,我们可以总结出一份关于信号的认识:
- 我们能识别一个信号并做出处理;
- 当信号到来时,如果我们在处理更重要的事而暂时不能处理该信号,我们需要将这个信号临时保存,等到合适的时候再进行处理;
- 信号的产生是随时的,我们无法准确处理,所以信号是异步发送的。
接下来我们首先来观察一下常见的信号吧:
编写以下程序并运行:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
using namespace std;
int main()
{
while (true)
{
cout << "hello signal,pid:" << getpid() << endl;
sleep(1);
}
return 0;
}
对于以上死循环打印的程序来说,ctrl+c可以让该进程结束。
可是『 ctrl+c』为什么能让该进程中止呢?
本质上操作系统会将键盘键入的『 ctrl+c 』解释成2号信号,然后操作系统将2号信号发送给目标进程,当进程收到2号信号后就会退出。
通过 『 kill -l』命令可以查看信号列表:
而2号信号为SIGINT,默认处理动作为终止进程。
常见信号为1-31号信号,没有0号信号和32、33信号,34-64号信号是实时信号。
所以『 ctrl+c』就相当于我们键入 kill -2 pid;
比如:
终止进程属于进程收到2号信号时的默认处理,我们说信号的处理方式分为三种:
- 默认动作;
- 自定义处理——捕捉;
- 忽略(也处理了只不过没有动作) ;
我们可以通过『 man 7 signal』查看信号的默认动作:
那么如何自定义处理方式呢,即如何捕捉?
我们可以使用『 signal函数』对2号信号进行捕捉,证明当我们按『 ctrl+c』时进程确实是收到了2号信号。
signal函数原型:
sighandler_t signal(int signum, sighandler_t handler);
使用signal函数时,我们需要传入两个参数,第一个是需要捕捉的信号编号,第二个是对捕捉信号的处理方法。
例如,下面的代码中将2号信号进行了捕捉,当该进程运行起来后,若该进程收到了2号信号就会打印出收到信号的信号编号。
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
using namespace std;
void handler(int sig)
{
cout << "get a signal:" << sig << endl;
}
int main()
{
signal(2, handler);//自定义处理——捕捉
while (true)
{
cout << "hello signal,pid:" << getpid() << endl;
sleep(1);
}
return 0;
}
当我们此时再键入『 ctrl+c』 时,你会发现终止进程的默认动作替换成我们自己定义的handler方法了。
由此也证明了,当我们按『 ctrl+c』 时进程确实是收到了2号信号。
2.信号的产生
信号产生的方式分为五种:kill命令、键盘输入、系统调用、软件条件以及异常。
2.1kill命令
我们可以通过键入指令的方式对进程发送信号,比如对于一个pid为1164130的进程,你可以通过发送9号信号来杀死该进程。
kill -9 1164130
2.2键盘输入
比如ctrl+c为2号信号,ctrl+\ 为3号信号,ctrl+z 为20号信号。
你可以通过以下代码验证:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
using namespace std;
void handler(int sig)
{
cout << "get a signal:" << sig << endl;
}
int main()
{
int signo;
for (signo = 1; signo <= 31; signo++)
{
signal(2, handler); // 自定义处理——捕捉
}
while (1)
sleep(1);
return 0;
}
那如果所有的信号都被替换了,我们该如何结束该进程呢,如果他不主动结束,难不成没办法让他结束了么?
不是的,设计者考虑到了这点并规定 9号信号SIGKILL和19号信号SIGSTOP 信号不能被捕捉,而且也不能被忽略。
2.2.1核心转储
『 ctrl+c』和『 ctrl+\』都能终止进程,他们之间有什么区别?
我们知道『 ctrl+c』对应着2号信号,『 ctrl+\』对应着3号信号,这两个信号的默认处理动作有什么不同呢?
查看这两个信号的默认处理动作,可以看到这两个信号的Action是不一样的,2号信号是Term,而3号信号是Core。
Term和Core都代表着终止进程,但是Core在终止进程的时候会进行一个动作,那就是核心转储。
核心转储说白了就是操作系统在进程收到某些信号而终止运行时,将该进程地址空间的内容以及有关进程状态的其他信息转而存储到一个磁盘文件当中,这个磁盘文件也叫做核心转储文件。
云服务器的核心转储功能一般是关闭的,为什么呢?
如果云服务器上的服务崩了,一般为了保证用户体验,我们都会设计让服务自动重启的功能,假设这个服务一直重启又一直崩的话,你如果开了核心转储又恰好在深夜,可能第二天服务器磁盘都爆了。
所以一般云服务器都不开核心转储,当然现在已经做出了优化,之前的核心转储文件格式一般为:core.pid,也就是说后面还要加上pid,现在一般就只是core,这样不管转储多少次还只是一个文件。
核心转储的目的就是为了在调试时,方便问题的定位。
首先为了演示,我们先将核心转储功能打开:
通过使用『 ulimit -a』命令查看当前资源限制的设定。
其中,第一行显示core文件的大小为0,即表示核心转储是被关闭的。
我们可以通过『 ulimit -c size』命令来设置core文件的大小。
core文件的大小设置完毕后,就相当于将核心转储功能打开了。此时如果我们再使用 ctrl+\对进程进行终止,就会发现终止进程后会显示core dumped。
并且会在当前路径下生成一个core文件。
如果在你已经打开核心转储功能后仍然没有生成core文件,你可以尝试运行以下命令再重新运行程序:
sudo bash -c "echo core > /proc/sys/kernel/core_pattern"
使用gdb对当前可执行程序进行调试(注意编译时g++后记得加上-g选项(调试)),然后直接使用core-file core命令加载core文件,即可判断出该程序在终止时收到了8号信号,并且定位到了产生该错误的具体代码。
那么你还记得进程等待函数waitpid函数的第二个参数status么?
pid_t waitpid(pid_t pid, int *status, int options);
该函数解释请看:【Linux】进程周边007之进程控制-CSDN博客
status参数是输出型参数,他记录了子进程在退出时的状态信息(这里我们只考虑低16位)。
如图所示0-6位的七个比特位代表终止信号,而8-15位的八个比特位代表退出状态信息,但是第8位当时我们并没有解释,那么现在我们就可以知道这个标志位是用来标志是否进行了『 核心转储』。
所以我们可以利用位操作将这些数值都读取出来:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
using namespace std;
int main()
{
pid_t id = fork();
if (id == 0)
{
int a = 10;
a /= 0; // 故意异常,收到SIGFPE-> core
exit(0);
}
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
std::cout << "exit code: " << ((status >> 8) & 0xFF) << std::endl;
std::cout << "exit signal: " << (status & 0x7F) << std::endl;
std::cout << "core dump: " << ((status >> 7) & 0x1) << std::endl;
}
return 0;
}
2.3系统调用
2.3.1kill函数
函数原型:
int kill(pid_t pid, int sig);
kill函数用于向pid为pid的进程发送sig号信号,如果信号发送成功,则返回0,否则返回-1。
2.3.2raise函数
函数原型:
int raise(int sig);
raise函数用于给当前进程(自己)发送sig号信号,如果信号发送成功,则返回0,否则返回一个非零值。
2.3.3abort函数
函数原型:
void abort(void);
abort函数可以给当前进程发送指定的SIGABRT信号,使得当前进程『 异常终止』。
2.4软件条件
2.4.1SIGPIPE信号
SIGPIPE信号实际上就是一种由软件条件产生的信号.
PIPE这个单词大家应该非常熟悉了,就是管道,我们之前讲解管道的4种情况的时候,有一种情况是:读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。
这就是所谓的触发了软件条件而递达的信号。
2.4.2SIGALRM信号
顾名思义闹钟信号,与之对应的是系统调用alarm。
alarm函数原型:
unsigned int alarm(unsigned int seconds);
alarm函数的作用就是,让操作系统在seconds秒之后给当前进程发送SIGALRM信号,SIGALRM信号的默认处理动作是终止进程。
alarm函数的返回值:
若调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置。
如果调用alarm函数前,进程没有设置闹钟,则返回值为0。
2.5异常
异常是如何产生信号的呢?
这里我们以两种异常情况作为示例:除零异常、野指针异常。
2.5.1除零异常
实际上异常产生信号需要CPU、OS共同配合完成。
当进行除法操作时,首先需要把除数和被除数放到CPU的寄存器中,然后进行算术运算并把结果写回寄存器当中。此外,CPU当中还有一组寄存器叫做标志寄存器EFLAGS/RFLAGS,包含状态和控制标志,这其中就有一个溢出标志OF。当OF被置1后,CPU会告诉OS出问题了,操作系统就会来查看问题并找到出问题的那个进程,并将所识别到的硬件错误包装成信号发送给目标进程,本质就是操作系统去直接找到这个进程的task_struct,并向该进程的位图中写入8信号,写入8号信号后这个进程就会在合适的时候被终止。
2.5.2野指针异常
对于野指针异常来说,实际上也是CPU、OS共同配合的结果。
首先,我们所说的野指针指的是虚拟地址,虚拟地址与物理地址的转化如果是成功的,那么就不会抛出野指针异常,如果是失败的,那么CPU就会告诉OS出问题了,OS就会找到那个进程并发出11号信号,让他终止。
虚拟地址与物理地址之间的转化是由一个硬件叫做MMU完成的,它是一种负责处理CPU的内存访问请求的计算机硬件,因此映射工作不是由CPU做的,而是由MMU做的,但现在MMU已经集成到CPU当中了。
当我们要访问不属于我们的虚拟地址时,MMU在进行虚拟地址到物理地址的转换时就会出现错误,然后将对应的错误写入到自己的状态信息当中。
所以你会发现程序崩溃,是因为程序当中出现的各种错误最终一定会在硬件层面上有所表现,进而会被操作系统识别到,然后操作系统就会发送相应的信号将当前的进程终止。
3.信号的保存
之前我们说,如果当前进程正在进行更重要的工作,那么它会将信号进行保存,等到合适的时候再做处理,那么信号的保存机制是如何实现的呢?在这个版块里我们会将之前提到过的位图做拓展,看一看内核中究竟是如何保存信号信息的。
3.1信号常见概念
首先我们来规范一下信号的相关概念:
- 信号递达(Delivery):进程实际执行信号的处理动作。
- 信号未决(Pending):信号从产生到递达之间的状态。
- 进程可以选择阻塞(Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意:阻塞和忽略是不同的,阻塞即未读未回(未处理),忽略即已读未回(已处理)。
3.2在内核中的表示
示意图:
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志(细节:pending先清零,再递达)。在上图中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会在改变处理动作之后再接触阻塞。
- SIGQUIT信号未产生过,但一旦产生SIGQUIT信号,该信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前,这种信号产生过多次,POSIX.1允许系统递达该信号一次或多次。Linux是这样实现的:普通信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里,这里只讨论普通信号。
再上图中你会发现block、pending和handler这三张表的每一个位置是一一对应的。
- 在block位图中,比特位的位置代表某一个信号,比特位的内容代表该信号是否被阻塞。
- 在pending位图中,比特位的位置代表某一个信号,比特位的内容代表是否收到该信号。
- handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认SIG_DFL、忽略SIG_IGN以及自定义sighandler。
那么对应如果我们想要修改这三个位图的内容,由于是内核级数据结构,所以OS一定提供了系统调用。
3.3sigset_t
在认识修改block和pending的系统调用之前,我们先来学习一种类型sigset_t信号集,顾名思义该类型可以表示一个信号集合,也就是说这一个类型就可以表示block位图或者pending位图的值。
- 如果该信号集表示的是block位图的值那么就称之为阻塞信号集或信号屏蔽字(Signal Mask);
- 如果该信号集表示的是pending位图的值那么就称之为未决信号集。
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”,至于这个类型内部如何存储这些bit则依赖于系统的实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
- sigemptyset函数:初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
- sigfillset函数:初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
- sigaddset函数:在set所指向的信号集中添加某种有效信号。
- sigdelset函数:在set所指向的信号集中删除某种有效信号。
- 以上四种函数都是成功返回0,出错返回-1。
- sigismember函数:判断在set所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1。
注意: 在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号处于确定的状态。
使用举例:
#include <stdio.h>
#include <signal.h>
int main()
{
sigset_t s; //用户空间定义的变量
sigemptyset(&s);
sigfillset(&s);
sigaddset(&s, SIGINT);
sigdelset(&s, SIGINT);
sigismember(&s, SIGINT);
return 0;
}
提问:调用信号集操作函数后,具体的信号设置写入到进程PCB中了吗?
没有,代码中定义的sigset_t类型的变量s,与我们平常定义的变量一样都是在用户空间定义的变量,所以后面我们用信号集操作函数对变量s的操作实际上只是对用户空间的变量s做了修改,并不会影响进程的任何行为。
因此,我们还需要通过系统调用,才能将变量s的数据设置进PCB。
那么接下来就让我们来认识控制block和pending的系统调用吧。
3.4操作block的系统调用sigprocmask
sigprocmask函数可以用于读取或更改进程的信号屏蔽字(阻塞信号集),该函数的函数原型如下:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数说明:
how:可以传递三种宏。
选项含义SIG_BLOCKset包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|setSIG_UNBLOCKset包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask|~setSIG_SETMASK设置当前信号屏蔽字为set所指向的值,相当于mask=set
set:要根据how参数指示,未来如何使用的信号屏蔽字。
oldset:输出型参数,将修改之前的信号屏蔽字保存到oldset。
返回值说明:
sigprocmask函数调用成功返回0,出错返回-1。
3.5获取pending的系统调用sigpending
sigpending函数可以用于读取进程的未决信号集,该函数的函数原型如下:
int sigpending(sigset_t *set);
sigpending函数读取当前进程的未决信号集,并通过set参数传出。该函数调用成功返回0,出错返回-1。
有了这两个系统调用我们可以来观察一下进程信号集的变化。
搭建场景:
- 先用上述的函数将2号信号进行屏蔽(阻塞)。
- 使用kill命令或组合按键向进程发送2号信号。
- 此时2号信号会一直被阻塞,并一直处于pending(未决)状态。
- 使用sigpending函数获取当前进程的pending信号集进行验证。
- 解除阻塞后,pending信号集清零,信号递达处理。
测试代码:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cassert>
#include <sys/wait.h>
void PrintSig(sigset_t &pending)
{
std::cout << "Pending bitmap: ";
for (int signo = 31; signo > 0; signo--)
{
if (sigismember(&pending, signo))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
void handler(int signo)
{
sigset_t pending;
sigemptyset(&pending);
int n = sigpending(&pending); // 读取未决信号集到pending
assert(n == 0);
// 3. 打印pending位图中的收到的信号
std::cout << "递达中...: ";
PrintSig(pending); // 如果是0,表示递达之前,pending 2号已经被清0. 如果是1,表示pending 2号被清0一定是递达之后
std::cout << signo << " 号信号被递达处理..." << std::endl;
}
int main()
{
// 对2号信号进行自定义捕捉 --- 不让进程因为2号信号而终止
signal(2, handler);
// 1. 屏蔽2号信号(用户级),未设置进内核
sigset_t block, oblock;
sigemptyset(&block);
sigemptyset(&oblock);
sigaddset(&block, 2);
// 1.1 开始屏蔽2号信号,设置进入内核中
int n = sigprocmask(SIG_SETMASK, &block, &oblock);
assert(n == 0);
// (void)n; // 骗过编译器,不要告警,因为我们后面用了n,不光光是定义
std::cout << "block 2 signal success" << std::endl;
std::cout << "pid: " << getpid() << std::endl;
int cnt = 0;
while (true)
{
// 2. 获取进程的pending位图
sigset_t pending;
sigemptyset(&pending);
n = sigpending(&pending);
assert(n == 0);
// 3. 打印pending位图中的收到的信号
PrintSig(pending);
cnt++;
// 4. 解除对2号信号的屏蔽(8s后)
if (cnt == 8)
{
std::cout << "解除对2号信号的屏蔽" << std::endl;
n = sigprocmask(SIG_UNBLOCK, &block, &oblock); // 2号信号会被立即递达, 默认处理是终止进程
assert(n == 0);
}
sleep(1);
}
return 0;
}
观察现象:
阻塞成功后,向进程发送2号信号,可以看到由于阻塞,2号信号一直处于未决态不被处理,当阻塞解除后,pending位图被清零,然后2号信号递达处理(先清零,再处理,代码中有验证部分)。
4.信号的处理
我们之前说如果进程在处理更重要的事情,信号就会被保存,等到合适的时候再做处理。
首先信号的保存我们已经了解了是利用信号集,那什么时候是合适的时候呢?
4.1信号是什么时候被处理的?
进程从『 内核态』切换回『 用户态』的时候(CPU中有寄存器专门来标识执行状态),信号会被检测并处理。
4.1.1内核态与用户态
- 内核态通常用来执行操作系统的代码,是一种权限非常高的状态。
- 用户态是一种用来执行普通用户代码的状态,是一种受监管的普通状态。
我们可以使用地址空间来说明:
在之前我们说明地址空间指的都是[0,3]GB的用户空间,而实际上还有另外的[3,4]GB的内核空间。
- 用户所写的代码和数据位于用户空间,通过『 用户级页表』与物理内存之间建立映射关系。
- 内核空间存储的实际上是操作系统代码和数据,通过『 内核级页表』与物理内存之间建立映射关系。
每个进程的内核级页表都是一样的,因为内核空间所存放的都是操作系统的代码和数据,所以你知道为什么一个进程可以很快的找到操作系统了么?因为操作系统实际上就在进程"里面",或者说我们使用系统调用或者访问系统数据,其实还是在进程本身的地址空间内部进行跳转的,所以内核态与用户态的切换也是在进程内部完成的。
4.2信号是如何被处理的?
信号捕捉图:
当我们在执行主控制流程的时候,可能因为中断、异常或系统调用而陷入内核,当内核处理完毕准备返回用户态时,此时就进行信号pending的检查。(此时仍处于内核态,有权力查看当前进程的pending位图)
在查看pending位图时,如果发现有未决信号,并且该信号没有被阻塞,那么此时就需要该信号进行处理。
如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。
但如果待处理信号是自定义捕捉的,即该信号的处理动作是由用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理动作,执行完后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码。
所以如果待处理信号是自定义捕捉的话,我们可以将信号捕捉图看作一个∞符号:
提问:内核态权限很高,为什么不能在内核态直接执行用户空间中的捕捉函数,还要切换回用户态执行呢?
因为OS不相信任何人,你不能保证用户空间中的捕捉函数是安全的,如果当你处于内核态时执行用户空间的代码,平时一些处于用户态时不被允许的操作由于此时你是内核态就可以执行了,所以为了安全考虑,处于什么状态执行什么代码是很有必要的。
4.3sigaction
捕捉信号除了用前面用过的signal函数之外,我们还可以使用sigaction函数对信号进行捕捉,sigaction函数的函数原型如下:
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
sigaction函数可以读取和修改与指定信号相关联的处理动作,该函数调用成功返回0,出错返回-1。
参数说明:
- signum:代表指定信号的编号。
- 若act指针非空,则根据act修改该信号的处理动作。
- 若oldact指针非空,则通过oldact传出该信号原来的处理动作。
其中,参数act和oldact都是结构体指针变量,他们的指针类型与函数同名,该结构体的定义如下:
struct sigaction {
void(*sa_handler)(int);
void(*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void(*sa_restorer)(void);
};
顾名思义,对于sigaction结构体的第一个成员来说:
- 将sa_handler赋值为常数SIG_IGN传给sigaction函数,表示忽略信号。
- 将sa_handler赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作。
- 将sa_handler赋值为一个函数指针,表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。
第二个成员sa_sigaction是实时信号的处理函数,这里我们不管。
第三个成员sa_mask的解释:
其实,当某个信号的处理函数被调用时,内核会自动将当前信号加入进程的信号屏蔽字,当处理完该信号后,才会从信号屏蔽字中移除。
这样做的目的很明显:为了防止信号嵌套式地进行捕捉处理。
可如果你不仅仅想要阻塞当前信号呢,就可以用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时,自动恢复原来的信号屏蔽字。
第四个成员sa_flags,设置为0即可。
第五个成员sa_restorer,我们不管。
sigaction函数的使用举例:
我们将2号信号自定义捕捉方式,并且设置sa_mask,当2号信号递达后,2号信号就会被阻塞,而由于此时我们又额外设置了sa_mask,所以3,4,5号信号都会一同阻塞,所以再次递达2,3,4,5号信号都会变为未决状态。
#include <iostream>
#include <signal.h>
#include <unistd.h>
void Print(sigset_t &pending)
{
std::cout << "curr process pending: ";
for (int sig = 31; sig >= 1; sig--)
{
if (sigismember(&pending, sig))
std::cout << "1";
else
std::cout << "0";
}
std::cout << "\n";
}
void handler(int signo)
{
std::cout << "signal : " << signo << std::endl;
// 不断获取当前进程的pending信号集合并打印
sigset_t pending;
sigemptyset(&pending);
while (true)
{
sigpending(&pending);
Print(pending);
sleep(1);
}
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
sigaction(2, &act, &oact);
while (true)
sleep(1);
return 0;
}
现象:
5.可重入函数
可重入函数:被不同执行流重复进入不会产生问题。
不可重入函数:被不同执行流重复进入导致产生了问题。
比如链表的插入场景:
解释:在主执行流中正常执行时,比如时间片用完或者异常等,进程发生调度,进入内核态,判断pending位图有没有待处理信号,做信号捕捉,信号捕捉中再次调用了主执行流中的insert函数, 此时就会引发问题,最终导致node2节点丢失。
像上例这样,insert函数被不同的执行流调用(main函数和sighandler函数使用不同的堆栈空间,它们之间不存在调用与被调用的关系,是两个独立的执行流程),有可能在第一次调用还没返回时就再次进入该函数,我们将这种现象称之为重入。
而insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数我们称之为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称之为可重入(Reentrant)函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标志I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
6.volatile
volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性。
搭建场景:
#include <stdio.h>
#include <signal.h>
int g_flag = 0;
void changeFlag(int signo)
{
(void)signo;
printf("将g_flag从%d->%d\n", g_flag, 1);
g_flag = 1;
}
int main()
{
signal(SIGINT, changeFlag);
while (!g_flag);
printf("process quit normal\n");
return 0;
}
以上代码正常情况下一定是当接收到2号信号时,进程结束,并打印相关字段。
但是当你允许编译器对代码进行优化时,编译器会检测到g_flag在主执行流中并没有修改的意图,并且高频度重复读取,为了提高效率,编译器就会将while(!g_flag)中的g_flag放到CPU寄存器中,这样就会导致,哪怕你在信号捕捉函数中对内存中的g_flag做了修改,也并不会影响到CPU寄存器中的g_flag,导致进程不退出。
允许编译器做优化,要在gcc指令后加 -O0 -O1 -O2 -O3 ...数字越大级别越高
所以优化后,我们发现,进程真的不结束了。
为了解决这一问题,我们可以使用volatile。
volatile作用:保持内存可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
所以我们现在对代码进行修改:
#include <stdio.h>
#include <signal.h>
volatile int g_flag = 0; //加入关键字修饰
void changeFlag(int signo)
{
(void)signo;
printf("将g_flag从%d->%d\n", g_flag, 1);
g_flag = 1;
}
int main()
{
signal(SIGINT, changeFlag);
while (!g_flag);
printf("process quit normal\n");
return 0;
}
发现进程正常结束。
7.SIGCHLD信号
在之前学习的时候,为了避免出现僵尸进程,父进程需要使用wait或waitpid函数等待子进程结束,父进程可以阻塞等待子进程结束,也可以非阻塞地查询的是否有子进程结束等待清理,即『 轮询』的方式。
但是这两种方式都有弊端,采用第一种方式,父进程阻塞就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
但其实,子进程在终止时会给父进程发生SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理动作,这样父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait或waitpid函数清理子进程即可。
例如,下面代码中创建了100个子进程,然后对SIGCHLD信号进行了捕捉,并将在该信号的处理函数中调用了waitpid函数对子进程进行了清理。
waitpid函数回顾:
pid_t waitpid(pid_t pid,int *status,int options);
返回值:
- 等待成功返回被等待进程的pid。
- 如果设置了选项
WNOHANG
,而调用中waitpid发现没有已退出的子进程可收集,则返回0。- 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在。
参数:
- 当参数pid=-1时,则等待任意子进程。
- 当参数pid>0时,等待进程标识符为pid的子进程。
- status:若不关心子进程的退出状态信息,则可以对status参数传入NULL。
- 当options=0时,为阻塞等待。
- 当options=
WNOHANG
时,为非阻塞等待,即父进程检测到子进程没有退出会立即返回0不会一直等待什么也不做(操作系统会循环式的检测子进程是否退出,这叫做基于非阻塞的轮询访问),而如果返回值小于0则代表等待失败,大于0代表等待成功并返回被等待进程的pid。- 非阻塞等待的优点是:轮询期间不要傻傻的等着,可以做做其他事情。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/types.h>
void CleanupChild(int signo)
{
if(signo==SIGCHLD)
{
while ((rid= waitpid(-1, nullptr, WNOHANG)) > 0){
std::cout << "wait child success: " << rid << std::endl;
}
std::cout << "wait sub process done" << std::endl;
}
int main()
{
signal(SIGCHLD, CleanupChild);
for (int i = 0; i < 100; i++)
{
pid_t id = fork();
if (id == 0)
{
std::cout << "I am child process: " << getpid() << std::endl;
exit(0);
}
}
//father
while (true);
return 0;
}
注意:
- SIGCHLD属于普通信号,记录该信号的pending位只有一个,如果在同一时刻有多个子进程同时退出,那么在handler函数当中实际上只清理了一个子进程,因此在使用waitpid函数清理子进程时需要使用while不断进行清理。
- 使用waitpid函数时,需要设置
WNOHANG
选项,即非阻塞式等待,否则当所有子进程都已经清理完毕时,由于while循环,会再次调用waitpid函数,此时就会在这里阻塞住。
事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用signal或sigaction函数将SIGCHLD信号的处理动作设置为『 SIG_IGN』,即忽略,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。
系统默认的忽略动作和用户用signal或sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。
此方法对于Linux可用,但不保证在其他UNIX系统上都可用。
例如,下面代码中调用signal函数将SIGCHLD信号的处理动作自定义为忽略。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
signal(SIGCHLD, SIGIGN);
for (int i = 0; i < 100; i++)
{
pid_t id = fork();
if (id == 0)
{
std::cout << "I am child process: " << getpid() << std::endl;
exit(0);
}
}
//father
while (true);
return 0;
}
此时,将SIGCHLD捕捉方式设置为忽略后,该父进程fork出来的子进程退出不会产生僵尸进程,并且也不会通知父进程。
=========================================================================
如果你对该系列文章有兴趣的话,欢迎持续关注博主动态,博主会持续输出优质内容
🍎博主很需要大家的支持,你的支持是我创作的不竭动力🍎
🌟**~ 点赞收藏+关注 ~**🌟
=========================================================================
版权归原作者 樊梓慕 所有, 如有侵权,请联系我们删除。