对于之前所提到的生产者消费者问题,采用共享内存解决生产者消费者问题时,N个缓冲区最多只能用N-1个,那么为什么有一个是用不了的呢?这是因为在判断缓冲区空或满时,用取余计算实现的,之所以牺牲一个位置是为了让缓冲区空和缓冲区满两种状态有两种不同的表达式,若是换一种方法,设置一个计数变量 count
,count
的值表示当前缓冲区已经使用的容量,count=0
表示缓冲区空,count=BUFFER_SIZE
表示缓冲区满(BUFFER_SIZE
为缓冲区大小),这样就解决了牺牲一个缓冲区容量的问题,如下图:
下面是实现生产者添加商品的伪代码,若不能理解该过程可以查阅数据结构中循环队列的内容:
// 生产者调用的方法
public void enter(Object item) {
// 缓冲区已经满无法继续添加
while (count == BUFFER_SIZE) ;
// 添加一个商品到缓冲区
buffer[in] = item;
in = (in + 1) % BUFFER_SIZE;
count++; // 计数加一
}
下面是实现消费者消费商品的伪代码:
// 消费者调动的方法
public Object remove() {
Object item;
// 当前缓冲区没有商品,无法消费
while (count == 0) ;
// 从缓冲区移除一个商品
item = buffer[out];
out = (out + 1) % BUFFER_SIZE;
count--; // 商品总数减一
return item;
}
上述思想看起来是没有问题的,但是在实现过程中会出现问题,问题就出在 count
的自增和自减操作,由于count++
和count--
属于高级指令,所以在机器执行过程中是分为三个步骤的,中间还有一步转交给寄存器的操作,如下图:
所以当一条指令分解成三个指令时,多个程序并发时就会出现问题,现假设count=5
,若生产一个商品,再消费一个商品,最后count
的值还是等于5
,可在并发执行时就不一定会这样,见下面的例子(注:以下例子只是情况的一种,在并发执行时会有非常多的不确定性),最终count
的值等于4
:
由以上的例子可知,对共享数据的并发访问可能导致数据的不一致性,要保持数据的一致性,就需要一种保证并发进程的正确执行顺序的机制,解决有界缓冲区问题的共享内存方法在类数据count
上存在竞争条件。
竞争条件:
- 若干个并发的进程(线程)都可以访问和操纵同一个共享数据,从而执行结果就取决于并发进程对这个数据的访问次序;
- 为了保证数据的一致性,需要有同步机制来保证多个进程对共享数据的互斥访问。
进程的类型分为两种,一种是独立进程,它独立执行,不受其他进程的影响;另一种就是协作进程,如刚刚所讲的生产者和消费者问题,这两个进程就属于协作进程。进程间资源访问冲突也分为两种,一种是共享变量的修改冲突,如上面的count
值,另一种则是操作顺序冲突,比如某个作业A,需要作业B提供数据进行计算,若B提供数据,那么A会受到影响。
进程间的制约关系分为如下两种:
由于多个进程相互竞争资源,若各个进程互不相让,此时就会发生死锁想象。
临界资源即共享资源,对于临界资源,多个进程必须互斥的对它进行访问,在进程中某些代码会访问到临界资源,这段代码就叫做临界区 (critical section),即进程中访问临界资源的一段代码,实现进程对临界资源的互斥访问就是让各进程互斥的进入自己的临界区,也就是说当某个进程在临界区中执行时,其他进程都不能访问自己临界区,这样就保证了某个时间内只有一个进程在临界区内使用临界资源,这样就实现了临界资源的互斥访问。
临界区的执行在时间上是互斥的,进程必须请求允许进入临界区,也就是说当某个进程想进入临界区时,比如进行某种操作来判断当前临界区是否有进程在执行,在具体实现时也是利用代码来判断的,整个进程的访问过程分为以下三个区:
临界区互斥问题的解决方案要满足如下三个要求:
如何实现进程间的互斥?这里举一个现实中游乐园的滑滑梯例子,滑滑梯一次只能进一个小朋友,当有很多小朋友想要玩的时候,那么一个解决办法是让他们轮流来玩,另一个解决办法是提出想玩滑滑梯申请。在解决进程间的互斥问题时,也是借助了这两个思想,这里介绍两种算法。
算法1:设立一个两进程公用的整型变量 turn
,用来描述允许进入临界区的进程标识有两个进程Pi,Pj
, 如果 turn==i
,那么进程 Pi
允许在其临界区执行,即采用轮流的方式,用turn
表示当前运行哪个进程进入临界区。
Pi
进入临界区的伪代码如下:
while (turn != i); // 判断是否轮到 Pi
critical section; // 执行临界区
turn = j; // 执行完临界区就轮到下一个 Pj
remainder section; // 执行剩余区
Pj
进入临界区的伪代码如下:
while (turn != j); // 判断是否轮到 Pj
critical section; // 执行临界区
turn = i; // 执行完临界区就轮到下一个 Pi
remainder section; // 执行剩余区
对于之前提到的临界区互斥问题的三个要求,该算法显然是满足第一个互斥要求的,实际上该算法是强制轮流进入临界区,没有考虑进程的实际需要,若 Pi
执行完临界区,turn
也转交给了Pj
,但此时Pj
不需要使用临界区,这时临界区处于空闲状态,但turn
这时不属于Pi
,所以Pi
依然无法执行临界区,容易造成资源利用不充分,所以不满足第二个要求有空让进,也不满足第三个要求有限等待。
算法2:由于算法1 只记住了哪个进程能进入临界区,没有保存进程的状态,设立一个标志数组 flag[]
,用来描述进程是否准备进入临界区,初值均为 FALSE
,先申请后检查 。可防止两个进程同时进入临界区。
Pi
进入临界区的伪代码如下:
flag[i] = TURE; // Pi 申请执行临界区
while (flag[j]); // 判断 Pj 是不是在执行临界区或它也想执行临界区
critical section;
flag[i] = FALSE; // Pi 执行完临界区,撤销之前的申请
remainder section;
Pj
进入临界区的伪代码如下:
flag[j] = TURE; // Pj 申请执行临界区
while (flag[i]); // 判断 Pi 是不是在执行临界区或它也想执行临界区
critical section;
flag[j] = FALSE; // Pj 执行完临界区,撤销之前的申请
remainder section;
该算法显然满足互斥要求,因为每次执行临界区前都会判断对方是否在执行临界区或是否也想进入临界区;设想某个时刻 Pi
和 Pj
都申请执行临界区,这样会导致双方谁也不能执行临界区,所以不满足有空让进的要求,算法2对比算法1的优点是不用交替进入,可连续使用,不用等待对方,缺点就是两进程可能都进入不了临界区。
算法3:在算法2的基础上进一步改进,同样是要先申请执行临界区,但要把turn
改为对方,然后再进行检查若当前对方在执行临界区或对方想要执行临界区且turn
也是对方,那么就要等待对方执行完。
Pi
进入临界区的伪代码如下:
flag[i] = TURE; // Pi 申请执行临界区
turn = j; // 让 Pj 下次执行
while (flag[j] && turn = j); // 判断 Pj 是不是在执行临界区或它也想执行临界区且当且turn为它
critical section;
flag[i] = FALSE; // Pi 执行完临界区,撤销之前的申请
remainder section;
Pj
进入临界区的伪代码如下:
flag[j] = TURE; // Pj 申请执行临界区
turn = i; // 让 Pi 下次执行
while (flag[i] && turn = i); // 判断 Pi 是不是在执行临界区或它也想执行临界区且当且turn为它
critical section;
flag[j] = FALSE; // Pj 执行完临界区,撤销之前的申请
remainder section;
算法3解决了算法1和算法2的缺点,同时算法3具有先到先入,后到等待的特点。
可以用临界区解决互斥问题,它们是平等进程间的一种协商机制,之前所提到的轮流和申请都是基于两个进程的临界区模型所提出来的,当进程数目过多的时候,显然要引入新的机制来解决互斥问题,操作系统可从进程管理者的角度来处理互斥的问题,信号量 (Semaphore) 就是操作系统提供的管理公有资源的有效手段。
信号量是在1965年,由荷兰学者Dijkstra提出(所以P、V分别是荷兰语的test (proberen)、increment (verhogen)),是一种卓有成效的进程同步机制。用于保证多个进程在执行次序上的协调关系的相应机制称为进程同步机制。
信号量是一个整型变量,代表信号量代表可用资源实体的数量。除了初始化之外,仅能通过两个不可分割的原子操作访问,即P(S)
和V(S)
,简称为P,V操作。
原子操作:指的是操作系统内最小的操作单位,它的执行时不可中断的。
由于S
代表当前可用资源的数量,当 S <= 0
时,会一直等待资源,所以存在忙等现象,又称自旋锁,此时CPU的利用率是不高的,伪代码如下:
P(S); // 申请资源
while (S <= 0); // 当前没有可用资源就要一直等待
S--; // 若有资源,就要总资源数减一
V(S); // 使用完资源要释放资源
S++; // 释放资源
为了解决忙等现象,引入了一种不需要忙等的方案,它将 S--
操作提前了,先减再判断 S
的值,若判断的 S < 0
,就让进程进入阻塞状态(通常是设置一个阻塞进程队列),在释放资源时,若 S >= 0
,则要唤醒阻塞的进程,伪代码如下:
P(S); // 申请资源
S--; // 总资源数减一
if (S < 0) {
block; // 若当前无可用资源,则将该进程阻塞
}
V(S); // 使用完资源要释放资源
S++; // 释放资源
if (S >= 0) {
wakeup; // 若当前有可用资源,则将之前阻塞的进程唤醒
}
S是与临界区内所使用的公用资源有关的信号量:
一般来说初始化指定一个非负整数值,表示空闲资源总数,在信号量经典定义下,信号量S的值不可能为负,后面的定义下可能为负,因为后面的定义是先做 S--
:
S≥0
表示可供并发进程使用的资源数;S<0
其绝对值就是正在等待进入临界区的进程数。在用信号量解决问题的时候,首先要分清楚这个问题是个同步问题,还是一个互斥问题,若是一个互斥问题,那么就要找到互斥的临界资源是什么,并把临界资源抽象成信号量,然后给信号量赋初值并给出正确的P,V操作。
问题描述:(由Dijkstra首先提出并解决)5个哲学家围绕一张圆桌而坐,桌子上放着5支筷子(注意是5支而不是5双),每两个哲学家之间放一支;哲学家的动作包括思考和进餐,进餐时需要同时拿起他左边和右边的两支筷子,思考时则同时将两支筷子放回原处。如何保证哲学家们的动作有序进行?如:不出现相邻者同时进餐,问题模型如下图:
先考虑该问题是一个同步问题还是一个互斥问题,显然它是一个互斥问题,那么它的临界资源就是筷子,把临界资源抽象成信号量为 Semaphore chopStick[] = new Semaphore[5];
,即一个容量为5的数组。
哲学家思考和进餐的过程如下面的伪代码:
Repeat
思考;
取chopStick[i]; // 一根筷子
取chopStick[(i+1) mod 5]; // 取旁边一根筷子
进餐;
放chopStick[i]; // 放回筷子
放chopStick[(i+1) mod 5]; // 放回筷子
Until false;
用信号量表示的伪代码如下:
while (true) {
// 取左边的筷子
chopStick[i].P();
// 取右边的筷子
chopStick[(i + 1) % 5].P();
// 进餐
// 放回左边的筷子
chopStick[i].V();
// 放回右边的筷子
chopStick[(i + 1) % 5].V();
// 思考
}
用信号量实现保证了互斥,但是这种实现下可能会出现死锁,当五个哲学家每人拿起了他左边的筷子,则桌子上筷子全部被拿完了,而没有一个哲学家凑齐了两支筷子,解决这个死锁的方法有如下几种:
问题描述:若干进程通过有限的共享缓冲区交换数据。其中,"生产者"进程不断写入,而"消费者"进程不断读出;共享缓冲区共有N个;任何时刻只能有一个进程可对共享缓冲区进行操作,问题模型如下图:
由于进程之间是共享了临界资源,所以他们之间肯定是互斥关系,所以要设置临界区保证进程间互斥访问,由于生产者生产商品给消费者使用,他们之间也存在着同步关系。
缓冲区的大小是固定为N
,当缓冲区满的时候,生产者是不能再生产商品的,当缓冲区为空的时候消费者是不能消费商品的,我们可以抽象以下变量:
0
;N
;1
。每生产一个商品就要进行 full++
操作,每消费一个商品就要进行 empty++
操作,full
和 empty
满足关系式 full + empty = N
。对于生产者来说它一开始要生产商品放到缓冲区里面,而缓冲区是互斥的,生产的时候还要看缓冲区里面是否还有空位,有空位才能够生产,所以对应的有两对P,V操作,一对是关于互斥信号量 mutex
的操作,一对是关于资源信号量 empty
的操作。在实现的时候要注意每个进程中各个P操作的次序是非常重要的。应先检查资源数目,再检查是否互斥,否则可能出现死锁。
对于生产者操作的伪代码如下;
P(empty); // 申请空位 empty--
P(mutex); // 申请占用缓冲区
// 生产一个商品放入缓冲区
V(mutex); // 释放占用的缓冲区
V(full); // 添加商品 full++
对于消费者操作的伪代码如下:
P(full); // 申请消费一个商品 full--
P(mutex); // 申请占用缓冲区
// 消费缓冲区中的一个商品
V(mutex); // 释放占用的缓冲区
V(empty); // 增加一个空位 empty++
用信号量表示生产者的伪代码如下:
public void enter(Object item) {
empty.P(); // 申请缓冲区中的一个空位
mutex.P(); // 申请占用缓冲区
// 添加一个商品到缓冲区
buffer[in] = item;
in = (in + 1) % BUFFER_SIZE;
mutex.V(); // 释放占用缓冲区
full.V(); // 增加一个商品
}
用信号量表示消费者的伪代码如下:
public Object remove() {
full.P(); // 申请消费一个商品
mutex.P(); // 申请占用缓冲区
// 从缓冲区消费一个商品
Object item = buffer[out];
out = (out + 1) % BUFFER_SIZE;
mutex.V(); // 释放占用的缓冲区
empty.V(); // 增加一个空位
return item;
}
问题描述:对共享资源的读写操作,任一时刻“写者”最多只允许一个,而“读者”则允许多个。
读写问题存在以下三种关系:
那么可以从两个方面来考虑这个问题,即有读者来会怎么样和有写者来会怎么样,当有读者来的时候:
当写者到来时:
总结来说写者是更任何人互斥的,读读是允许的,并且可以发现只有第一个和最后一个读者是会影响写者的,那么如何知道哪个读者是第一个来的,哪个读者是最后一个走的呢?我们的解决方法是设置一个变量来统计读者的个数,初值可以设为 0
,来一个读者就加一,走一个读者就减一,这里引入一个共享变量必然会成为临界资源,对于这个临界资源时肯定要对它进行保护的,采用的采用信号量机制如下:
Wmutex
表示"允许写",初值是1
;Rcount
表示“正在读”的进程数,初值是0
;Rmutex
为了保护临界资源Rcount
,它表示对Rcount
的互斥操作,初值是1
。写者的操作伪代码如下:
P(Wmutex); // 申请写信号量
write; // 写
V(Wmutex); // 释放写信号量
读者的操作相对复杂,其伪代码如下:
P(Rmutex); // 申请对 Rcount 的使用
if (Rcount == 0) {
// 当前读者是第一个读者
// 若允许他读,则要不允许后来的读者写
// 要将写操作的信号量做 P 操作
P(Wmutex);
}
++Rcount; // 读者数加一,上下对 Rmutex 的P,V操作实际上是为了保护 Rcount
V(Rmutex);
…
read; // 读
…
P(Rmutex);
--Rcount; // 读完之后读者数减一,上下对 Rmutex 的P,V操作实际上是为了保护 Rcount
if (Rcount == 0) {
// 当前读者是最后一个离开的读者
// 此时应该释放写操作,对写操作做 V 操作
V(Wmutex);
}
V(Rmutex);
信号量S
为一个整型的变量,它描述的是当前可用资源的数目,当 S > 0
时表示有S个资源可用,当 S = 0
时表示无资源可用,当 S < 0
则 ∣ S ∣ | S | ∣S∣表示 S
等待队列中的进程个数,P(S)
表示申请一个资源,V(S)
表示释放一个资源,信号量的初值应该大于等于0。
P,V操作必须成对出现,有一个P操作就一定有一个V操作,且有以下规律:
对于前后相连的两个P(S1)
和P(S2)
,顺序是至关重要的,同步P操作应该放在互斥P操作前。