精讲Linux-进程信号

Posted _End丶断弦

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了精讲Linux-进程信号相关的知识,希望对你有一定的参考价值。

初始信号

生活角度中的信号

  • 当我们在网上买东西,再等待不同商品快递的到来。但即便快递没有到来,我们也知道快递来临时,我们该怎么处理快递。也就是我们能“识别快递”
  • 当快递到了,但是我们正在忙其他的事情,这段时间内我们没有取快递,但是我们是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”
  • 在收到短信时,再到你拿到快递期间,是有一个时间窗口的,在这段时间,我们并没有拿到快递,但是我们知道有一个快递已经来了。本质上是我们“记住了有一个快递要去取”
  • 当我们有时间时,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(打开快递,使用商品)2. 执行自定义动作(快递是吃的,马上干掉它)3. 忽略快
    递(快递拿到后放一边,继续忙自己的事情)

技术应用角度的信号

我们写个简单的死循环代码:

  1 #include<iostream>
  2 #include<unistd.h>                                                                                                  
  3 using namespace std;
  4 
  5 int main()
  6 
  7   while(1)
  8   
  9     cout<<"This is signal"<<endl;
 10     sleep(1);
 11   
 12   return 0;
 13 

为什么我们按下键盘的Ctrl+c就终止掉程序了呢?

我们按下Ctrl+c时键盘输入产生一个硬件中断,被操作系统获取,然后操作系统解释成信号(2号信号)发送给进程,进程收到2号信号后退出。我们可以用signal函数来测试进程是不是收到了2号信号。使用该函数要传入2个参数,第1个参数是信号编号,第2个参数是你要怎么处理这个信号。

确实是收到了2号信号,但是为什么没有退出呢?因为我们把它默认退出改成了打印。

Ctrl+c只能发送给前台进程。

注意:系统当中,打开一个终端一个bash中只允许有一个前台进程。

把进程放在后台运行命令:
要运行的程序名+&

前台进程和后台进程的区别

前台进程演示:

前台进程我们敲命令是没用的,可以终止进程

后台进程演示:

后台进程我们输入命令还可以执行,但是却无法用Ctrl+c干掉。

干掉后台进程可以用fg转成前台进程在干掉,或者用kill + pid干掉它。

这样就干掉了后台进程。

信号概念

信号是进程之间事件异步通知的一种方式,属于软中断。俗话说就是通知事件发生。

查看信号列表

kill-l //查看信号列表


1-31是普通信号,34-64是实时信号。

信号处理常见方式

信号处理方式有3种:

  1. 忽略此信号。
  2. 执行该信号的默认处理动作。
  3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称捕捉一个信号。

产生信号

1.通过终端产生信号

SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,现在我们来验证一下。

Core Dump
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump,也就是核心转储功能。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。

用命令查看资源限制:

ulimit -a


默认core文件是关闭的,为了测试我们在云服务中开启coer文件,用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K

ulimit -c 1024

 10 int main()
 11 
 12  
 13   while(1)
 14   
 15     cout<<"This is signal"<<endl;
 16     sleep(1);
 17                                                                                                                 
 18   return 0;
 19 


ctrl+c和ctrl+\\都可以终止掉进程,但我们打开core,ctrl+\\产生了core dumped文件


ulimit命令改变了Shell进程的Resource Limit,test进程的PCB由Shell进程复制而来,所以也具 有和Shell进程相同的Resource Limit值,这样就可以产生Core Dump了。

   10 int main()
   11 
   12  // signal(2,handler);
   13   int a = 10/0;                                                                                                
   14   return 0;
   15 

我们来个整数除0,使用一下core文件

core-file core.+进程pid

使用调试时要加上-g选项,Liunx用的是release版本

核心转储功能:我们可以通过调试找到代码的问题所在。这种调试这叫做事后调试。

2.通过系统调用函数产生信号

通过kill命令向进程发送信号

kill -l +信号 +进程pid

写一个死循环试验:

kill -信号编号 +进程pid

kill命令是通过调用kill函数实现的

函数原型:

int kill(pid_t pid, int signo);

成功返回0,失败返回-1.

  6  void handler(int sig)
  7 
  8   printf("catch a sig:%d\\n",sig);                                                                
  9 
 10 
 11 int main(int argc,char* argv[])
 12 
 13   if(argc == 3)
 14   
 15     kill(atoi(argv[1]),atoi(argv[2]));
 16   
 17   return 0;
 18 


我们把它放在后台睡眠,通过给它发送9号信号就干掉它了。

rasie函数

raise函数可以给当前进程发送指定的信号(自己给自己发信号)。
成功返回0,错误返回-1.

  1 #include<stdio.h>
  2 #include<unistd.h>
  3 #include<signal.h>
  4 #include<sys/types.h>
  5  void handler(int sig)
  6 
  7   printf("catch a sig:%d\\n",sig);
  8 
  9 
 10 
 11 int main()
 12 
 13 
 14   signal(3,handler);
 15   while(1)
 16   
 17     raise(3);                                                                                                       
 18     sleep(1);
 19   
 20   return 0;
 21 


给当前进程发送3号信号。

abort函数使当前进程接收到信号而异常终止。

函数原型:

#include <stdlib.h>
void abort(void);
//就像exit函数一样,abort函数总是会成功的,所以没有返回值。
  1 #include<stdio.h>
  2 #include<unistd.h>
  3 #include<signal.h>
  4 #include<sys/types.h>
  5 #include<stdlib.h>
  6  void handler(int sig)
  7 
  8   printf("catch a sig:%d\\n",sig);
  9 
 10 
 11 
 12 int main()
 13 
 14 
 15   signal(6,handler);                                                                                                
 16   while(1)
 17   
 18     abort();
 19     sleep(1);
 20   
 21   return 0;
 22 


就终止掉了进程。

3. 由软件条件产生信号

SIGPIPE是一种由软件条件产生的信号,在“管道”中遇见过了。

alarm函数

#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。

 11 int main()
 12 
 13   int count = 0;
 14   alarm(1);                                                                                                         
 15   while(1)
 16   
 17     count++;
 18     printf("count:%d\\n",count);
 19   
 20 
 21   return 0;
 22 


到4万多就退了,而我们的cpu运算是很快的,我们的代码是++一次打印到屏幕一次涉及到IO,效率就会很低,我们可以等+的时间到了在打印数据,定义一个全局的count++。

  1 #include<stdio.h>
  2 #include<unistd.h>
  3 #include<signal.h>
  4 #include<sys/types.h>
  5 #include<stdlib.h>
  6 int count = 0;
  7  void handler(int sig)
  8 
  9   printf("catch a sig:%d\\n",sig);
 10   printf("count=%d\\n",count);
 11   exit(0);                                                                                                          
 12 
 13 
 14 int main()
 15 
 16   signal(SIGALRM,handler);
 17   alarm(1);
 18   while(1)
 19   
 20     count++;
 21   
 22 
 23   return 0;
 24 


这次的count就非常大了,达到了4亿多,足以看出IO的效率很低。

4.硬件异常产生信号

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。

   14 int main()
   15 
   16   int *p;
   17   *p = 10;                                                                                                        
   18   return 0;
   19 


就是11号信号。

阻塞信号

信号其他相关常见概念

  • 实际执行信号的处理动作称为信号递达(Delivery)
  • 信号从产生到递达之间的状态,称为信号未决(Pending)。
  • 进程可以选择阻塞 (Block )某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
  • 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

在内核中表示


pending和block都是位图,有32个比特位。
pending位图保存信号,有2个意思。是谁?是否?是谁表示是哪个信号,是否收到。block位图记录信号被屏蔽的信息。它们2个比特位的位置是一样的,但是比特位的内容是不一样的。handler是数组,数组内容是函数指针,自己的函数地址填入叫做自定义捕捉信号。

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

sigset_t

从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。
也就是说我们可以用sigset来设置信号操作,但是不建议使用,建议使用下面的函数进行操作。

信号集操作函数

下面的一系列函数供我们来操作。

#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
  • 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
  • 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号
  • 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
  • 这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。

sigprocmask函数

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)
函数原型:

int sigprocmask(int how, const sigset_t *set, sigset_t *oset)

成功返回0,出错返回-1。

参数说明:

如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。

注意:如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。

sigpending

读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。

int sigpending(sigset_t *set);

做一个测试:

1.利用上述的函数将2号信号屏蔽
2.向进程发送2号信号
3.此时2号信号被屏蔽,处于pending状态
4.通过sigpending函数来读取pending信号集来查看验证。

  1 #include<stdio.h>                                                                                                                   
  2 #include<unistd.h>
  3 #include<signal.h>
  4 #include<sys/types.h>
  5 #include<stdlib.h>
  6 
  7 void printsigset(sigset_t *set)
  8 
  9   int i = 1;
 10   for(;i<32;++i)
 11   
 12     if(sigismember(set,i))
 13     
 14       printf("1");
 15     
 16     else
 17     
 18       printf("0");
 19     
 20   
 21   printf("\\n");
 22 
 23 
 24 int main()
 25          
 26   sigset_t set,oset;
 27   sigemptyset(&set);//初始化信号集对象
 28   sigemptyset(&oset);                 
 29   sigaddset(&set,SIGINT);//发送2号信号
 30                                       
 31   sigprocmask(SIG_BLOCK,&set,NULL);//阻塞2号信号 
 32   sigset_t pending;                              
 33   sigemptyset(&pending);//pending位图置空        
 34                                          
 35   while(1)                               
 36          
 37     sigpending(&pending);//获取未决信号集
 38     printsigset(&pending);
 39     sleep(1);
 40   
 41   return 0;
 42 
 43 

效果如下:

一开始是没有收到任何信号的,当给它发送2号信号,第2个比特位由0变成了1。为了看到2号信号递达后pending的变化,我们可以设置一段时间后解除对2号信号的屏蔽,并且我们对2号信号进行捕捉自定义执行我们自己的动作。
效果如下:

当解除2号信号时,它执行我们自定义的动作,第2个比特位也从1变为了0.

捕捉信号

用户空间和内核空间


(在32位下)程序地址空间中有1-3GB是用户区,3-4GB是内核区。我们的进程映射的物理空间用的用户级页表,每个进程都会有自己的用户级页表。内核区用的是内核级页表映射达到物理内存,所有的进程用的是同样一张的内核页表。用户是没有权限随意的访问系统的代码和数据的。

内核态和用户态

  • 用户态:用来执行系统的代码时的状态有很大的权限
  • 内核态:执行普通用户的代码的状态,权限小
    我们的代码是在用户态和内核态进行切换的。如下图所示:

内核如何捕捉信号

一个信号被递达:是在内核态切换到用户态是进行相关检测的。

那内核是怎么捕捉信号的呢?

为了方便好记可以画个简化的图:

就像数学中的正无穷的符号差不多,但是相交的点是在信号检测,是在内核区的。和横线的4个交点就说明进行了4的状态切换。

信号到用户自定义的函数,为什么切换到用户在执行呢?内核是由权限执行用户的代码

因为如果是非法的代码由内核来执行就会容易中病毒,因为内核具有高的权限的,所以系统进行切换到用户区执行,用户态的权限是微小的。

sigaction

还可以用sigaction函数进行信号捕捉。
函数原型:

int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

参数说明:

  • signo:指定信号的编号
  • 若act指针非空,则根据act修改该信号的处理动作
  • 若oact指针非 空,则通过oact传出该信号原来的处理动作。
  • act和oact指向sigaction结构体
    sigaction结构体如下:
struct sigaction 
 void     (*sa_handler)(int);
 void     (*sa_sigaction)(int, siginfo_t *, void *);
 sigset_t   sa_mask;
 int        sa_flags;
 void     (*sa_restorer)(void);
;

第2个和第5个成员是关于实时信号我们不用管。
sa_handler:收到信号,做什么动作
sa_mask:要屏蔽的信号,默认为0
sa_flags:默认设为0

来个例子测试一手:

  1 #include<stdio.h>
  2 #include<unistd.h>
  3 #include<signal.h>
  4 #include<sys/types.h>
  5 #include<stdlib.h>
  6 
  7 void handler(int sig)
  8 
  9   printf("get a sig:%d\\n",sig);                                                                               
 10 
 11 
 12 
 13 int main()
 14 
 15   struct sigaction act,oact;
 16 
 17   act.sa_handler  = handler;
 18   act.sa_flags = 0;
 19   sigemptyset(&act.sa_mask);
 20 
 21   sigaction(2,&act,&oact);
 22    while(1)
 23    
 24       printf("i am pid\\n");
 25       sleep(1);
 26    
 27   return 0;
 28 


我们按下Ctrl+c,进程收到了2号信号。由于是我们自定义处理所以它没有退出。

可重入函数


当我们插入链表时,先插入node1,刚让node1->next指向新节点时候来了一个信号,这个信号也是让我们进行插入操作。此时从用户态到内核态中处理,插入完毕后回到用户态回到main函数里继续执行插入操作。此时head从指向node2变成了了指向node1,但是node2却被丢了造成了内存泄漏问题。

像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入函数。

当一个函数符合以下条件之一则是不可重入的:

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

volatile

先来看一段代码:

     1 #include<stdio.h>
    2 #include<unistd.h>
    3 #include<signal.h>
    4 #include<sys/types.h>
    5 
    6 int flag= 0;
    7 
    8 void handler(int sig)
    9 
   10   printf("flag is to 1\\n");
   11   flag = 1;
   12 
   13 int main()
   14 
   15 
   16   signal(2,handler);
   17   while(!flag);
   18   printf("i am quit!\\n");                                                                                  
   19 
   20   return 0;
   21 

定义1个全局flag变量,不发送2号信号会在死循环,当我们按下Ctrl+c是进程退出。

我们在编译是加上-O2选项

我们按Ctrl+c但是进程却没有退出。我们加了选项把flag的值优化到了CPU的寄存器中,while循环检查的flag不是内存中最新的flag,就会出现二义性的问题。此时就要用volatile。

即使有优化,当我们按Ctrl+c时进程退出了。

volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。

本篇文章到这就已结束了。

以上是关于精讲Linux-进程信号的主要内容,如果未能解决你的问题,请参考以下文章

Linux进程概念(精讲)

Linux进程控制(精讲)

超详细的Linux进程控制精讲

linux系统上信号发送和信号接收讲解

第三百五十五节,Python分布式爬虫打造搜索引擎Scrapy精讲—scrapy信号详解

linux下 进程信号量和线程信号量的区别和联系是啥