在这一篇博客,我们将学习经典的进程同步问题,较有代表性的是“生产者—消费者”问题、“读者—写者”问题、“哲学家进餐”问题,通过对这些问题的研究和学习,可以帮助我们更好地理解进程同步的概念及实现方法。
生产者-消费者(producer-consumer)问题是一个著名的进程同步问题。它描述的是:有一群生产者进程在生产产品,并将这些产品提供给消费者进程去消费。为使生产者进程与消费者进程能并发执行,在两者之间设置了一个具有 n 个缓冲区的缓冲池,生产者进程将它所生产的产品放入一个缓冲区中;消费者进程可从一个缓冲区中取走产品去消费。尽管所有的生产者进程和消费者进程都是以异步方式运行的,但它们之间必须保持同步,即不允许消费者进程到一个空缓冲区去取产品,也不允许生产者进程向一个已装满产品且尚未被取走的缓冲区中投放产品。
可利用一个数组来表示上述的具有 n 个(0,1,…,n-1)缓冲区的缓冲池。用输入指针 in 来指示下一个可投放产品的缓冲区,每当生产者进程生产并投放一个产品后,输入指针加 1;用一个输出指针 out 来指示下一个可从中获取产品的缓冲区,每当消费者进程取走一个产品后,输出指针加 1。
由于这里的缓冲池是组织成循环缓冲的,故应把输入指针加1 表示成 in:= (in+1)mod n; 输出指针加 1 表示成 out:= (out+1) mod n。当 (in+1) mod n=out时表示缓冲池满;而 in=out 则表示缓冲池空。
此外,还引入了一个整型变量 counter,其初始值为 0。每当生产者进程向缓冲池中投放一个产品后,使 counter 加 1;反之,每当消费者进程从中取走一个产品时,使 counter 减 1。
在生产者进程中使用一局部变量 nextp,用于暂时存放每次刚生产出来的产品;而在消费者进程中,则使用一个局部变量 nextc,用于存放每次要消费的产品。
由于生产者—消费者问题是相互合作的进程关系的一种抽象,例如,在输入时,输入进程是生产者,计算进程是消费者;而在输出时,计算进程是生产者,而打印进程是消费者。
假定在生产者和消费者之间的公用缓冲池中,具有 n 个缓冲区,这时可利用互斥信号量 mutex 实现诸进程对缓冲池的互斥使用。利用资源信号量 empty 和 full 分别表示缓冲池中空缓冲区和满缓冲区的数量,这两个信号量是用来同步进程的。那么当生产一个资源成功,empty-1;
又假定这些生产者和消费者相互等效,只要缓冲池未满,生产者便可将消息送入缓冲池;只要缓冲池未空,消费者便可从缓冲池中取走一个消息。
对生产者—消费者问题可描述如下:
Var mutex: semaphore:=1;
empty: semaphore:=n;
full: semaphore:0;
buffer:array[0,…,n-1] of item;
in: integer:=0;
out: integer:=0;
begin
parbegin
proceducer: begin
repeat
......
producer an item nextp; //生产下一个产品nextp
......
wait(empty); //申请一个空缓冲区,申请成功才能往里放产品,empty-1
wait(mutex); //判断有没有消费者进程往buffer里拿产品
buffer(in):=nextp; //将产品nextp放入下标为in的缓冲区中去
in:=(in+1) mod n; //缓冲池是组织成循环缓冲的,这里对n取余是为循环
signal(mutex); //释放缓冲池,可以让消费者进程从buffer取产品了
signal(full); //由于放产品成功,所以满缓冲区多了一个,故而释放
//一个满缓冲区, full+1
until false;
end
consumer: begin
repeat
wait(full); //申请一个满缓冲区,申请成功才能从里拿产品
wait(mutex); //判断有没有生产者进程往buffer里放产品
nextc:=buffer(out); //将缓冲区序列中下标为out的产品品取出作为下一个消费品
out:=(out+1) mod n; //使下标out+1
signal(mutex); //释放缓冲池,可以让生产者进程从buffer放产品了
signal(empty); //由于取产品成功,所以空缓冲区多了一个,故而释放
//一个空缓冲区,empty+1
consumer the item in nextc;
until false;
end
parend
end
在生产者—消费者问题中应注意:
互斥必须在一个进程中成对出现。同步必须将P(wait)操作与V(signal)操作分开,是因为进程只能阻塞自己,但不能唤醒自己,那么要实现同步,必须由合作同步的进程进行唤醒操作。虽然P操作不能颠倒顺序,必须先执行对资源信号量的操作,然后执行对互斥信号量的操作。但V操作可以颠倒顺序。
关于程序中涉及到的wait()操作与signal()操作可以参考前一篇博客:操作系统学习-6. 信号量。
对于生产者—消费者问题,也可利用 AND 信号量来解决,即用 Swait(empty,mutex)来代替 wait(empty)和 wait(mutex);用 Ssignal(mutex,full)来代替 signal(mutex)和 signal(full);用 Swait(full,mutex)来代替 wait(full)和 wait(mutex),以及用 Ssignal(mutex,empty)代替Signal(mutex)和 Signal(empty)。利用 AND 信号量来解决生产者—消费者问题的算法描述如下:
Var mutex: semaphore:=1;
empty: semaphore:=n;
full: semaphore:0;
buffer:array[0,…,n-1] of item;
in: integer:=0;
out: integer:=0;
begin
parbegin
proceducer: begin
repeat
......
producer an item nextp;
......
Swait(empty,mutex)
buffer(in):=nextp;
in:=(in+1)mod n;
Ssignal(mutex,full);
until false;
end
consumer:begin
repeat
Swait(full,mutex);
Nextc:=buffer(out);
Out:=(out+1) mod n;
Ssignal(mutex,empty);
consumer the item in nextc;
until false;
end
parend
end
关于程序中涉及到的Swait()操作与Ssignal()操作可以参考前一篇博客:操作系统学习-6. 信号量。
由 Dijkstra 提出并解决的哲学家进餐问题(The Dinning Philosophers Problem)是典型的同步问题。该问题是描述有五个哲学家共用一张圆桌,分别坐在周围的五张椅子上,在圆桌上有五个碗和五只筷子,他们的生活方式是交替地进行思考和进餐。平时,一个哲学家进行思考,饥饿时便试图取用其左右最靠近他的筷子,只有在他拿到两只筷子时才能进餐。进餐完毕,放下筷子继续思考。
经分析可知,放在桌子上的筷子是临界资源,在一段时间内只允许一位哲学家使用。为了实现对筷子的互斥使用,可以用一个信号量表示一支筷子,由这五个信号量构成信号量数组。其描述如下:
Var chopstick: array[0,…,4] of semaphor;
所有信号量均被初始化为 1(说明筷子是临界资源),5位哲学家都是平等的,故考虑第 i 位哲学家的活动可描述为:
repeat
wait(chopstick[i]); //申请拿起左边的筷子
wait(chopstick[(i+1)mod 5]); //申请拿去右边的筷子
......
eat; //吃饭
......
signal(chopstick[i]); //吃完饭后释放左边的筷子
signal(chopstick[(i+1)mod 5]); //吃完饭后释放右边的筷子
......
think; //思考
until false;
在以上描述中,当哲学家饥饿时,总是先去拿他左边的筷子,即执行 wait(chopstick[i]);成功后,再去拿他右边的筷子,即执行 wait(chopstick[(i+1)mod 5]);又成功后便可进餐。进餐完毕,又先放下他左边的筷子,然后再放右边的筷子。
这里wait()操作与signal()操作的顺序都可以颠到,因为左边的筷子与右边的筷子是平等的。但是一旦确定了一个哲学家拿筷子的顺序,那么5个哲学家的拿筷子的顺序都要一样。
但该解法有可能引起死锁。假如五位哲学家同时饥饿而各自拿起左边的筷子时,就会使五个信号量 chopstick 均为 0; 当他们再试图去拿右边的筷子时,都将因无筷子可拿而无限期地等待。
对于这样的死锁问题,可采取以下几种解决方法:
关于程序中涉及到的wait()操作与signal()操作可以参考前一篇博客:操作系统学习-6. 信号量。
在哲学家进餐问题中,要求每个哲学家先获得两个临界资源(筷子)后方能进餐,这在本质上就是前面所介绍的 AND 同步问题,AND信号量机制简单来说就是“宁可锦上添花,也不雪中送炭”“把资源优先集中提供给一个进程”。故用 AND 信号量机制可获得最简洁的解法。描述如下:
Var chopsiick array of semaphore:=(1,1,1,1,1);
processi
repeat
think;
Sswait(chopstick[(i+1)mod 5],chopstick[i]); //一次性分配两个筷子
eat;
Ssignat(chopstick[(i+1)mod 5],chopstick[i]); //一次性释放两个筷子
until false;
AND信号量通过原语操作来实现同时申请资源并且同时一次性释放所占用的资源。
关于程序中涉及到的Swait()操作与Ssignal()操作可以参考前一篇博客:操作系统学习-6. 信号量。
一个数据文件或记录,可被多个进程共享,有的进程要求读文件,有的进程则要求写文件。我们把只要求读该文件的进程称为“Reader进程”,进程则称为“Writer 进程”。
允许多个进程同时读一个共享对象,因为读操作不会使数据文件混乱。但不允许一个 Writer 进程和其他 Reader 进程或 Writer 进程同时访问共享对象,因为这种访问将会引起混乱。所谓“读者—写者问题(Reader-Writer Problem)”是指保证一个 Writer 进程必须与其他进程互斥地访问共享对象的同步问题。读者—写者问题常被用来测试新同步原语。
需要注意的是这个贡献对象不是临界资源,因为它允许多个进程同时访问,只要不是Writer进程就行。
为实现 Reader 与 Writer 进程间在读或写时的互斥而设置了一个互斥信号量 Wmutex。另外,再设置一个整型变量 Readcount 表示正在读的进程数目。因为 Readcount是一个可被多个 Reader 进程访问的临界资源,但是不允许同时多个Reader进程对它进行改变。因此,也应该为它设置一个互斥信号量rmutex。(这里也是第一次将一个变量设置为临界资源,并为它设置互斥信号量)。
由于只要有一个 Reader 进程在读,便不允许 Writer 进程去写。因此,当Readcount>0时,说明已有Reader进程在安全的读数据。仅当 Readcount=0,表示尚无 Reader 进程在读时,Reader 进程才需要执行 Wait(Wmutex)操作。若 Wait(Wmutex)操作成功,Reader 进程便可去读,相应地,做 Readcount+1 操作。同理,仅当 Reader 进程在执行了 Readcount 减 1操作后其值为 0 时,才须执行 signal(Wmutex)操作,以便让 Writer 进程写。
读者—写者问题可描述如下:
Var rmutex: semaphore:=1;
wmutex: semaphore:=1;
Readcount: integer:=0;
begin
parbegin
Reader: begin
repeat
wait(rmutex); //申请修改readcount,没有其他reader
//进程在读,才可以继续进行
if readcount=0 then wait(wmutex); //没有其他reader进程在读,判断是否有
//writer进程在写。没有才可以继续
Readcount:=Readcount+1; //在读进程数量+1
signal(rmutex); //释放rmutex,别的reader可以访问readcount了
......
perform read operation; //执行read过程
......
wait(rmutex); //读完了,也要修改readcount,申请访问readcount
readcount:=readcount-1; //read完了,read进程数目减1,readcount-1
if readcount=0 then signal(wmutex); //没有read进程在读
signal(rmutex);
until false;
end
writer: begin
repeat
wait(wmutex); //写进程申请占用临界资源wmutex
perform write operation; //执行写过程
signal(wmutex); //执行完毕,释放资源wmutex
until false;
end
parend
end
这里补充一点对wait()与signal()的理解:
关于程序中涉及到的wait()操作与signal()操作可以参考前一篇博客:操作系统学习-6. 信号量。