经典PV问题系列二:经典详解

上一节讨论了计算机解决互斥问题的方法,这一节我们将正式讨论各种PV问题。首先给出信号量和PV操作的定义:

struc semaphore
{
	int count;
	queueType queue;
}
// 对信号量可以实施的操作:初始化、P和V(P、V分别是荷兰语的test(proberen)和increment(verhogen))
P(s)
{
	s.count --;
	if (s.count < 0)
	{
		该进程状态置为阻塞状态;
		将该进程插入相应的等待队列s.queue末尾;
		重新调度;
	}
}
V(s)
{
	s.count ++;
	if (s.count < = 0)
	{
		唤醒相应等待队列s.queue中等待的一个进程(通常从队首取);
		改变其状态为就绪态,并将其插入就绪队列;
	}
}

理解:通常,信号量的取值可以解释为:S值的大小表示某类资源的数量。当S>0时,表示还有资源可以分配;当S<0时,其绝对值表示S信号量等待队列中进程的数目。每执行一次P操作,意味着要求分配一个资源;每执行一次V操作,意味着释放一个资源。

注意:
1、对信号量的操作只有初始化、P、V三种,内部的count和queue是透明的,不能读count或操作queue,所以我们经常看到再设置一个int count变量来跟踪信号量的值内部count值。 
2、我们需要明白的是:每一个信号量都有一个等待队列。如果进程在P操作中被设置为阻塞状态,则该进程被挂在这个P操作的信号量的队尾。在这里我们相当于使用了sleep函数的功能和wackup函数的功能,本节我们不去详细讨论这两个函数的实现,因为这一节中我们主要关注如何用PV解决互斥同步问题,而不是操作系统如何设置等待队列。 
3、P操作和V操作均为原语,即在执行过程中不允许被中断。原语可以通过屏蔽中断、测试与设置指令等来实现。
4、存在其他合理定义P、V的方法。以下解题均使用上述P、V的定义。

1、普通进程间互斥

令S初值为1

进程A
P(S);
临界区操作;
V(S);
进程B
P(S);
临界区操作;
V(S);

2、简单进程间同步

考虑AB两个进程,B进程必须在A进程之后进行,这就是简单的进程间同步。我们考虑B是消费者,A是生产者,缓冲区只有一个,其实这样就等价为了缓冲区只有一个的生产者消费者问题。这也是经典的让两个函数轮转的同步方法,在很多问题中都有变形应用。
设置两个信号量S1和S2,初值均为0。S1表示缓冲区是否装满信息,S2表示缓冲区中信息是否取走。

进程A
把消息送入缓冲区
V(S1);
P(S2);
// 可以将上句放入在开头,S2初值为1
进程B
P(S1);
把信息从缓冲区取走;
V(S2);

再考虑三个进程:A从输入设备上不断读数据,并放入缓冲区B1;B从缓冲区B1的内容复制到B2;C从缓冲区B2的内容放入打印机打印。
设置四个信号量S1:1 S2:0 S3:0 S4:1。

进程A
P(S1);
从输入设备读入到B1
V(S2);


进程B
P(S2);
P(S4);
从B1复制到B2
V(S1);
V(S3);
进程C
P(S3);
从B2复制到打印机
V(S4);



3、生产者-消费者问题(有界缓冲区问题)

问题描述:多个生产者生产某种类型的产品放置在缓冲区中,每次送一个产品,如果缓冲区满则等待;多个消费者从缓冲区中取数据,每次取一项,如果缓冲区空则等待。同一时间只能有一个生产者或消费者对缓冲区进行操作。

首先应该明白,解决这个问题的方法决不止以下这一种,但以下介绍的方式是最直接的。其他间接方式比如:利用P、V模拟管程,然后用管程解决生产者-消费者问题,其本质也是用PV解决问题。以下所有问题均同理。

解决同步问题:生产者不能往“满”的缓冲区中放产品,设置信号量empty,初值为n,用于指示空缓冲区的数目。消费者进程不能从“空”缓冲区中取产品,设置信号量full,初值为0,用于指示满缓冲区的数目。
解决互斥问题:设置信号量mutex,初值为1,用于实现缓冲区和缓冲区内产品数量的互斥(同一时间只能有一个生产者或消费者对缓冲区进行操作)

生产者进程
生产产品;
P(empty);
P(mutex);
往缓冲区放入产品;
V(mutex);
V(full);
消费者进程
P(full);
P(mutex);
从缓冲区取产品
V(mutex);
V(empty);
消费产品;

假设将生产者代码中两个P操作交换一下顺序,使得mutex的值在empty之前而不是之后被P。如果缓冲区完全满了,生产者将阻塞,mutex值为0,。这样一来,当消费者下次试图访问缓冲区时,它先对full执行P操作,成功,接着它又对mutex执行P操作,由于mutex值为0,则消费者也被挂在mutex的阻塞队列上。所有生产者和消费者进程都将阻塞在此。这种情况就是死锁(dead lock)。

我们可以看到,当消费者取产品时,生产者不能放产品;生产者放产品时,消费者也不能取产品。 mutex 锁是针对整个缓冲区而言的。这样做也许会比较低。我们考虑以下做法。

semaphore empyt = N, full = 0, mutexP = 1, mutexC = 1;
void Producer()
{
	生产产品;
	P(empty);
	P(mutexP);
	往缓冲区buffer[pi]放入产品;
	pi = (pi+1) % N;
	V(mutexP);
	V(full);
}
void Customer()
{
	P(full);
	P(mutexC);
	从缓冲区buffer[ci]取产品;
	ci = (ci+1) % N;
	V(mutexC);
	V(empty);
}

我们分别用互斥量 mutexP 和 mutexC 来保护 pi 和 ci 指针,如果 pi != ci ,则程序显然可以正常工作。若 pi == ci ,则要么缓冲区为空,此时 full = 0,消费者会被阻塞在 full 上;要么缓冲区为满,此时 empty = 0,生产者会被阻塞在empty上。代码可以正常工作,而且增大了并行性。

4、信号量和P、V操作小节

到目前为止,我们接触的依然是比较简单的同步互斥问题。信号量和PV操作的表达能力极强,理论上可以解决任何进程的同步互斥问题,但通常PV操作使用时不够安全,容易出现死锁,面对复杂问题时用PV操作实现也很复杂。我们在此总结一下信号量和PV操作的使用方法,以便应对更复杂的问题。这些结论是所有情况通用的。

P、V操作在使用时必须成对出现,有一个P操作就一定有一个V操作。当为互斥操作时,他们同处于同一进程;当为同步操作时,则不在同一进程中出现。

如果进程中P(S1)和P(S2)两个操作在一起,那么P操作的顺序至关重要,尤其是一个同步P操作与一个互斥P操作在一起时,同步P操作应出现在互斥P操作前。而两个相邻V操作的顺序则无关紧要。

5、第一类读者-写者问题

读者-写者问题描述:多个进程共享一个数据区,这些进程分为两组:读者进程:只读数据区中的数据。写者进程:只往数据区写数据。要求满足条件:允许多个读者同时执行读操作;不允许多个写者同时执行写操作;不允许读者、写者同时操作。

以上被称为读者-写者问题定义。如果是第一类读者-写者问题,则是在此基础需要满足读者优先。读者优先的思想是除非有写者正在写文件,否则没有一个读者需要等待。另一个第二类读者-写者问题,即写者优先,其思想是一旦一个写者到来,它应该尽快对文件完成写操作。换句话说,如果有一个写者在等待,则新到来的读者不允许进行读操作。显然这两类读者-写者问题都会导致“饥饿”现象,或者是写者饥饿,或者是读者饥饿。

我们先来详细分析第一类读者-写者问题。(详细分析各种情况是非常有用的,就好比弄清产品经理的项目需求是多么重要一样)
如果读者到:无读者、写者,新读者可以读;有写者等,但有其它读者正在读,则新读者也可以读;有写者写,新读者等。
如果写者到:无读者,新写者可以写;有读者,新写者等待;有其它写者,新写者等待。

解决方案:设rc记录当前正在读的读者进程个数,由于多个读者会对rc进行修改,所以rc是一个共享变量,需要互斥使用,故设置信号量mutex,初始为1。在设置信号量write,用于写者之间互斥,或第一个读者和最后一个读者与写者的互斥,初始为1。

void reader(void)
{
	P(mutex);
	rc = rc + 1;
	if (rc == 1) P (write);
	V(mutex);
	读操作;
	P(mutex);
	rc = rc - 1;
	if (rc == 0) V(write);
	V(mutex);
}
void writer(void)
{
	P(write);
	写操作;
	V(write);
}

假设读者先到来,则第一个读者拿了write锁,之后的所有读者均可畅通无阻的读,而所有写者被挂在write上,直到所有读者均读完释放write锁。当一个写者已经进入临界区执行写操作时,若有n个读者在等待,则第一个读者等在信号量write上,其余读者(n-1个)等在信号量mutex上排队。当一个写者执行V(write)后,可能释放一个写者,也可能释放若干读者,取决于谁等在前面。

6、第二类读者-写者问题

这将是一个相当有难度的PV问题,里面的一些细节和trick需要仔细品味。

同理,我们也先来详细分析一下问题需求:
如果读者到:无读者、写者,新读者可以读;有写者等,读者必须等待所有等待的写者完成写操作后才能读;有写者正在写,新读者等。
如果写者到:无读者,新写者可以写;有读者正在读,新写者等待;有其它写者正在写,新写者等待;有其他读者等待,写者优先。

int readcount, writecount; //(initial value = 0)
semaphore mutex_rdcnt, mutex_wrcnt, mutex_3, w, r; //(initial value = 1)
 
//READER
  P(mutex_3);
  P(r);
  P(mutex_rdcnt);
  readcount++;
  if (readcount == 1)
      P(w);
  V(mutex_rdcnt);
  V(r);
  V(mutex_3);
 
 // reading is performed
 
  P(mutex_rdcnt);
  readcount--;
  if (readcount = 0) 
      P(w);
  V(mutex_rdcnt);
 
//WRITER
  P(mutex_wrcnt);
  writecount++;
  if (writecount = 1) 
      P(r);
  V(mutex_wrcnt);
 
  P(w);
   // writing is performed
  V(w);
 
  P(mutex_wrcnt);
  writecount--;
  if (writecount = 0) 
      V(r);
  V(mutex_wrcnt);

对于读者, mutex_rdcnt 和 w 的功能分别对应第一类读者写者问题中的 mutex 和 w 信号量。w 对应于规则“一旦有一个读者正在读,则第一个读者拿掉 w 锁,以禁止写者写”,同理 r 对应于规则“一旦有一个写者正在写,则第一个写者拿掉 r 锁,以禁止读者读”。而 mutex_rdcnt 和 mutex_wrcnt 分别用于保护 readcount 和 writecount 变量。另外,WRITER 区别于 READER 的一点是:在写操作前 WRITER 要拿 w 锁,而 READER 不需要。这条规则对应于“读操作可以同时进行,而写操作之间必须互斥”。不过,无论 READER 还是 WRITER ,在“读操作区”和“写操作区”的之前和之后都有那五行相似的代码,用于实现读操作和写操作之间的互斥。

那为什么第一类读者-写者问题中 WRITER 不需要前后那五行的代码,只需要拿掉 w 锁呢? WRITER 仅拿掉 w 锁固然能实现读者和写者的互斥,但是只要有一个读者先拿了 w 锁,写者就算比读者先到,写者也会被阻塞在 w 上,而后来的读者畅通无阻,直到没有读者到来时写者才能拿到 w 锁,这就是所谓的“读者优先”。为了实现“写者优先”,第一个写者必须主动拿掉读者的 r 锁,让后来的读者被阻塞,之后无论读者先来还是写者先来,都优先让写者依次执行。在代码中体现为:对 READER 先检查r锁,如果有一个 WRITER 拿了就一直阻塞;对 WRITER 先拿下 r 锁再说,之后用 w 锁保证写者的依次执行。注意无论读者群还是写者群都是先 P(r) 再 P(w) ,保证了没有死锁。

在此我们一直都没有讨论 mutex_3 ,这个信号量是必要的因为我们绝对地坚持写者优先策略。mutex_3 保证了 READER 取放 r 锁的原语性。如果 READER 在读操作或 WRITER 在写操作的过程中到来读写序列,不用 mutex_3 依然可以满足第二类读写问题的需求。但是如果 READER 在取完 r 锁未放回的过程中到来读写序列,则大量读写序列阻塞在 r 锁上,先阻塞的读者会使后来的写者无法先获得 r 锁,这样就无法满足“写者优先”的性质了。加入 mutex_3 锁使得 r 锁要么被读者拿走,而上面挂的是一个写者,其它读者被挂在外面的 mutex_3 锁上;要么被写者拿走,最多只有一个读者挂在上面,而下次释放 r 锁的时候就是 writecount 为0的时候了。

注意我们为什么反对以上情形(如果 READER 在取完 r 锁未放回过程中到来读写序列)中根据队列顺序获得 r 锁。在以上情形中,假设到来的读写序列是:-写-读-读-写-读-写-读。如果是正确的第二类读者写者问题,则解决方式是:写-写-写-(读-读-读-读),括号表示可并行。如果不加 mutex_3 锁,则在上述情形中会出现-写-(读-读)-写-(读)-写-(读)的情况。这样严重影响了并行性。无论是第一类还是第二类读写者问题,虽然都有饥饿现象,但是根据贪心策略,总体并行效率都是最高的。写者对读者群的分割使得总体的效率降低,这违背了我们问题需求的初衷。

7、第三类读者-写者问题

紧接上题,如果我们忽略掉最大的效率,一定要保证没有一个读写者饥饿呢?这就是第三类读者-写者问题。当然这类问题实际应用没有第二类广:如果一个对一个条目进行了修改,那么一般期待读到新修改后的条目,而不是旧的;况且第二类读者写者解法可以保证最大的并行度。

semaphore mutex1 = 1;
semaphore w = r = 1;
int rc= 0;
Reader:
while (TRUE) {
	P(r);
	P(mutex1);
	rc= rc+ 1;
	if (rc== 1) P (w);
	V(mutex1);
	V(r);
	读操作
	P(mutex1);
	rc= rc-1;
	if (rc== 0) V(w);
	V(mutex1);
}
Writer:
while (TRUE) {
	P(r);
	P(w);
	V(r);
	写操作
	V(w);
}

对于上述Writer,也可以将 V(r) 放置于 V(w) 之后。但相当于扩大了临界区。

8、睡眠理发师问题

问题描述:理发店里有一位理发师,一把理发椅和N把供等候理发的顾客坐的椅子。如果没有顾客,则理发师便在理发椅上睡觉。当一个顾客到来时,他必须先唤醒理发师;如果顾客到来时理发师正在理发,则如果有空椅子,可坐下来等;否则离开。试用P、V操作解决睡眠理发师问题。

Semaphore barberReady = 0
Semaphore mutex = 1     // 用于对customers数量的互斥
int count = 0     // 记录等待客户的数量
Semaphore custReady = 0         // 记录等待客户的信号量
 
void Barber()
{
	while (true)
	{
		P(custReady);	// 尝试获得一个顾客,否则等待
		P(mutex);
		count--;
		V(mutex);
		V(barberReady);	// 理发师准备好了
		// 剪发
	}
}
 
void Customer()
{
	P(mutex);
	if (count != N)	// If there are any free seats
	{
		count++;
		V(mutex);
		V(custReady);	// 将准备好的客户数目加一,可能唤醒理发师
		P(barberReady)         // 等待直到理发师准备好
		// 剪发
	}
	else
	{
		V(mutex);
		// 离开
	}
}

有没有觉得有点像生产者-消费者问题:理发师生产出一个barberReady被Customer拿走,Customers生产出一个custReady被理发师拿走。

这个问题的答案并不算很复杂,在这里不再多讨论。我们可以继续考虑睡眠理发师问题的拓展:即理发店里不仅有一个理发师,而是有多个理发师,都可以分别有睡眠或给某位客户理发。在wiki里,只是简单地提了这样一句话:"A multiple sleeping barbers problem has the additional complexity of coordinating several barbers among the waiting customers."。

这句话可以这样理解:如果只有一个理发师,那么我们说“客户在理发”一定是在被这一个理发师理发;如果有多个理发师,我们必须解释清楚“客户在被哪一个理发师理发”。在生产者-消费者问题中其实有同样的问题:缓冲区数目为N,消费者取哪个产品呢?生产者放到哪个空槽里呢?我们不讨论上述这些细节的原因是:这些问题不涉及同步和互斥。对于生产者和消费者问题,可以采取以下方式:设置一个大小为N的环形缓冲区,生产者和消费者各自拥有一个同向移动的指针分别用于取放数据,那么只要我们保证了对缓冲区操作的互斥,就保证了这些细节的互斥。

那么对于睡眠理发师问题,我们同样可以设置一个大小为N的环形缓冲区,客户和理发师们各有一个指针,每次客户对count加减之后,都将自己信息放入缓冲区并移动指针;每次理发师在对count操作后也可以从自己指针相应位置取信息。而mutex已保证了这些操作的互斥性。只需加上缓冲区机制和多开几个理发师进程,就是睡眠理发师问题拓展的答案了。

9、哲学家就餐问题

wiki上已有足够详细的说明。服务生解法可认为采取的策略是:仅当一哲学家左右两边的筷子都可用时,才允许他抓起筷子。书中提到另一种解决方法是:让所有哲学家顺序编号。对于奇数号的哲学家必须先抓起左边的筷子,然后抓起右边的;而对偶数号哲学家则反之。

10、吸烟者问题

问题描述:三个吸烟者在一间房间内,还有一个香烟供应者。吸烟者负责制造并抽掉香烟,他们需要三样东西:烟草、纸和火柴。供应者有丰富的货物提供。三个吸烟者中,第一个有无限的自己的烟草,第二个有无限的自己的纸,第三个有无限的自己的火柴。供应者将两样东西放在桌子上,允许一个吸烟者进行对健康不利的吸烟。当吸烟者完成吸烟后唤醒供应者,供应者再放两样东西(随机地)在桌面上,然后唤醒另一个吸烟者。吸烟者不会囤积不属于自己的货物。

我们可以把供应者看作操作系统,它拥有资源;把吸烟者看作应用程序。对于每个资源,它什么时候可利用通常是不确定的,我们可以认为相当于随机产生资源。资源就绪后操作系统会进行资源的发放,我们期待可以利用资源的应用进程会被唤醒。

这个问题有三个版本:1、不可能解决的版本。Patil对这个问题添加了两个限制:不允许修改操作系统的代码、不允许使用额外的条件状态和信号量数组。第二个限制太严格,以至于后来被证明不可能存在一种解法。

2、无聊的版本。如果去掉了这两个限制,问题将变得非常简单。而在很多中文的资料中所讨论的恰是这个版本。

Semaphore A[3] = 0, T = 1;	// T表示table,A数组表示三个吸烟者
void Arbiter()
{
	while(TRUE)
	{
		P(T);
		// 放在桌上两种原料
		V(A[k])	// 唤醒持有另一种原料的吸烟者k
	}
}
void Smoker()
{
	while(TRUE)
	{
		P(A[k]);
		// 从桌上制作香烟
		V(T);
		// 吸烟
	}
}

3、有趣的版本。我们只对问题添加第一个限制:不允许修改操作系统的代码。这个限制是合理且实用的,因为你不能因为多一个特定应用程序就修改操作系统。这个问题之所以比较难,是因为以下代码是不工作的。

semaphore T = 1, bobacco = 0, paper = 0, match = 0;
void Arbiter()
{
	while(TRUE)
	{
		P(T);
		随机i,j;
		V(tobacco);
		V(paper);
	}
}
void SmokerA()
{
	while(TRUE)
	{
		P(tobacco);	// 拿自己所需的部分
		P(paper);
		造烟;
		V(T);
	}
}

上述解法可能产生死锁。

为了不休改Arbiter,解决方法是再添加三个进程,我们称之为Pusher。以及再添加三个信号量和一个mutex,代码如下:

semaphore tobaccoSem = 0, paperSem = 0, matchSem = 0, mutex = 1;
bool isTobacco = isPaper = isMatch = false;
void PusherA() {
	P(tobacco);
	P(mutex);
	if (isPaper) {
		isPaper = 0;
		P(matchSem);
	} else if (isMatch) {
		isMatch = 0;
		P(paperSem);
	} else
		isTobacco = 1;
	V(mutex);
}
void Smoker_with_tobacco() {
	P(tobaccoSem);
	做香烟;
	V(T);
	吸烟;
}

我们使用了三个Pusher进程事先接受操作系统发出的操作量唤醒信号,每次操作系统会唤醒两个Pusher进程,而这些Pusher进程间用mutex实现互斥。后被唤醒的进程负责再唤醒Smoker进程。这样,这个问题才算被解决。

至此,所有经典问题已经详解完成。所有问题的基础是信号量在同步和互斥上的应用模式,比较复杂的问题有生产者-消费者问题和读者-写者问题。下一节我们总结各类习题。

  • 7
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值