Linux信号通信之信号

时间:2023-01-13 09:54:05

什么是信号

今天我们来讲的是信号。在正式开始今天的内容之前,我们不妨来看一看这样一段代码:

#include<iostream>
int main()
{
    int* p=nullptr;
    *p=20;
   return 0;
}

熟悉C++语言的同学肯定知道这段代码编译运行以后会出现段错误!而段错误经常就是程序崩溃的主要原因! 那么你有没有想过,为什么进程出现了段错误就会崩溃呢?或者说,在发生了段错误的时候,操作系统对进程做了什么呢? 这些问题都涉及到我们今天要讲的主题!实际上,进程在出现段错误的时候把崩溃是因为进程自身终止了!而终止的原因就是进程收到了来自操作系统发送的信号! 那么接下来我们就来讲讲这个Linux中的信号机制。

生活中的信号

首先,我们先来看生活中的信号。在日常生活中,我们的身边充斥着各种各样的信号:红绿灯,闹钟,下课铃声等等。 那么我们是天生就会知道这些信号吗?并不是,我们也是通过后天的学习我们才能知道对应的信号代表的含义和我要做什么! 换言之,我们对于这些信号具有识别能力。 而当这个信号产生以后,由于我们早就知道对应的信号产生以后我们需要做什么,即使这个信号现在还没有产生! 换言之,我们通过后天的学习已经知道了对应信号的处理方式!由于我们能够识别信号并且知道对应信号的处理方法,所以我们才具有处理信号的能力!

进程的信号

那么生活中的信号是如此。对应到进程来说也是如此!信号是给进程发送的,这意味着进程一定有处理信号的能力!简单来说,进程一定要具备以下的能力:

1.进程能够识别对应的信号
2.进程能够对相应的信号进行处理!

1和2都是操作系统早就给进程提供好了的。说的更准确一点,进程处理信号的能力就是编写操作系统代码的程序员早就写好的!

Linux信号种类

在Linux系统里面,程序员早就已经内置了一大批的信号,我们可以使用kill -l命令来查看对应系统里面的信号种类:
Linux信号通信之信号

其中1-31号信号是我们重点要研究的信号,这些信号我们称之为常用信号。而34-64号信号是用于实时系统的信号,比如车载系统之类的嵌入式系统。34号之后的信号到来以后,操作系统必须立马处理相信的信号!而Linux系统中没有0号信号!

前台进程和后台进程

我们来看这么一段代码:

#include<iostream>
#include<cstdlib>
#include<unistd.h>
int main()
{
  pid_t id=fork();
  //子进程
  if(id == 0)
  {
    while(true)
    {

        std::cout<<"I am a  child"<<"My pid is :"<<getpid()<<std::endl;
        sleep(1);
    }

  }
  //父进程
  else 
  {
     int cnt=5;
     while(cnt)
     {
       std::cout<<"I am a parent"<<"My pid is"<<getpid()<<": and I will in"<<cnt--<<"s quit!"<<std::endl;
        sleep(1);
     }
     exit(0);
  }
  return 0;
}

根据我们之前学习的知识,5秒以后,父进程退出。那么创建的子进程就会变成孤儿进程,自动被操作系统领养 这个时候你就会发现当前的屏幕中子进程依旧疯狂在打印。但是你会惊奇发现,虽然子进程疯狂打印,但是你输入指令,系统还是会及时做出命令行解析! 换句话说,bash命令行解析似乎并没有收到子进程疯狂打印的影响!事实上确实如此!虽然我们依旧看到子进程在疯狂刷屏,但其实子进程已经被操作系统悄无声息的放到了后台进行运行! 下面就来介绍一下一对概念---->前台进程和后台进程
我们知道,很多特摄剧里面都有变身的桥段。那么在变身前的那个英雄主角是我们能够看到的。而变身之后的打斗工作则是由内部的皮套演员给我们演出的。而类比推理,占据当前我们显示窗口的进程就是前台进程!而在后台默默无闻运行的进程就是后台进程! 虽然从严格意义上来说,孤儿进程不算是后台进程,但是从实际的效果来说是类似的。我们用的ctrl+c通常终止的是前台进程!而在后台的进程是无法使用ctrl终止的! 而在Linux中可以使用如下的方式创建后台进程

#创建后台进程,在创建一个进程的时候带上&就可以把这个进程放入后台
sleep 200 &
#查看后台进程(这个命令是查看作业的情况)
jobs

Linux信号通信之信号
而前面的这个1就是作业编号。而如果想把对应的后台进程提到前台,我们需要使用如下的命令:

fg 1 #fg+作业编号:把对应的后台进程放到前台

Linux信号通信之信号
而把一个前台进程重新放回后台就相对比较麻烦了:

#首先输入ctrl+z先让进程停止
#接下来使用bg命令放入后台
bg 1

Linux信号通信之信号
关于前台后台进程的相关知识就先介绍到这里了。

进程对信号的处理策略

在进程的运行过程中,信号随时都有可能产生。那么一旦信号到来,那么进程都需要马上过去处理对应的信号吗?并不是!要比较进程当前做的事情和信号对应处理动作的重要性! 假如进程现在正在做有关用户在银行的余额的计算动作。这个时候突然来了个进程终止的信号,显然我们不能终止!也就是说,当信号到来的时候,进程暂时不处理对应的信号!但是不代表这个信号不被处理!也就是进程必须有一种手段能够记住是否有信号到来,到来的是什么样的信号!
在Linux中,进程对于信号有如下的三种处理动作:

1.默认动作
2.忽略当前信号
3.自定义处理动作

那么在进行处理动作之前我们需要记录信号是否到来,什么信号产生!而这些事情使用0和1标记即可! 所以操作系统在内部也是使用位图来标记信号是否到来,而位图是属于操作系统内部的数据,只有操作系统才有权力修改位图。因为操作系统是进程的管理者 那么所谓的发送信号,从底层的本质上来看:发送信号的本质就是操作系统对应向进程的信号位图中写入0或者1的动作!

Linux产生信号的方式

Linux产生信号的方式主要有如下的几种:

1.通过终端按键产生信号
2.使用系统调用向进程发送指定信号
3.由软件条件产生信号

第一种方式不难能够理解,我们经常使用ctrl+c来终止进程。其实最终ctrl+c会通过某种手段,最终转换成操作系统向前台进程写入对应的信号 而ctrl+c对应转换的信号就是2号信号,2号信号的默认动作就是终止进程自身。 第二种方式,因为进程收到信号的本质就是操作系统写入进程的信号位图。这些数据是操作系统内部的数据,用户不能够直接操作,所以必须通过操作系统的系统调用进行操作! 我们接下来就来看一看Linux系统给我们提供的发送信号的系统调用

系统调用发送信号

kill调用

Linux信号通信之信号
系统不仅提供了kill命令,还提供了一个kill系统调用接口,我们就可以基于kill系统调用写一个我们自己的kill命令

#include<iostream>
#include<cstring>
#include<cstdlib>
#include<sys/types.h>
#include<signal.h>
/*
 * 我们自己的kill命令
 * 格式 ./mykill sig pid
 * */
static void useage()
{
   std::cout<<"./mykill "<<" signum "<<" pid "<<std::endl;
}
int main(int argc,char* argv[])
{  
    
   if(argc!= 3)
   {
     //说明数量不合理
     std::cerr<<"command nums is error!"<<std::endl;
     useage();
     return 1;
   }
   //走到这里就是合法
  int sig=atoi(argv[1]);
  pid_t id=atoi(argv[2]);

  if(0==kill(id,sig))
  {
     std::cout<<"this process  has been killed"<<std::endl;
  }
   return 0;
}

Linux信号通信之信号

raise调用

实际上,系统还提供了一种手段,可以让进程给自己发送信号。这个系统调用就叫做raise,我们先来看一看官方手册是如何描述raise系统调用的
Linux信号通信之信号
其实这个系统调用的底层也是调用的kill,手册里面说这个系统调用等价于kill(getpid(),sig),接下来我们就简单地使用一下这个系统调用

/*
使用raise系统调用
*/
#include<iostream>
#include<unistd.h>
#include<cstdio>
#include<cstring>
#include<cstdlib>
#include<sys/types.h>
#include<sys/wait.h>
#include<signal.h>

int main()
{
  
  pid_t id=fork();
  int cnt=5;
  //child
  if(id == 0)
  {
     while(cnt)
     {
        std::cout<<"我是子进程,我将在"<<cnt--<<"秒后给自己发送2号信号"<<std::endl;
        sleep(1);
     }
     raise(2);
  }
  //父进程给回收子进程
  //获取退出信息
   int status=0;
   printf("我是父进程,我在等待子进程退出\n");
   waitpid(id,&status,0); 
  printf("子进程退出!退出码是%d,退出信号是%d\n",(status>>8)&0xFF,status&0x7F); 
  return 0;
}

Linux信号通信之信号
可以看到,raise接口确实给我们的进程发送了2号信号。而我们要注意到,如果一个进程收到了信号,那么绝大多数情况下,退出码已经没有什么实际意义了!因为收到信号基本代表进程已经发生异常了!更多情况是,进程收到信号的同时已经结束了。根本连获取进程退出码的机会都没有!

abort

在之前的文章里面我们介绍了使用exit和_exit函数可以退出相应的进程。实际上,我们还有一个可以退出进程的接口,这个接口就是abort 我们先来看手册里面对于abort接口是如何说明的:
Linux信号通信之信号
手册里面说了,调用abort调用最后会给自己发送SIGABRT信号,接下来我们就通过代码来用一用这个abort接口

#include<iostream>
#include<unistd.h>
#include<cstdio>
#include<cstring>
#include<cstdlib>
#include<sys/types.h>
#include<sys/wait.h>
#include<signal.h>
/*
 * abort接口的使用
 * */
int main()
{
  
  pid_t id=fork();
  int cnt=5;
  //child
  if(id == 0)
  {
     while(cnt)
     {
        std::cout<<"我是子进程,我将在"<<cnt--<<"秒后调用abort()"<<std::endl;
        sleep(1);
     }
     
     abort();
    // raise(2);
  }
  //父进程给回收子进程
  //获取退出信息
   int status=0;
   printf("我是父进程,我在等待子进程退出\n");
   waitpid(id,&status,0); 
  printf("子进程退出!退出码是%d,退出信号是%d\n",(status>>8)&0xFF,status&0x7F); 
  return 0;
}

Linux信号通信之信号
通过运行结果,我们不难可以看到:abort接口发送的是6号信号,而6号信号恰好就是SIGABRT!

alarm

我们知道操作系统是硬件的管理者,但是硬件也推动者操作系统管理工作。对于cpu来说,cpu内部集成了很多时钟硬件。这些时钟硬件定时给操作系统发送时钟中断来推动cpu切换进程 而Linux系统就提供了对应的系统调用来发送时钟信号。这个系统接口就叫做alarm。我们先来看alarm接口的说明
Linux信号通信之信号
而这个alarm最后会给进程发送一个SIGALRM信号,这个信号的默认动作也是终止这个进程。由于使用较为简单,所以这里不展开详细介绍。

通过终端按键产生信号

当前台进程失控的时候,我们就可以用ctrl+c的方式终止进程。而这种通过键盘按键终止进程的方式,本质是通过一系列的软件层驱动,把对应键盘输入的信息转换成信号。也就是最终把前台进程终止的还是信号! 而我们除了ctrl+c可以终止前台进程,我们还有ctrl+'\'也是可以向对应的进程发送信号终止前台进程!

通过软件条件产生信号

前面两种产生信号的方式相对比较容易理解。最后一种软件条件产生信号就相对不是那么容易理解了。首先我们回想我们刚刚开始写代码的时候。我们会写出各种除零错误、或者是野指针越界的代码。 这些代码无一例外运行起来就是各种崩溃! 前面我们知道,进程崩溃的本质就是进程收到了信号!但是,不知道你有没有想过这么一个问题:操作系统是如何知道进程出现了异常呢? 实际上,操作系统能够知道进程出现了异常还是离不开硬件对于操作系统的支持!cpu内有状态字寄存器,而当出现除0错误的时候,cpu内部的状态字寄存器就设置成了错误状态 那么操作系统就可以读取状态寄存器中的数据获取到进程出错的信息。接下来进程就会做如下的两件事:

1.寻找出错的进程---->找进程
2.识别错误的原因---->这里是浮点数错误

完成这两件事情以后,操作系统就构建相对应的信号给对应的进程。进程收到对应的信号,默认处理动作就是终止自己。
如果说,除0错误这种软件条件还不够明显的话。那么野指针引起的进程崩溃实打实的就是软件条件了!在先前的学习中,我们知道C/C++语言使用的都是逻辑地址,而操作系统是把进行逻辑地址和物理地址的转化进行数据读取和写入 地址转化的工作是由 MMU和页表配合完成,而出现野指针访问的时候,地址转化就会出现问题。此时操作系统就会重复上面的两件事 而野指针这种问题实打实的就是软件(mm_struct构建的虚拟地址),这种软件条件产生的信号。

信号的自定义处理

前面我们知道了进程对于信号的处理动作有三种:

1.默认行为---->终止自己
2.忽略行为
3.自定义处理

那么对于1和2相对来说比较简单,我们接下来就来看看第三种自定义处理方案。有的时候,默认的处理行为和忽略信号并不是最合适的选择,这个时候我们就需要自定义信号的处理行为 同样Linux系统也给我们提供了一系列系统接口来帮助我们自定义信号的处理行为

signal

我们先来看第一个接口:signal(),我们直接来看手册中对于signal接口的说明:
Linux信号通信之信号

#include<iostream>
#include<cstdlib>
#include<unistd.h>
#include<signal.h>
/*
 * 信号自定义处理,signal的第三个参数是一个void(*)(int signo)的函数指针
 * 这个机制被成为回调函数
 * */
void handler(int signo)
{
  std::cout<<"收到了对应的信号,信号是 "<<signo<<std::endl;
}
int main()
{
  
  //自定义2号信号
  //注册对应的自定义处理动作,当进程收到2号信号的时候就会
  signal(2,handler);
  while(true)
  {
   
    std::cout<<"我是一个进程,我的进程pid是"<<getpid()<<std::endl;
    sleep(1);
  }
  
  return 0;
}

Linux信号通信之信号
运行起来发现,这次我们再向进程发送2号信号的时候,进程不会执行默认动作把自己终止,而是执行我们对应的自定义处理动作!而实际上,我们之前用来终止前台进程的ctrl+c对应发送的信号就是2号信号!
Linux信号通信之信号
有聪明的小伙伴就会想:如果我把常用的1-31号信号全部都注册对应的自定义处理行为,是不是这个进程就成为一个刀枪不入的进程了!

#include<iostream>
#include<cstdlib>
#include<unistd.h>
#include<signal.h>
/*
 * 信号自定义处理
 * */
void handler(int signo)
{
  
   std::cout<<"自定义处理"<<signo<<std::endl;
   sleep(1);
}
int main()
{
  
  //自定义捕捉1-31所有信号,这个进程是否就刀枪不入
  for(int sig=1;sig<=31;++sig)
  {
     signal(sig,handler);
  }
  while(true)
  {
    std::cout<<"我是一个进程,我的进程pid是"<<getpid()<<std::endl;
    sleep(1);
  }
  return 0;
}

Linux信号通信之信号
可以看到,虽然我们注册了所有信号的捕捉动作。但是9号信号没有受任何影响!也就是说9号信号永远都会执行默认行为,就是终止对应的进程,9号信号也成为管理员命令!

sigaction

除了上面介绍的signal接口可以自定义信号的处理动作。Linux系统还提供了另外一套接口用来自定义信号处理,这个接口就是我们将要介绍的sigaction 我们先来看对应的手册里面对于这个系统接口的介绍:
Linux信号通信之信号
这个函数还涉及了一个和函数名同名的结构体,对应结构体的说明如下:
Linux信号通信之信号
这个结构体的第一个字段就是我们需要自定义的信号处理动作,下面我们就简单来使用一下这个接口:

#include<iostream>
#include<cstdlib>
#include<unistd.h>
#include<signal.h>
/*
 * 信号自定义处理
 * */
void handler(int signo)
{
  
   std::cout<<"自定义处理"<<signo<<std::endl;
   sleep(1);
}
int main()
{
  
  //自定义捕捉1-31所有信号,这个进程是否就刀枪不入
  //for(int sig=1;sig<=31;++sig)
  //{
  //   signal(sig,handler);
  //}
  //使用sigaction进行自定义捕捉信号处理
  //这里要注意,不能省略struct关键字,否则会被编译器识别成为函数 
  //由于内核的接口使用的C语言编写,所以建议使用内核的接口体都带上struct关键字
   struct sigaction act;
   act.sa_handler=handler;
   //自定义捕捉2号,第三个参数是输出型参数,不关心当前的sigaction状态就设为nullptr
   sigaction(2,&act,nullptr);
  while(true)
  {
   
    std::cout<<"我是一个进程,我的进程pid是"<<getpid()<<std::endl;
    sleep(1);
  }
  return 0;
}

Linux信号通信之信号
顺便补充一点,Linux中的ctrl+\的方式发送的是3号信号,SIGQUIT。这个按键也可以用于终止前台失控的进程

Coredump

不知道大家是否还会记得Linux下进程退出信息的组成是怎么样的:
Linux信号通信之信号
当时我们没有介绍这个coredump,今天我们就来介绍介绍这个coredump标记位置。 这个coredump的学术名字叫做核心转储。当进程收到某些特定信号的时候会触发这个机制!,这个机制会记录进程运行的时候发生异常的上下文数据。(把这些数据转储到磁盘上,方便调试!)->生成一个.coredump文件 并且会在对应的进程退出信息中把coredump位由0置为1。而这些个coredump文件能够使用gdb进行调试

#include<iostream>
#include<unistd.h>
#include<cstdio>
#include<cstring>
#include<cstdlib>
#include<sys/types.h>
#include<sys/wait.h>
#include<signal.h>
//coredump核心存储
int main()
{

  pid_t id=fork();
  //子进程
  if(id==0)
  {
   
     int *p=nullptr;
     *p=20;
  }
  int status=0;
  //父进程回收 
  waitpid(id,&status,0); 
  printf("coredump核心位: %d\n",(status>>7)&0x1);

  return 0;
}

运行结果如下:
Linux信号通信之信号
奇怪,明明出现了段错误!为什么没有产生coredump文件呢?其实是因为我们使用的是云服务器,这是生产环境,默认是把对应的coredump文件关闭了。我们可以使用ulimit命令查看系统的coredump文件数量

ulimit -a #查看系统中的资源数量

Linux信号通信之信号
那么如果想要生成对应的coredump文件,我们就要设置core file size这个参数

ulimit -c 10 #设置core file size为10

Linux信号通信之信号
接下来我们再运行一下程序:
Linux信号通信之信号

#调试对应的core文件
gdb+进程名 (注意编译要带上-g)
core-file core.13079

Linux信号通信之信号
Linux信号通信之信号
这个core文件直接就帮助我们定位到了错误的地方,剩下解决错误的工作就交给我们程序员了。那么这么好用的coredump机制为什么云服务要关掉呢?

1.云服务器上如果一个服务崩溃了,第一件事情应该是重启服务,而并不是调试错误!而且服务上线以后,基本都是发布版本,也没办法进行调试
2.core文件太大,如果出现太多的话,会过分占据磁盘空间,甚至会占据操作系统在磁盘上的空间!所以coredump默认是被关掉的!

函数重入

首先我们来看一段代码:

#include<iostream>
#include<vector>
#include<cstdlib>
#include<unistd.h>
#include<signal.h>
std::vector<int> v;
void handler(int sig)
{  
  std::cout<<sig<<std::endl;
   v.insert(v.begin(),3);
}
int main()
{
  v.reserve(10);
  signal(2,handler);
  while(true)
  {
	   v.insert(v.begin(),3);
	   std::cout<<"insert"<<std::endl;
	   sleep(1);
   }
  return 0;
}

我们写了一段简单的代码,这个代码不断往vector头插元素,其中我们也对2号信号做了自定义动作注册。对应的处理动作也是头插。那么在这个进程运行的过程中可能会发生如下的问题:
Linux信号通信之信号
一个函数在多个执行流里面被反复调用,我们就称这个函数叫做函数被重入。

可重入函数和不可重入函数

首先我们要明确一个概念,一个函数被重入本身这个事情是没有错误的!而根据一个函数重入之后是否会出现问题我们可以把这个函数分成可重入函数和不可重入函数。 比如上面举出的vector的insert函数就是一个经典的不可重入函数。而绝大多数函数都是不可重入函数。而不可重入函数大致具有如下的特征:

1.调用了malloc或者free这种系统内存操作的函数,这些函数内部都使用了全局的链表结构来管理内存
2.调用了系统的I/O接口,这些接口一般都是使用了全局的数据结构进行管理

volatile关键字

接下来我们来讲一下C语言中相对比较冷门的关键字---->volatile 可能很多学习C语言的同学都没有听说过这个关键字。这个很正常,因为我们先前写的代码基本都是单执行流的代码,而volatile关键字的价值在多执行流的代码中才能得到很好的体现。今天我们就从信号自定义处理的情况来看待这个关键字

/*
为了方便演示,这里使用的是C语言
*/
#include<stdlib.h>
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
int  quit=0;
void handler(int signo)
{
   printf("收到信号:%d\n,更改quit为1",signo);
   quit=1;
}
int main()
{
  signal(2,handler); 
  //这个循环什么都不做
  while(!quit) ;
  return 0;
}

编译的时候用较高的等级优化:

gcc -o $@ $^ -O2 #以O2级别进行优化

运行起来之后,结果如下
Linux信号通信之信号
理论来说,发送2号信号,进程就会自定义处理2号信号。把quit更改为1。对应的循环就会退出!但是这里显然并没有发生这一行为!
这种现象的主要原因就是编译器在后面自作主张的优化!
Linux信号通信之信号
而解决这种情况的方式就是在quit前面加一个volatile关键字修饰

/*
为了方便演示,这里使用的是C语言
*/
#include<stdlib.h>
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
//使用volatile关键字修饰
volatile int  quit=0;
void handler(int signo)
{
   printf("收到信号:%d\n,更改quit为1",signo);
   quit=1;
}
int main()
{
  signal(2,handler); 
  //这个循环什么都不做
  while(!quit) ;
  return 0;
}

Linux信号通信之信号
即使此时是同样的优化等级,这次发送2号信号进程也停了下来!这就是volatile关键字的作用:禁止编译器把变量优化到寄存器里面,保持内存可见性!

进程如何处理信号(了解)

那么一个进程是如何处理信号的呢?这个问题相对比较复杂,因为其中还涉及进程身份的切换。首先我们来回顾一下PCB,进程地址空间,页表,还有物理内存空间的关系
Linux信号通信之信号
由图上可以知道,除了用户层有页表以外,内核空间也有一张页表!这张页表负责的就是转换内核空间代码的逻辑地址和物理地址!而这个内核级别的页表只有1份!是能够被所有进程都可以看到的! 无论进程如何进行切换,内核的代码和数据都是可以被找到的!但是访问内核页表的前提是我们要具有权限! 所以我们需要进行身份切换!而如何做到身份切换呢?实际上还是由硬件完成的!cpu内部有寄存器CR3(对应使用比特位标志当前用户身份!0表示内核态,3表示用户态)。 其中内核态拥有访问所有数据的能力!但是用户只有访问自己用户区数据的能力。
而当出现下面的几种情况中的任意一种情况,就会发生身份切换

1.系统调用->访问内核数据和代码
2.进程时间片到了,需要切换进程—>访问操作系统内部的切换算法

而进程处理信号的时候就是在内核态切换成用户态的时候!而自定义处理动作也是用户定义的方法,并且执行自定义处理动作的时候也是用的用户态! 目的就是为了保护操作系统内核,防止有人植入恶意代码。毕竟内核的权限太大了!

未决信号集,阻塞信号集,自定义处理信号集

前面我们说了,当信号来的时候,进程可能正在忙着处理更重要的事情。而不会立即去处理信号。暂时不处理信号不代表不处理信号!所以进程一定要记住什么时候到来什么信号! 信号的到来的记录用的是位图。在Linux系统里面,有三个对应的位图结构来记录信号:

1.pending表:未决信号集:所谓的未决指的是已经到来的信号,但是我们尚未对信号进行处理
2.block表:阻塞信号集:所谓的阻塞信号集,就是当这个信号到来的时候,把这个信号一直保持在未决的状态,直到操作系统对它解除阻塞!
3.handler表:表示已经注册了自定义处理方法的信号的集合

Linux信号通信之信号
由于这三张表是属于内核数据结构,我们不能直接对这些位图使用位操作进行修改!因此,操作系统也给我们提供了对应的系统调用接口来供用户修改对应的位图信息。下面我们就来看一看对应的接口

 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);//判断指定信号是否在信号集
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);//讲set信号集的内容以how的模式设置到当前信号集中,并且可以通过oldset信息获取设置后的信号集
int sigpending(sigset_t *set); //获取信号pending集

虽然接口比较多,但是使用的方式基本差不多。
相对比较复杂的就是sigprocmask接口,我们来看一看手册中对于sigprocmask接口的使用说明:
Linux信号通信之信号
其中这个how参数有如下的选项

SIG_BLOCK:把当前已有的信号集和作为参数的信号都添加到阻塞信号集中
SIG_UNBLOCK:把当前阻塞信号集中的信号从阻塞信号集中移除
SIG_SETMASK:把阻塞信号集设置成为当前的集合

接下来,我们通过一段代码来使用一下这个接口:

#include<iostream>
#include<stdlib.h>
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
sigset_t sset; //定义一个信号集 
//设置信号集相关函数
void setSigset()
{
  //清空信号集 
  sigemptyset(&sset);
  //添加2号信号到信号集里面 
  sigaddset(&sset,2);
  //设置信号集为阻塞信号集 
  sigprocmask(SIG_BLOCK,&sset,nullptr);
}
/*
 * 信号集接口的使用
 * 使用sigprocmask接口阻塞2号信号,打印系统的位图
 * */
//获取对应的信号集信息
void getPending()
{
  for(int sig=1;sig<=31;++sig)
  {
    if(sigismember(&sset,sig))
      std::cout<<"1";
    else 
      std::cout<<"0";
  }
  std::cout<<std::endl;
}
int main()
{ 
  setSigset();
  while(true)
  {  
     printf("我是一个进程,我的pid是%d\n",getpid());
     if(0==sigpending(&sset))
     {
       std::cout<<"获取信号集成功!"<<std::endl;
       getPending();
     }
     sleep(1);
  }
  return 0;
}

运行结果如下:
Linux信号通信之信号
从实验现象可以看出,当我们发送ctrl+c的时候,操作系统的pending位图确实发生了变化,由于此时的sigprocmask接口的参数是把对应的信号添加到阻塞信号集,所以当2号信号到来的时候,2号信号就会一直处于未决状态。知道我们主动解除对2号信号的阻塞! 同样的,9号信号也是不可以被阻塞的!所有任何时候使用9号信号都是可以杀死对应的进程的!需要注意的是,如果同样一个信号重复多次到来,在处理这个信号的时候,操作系统会自动把这个信号添加到屏蔽字信号集中,以防止操作系统对这个信号进行递归式处理,消耗系统资源!

SIGCHLD(了解)

关于子进程退出的问题,我们在之前的文章中已经反复强调。如果不是孤儿进程的话,要时刻注意子进程退出以后的资源回收问题,避免僵尸进程! 那么子进程退出的时候是默默无闻退出吗?或者说,父进程如何知道子进程退出了?
首先我们要明确一点,子进程并不是默默无闻退出的,在退出的时候,它会向父进程发送SIGCHLD信号告诉父进程它要退出了。

#include<iostream>
#include<stdlib.h>
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
#include<sys/wait.h>
sigset_t sset; //定义一个信号集 
//设置信号集相关函数
void setSigset()
{
  //清空信号集 
  sigemptyset(&sset);
  //添加2号信号到信号集里面 
  sigaddset(&sset,2);
  //设置信号集为阻塞信号集 
  sigprocmask(SIG_BLOCK,&sset,nullptr);
}

/*
 * SIG_CHILD信号:子进程退出的时候会发送这个信号给父进程 
 * */
void handler(int sig)
{
  std::cout<<"收到了信号,信号的编号是:"<<sig<<std::endl;
}
int main()
{ 
  signal(SIGCHLD,handler);
  pid_t id=fork(); 
  if(id==0)
  {
    //child 
    exit(0);
  }
  //parent 
  pid_t ret=waitpid(id,nullptr,0);
  if(ret > 0)
  {
    std::cout<<"回收子进程成功"<<std::endl;
  }
  return 0;
}

运行结果如下:
Linux信号通信之信号
可以看到收到了17号信号。而17号信号就是SIGCHLD,实际上不仅仅是子进程退出会发送这个信号。子进程挂起,子进程就绪。父进程都会收到这个信号! 那么这个信号有什么实际价值呢? 或者说,我们应该如何应用这个特性呢?

使用SIGCHLD

首先,我们可以利用这个特性来进行所有子进程的信息资源回收!可以通过注册对应的SIGCHLD捕捉动作,达到让父进程可以自动回收退出的子进程

#include<iostream>
#include<cassert>
#include<stdlib.h>
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
#include<sys/wait.h>
sigset_t sset; //定义一个信号集 
//设置信号集相关函数
void setSigset()
{
  //清空信号集 
  sigemptyset(&sset);
  //添加2号信号到信号集里面 
  sigaddset(&sset,2);
  //设置信号集为阻塞信号集 
  sigprocmask(SIG_BLOCK,&sset,nullptr);
}

void handler(int sig)
{   
   assert(sig==SIGCHLD);
   //消除告警
    (void)sig;
    while(true)
   {
       pid_t ret=waitpid(-1,nullptr,WNOHANG);
       if(ret < 0)
       {
          std::cout<<"子进程回收完毕"<<std::endl;
          break;
       }
       else if(ret==0)
       {
          std::cout<<"还有尚未退出的进程"<<std::endl;
       }
       else 
       {
         std::cout<<"回收的进程id是"<<ret<<std::endl;
       }
   }
}
int main()
{
  signal(SIGCHLD,handler);
  int cnt=6;
  //创建4个子进程
  for(int i=0;i<4;++i)
  {
     pid_t id=fork();
     if(id==0)
     {
        while(cnt)
        {
          std::cout<<"子进程还有"<<cnt--<<"秒退出"<<std::endl;
          sleep(1);
        }
        exit(0); 
     }
     sleep(2);
  }
  //父进程 
  while(true)
  {
    std::cout<<"我是父进程,我的pid是"<<getpid()<<std::endl;
    sleep(1);
  }
  return 0;
}
#使用脚本观察进程退出状态
while :; do ps ajx | head -1 && ps ajx | grep proc | grep -v grep;
sleep 1; echo "#####################";done

Linux信号通信之信号
从脚本的情况可以看到,这里的进程不断减少,到最后只剩下了父进程。所有的子进程都得到了回收!这段代码还有如下的细节需要注意

1.第一个参数设置成为-1表示等待任意的子进程退出
2.设置0阻塞式等待可能会错过某些进程退出是发送的信号
3.使用死循环来进行回收,防止因为信号在处理期间因为相同信号自动添加到信号屏蔽字集合中而导致部分子进程没有回收成功

不处理SIGCHLD

前面我们讲了使用SIGCHLD进行子进程资源回收。那么还有一种策略就是不使用这个SIGCHLD! 在Linux系统中,系统给我们定义了两个宏:

1.SIG_ING:表示忽略这个信号
2.SIG_DFL:表示执行的默认动作

而在设计Linux系统的内核代码的时候,对于SIGCHLD的默认处理行为就是就是SIG_IGN。虽然默认行为就是这个SIG_IGN,但是在实际使用的时候,使用signal调用的时候设置这个处理的动作为SIG_IGN以后,子进程退出以后,即使父进程不用waitpid回收,对应的子进程也不会出现僵尸进程状态

#include<iostream>
#include<cassert>
#include<stdlib.h>
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{ 
  //给SIGCHLD定义SIG_IGN
  signal(SIGCHLD,SIG_IGN);
  pid_t id=fork();
  if(id==0)
  {
     sleep(5);
     exit(0);
  }
  std::cout<<"退出"<<std::endl;
  sleep(5);
  return 0;
}

Linux信号通信之信号
可以看到这里的子进程退出以后,虽然我们没有调用waitpid接口回收,但是也没有出现僵尸进程!

结语

以上就是本文的主要内容,如果有不足或者是错误的地方还望指出。希望能够和大家一起进步