目录
先导知识
信号量与信号没有任何关系,它们是两个完全不同的概念!
操作系统的本质,就是一个死循环;操作系统的执行,时基于各种硬件中断的!
所有用户的行为,都是以进程的形式在 OS 中表现得,操作系统只要把进程调度好,就能完成所有得用户任务。
在终端中将一个可执行程序放在后台运行:./xxx & (在运行前台进程的基础后面加一个 &),前台进程只能有一个,后台进程可以有多个。
volatile 关键字作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被编译器以任何方式优化,对该变量的任何操作,都必须在真实的内存中进行操作。
|--------|---------------------------------|
| 用户态 | 受控状态,只能访问自己的 0~3G 空间 |
| 内核态 | 可以让用户以 OS 的身份可以多访问通用的那 3~4G 空间 |
| 状态转换时间 | 在进行系统调用的时候,从用户态切换至内核态 |
[操作系统的用户态和内核态]
信号量
信号量维护一个计数器,表示可用资源的数量。当一个执行流想要去访问公共资源内的某一份资源时,需要先去申请信号量资源,并不是直接访问,而申请信号量资源,其实就是对信号量的的计数器进行 --(减减)操作,当减减操作执行成功后,这个执行流就完成了对资源的预定工作!
那为什么访问公共资源前要在先做这么多事呢?
在进行进程间通信的时候,多个执行流会看到同一份资源,而它们可能会同时对这一份公共资源进行并发访问,这样就会导致数据不一致的问题,所以,就需要将公共数据保护起来,被保护起来的公共资源,叫临界资源,而访问该临界资源的代码,叫临界区!临界资源一次只允许一个进程访问,实现的方法有:互斥和同步。
同步的方式有:匿名管道、命名管道,消息队列等
而互斥的实现方法,就是维护一个值为1的信号量,当有执行流申请信号量资源成功的时候,就将信号量的值减减,减减后信号量的值就变为了0,当信号量的值为0的时候,临界资源就不能被访问了,这样就实现了,这块共享内存(临界资源)只能同一时间只能被一个进程访问!
但信号量本质上也是公共资源(因为信号量资源会被多个进程看到),所以在内核系统层面,维护着一个专门设计的管理进程间通信的IPC模块资源,里面使用了类似多态的原理,使用 strcut kern_ipc_perm* 类型的数组来同一管理 IPC 资源,而信号量也是存储在这个 IPC 体系里的,因为在内核中,所以进程都能够访问,也就变成了公共资源。
信号
信号概念及产生信号的一般方式
在操作西系统中,信号是进程之间事件异步通知的一种方式,属于软中断,例如,当在 shell 命令行启动一个前台进程后,在键盘按下 ctrl + C 组合键,ctrl + C 就会被OS获取,解释成一个信号,前台进程因为受到了信号,进而引起进程退出!信号是一种向目标进程发送通知进程的一种消息机制,本质就是软件,用来模拟中断的行为!
使用 kill -l 命令可以查看系统定义的信号列表
bash
kill -l
进程产生信号有四种方式:
- 通过终端按键产生信号
- 通过系统调用函数向进程发信号
- 由软件条件产生信号
- 由硬件异常产生信号
1、由终端按键产生信号
常见的有:
ctrl + c ------ 发出 2 号信号(中断信号)
ctrl + \ ------ 发出 3 号信号(离开信号)
ctrl + z ------ 发出 20 号信号(暂停信号)
可以使用下面的程序来验证,但需要知道当前进程的 pid,否则可能会造成进程无法关闭的情况
cpp
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
void handler(int signo)
{
cout << " I get a sig:" << signo << " ___mypid: " << getpid() << endl;
}
int main()
{
for(int i = 0; i <= 64; i++) signal(i, handler);
while(1);
return 0;
}
2、调用系统函数向进程发信号
bash
// kill函数可以给一个指定的进程发送指定的信号。
// raise函数可以给当前进程发送指定的信号(自己给自己发信号)
// 这两个函数都是成功返回0,错误返回-1
#include <signal.h>
int kill(pid_t pid, int signo);
int raise(int signo);
// abort函数使当前进程接收到信号而异常终止。
#include <stdlib.h>
void abort(void);
kill 命令,也都是通过调用 kill 函数来实现的
指定发送某种信号的kill命令可以有多种写法,上面的命令还可以写成 kill -signal id 或 kill - i id , signal 是具体某个信号,i 是这个信号的编号,id 被发送信号的进程 pid
3、通过软件条件产生信号
SIGPIPE 就是管道中一种由软件产生的信号,当读端关闭,写端一直写入,OS会直接杀掉写端进程,通过向目标文件发送 SIGPIPE(13) 信号,终止目标进程,这就是一种软件条件。
还有一种软件条件,叫 "闹钟"
bash
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号,该信号的默认处理动作是终止当前进程。当在 seconds 秒之前,进程被被终止了,而信号还没有发送,它剩余的秒数就会被 "保存" 在 alarm 函数中,当下次再调用 alarm 函数时,只需将 seconds 值设置为0,表示取消以前设定的闹钟,alarm 函数的返回值就可以得到,是以前设定的闹钟时间还余下的秒数。
4、硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE 信号发送给进程。再比如当前进程访问了非法内存地址,MMU 会产生异常,内核会将这个异常解释 SIGSEGV 信号发送给进程。
CMOS 周期性的、高频率的在给CPU发送时钟中断,通过这种中断来使操作系统的各种调度方法运行起来了!
进程递达、阻塞和捕捉
实际执行信号的处理动作称为信号递达(Delivery)
信号从产生到递达之间的状态,称为信号未决(Pending)。
进程可以选择阻塞 (Block )某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达(没有恢复不阻塞的情况下),而忽略是在递达之后可选的一种处理动作。
每种信号在进程种都有两个标志位,block 和 pending(比特位的位置表示编号)还有一个函数指针表示处理动作。标志位在进程的 task_struct 中是通过位图来维护的,通过比特位来控制 "有没有"、"在不在"。例如,block 位图的最低一个比特位就表示是否对 1 号信号进行阻塞,pending 位图的最低一个比特位就表示是否收到 1 号信号。
每个信号都只有一个 bit 标志,非0即1,不记录信号产生 / 收到了多少次,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,sigset_t称为信号集,这个类型可以表示每个信号的"有效"或"无效"状态。
在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的"屏蔽"应该理解为阻塞而不是忽略。
信号集操作函数
sigset_t 类型对于每种信号用一个bit表示"有效"或"无效"状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作 sigset_t 变量,而不应该对它的内部数据做任何解释。
1、设置信号集
cpp
#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。
2、维护信号集
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
cpp
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
// 返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过 oset 参数传出(输出型参数)。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据 set 和 how 参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
如果调用 sigprocmask 解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
3、读取当前进程的信号集
cpp
#include <signal.h>
sigpending(&s);
sigpending 函数可以读取当前进程的未决信号集,通过 s 参数传出。调用成功则返回0,出错则返回-1。
信号的捕捉
进程在从内核态返回到用户态的时候,进行信号的检测和处理。
那么内核如何实现信号捕捉呢?
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。在中断处理完毕后要返回用户态的 main 函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数。sighandler 函数和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
在信号捕捉中,一共会涉及到四次状态转换!
可重入函数
可重入函数(reentrant function)是指在多个执行流下,能够被同时调用而不会产生冲突或错误的函数。
这种函数能够保证在任意时刻,无论被同一个还是不同执行流调用,都能正确地完成预期的功能。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
下图为函数不可重入的举例:
main 函数调用 insert 函数向一个链表 head 中插入节点 node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler 也调用 insert 函数向同一个链表 head 中插入节点node2,插入操作的 两步都做完之后从sighandler 返回内核态,再次回到用户态就从 main 函数调用的 insert 函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了,与预期结果不符合,因此不是可重入函数!