信号入门
生活角度的信号
你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“ 识别快递 ”当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需 5min 之后才能去取快递。那么在在这5min 之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“ 在合适的时候去取 ” 。在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“ 记住了有一个快递要去取 ”当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种: 1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友) 3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
技术应用角度的信号
用户输入命令 , 在 Shell 下启动一个前台进程。用户按下 Ctrl - C , 这个键盘输入产生一个硬件中断,被 OS 获取,解释成信号,发送给目标前台进程前台进程因为收到信号,进而引起进程退出
这其实是因为当我们按下ctrl+c时,产生了一个硬件中断,被操作系统获取到,然后系统发送了一个信号给前台进程,前台进程收到信号后退出了进程
进程的几个注意事项
1.一个bash中终端中只能有一个前台进程
当我们在这个前台进程中输入我们之前的命令,发现没有作用
2.Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程
shell可以同时运行一个前台进程和多个后台进程,也就是说一个bash终端只能运行一个前台进程,但是后台可能会有多个后台进程在运行。
为什么说信号堆进程控制是异步的?因为一个进程在做自己的事情,信号不知道什么时候来,进程可能在任何时候收到信号而终止,所以是异步的。异步的意思是,不知道什么时候会发送信号。
信号的概念
信号是进程之间事件异步通知的方式,属于软中断
其实信号就是给进程一个信号,这个信号告诉了进程应该执行什么命令,在默认情况下就是退出
用kill -l命令 查看系统定义的信号
每个信号都有一个编号和一个宏定义名称 , 这些宏定义可以在 signal.h 中找到 , 例如其中有定 义#defineSIGINT 2编号 34 以上的是实时信号 , 本章只讨论编号 34 以下的信号 , 不讨论实时信号。这些信号各自在什么条件下产生, 默认的处理动作是什么 , 在 signal(7) 中都有详细说明 : man 7 signal
我们的1-31是普通信号,34-64是实时信号,信号由宏定义与编号组成,可以方便我们查看
信号处理常见方式概览
可选的处理动作有以下三种 :1. 忽略此信号。2. 执行该信号的默认处理动作。3. 提供一个信号处理函数 , 要求内核在处理该信号时切换到用户态执行这个处理函数 , 这种方式称为捕捉(Catch)一个信号
我们编写了一个handler函数,利用signal将2号信号捕捉为一个handler函数去执行
注意:signal是修改了进程对信号的处理方式,等收到改变的信号时,直接实行自定义的函数,但是我们的修改并不是万能的,系统为了 安全考量,9号进程,无法被捕捉
产生信号
通过终端按键来产生信号
就比如我们上面通过ctrl+c来给进程传递信号,发送了2号信号,我们也可以通过ctrl+\发送三号信号
通过系统函数来向进程发信号
就如同我们上面终止进程使用的kill函数
使用方法就是给pid为19418的进程发送9号信号,成功返回0,失败返回-1;
这里我们自己调用了kill函数来终止了test进程,从内部也可以看出kill命令是通过系统调用kill实现的
补充:我们上面使用了强制类型转化,我们这里再对强制类型转换与转化概念进行澄清
强制类型转换并没有真正改变数据的值,在内存中的保存方式跟原来一致,而转化则会改变数据的值,上面将char* 类型转化成int型不是强制类型转换
raise函数
我们的raise函数的作用就是给当前进程发信号的
每隔一秒发送2号信号
abort函数
作用:使当前进程收到6号信号,后异常终止(abort函数一定会终止进程,不管有没有重新捕捉信号)
捕捉完成后直接退出了
由软件条件产生信号
我们之前其实已经接触过软件条件生成的信号了,那就是在之前的匿名管道中,系统关闭了读进程,此时写进程就会收到软件条件生成的信号而被关闭
我们可以看到,1秒钟之内,count增加了18757次,但实际上是这样吗?
我们再来看下这种代码
逻辑是一样的,但是这个结果却大的很多,这是为什么呢?实际上是因为我们输出的时候其实是I/O,效率是很低的,这个程序不向I/O输出直接打的就是实际加的值,所以会高很多
硬件异常产生信号
硬件异常产生信号就是硬件发现了进程的异常,而又因为硬件受OS管理,此时硬件就会自动通知操作系统,操作系统再向当前进程发送适当的信号
不过硬件产生的异常通常操作系统发的都是终止信号,因为只要异常没有被消除,操作系统就会一直给进程发送信号
总结:
上面所说的所有信号产生,最终都要有OS来执行,为什么?
答:OS是进程的管理者
信号的处理是否是立即处理的?
答:并不是,而是在合适的时候,这个时间是操作系统进行判断的
信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来,记录在哪里最合适呢?
答:记录在一个位图中,位置代表着信号编号,01代表着是否记录成功
实时信号是由链表构成的,同时间可以接收多个信号,不会丢失
信号记录与信号处理
信号的其他相关概念:
实际执行信号的处理动作称为信号递达 (Delivery)信号从产生到递达之间的状态 , 称为信号未决 (Pending) 。进程可以选择阻塞 (Block ) 某个信号。被阻塞的信号产生时将保持在未决状态 , 直到进程解除对此信号的阻塞 , 才执行递达的动作 .注意 , 阻塞和忽略是不同的 , 只要信号被阻塞就不会递达 , 而忽略是在递达之后可选的一种处理动作
在内核中的表示
每个信号都有两个标志位分别表示阻塞 (block) 和未决 (pending), 还有一个函数指针表示处理动作。信号产生时, 内核在进程控制块中设置该信号的未决标志 , 直到信号递达才清除该标志。在上图的例子中,SIGHUP 信号未阻塞也未产生过 , 当它递达时执行默认处理动作。SIGINT 信号产生过 , 但正在被阻塞 , 所以暂时不能递达。虽然它的处理动作是忽略 , 但在没有解除阻塞之前不能忽略这个信号, 因为进程仍有机会改变处理动作之后再解除阻塞。SIGQUIT 信号未产生过 , 一旦产生 SIGQUIT 信号将被阻塞 , 它的处理动作是用户自定义函数 sighandler 。如果在进程解除对某信号的阻塞之前这种信号产生过多次 , 将如何处理 ?POSIX.1 允许系统递送该信号一次或多次。Linux 是这样实现的 : 常规信号在递达之前产生多次只计一次 , 而实时信号在递达之前产生多次可以依次放在一个队列里。
我们通过观察上面的信号表示可以发现,信号是由两个位图+一个数组构成的,我们来通过原图中的例子进行分析
当前进程对1号进程不阻塞,未收到1号信号,若收到1号信号则按默认方式处理
当前进程对2号进程阻塞,收到了2号信号,但由于阻塞,无法递达,处于未决状态,当阻塞取消,则按照忽略方式处理
当前进程对3号进程阻塞,并未收到3号信号,当收到3号信号,则会进入未决状态,当阻塞取消,进程会以自定义方式进行处理
信号记录
进程收到信号后的记录方式就是将两位图中对应的位置置1
信号处理
信号处理就是在未阻塞的情况下收到信号后,会对该进程在handle函数指针数组中找对应的递达方法,来处理信号,而处理的方法则是三种:1.默认,2.忽略,3.自定义捕捉
捕捉信号
若信号处理动作是由用户进行自定义设置的,则在信号递达时,则会调用这个函数,这一过程被称为信号的捕捉,但是操作系统在收到信号后并不是立马进行处理的,而是在合适的时候处理,其实这个合适的时候就是:计算机从内核态切成用户态时检测并处理信号
内核态与用户态
其实我们的计算机不只有一种状态,我们所使用的叫用户态,操作系统所使用的叫内核态
当程序在运行我们用户编写的代码并且未中断或异常时,所处的就是用户态,而当程序中断,异常,或者系统调用时,计算机就会切换到内核态,服务于操作系统,事实上,一个进程在运行时,可能不断地在内核态与用户态之间进行切换,内核态权限是比用户态高的,这样才支持自动切换
那么计算机是如何实现用户态与内核态的互相切换的呢?
其实在我们的虚拟地址空间中,存在有两块大区域,内核区域与用户区域,两个块区分别储存的是用户或内核的代码与数据
而当计算机处于用户态时,在虚拟地址空间的用户区,通过用户级页表,找到代码与执行数据
当计算机处于内核态时,在虚拟地址空间的内核区,通过内核级页表,找到代码与数据执行,不过内核级页表中的每个进程都是相同的,因为操作系统只有一个,每个进程地址空间的内核区页表都指向的是同一块物理内存
那么我们如何知道计算机当前处于用户态还是内核态?
在CPU中有一个寄存器CRO,里面有标志位记录了计算机是处于内核态还是用户态
信息捕捉示意图
在信号的处理过程中,我们一共会发生4次切换内核态与用户态的过程
进程处理信号的时机是在从内核态切回用户态时完成的
内核态权限比用户态高,为什么执行自定义信号处理函数还需要从内核态切换到用户态?
就是因为内核态权限高,如果自定义信号处理函数中有非法动作,比如修改操作系统,在内核态能处理,但是用户态不能处理,这样会导致安全隐患。毕竟自定义信号处理函数是用户写的。如果不断受到一个信号,该信号处理动作为自定义的,而自定义函数中有系统调用,执行系统调用,会要从用户态切换到内核态,当从内核态切换到用户态时,又受到同样等信号,需要处理吗?在处理信号时,有内核态到用户态的情况,在这过程中有收到相同信号,需要处理吗?
不会执行,操作系统在执行该信号时,会将进程block位图中信号位置设为1,阻塞该信号。一种信号只能同时处理一个,但是可以同时处理多种信号。
信号集操作函数
sigset_t 类型对于每种信号用一个 bit 表示 “ 有效 ” 或 “ 无效 ” 状态 , 至于这个类型内部如何存储这些 bit 则依赖于系统实现, 从使用者的角度是不必关心的 , 使用者只能调用以下函数来操作 sigset_ t 变量 , 而不应该对它的内部数据做任何解释, 比如用 printf 直接打印 sigset_t 变量是没有意义的
#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 清零 , 表示该信号集不包含 任何有效信号。函数 sigfifillset 初始化 set 所指向的信号集 , 使其中所有信号的对应 bit 置位 , 表示 该信号集的有效信号包括系统支持的所有信号。注意 , 在使用 sigset_ t 类型的变量之前 , 一定要调 用 sigemptyset 或 sigfifillset 做初始化 , 使信号集处于确定的状态。初始化sigset_t 变量之后就可以在调用 sigaddset 和 sigdelset 在该信号集中添加或删除某种有效信号这四个函数都是成功返回 0, 出错返回 -1 。 sigismember 是一个布尔函数 , 用于判断一个信号集的有效信号中是否包含某种 信号, 若包含则返回 1, 不包含则返回 0, 出错返回 -1 。
sigprocmask修改阻塞位图
调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字 ( 阻塞信号集 ) 。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
oset为输出型参数,返回未修改前阻塞位图的信息
sigpending 获取未决位图
#include <signal.h>
sigpending
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
sigaction自定义捕捉函数
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
sigaction 函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回 0, 出错则返回 - 1 。 signo是指定信号的编号。若act 指针非空 , 则根据 act 修改该信号的处理动作。若 oact 指针非 空 , 则通过 oact 传出该信号原来的处理动作。act 和 oact 指向 sigaction 结构体将 sa_handler 赋值为常数 SIG_IGN 传给 sigaction 表示忽略信号 , 赋值为常数 SIG_DFL 表示执行系统默认动作, 赋值为一个函数指针表示用自定义函数捕捉信号 , 或者说向内核注册了一个信号处理函 数 , 该函数返回值为void, 可以带一个 int 参数 , 通过参数可以得知当前信号的编号 , 这样就可以用同一个函数处理多种信号。显然, 这也是一个回调函数 , 不是被 main 函数调用 , 而是被系统所调用。
可重入函数
main 函数调用 insert 函数向一个链表 head 中插入节点 node1, 插入操作分为两步 , 刚做完第一步的 时候 , 因为硬件中断使进程切换到内核, 再次回用户态之前检查到有信号待处理 , 于是切换 到 sighandler 函数,sighandler 也调用 insert 函数向同一个链表 head 中插入节点 node2, 插入操作的 两步都做完之后从sighandler返回内核态 , 再次回到用户态就从 main 函数调用的 insert 函数中继续 往下执行 , 先前做第一步之后被打断, 现在继续做完第二步。结果是 ,main 函数和 sighandler 先后 向链表中插入两个节点 , 而最后只有一个节点真正插入链表中了。像上例这样 ,insert 函数被不同的控制流程调用 , 有可能在第一次调用还没返回时就再次进入该函数 , 这称为重入,insert 函数访问一个全局链表 , 有可能因为重入而造成错乱 , 像这样的函数称为 不可重入函数 , 反之 ,如果一个函数只访问自己的局部变量或参数, 则称为可重入 (Reentrant) 函数
调用了 malloc 或 free, 因为 malloc 也是用全局链表来管理堆的。调用了标准 I/O 库函数。标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构。
volatile关键字
我们其实之前在许多场景中都见过这个关键字,这个关键字的作用就是取消优化,我们的编译器会在我们的代码中做一些系统级的优化,比如我们的不会修改的变量,按照常理来看变量都应该存储在内存中,但实际上编译器根据这个变量不会被修改的性质,直接将其放在寄存器中以此来提高效率,而我们有时候就会因为编译器这个特性而产生一些错误,我们可以通过添加volatile关键字修饰变量来屏蔽优化,直接内存进行