精讲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.通过终端产生信号
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-进程信号的主要内容,如果未能解决你的问题,请参考以下文章