引言
承接上文我们介绍了信号量机制和应用信号量机制实现的进程同步和互斥,这一节我们将围绕一些经典问题对信号量机制展开更深入地探讨。
生产者/消费者问题
对于这个问题有两个进程,一个是生产者进程,一个是消费者进程;
-
一个互斥关系:在同一时间内,只能有一个进程访问同一个缓冲区,要么放入产品,要么取走产品;
-
两个同步关系:
-
对于生产者,如果缓冲区没满,它就会生产产品并将其放入缓冲区,如果缓冲区已满,它就会进行等待;
-
对于消费者,如果缓冲区不空,它就会取走产品并释放缓冲区,如果缓冲区已空,它就会进行等待。
-
因此,我们在这里需要准备两个进程,一个是表示生产者进程的 producer
,简写成进程P,一个是表示消费者进程的 consumer
,简写成进程C;
有界缓冲
单缓冲
让我们回忆一下上一节中我们提到的同步举例,当时以此为例说明了进程同步的重要性
如图所示,get
、copy
、put
对应三个进程动作
- f是键盘输入流,属于无界缓冲区(可以放多个数据/取多个数据)
get
是拿数据(s从f拿)- s、t是单缓冲区(一次只能放一个数据,一次也只能读一个数据)
copy
执行某些特定的运算。- g是显示器的输出缓冲区
put
是送数据(t送给g)
对于像s和t这种单缓冲区,总是生产者先生产一个产品放下,然后消费者取走一个产品消耗,示意图如下
理论上对于单缓冲区,进程的执行顺序是P1→C1→P2→C2……Pi→Ci的序列,即生产者和消费者进程是交替进行,否则就会造成死锁,后面会解释。
为了简化问题,假设我们只针对单缓冲区,使用一个信号量是否能解决生产者消费者问题呢?
小伙伴们很容易想到这样的代码,但是ppt中标志有错误,请继续思考一下这样设计的问题。
虽然使用mutex
很好的解决了互斥问题,但是无法解决同步问题。
举个栗子,假设第一个生产者进程P1往单缓冲区中放产品,此时缓冲区已经满了,P2往单缓冲区中放不进产品,会自我阻塞无法及时释放mutex
,后来消费者进程得不到mutex
,于是出现了“死锁”的局面。后续所有的生产者/消费者进程都会阻塞在这里,谁也没办法前进一步。
同理,如果第一个消费者进程C1在单缓冲区里面取数据,此时缓冲区为空,C1拿不到数据就会卡在中间不释放mutex
,这样其他进程也会卡死在mutex
上。
所以,我们必须设置其他的信号量来检查缓冲区是否已满,如果已经满了就不能再往里面写数据,还有一个信号量检查缓冲区是否为空,如果为空就不能往里面取数据。
不考虑互斥,单缓冲区代码就要这样写
考虑互斥,完整代码这样写
semaphore mutex=1,empty=1,full=0;
producer(){
while(1){
生产产品
P(empty)
P(mutex)
把产品放入缓冲区
V(mutex)
V(full)
}
}
consumer(){
while(1){
P(full);
P(mutex)
从缓冲区中取走产品
V(mutex)
V(empty)
使用产品
}
}
多缓冲
虽然单缓冲区实际比较少见,对于绝大多数的缓冲区都能容纳比较多的产品,但是它往往更能暴露问题——缓冲区是有界的,放置产品数有上限,消耗产品有下限。
考虑缓冲区大小n,我们同样需要准备三个信号量。第一个信号量是互斥信号量,实现对缓冲区这个资源的互斥访问,用 mutex = 1
表示;第二个信号量是同步信号量,表示空闲缓冲区的数量,用 empty = n
表示;第三个信号量也是同步信号量,表示非空闲缓冲区的数量,也即产品数量,用 full = 0
表示。
semaphore mutex=1,empty=n,full=0;
producer(){ consumer(){
while(1){ while(1){
生产产品 P(full)
P(empty) P(mutex)
P(mutex) 从缓冲区中取走产品
把产品放入缓冲区 V(mutex)
V(mutex) V(empty)
V(full) 使用产品
} }
} }
这个实际上就是最后的代码了。现在我们试着跑一下流程:
-
初始 empty = n,表示所有缓冲区都是空闲的,而 full = 0,表示一个产品都没生产出来。
-
假如处理机首先来到 consumer 进程,那么就会通过
P(full)
检查是否有产品,这里当然是没有,所以它只能进行等待; -
处理机来到 producer,首先通过
P(empty)
检查是否有空闲缓冲区,这里当然有,于是它开始把生产的产品放入缓冲区,随后记录产品的数量,这个过程可以反复进行,直到所有缓冲区都被占用了,此时 producer 就会等待 consumer 进程取出产品、释放缓冲区; -
其中还有可能的情况是,producer 尚未占用完所有缓冲区,进程就切换到 consumer 了,那么这时候 consumer 因为检查到有产品,所以会取出产品、释放缓冲区,两者互不影响。
注意:
这里的前两个P操作是不能换顺序的。
原因是当没有先检查缓冲区是否没满或者是否非空,就强行进行互斥的“上锁”会引发死锁。假如还是按照前面的流程,一开始处理机在 consumer 这里,那么 consumer 实际上在没有检查缓冲区是否非空的情况下就直接“上锁”了,这导致它在 P(full)
这一步的时候被卡住,只能等待,而在之后切换到 producer 的时候,正常情况下他应该生产可供对方消费的产品,但是由于对方已经上了锁,所以 producer 在 P(mutex)
这一步也被卡住了,只能进行等待。这也就是说,producer 等待 consumer 释放临界区,而 consumer 等待 producer 使用缓冲区,双方陷入循环等待,造成了“死锁”。
实际上的生产消费问题会更加复杂,这里不再赘述。
接下来拓展说下缓冲区相关的问题
循环缓冲
圆形缓冲区(circular buffer),也称作圆形队列(circular queue),循环缓冲区(cyclic buffer),环形缓冲区(ring buffer),是一种用于表示一个固定尺寸、头尾相连的缓冲区的数据结构,适合缓存数据流
设计的时候可以通过取余操作来控制下标不越界,定义两个读写指针in
,out
然后分别控制读写。
同样,这里我们将Pi中empty/mutex的顺序更改之后,就会出错。
假设按照P1P2……Pn|C1C2……Cn的顺序执行:
对于生产者进程:先是empty阻塞(放不下了),导致mutex无法释放,然后把后续生产者进程阻塞在mutex上
对于消费者进程:先是mutex阻塞(由于生产者进程),然后太多的消费进程是full阻塞(没有产品消费)
代码可以参考教材上的范例。
无界缓冲
之前有说f是一个无界缓冲,比如键盘输入。
无界缓冲区的特点是
- 生产者生产产品,可以一直往前放
- 消费产品需要看两个指针是否满足小于条件才能放入,即out指针所指位置小于in指针。
如图所示,in指针可以一直向前放产品,但是out指针必须在in的后面取产品。
代码可以参考教材上给的范例