在之前的章节中,我们提到了进程互斥,以及进程互斥实现的几种方式,那么今天我们再来讲解一种,基于 信号量
来实现进程之间的同步、互斥的方式。
用户进程可以通过使用操作性提供的一对原语
来对信号量
进行操作,从而很方便的实现进程互斥、进程同步。
什么是信号量
信号量
可以这么简单的来理解,它其实就是一个变量,这个变量可以是一个整数,也可以是更复杂的记录型变量,可以用一个信号量来表示系统中某种资源的数量。
比如:系统中只有一台打印机,就可以设置一个初始值为 1 的信号量。
那怎么操作这个信号量
变量呢? 那么就是原语。
原语
是一种特殊的程序段,这个程序段它是能够保证原子性
操作的,执行只能一气呵成,不能被中断。至于为什么原语能够保证原子性,是因为开中断/关中断这两个指令来实现的。
知道原语的特性之后,那什么原语能够操作信号量呢? 那就是 wait(S) 原语和 signal(S) 原语,可以把原语理解成平时我们写代码的函数、方法,括号里面的信号量 S 其实就是调用函数、方法的时候,传入的一个参数。
wait、signal 原语简称为 P、V 操作,经常把 wait(S)、signal(S) 简称为 P(S)、V(S)。
那我们接下来看看两种不同类型的信号量。
整型信号量
上文有说到信号量其实就是一个变量,整型信号量顾名思义就是用一个整型的变量作为信号量,用来表示系统某种资源的数量。 那信号量变量和普通的变量有什么区别呢? 普通的变量可以做加减乘除运算等等,而对信号量的操作只有三种:初始化、P 操作、V 操作。
我们来写一段伪代码来解释一下,整型信号量是如何实现进程互斥的,并且有什么缺点。
现在假设有一台计算机中,只有一个打印机资源:
int s = 1; // 初始化整形信号量 s,表示当前系统中可用的打印机资源数量
void wait(int S){ // wait 原语,也就是 P 操作,相当于进入区
while(S <= 0); // 如果资源不够,就一直循环等待
S = S -1; // 如果资源够,则占用一个资源,
}
void signal(int S){ // sginal 原语,也就是 V 操作,相当于 退出区
S = S + 1;// 使用完资源后,在退出区释放资源
}
上面就是对应 P、V 操作中的逻辑,现在我们有 P0、P1 进程现在都需要来使用打印机,我们来看看执行过程:
P0、P1 的执行过程都是如下:
wait(S); // 进入区,申请资源
使用打印机.. // 临界区,访问资源
signal(S) // 退出区,释放资源
假设现在 P0 先执行 P 操作,那么它是可以申请到资源的,当 P0 还是临界区中访问资源,这个时候 P1 进程也开始申请资源,但是由于资源被 P0 使用了,P1 会卡在 while 循环那一直等待,一直到 P0 执行了 V 操作释放资源,P 1才可以进入临界区。
这里要注意的是,我们要记住 P、V 操作都是原语来实现的,原语是具有原子性操作的,可以解决并发的问题,来保证进程互斥。但是缺点也很明显,没有获取到资源的进程,会一直等待,不满足 让权等待
的规则。
让权等待
就是当进程访问不了临界资源的时候,需要立即释放 CPU 资源,不能一直等待着。
这个问题也就是整型信号量
和记录型信号量
最大的区别,那么接下来我们一起来看看记录型信号量
是如何解决这个问题的。
记录型信号量
为了解决整型信号量
的问题,记录型信号的变量就比较复杂一点,如下:
typedef struct {
int value; // 剩余资源数
Struct process *L; // 等待队列
} semaphore;
相比整型信号量
多了一个等待队列,这个等待队列的作用就是当进程进入不到临界区的时候,会把线程设置成阻塞状态,并且会把想要获取该资源的线程放到这个等待队列当中。
我们一起来看下具体的逻辑,我们还是用伪代码的方式讲解:
void wait(semaphore S){
S.value --;
if(S.value < 0){
block(S.L); // 把进程设置阻塞状态,并且把进程挂载到等待队列中
}
}
void signal(semaphore S){
S.value ++;
if(S.value <= 0){
weakup(S.L); // 从等待队列中唤醒一个进程
}
}
对信号量 S 的一次 P操作,就意味着进程请求一个单位的该类资源,因此需要执行 S.value-- ,表示资源数量 -1,当 S.value < 0 的时候,表示该类资源已经分配完成,因此该进程调用 block 原语进行自我阻塞,从运行态转换为阻塞态,主动放弃 CPU 执行权,并且放入到 S.L 的等待队列中。 这样就能解决整型信号量
的让权等待问题。
当使用完临界资源之后,对信号量 S 进行一个 V 操作,释放一个单位的该类资源,因此需要执行 S.value++,表示该类资源数量 +1,如果 +1 之后 S.value 的数量还是 <= 0,表示还有进程正在等待该类资源的释放,因此需要调用 wakeup 原语唤醒等待队列中的一个进程,从阻塞态转换为就绪态。
使用信号量实现进程互斥
使用信号量实现互斥的方式其实上面也简单说过了,那么我们再来总结一下,
在上文也有提到,信号量可以代表一种临界资源的数量,那么在操作系统里面有那么多资源,我们需要对不同的临界资源设置不同的互斥信号量。
比如说还是以打印机的资源为例子:我们先设置一个打印机资源的信号量,代码如下:
semaphore mutex = 1 ; // 定义一个信号量
p1(){
p(mutex) // 申请资源
临界区代码...
v(mutex) // 释放资源
}
p2(){
p(mutex) // 申请资源
临界区代码...
v(mutex) // 释放资源
}
以上面的代码为例子,当 p1 先执行代码,获取了临界资源,在这个时候 p2 申请资源是会被阻塞起来的,一直阻塞到 p1 释放了资源,p2 才可以接着继续运行,这样就能实现进程互斥。
这里需要注意的是,P、V 操作必须成对出现,如果缺少 P 操作就不能保证临界区资源互斥访问,缺少 V 操作会导致资源永远不被释放,等待进程永远不会被唤醒。
那么接下来,我们再来看看信号量如何做同步操作。
使用信号量实现进程同步
同步其实也很好实现实现,我们在初始化信号量的时候,初始化的值要是为 0,然后先执行 V 操作,后执行 P 操作,这样就能实现进程同步。 我们还是来看个代码例子:
semaphore s = 0 ; // 定义一个信号量,初始化为0
p1(){
代码1
代码2
v(s)
代码3
}
p2(){
p(s)
代码4
代码5
代码6
}
假设现在我们要实现:代码4一定要在代码2后面执行,保证执行顺序,我们来观察一下上面这段代码:
假设我们现在是 P2 先开始执行,但是由于 s 初始化是0,所以 p2 在执行 P 操作的时候,会被阻塞,一直等到 P1 执行了 V 操作,P2 才可以被唤醒执行,这样就能保证 代码4 一定是在 代码2 后面执行的。
我们只要记住:前 V 后 P 的操作,就能使用信号量实现进程同步。