前言
信号在我们生活中很常见,下面我们举一举生活中信号的例子:
你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“ 识别快递 ”
当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需 5min 之后才能去取快递。那么在在这5min 之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“ 在合适的时候去取 ” 。
在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“ 记住了有一个快递要去取 ”
当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种: 1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你的朋友) 3. 忽略快递(快递拿上来之后,扔掉床头,继续睡觉)
快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
在讲进程信号之前我们先引入四个重要的概念:
1.互斥,任何一个时刻,都只允许一个执行流在进行共享资源的访问(这样的操作可以通过加锁来实现)
2.我们把任何一个时刻,都只允许一个执行流在进行访问的共享资源,叫做临界资源。
3.临界资源是要通过代码访问的,凡是访问临界资源的代码,叫做临界区。
4.原子性 :只有两种确定状态的属性 (就比如1和0,能存在中间值0.5)
一、认识信号量
感性的认识:
信号量也被称为信号灯,本质上就是一个描述资源数量计数器,下面我们举个生活中的例子来理解信号量:
在生活中我们会去电影院看电影,但是在看电影之前我们必须先买票,而买票的本质功能有两个,第一个是对座位资源的预订机制,第二个是确保不会因为多放出去特定的座位资源而导致座位冲突。而信号量其实对应的就是买票,因为任何一个执行流,想访问临界资源中的任何一个子资源的时候是不能直接访问的,必须得先申请信号量资源(也就是买票),而我们前面说过信号量的本质就是个计数器,所以我们在申请信号量资源的时候只需要让这个计数器加加或减减即可(如果申请成功,那么计数器需要--,因为我们的信号量资源少了一个。如果申请成功后不想用了,那么就让计数器++,代表有人将我们的信号量资源归还了)。也就是说只要我们申请信号量成功,我就一定能在未来拿到一个子资源。同样的例子,如果我们的电影院只有一个座位仅供专属VIP座,那么这个情况就叫做互斥,因为在这期间只有一个VIP能使用这个座位,没有其他的人来抢座位。刚刚我们说了信号量本质是个计数器,既然是计数器就必须让所有的进程都看到,否则无法保证自己的操作是原子的。可以理解为:让不同的进程看到同一份资源(这个资源就是信号量)。
下面我们来认识一下信号量的接口:
首先第一个接口是获取信号量semget:
如果看了我们上一篇共享内存的文章的话,一定可以认识semget这个接口的参数,因为和获取共享内存接口shmget一模一样。第二个参数nsems的含义是代表信号量的个数,也就是说我们一次可以申请多个信号量。要查看我们的信号量的命令是ipcs -s:
同样和共享内存一样,删除某个信号量的指令是ipcrm -s +semid。下面我们看看删除信号量的系统调用接口,不出意外的话就是semctl这个函数了:
这个函数与共享内存的删除接口不一样的地方是多了一个可变参数列表,第二个参数semnum是代表对哪一个信号量做操作(因为刚刚我们说过了可以同时申请多个信号量)。
semop这个函数可以完成对信号量的计数器-1+1操作:
这个函数的第二个参数结构体就是完成我们对信号量的-1+1操作的,下面我们看看这个结构体:
比如说我们要对一个信号量做减操作,那么就可以在num这个下标填0(num是一个数组),sem_op填-1(因为要减减),flag默认即可。
对于信号量的接口我们差不多已经看完了,下面我们来理解一下IPC:
我们可以发现不管是共享内存还是信号量,系统用来描述他们的结构体都是XXXid_ds:
那么操作系统是分开管理这些IPC资源的还是一起管理的呢?
我们以左边三个结构体为例,在操作系统中有一个这个结构体类型的指针数组,这个数组按下标依次存放右边三个不同的ipc结构体的地址,对于这个指针数组来讲,要保存其他类型的ipc结构体只需要将这个结构体类型强转为系统用于管理的这个结构体指针类型,这样就完成了将内核中的所有ipc资源统一以数组的方式进行管理。以上就是操作系统管理这个IPC资源的原理,上面的操作不知道有没有看出是什么原理呢,其实这就是多态!
二、信号的产生
红绿灯,闹钟,下课铃都是信号,而这些信号被看懂前是需要我们被培养过,比如说有人告诉我们红灯停,所以我们知道红灯要停下,我们可以把进程比作自己,信号就是一个数字,进程在没有收到信号的时候其实进程早就知道该如何处理信号了(因为这是程序员教的,程序员写代码让进程认识信号),而由于信号可能会随时产生,所以在信号产生前,进程可能在做优先级更高的事情,这个时候进程是可以不用立马处理这个信号的,但是要在后续合适的时间处理刚刚没有处理的信号,由于这样的原因所以我们必须将信号保存起来,这样即使当时没有处理信号也能在后续的时间处理这个信号。总结:进程收到信号的时候,如果没有立马处理这个信号,需要进程具有记录信号的能力。
首先我们要知道查看信号的命令 kill -l:
在这些信号中,只有1-31是我们要学的,因为1-31叫做基本信号,34-64叫做实时信号,而我们现在的操作系统都是分时的,所以我们只学习基本信号。因为信号的产生对于进程来说是异步的,那么进程该如何记录对应产生的信号呢?答案是先描述再组织。怎么描述呢?简单的说0 1就能描述一个信号,用位图来管理这个信号。如下图:
下面我们用代码来对信号进行简单的测试:
#include <iostream> #include <unistd.h> int main() { while (true) { std::cout<<"我是一个进程,我正在运行 ...,pid:"<<getpid()<<std::endl; sleep(1); } return 0; }
下面我们将程序运行起来试一试信号:
首先我们看到的现象是我们成功用9号信号杀死了一个进程,这就是通过指令的方式发信号。
当然对于前台进程而言,我们可以从键盘上输入ctrl +c 终止前台进程:
而如何将一个进程变为后台进程我们也说过了,就是在后面加上&符号:
后台进程是无法被ctrl+c这样的命令杀死的,所以最后我们用kill-9杀死了这个进程。其实ctrl+c也是操作系统像进程发信号,只不过我们看不到,下面我们通过signal函数的方式查看操作系统给进程发的信号:
signal这个函数的第一个参数为信号编号,第二个参数为如果操作系统像这个进程发了一个信号,这个函数会将这个信号拿走用于自定义的功能,而不是再像以前一样听取操作系统的指令。下面我们演示一下:
#include <iostream> #include <unistd.h> #include <signal.h> void handler(int sig) { std::cout<<"get a signal: "<<sig<<std::endl; } int main() { signal(2,handler); while (true) { std::cout<<"我是一个进程,我正在运行 ...,pid:"<<getpid()<<std::endl; sleep(1); } return 0; }
这段代码的意思是当操作系统向我们发送2号信号的时候(ctrl + c 就发送的2号信号)我们不在执行原来的终止程序,而是去打印出来signal信号:
也就是说我们通过signal函数成功捕获了操作系统向我们发送的2号信号(这个2号信号就是我们按下ctrl+c的时候操作系统转化为信号发送给进程的)当然如果上面的图片还是没看懂那么我们也可以这样:
下面这两张图就清楚的证明了我们发送的ctrl+c信号就是2号信号,因为我们发送2号信号不会中断程序,ctrl+c也不会中断程序。下面我们要说一下,在我们用回调函数的时候,就像我们上面的代码,在调用signal函数的时候是不会调用handler函数的,这里只是更改了2号信号的处理动作,并没有调用handler方法。比如下面这样:
在我们调用show方法的时候是不会调用print的函数的,下面我们将代码运行起来:
我们可以看到只打印了hello show,那么如何在调用show的时候还调用print函数呢?其实很简单,在show中调用函数指针即可:
这也就证明了我们调用signal函数的时候是不会调用handler函数的。
下面我们将所有信号都自定义捕捉,这样是不是这个进程就无敌了没有指令可以杀掉这个进程了呢?
int main() { //signal(2,handler); for (int i = 1;i<=31;i++) { signal(i,handler); } while (true) { std::cout<<"我是一个进程,我正在运行 ...,pid:"<<getpid()<<std::endl; sleep(1); } return 0; }
我们可以看到其他信号确实都被捕捉了,但是kill -9还是会杀掉进程,因为操作系统不允许有进程不被杀死。
下面我们讲一下信号的产生原理:
我们平时在输入的时候,计算机怎么知道我从键盘输入了数据呢?键盘是通过硬件中断的方式通知系统我们的键盘已经被按下了。:
上图中的圆圈代表CPU,边上的毛代表CPU的针脚,而键盘会通过中断控制器找到对应与CPU的针脚:
当我们从键盘输入指令后cpu的寄存器会存储键盘的中断号,然后CPU通过中断号去中断向量表中查找与之中断号相对应的函数方法,这样就完成了我们从键盘输入ctrl + c然后转化为2号信号并且杀死进程的操作。
下面我们将上面所讲的知识先小小的总结一下:
1. 用户输入命令,在Shell下启动一个前台进程。
.
用户按下Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程
.
前台进程因为收到信号,进而引起进程退出。
2. Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
3. Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
4. 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步
(Asynchronous)的。
当然除了上面我们用数字当信号,也可以用宏来使用: