Linux篇第十二篇——信号(概念+信号的产生+阻塞信号+捕捉信号)

Posted 呆呆兽学编程

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux篇第十二篇——信号(概念+信号的产生+阻塞信号+捕捉信号)相关的知识,希望对你有一定的参考价值。

⭐️ 本篇博客要给大家介绍一些关于进程间通信的一些知识。其中包括信号是什么,如何产生的,信号如何保存,什么时候处理,如何捕捉信号等等一些问题,在今天的博客中,你都将找到答案。

目录


🌏信号概述

🌲认识信号

生活中的信号:
你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能 “识别快递”
当快递员到了你楼下,你也收到快递到来的通知,但是你在忙学习,需5min之后才能去取快递。那么在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成 “在合适的时候去取”
在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”当你时间合适,顺利拿到快递之后,就要开始处理快递了。
而处理快递一般方式有三种:

  1. 执行默认动作(幸福的打开快递,使用商品)
  2. 执行自定义动作(快递是零食,你要送给你你的女朋友)
  3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)

快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话

技术角度的信号:
根据我们之前所学的知识,用户通过按键盘按下Ctrl-C可以终止进程。键盘输入会产生一个硬件中断,被操作系统获取后,然后解释为信号,发送2号信号给进程,进程收到信号,然后终止进程。

实例演示:

#include <stdio.h>
#include <unistd.h>

int main(int argc, char *argv[])

	while (1)
		printf("I am a process,I am waitting signal\\n");
		sleep(1);
	
  return 0;

代码运行结果如下:

程序开始是正常运行,但是当我们按下Ctrl+C时,该进程受到2号信号,然后终止进程。下面我要给大家用代码验证一下Ctrl+C代表的是2号信号。
在验证这个事情之前,我先给大家介绍一个函数signal

功能: 对一个信号注册特定的处理动作(注册一个对信号的捕捉方式)
函数原型:

#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

参数:

  • sig: 要注册的信号
  • handler: 处理动作,有三种:SIG_DFL(默认)SIG_IGN(忽略)自定义(函数指针)
    其中函数指针指向的函数有一个int类型的参数,无返回值,这个函数指针就是用户给信号自定义的处理动作,通过函数实现

实例演示:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>

int count = 0;

void handler(int signo)

    printf("catch a signal : %d\\n", signo);


int main(int argc, char *argv[])

	// 注册一个对特定信号处理的动作或者说注册对一个信号的捕捉方式
	signal(2, handler);
	while(1)
	  printf("I am a process, I am running...\\n");
	  sleep(1);
	
	return 0;

代码运行结果如下:

从代码运行结果可以看出,Ctrl+C代表的是2号信号,且这个2号信号不再做终止进程的动作,而是打印了一句话。因为signal这个函数修改了2号信号的默认动作,让它执行自定义动作。
注意:

  1. Ctrl+C产生的信号只能发给前台进程。执行程序时在命令后面加上&可以把进程放到后台运行,后面可以通过一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。如果后期想把这个后台进程放到前天,可以通过fg指令,把这个后台进程放到前台
  2. Shell可以同时运行一个前台进程和任意多个后台进程(前台进程:后台进程=1:n),只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
  3. 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步)的

上面我们从生活角度和技术角度谈了一下信号的概念,其实二者是可以进行类比的,进程就是那个在学习的你,快递员就是操作系统,操作系统给进程发信号就相当于给你送快递,进程处理这个信号的动作就是你去取快递。

总结: 信号是进程之间事件通知的一种方式

🌲查看信号

可以通过kill -l命令查看系统定义的信号列表:

可以看到的是,系统下总共有62个信号,1-31号属于普通信号,34-64属于实时信号。本篇博客只谈普通信号。
如果你想了解某个信号的产生条件和默认处理动作,可以通过指令man signal_id signal

🌲信号常见处理方式

上面介绍了signal函数,这个函数可以更改处理信号的动作,有三种处理动作:

  1. 默认(default)
  2. 忽略(ignore)
  3. 自定义捕捉

后面还会介绍一个sigaction 函数,这也是一个对信号处理的函数,也有以上三个动作

🌏产生信号

🌲通过按键产生

前面我们介绍过了,通过按键Ctrl C可以发送2号信号(SIG_INT),默认处理动作是终止进程。还有可以通过按键按下Ctrl \\,发送3号信号(SIG_QUIT),默认处理动作是终止进程并且Core Dump (在进程等待那里我们留下来这个问题,这里进行讨论).

讨论Core Dump:
Core Dump是什么?

当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。我们可以通过使用gdb调试查看core文件查看进程退出的原因,这也叫事后调试

Core Dump使用演示:
一个进程允许产生多大的core文件取决于进程的Resource Limit,这个信息报错在PCB中。默认情况下,不允许产生core文件,因为core文件中比较大(容易浪费资源),包含用户密码等敏感信息,不安全。我们可以通过命令ulimit -a 查看系统允许我们产生多大的core文件

可以发现的是,系统是不允许产生这个core文件的,但是我们可以通过命令ulimit -c size 修改,允许产生size大小的core文件

此时我们编写一个程序:

#include <stdio.h>

int main()

	printf("waiting signal...\\n");
	while(1);
	return 0;

代码运行结果如下: 运行起来后,用Ctrl \\信号终止进程并参数core文件

查看目录下的文件,会发现多了一个core文件,可以看到的是,这个文件很大,这也是系统不允许我们产生这个文件的一个原因


此时通过gdb调试器打开这个程序,然后通过指令core-file长core文件的错误信息,就可以发现这个进程是被收到3号信号如何退出的

🌲通过系统调用

之前我们介绍过kill -9可以发送9号信号杀死进程,同样地,后台进程也可以被干掉。
下面介绍三个系统函数:

  1. kill

功能: 给任意进程发送任意信号

#include <signal.h>
int kill(pid_t pid, int sig); 

参数:

  • pid:进程pid
  • sig:要发送的信号

返回值: 成功返回0,失败返回-1

实例演示:

#include <stdio.h>
#include <signal.h>

void handler(int signo)

  printf("catch a signal : %d\\n", signo);


int main(int argc, char *argv[])

  signal(9, handler); 
  if (argc == 3)
    // 给指定的进程发送指定的信号
    kill(atoi(argv[1]), atoi(argv[2]));
  
  return 0;

代码运行结果如下: 运行一个后台进程,然后通过mytest程序加参数的方式发生9号信号杀死后台sleep进程

  1. raise:

功能: 给进程自己发送信号

#include <signal.h>  
int raise(int sig);  

参数:

  • sig:要发送的信号

返回值: 成功返回0,失败返回-1
和kill比较:

 raise函数相当于kill(getpid(), sig)  

实例演示:

#include <stdio.h>
#include <signal.h>

void handler(int signo)

  printf("catch a signal : %d\\n", signo);


int main()

  signal(2, handler);
  while(1)
     raise(2);
     sleep(1);
  
  return 0;

代码运行结果如下: 可以看到的是,该进程不断给自己发送2号进程

  1. abort

功能: 使用当前进程收到信号而异常终止(发送6号信号)
函数原型:

#include <stdlib.h>  
void abort(void); 

实例演示:

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>

void handler(int signo)

  printf("catch a signal : %d\\n", signo);


int main()

  signal(6, handler);
  while(1)
     abort();
  
  return 0;

代码运行结果如下:


可以看出,即使我们对6号信号的处理动作进行了修改,但是这个信号还是把该进程终止了,这就说明了abort的函数永远是成功的。

🌲通过软件条件产生

在上一篇博客介绍过,管道如果读端不读了,存储系统会发生SIGPIPE 信号给写端进程,终止进程。这个信号就是由一种软件条件产生的,这里再介绍一种由软件条件产生的信号SIGALRM(时钟信号)
先说alarm函数:

功能: 设定一个闹钟,操作系统会在闹钟到了时送SIGALRM 信号给进程,默认处理动作是终止进程
函数原型:

#include <unistd.h> 
unsigned alarm(unsigned seconds); 

参数:

  • second:设置时间,单位是s

返回值: 0或者此前设定的闹钟时间还余下的秒数

实例演示:
实例1:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>

int main()

  // 由软件条件产生信号  alarm函数和SIGPIPE
  alarm(5);

  while (1)
    printf("count:%d\\n", ++count);
    sleep(1);
  
  return 0;

代码运行结果如下: 5s后,闹钟到了,发生闹钟信号终止进程

实例2: 看下面两段代码
代码1: 不断打印count的数

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>

int count = 0;

int main()

  alarm(1);
  while (1)
    printf("count:%d\\n", ++count);
  
  return 0;


代码2: 最后打印

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>

int count = 0;

void handler(int signo)

  printf("count:%d\\n", count);
  exit(0);


int main()

  
  // 由软件条件产生信号  alarm函数和SIGPIPE
  signal(14, handler);
  alarm(1);
  while (1)
    count++;
  
  return 0;


代码运行结果:
代码1:

代码2:

对比两个程序最后运行的结果,同样是1s,程序1的count最后加到了2w,但是程序2的count且加到了5亿多。二者是1w倍的关系。相差这么大时什么原因导致的呢?

但是是IO,因为程序1每加1次都在打印,但是程序2只是最后一次才打印,所以程序1在运行的过程中不断的在进行IO操作,IO操作其实是很慢的。得出结论:一个体系结构中,IO是影响程序运行效率的最大一方面

🌲通过硬件异常产生

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
这里给大家介绍两个硬件异常:CPU产生异常MMU产生异常

CPU产生异常 发生除零错误,CPU运行单元会产生异常,内核将这个异常解释为信号,最后OS发送SIGFPE信号给进程

实例演示:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>


int main()

  
  // 由软件条件产生信号  alarm函数和SIGPIPE
  // CPU运算单元产生异常,内核将这个异常处理为SIGFPE信号发送给进程
  int a = 10;
  int b = 0;
  printf("%d", a/b); 
  return 0;

代码运行结果如下:

MMU产生异常: 当进程访问非法地址时,mmu想通过页表映射来将虚拟转换为物理地址,此时发现页表中不存在该虚拟地址,此时会产生异常,然后OS将异常解释为SIGSEGV信号,然后发送给进程
实例演示:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>


int main()

  // MMU硬件产生异常,内核将这个异常处理为SIGSEGV信号发送给进程
  signal(11, handler);
  int* p = NULL;
  printf("%d\\n", *p);
  return 0;

代码运行结果如下:

🌏阻塞信号

🌲了解几个概念

  • 实际执行信号的处理动作称为信号递达
  • 信号递达的三种方式:默认、忽略和自定义捕捉
  • 信号从产生到递达之间的状态,称为信号未决(Pending)。
  • 进程可以选择阻塞 (Block )某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。

注意: 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

🌲信号在内核图中的表示

OS发生信号给一个进程,此信号不是立即被处理的,那么这个时间窗口中,信号就需要被记录保存下来,那么信号是如何在内核中保存和表示的呢?
先看下面的图(有三张表):

说明: 上面有三种表,分别是信号阻塞位图block表,信号未决位图pending表,信号处理动作handler表

  • block表:每个信号对应1位,如果该位为1,那么代表该信号被阻塞,为0代表不被阻塞
  • pending表:如果该位为1,代表收到该信号,处于未决状态,为0代表还没收到该信号或者收到信号已经被递达了
  • handler表:代表对该信号处理动作,前面说过有三种,默认、忽略和自定义捕捉,其中自定义捕捉就是用户自定义的函数。handler表本质其实是函数指针数组,存放的是用户自定义函数的指针

分析图中几个信号:

  • 1号信号未阻塞也未产生过,但它递达时执行默认处理动作。
  • 2号信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
  • 3信号未产生过,一旦产生3信号将被阻塞,它的处理动作是用户自定义函数handler1。

总结:

  • 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
  • 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?

POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。

回答几个问题:

  1. 所有信号的产生都要由OS来进行执行,这是为什么?

信号的产生涉及到软硬件,且OS是软硬件资源的管理者,还是进程的管理者。

  1. 进程在没有收到信号的时候,能否知道自己应该如何对合法信号进行处理呢?

答案是能知道的。每个进程都可以通过task_struct找到表示信号的三张表。此时该进程的pending表中哪些信号对应的那一位比特位是为0的,且进程能够查看block表知道如果收到该信号是否需要阻塞,可以查看handler表知道对该信号的处理动作。

  1. OS如何发生信号?

OS给某一个进程发送了某一个信号后,OS会找到信号在进程中pending表对应的那一位比特位,然后把那一位比特位由0置1,这样OS就完成了信号发送的过程。

🌲信号集及信号集操作函数

sigset_t: 未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,也被定义为一种数据类型。这个类型可以表示每个信号状态处于何种状态(是否被阻塞,是否处于未决状态)。阻塞信号集也叫做当前进程的信号屏蔽字,这里的“屏蔽”应该理解为阻塞而不是忽略。
信号集操作函数: sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释
注意: 对应sigset类型的变量,我们不可以直接使用位操作来进行操作,而是一个严格实现系统给我们提供的库函数来对这个类型的变量进行操作

下面是信号集操作函数的原型:

#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指向的信号集,将所有比特位置0
  • sigfillset: 初始化set指向的信号集,将所有比特位置1
  • sigaddset: 把set指向的信号集中signum信号对应的比特位置1
  • sigdelset: 把set指向的信号集中signum信号对应的比特位置0
  • sigismember: 判断signum信号是否存在set指向的信号集中(本质是信号判断对应位是否为1)

注意: 在实现这些函数之前,需要使用sigemptysetsigfillset对信号集进行初始化。前四个函数的返回值是成功返回0,失败返回-1。最后一个函数的返回值是真返回1,假返回-1

阻塞信号集操作函数——sigprocmask:

功能: 读取或更改进程的信号屏蔽字
函数原型:

#include <signal.h> 
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

参数:

  • how:三个选项

    • SIG_BLOCK:把set中的信号屏蔽字添加到进程的信号屏蔽字中,mask = mask|set
    • SIG_UNBLOCK:把set中的信号屏蔽字在进程信号屏蔽字的那些去掉,mask = mask&~set
    • SIG_SETMASK:设置当前进程的信号屏蔽字为set,mask = set
  • set:如果为非空指针,则根据how参数更改进程的信号屏蔽字

  • oset:如果为非空指针,将进程原来的信号屏蔽字备份六种oset中
    返回值: 成功返回0,失败返回-1

未决信号集操作函数——sigpending:

功能: 读取进程的未决信号集
函数原型:

#include <signal.h> 
int sigpending(sigset_t *set);

参数:

  • set:读取当前进程的信号屏蔽字到set指向的信号屏蔽中

返回值: 成功返回0,失败返回-1

实例演示:
实例1: 把进程中信号屏蔽字2号信号进行阻塞,然后隔1s对未决信号集进行打印,观察现象

#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void PrintPending(sigset_t* pend)

  int i = 0;
  for (i = 1; i < 32; ++i)
  
    if (sigismember(pend, i))
      printf("1");
    
    else
      printf("0");
    
  
  printf("\\n");


int main()

  sigset_t set, oset;
  sigset_t pending;
  // 使用系统函数对信号集进行初始化
  sigemptyset(&set);
  sigemptyset(&oset);
  sigemptyset(&pending);

  // 阻塞2号信号
  // 先用系统函数对set信号集进行设置
  sigaddset(&set, 2);
  // 使用sigprocmask函数更改进程的信号屏蔽字
  // 第一个参数,三个选项:SIG_BLOCK(mask |= set) SIG_UNBLOCK(mask &= ~set) SIG_SETMASK(mask = set)
  sigprocmask(SIG_BLOCK, &set, &oset);
  
  int flag = 1; // 表示已经阻塞2号信号
  int count = 0;
  while (1)
    // 使用sigpending函数获取pending信号集
    sigpending(&pending);
    // 打印pending位图
    PrintPending(&pending);
    sleep(1);
  
  return 0;

代码运行结果如下: 可以看到,进程收到2号信号时,且该信号被阻塞,处于未决状态,未决信号集中2号信号对应的比特位由0置1

实例2: 将上面的代码进行修改,进行运行10s后,我们将信号屏蔽字中2号信号解除屏蔽

int count = 0;
while (1)
	// 使用sigpending函数获取pending信号集
	sigpending(&pending);
	// 打印pending位图
	PrintPending(&pending);
	if (++count == 10)
		// 两种方法都可以
		sigprocmask(SIG_UNBLOCK, &set, &oset);
		//sigprocmask(SIG_SETMASK, &oset, NULL);
	
sleep(1);


代码运行结果如下: 可以看出的是,2号信号解除阻塞后,信号被递达了,进程终止

🌏捕捉信号

🌲捕捉过程的介绍

先思考一个问题:信号是什么时候被进程处理的?

首先,不是立即被处理的。而是在合适的时候,这个合适的时候,具体指的是进程从用户态切换回内核态时进行处理

这句话如何理解,什么是用户态?什么是内核态?

  • 用户态: 处于⽤户态的 CPU 只能受限的访问内存,用户的代码,并且不允许访问外围设备,权限比较低
  • 内核态: 处于内核态的 CPU 可以访问任意的数据,包括外围设备,⽐如⽹卡、硬盘等,权限比较高

注意: 操作系统中有一个cr

以上是关于Linux篇第十二篇——信号(概念+信号的产生+阻塞信号+捕捉信号)的主要内容,如果未能解决你的问题,请参考以下文章

Linux从青铜到王者第十二篇:Linux进程间信号第二篇

第十二篇Camunda系列-事件篇-信号事件

第十二篇Camunda系列-事件篇-信号事件

第二十二篇:信号的接收和处理

Lua从青铜到王者基础篇第十二篇:Lua错误处理

Linux篇第十五篇——多线程(生产消费模型+POSIX信号量)