【操作系统】进程管理——用信号量机制解决问题,以生产者-消费者问题为例(个人笔记)
学习日期:2024.7.10
内容摘要:利用信号量机制解决几个经典问题模型
目录
引言
问题模型
生产者-消费者问题(经典)
多生产者-多消费者问题
吸烟者问题
读者写者问题(难点)
哲学家进餐问题(避免死锁)
引言
在《信号量机制》章节中,我们已经学习了信号量的概念和用其实现进程同步和互斥的工作原理,下面结合实际的模型来看信号量机制如何起效。
PV操作题目分析步骤:
1.关系分析。找出题目中描述的各个进程,分析它们之间的同步、互斥关系。
2.整理思路。根据各进程的操作流程缺点P、V操作的大致顺序。
3.设置信号量。根据题目条件确定信号量初始值(互斥一般为1,同步一般看对应资源的初始值)
简记口诀:
互斥:紧邻操作,成对PV。(紧邻互斥操作,在同一进程内完成PV)
同步:前V后P,前后后前。(在不同进程内进行操作以保证顺序执行,在前一个进程完成后进行V操作,在后一个进程开始前进行P操作)
问题模型
生产者-消费者问题(经典)
系统中有一组生产者进程和消费者进程,生产者每次生产一个产品放入缓冲区,消费者每次从缓冲区中取出一个产品使用(“缓冲区”理解为二者共享的仓库,“产品”理解为某种数据)
缓冲区是一个初始为空,大小固定为n,双方共享的区域,且缓冲区是临界资源,各进程必须互斥访问。(原因:如果不是互斥访问的,可能两个生产者进程在同一片区域放入数据,导致数据覆盖丢失)
首先分析,显然:
①缓冲区没满的情况下,生产者才能生产,二者有同步关系。
②缓冲区不空的情况下,消费者才能消费,二者有同步关系。
③进程对缓冲区的访问必须互斥。
那么,设置三个变量,一个mutex表示互斥标记,一个empty表示空闲区数目,一个full表示装入区数目。每次生产者进程行动时,P(empty),V(full),消费者行动时则反之
这样就能实现通过PV操作来表示资源的获取与释放。
注意!实现互斥是在同一进程中进行一对PV操作,在“把产品放入缓冲区/从缓冲区中取出”这一对临界资源的访问行为前后,成对进行。
而同步关系是在两进程分开执行,在一个进程中执行P,另一个执行V。具体理论部分可以参考上一章。
Q:能否交换相邻P操作的顺序?
不能。假如交换了生产者的前两个P操作的顺序,若缓冲区内已经放满产品,此时empty=0,full=n。若生产者先进行P(mutex)声明自己独占了缓冲区,然后P(empty)发现缓冲区的empty区不足,就会进入阻塞状态。
之后消费者想占用缓冲区,释放empty资源时,因为生产者已经占用了mutex,没有释放,因为互斥会导致无法进行V(empty)操作
这样,生产者在等待消费者释放empty,消费者在等待生产者解除对缓冲区的占用,双方会无限等待下去,形成死锁。
所以表示互斥的PV部分要成对出现,成对执行,且实现互斥的P操作一定在实现同步操作的P操作之后。
Q:能否交换相邻V操作的顺序?
可以,V操作不会导致进程阻塞,但是为了方便理解记忆,还是让实现互斥的PV操作紧邻互斥行动区为好。
Q:能否不依赖互斥标识资源mutex?
通常不行,但是特殊情况下可以。这个特殊情况就是缓冲区容量为1的情况(即empty的初始值为1),此时如果一个生产者生产了产品,执行P(empty),另一个生产者也想生产产品时,因为empty的值已经是0,就会发生阻塞,从而避免了生产者同时访问的问题。而在生产者执行完互斥操作,V(full)之前,因为full的值为0,消费者进行P(full)时也会阻塞,避免了生产者访问时消费者同时访问的问题,从而不依赖mutex就能实现互斥访问。
多生产者-多消费者问题
是生产者-消费者问题的变形,区别在于增加了若干组生产者和消费者。比如说1号生产者会生产1型商品,2号生产者生产2型商品,而1号消费者只消费1型商品,2消费者只消费2型商品。
所有的生产者和消费者仍旧共享一个缓冲区,此处的“多”指的是“多类”而不是“多个”,与上一问题的根本区别是有多类生产者和消费者。
关系分析:
①首先,同生产者-消费者问题的前提一样,缓冲区没满才能生产,不空才能消费,且互斥访问。
②根据不同资源的类型,分别PV不同的变量,来为每对生产者和消费者确立同步关系。
在分析同步问题的时候,不能从单个进程行为的角度来分析,而应该从事件角度分析。
不要想着“x号生产者生产了一个x号产品,x号消费者......”,然后设置四个同步信号量。而是从事件的角度分析,把进程的先后顺序概括为事件的先后顺序,在这个例子中,就是生产产品事件先于消耗产品事件。
生产产品可以是1号生产者,也可以是2号生产者,它们只需要每次占用一个仓库空间P(empty),然后增加一个对应产品V(product_x)。同样的,消费者也只要每次消耗一个产品P(product_x),然后释放一个空间P(empty)就行了。最后再在互斥操作前后加上mutex的成对PV操作保证互斥,根据空间的大小设置empty的初始值,就可以解决问题了!
吸烟者问题
仍然是生产者-消费者问题的变形。更准确的来说,是“可生产多种产品的单生产者-多消费者问题”
假设一个系统有三个抽烟者进程和一个供应者进程,每个抽烟者进程不停的卷烟并抽烟,这需要三种材料:烟草、纸卷和胶水。在三个抽烟者中,第一个有烟草,第二个有纸卷,第三个有胶水。供应者会无限提供三种材料,每次将两个材料置于桌面,而拥有剩下那个材料的抽烟者就会拿走,完成抽烟,然后给供应者一个信号告诉他完成了,供应者就会放另外两种材料在桌上,让三个抽烟者能够轮流抽烟。
首先,桌子可以抽象为容量为1,互斥访问的缓冲区。为什么放两件东西容量为1呢?
我们将供应者供应的两件东西看作是一个组合,则他一共供给三个组合,第一个消费者需要纸卷+胶水,定义为组合1,第二个消费者需要烟草+胶水,定义为组合2,同理得组合3。
这样问题就抽象为了一个生产者能生产1号,2号,3号三种产品,分别对应三个消费者,要进行轮流消费,缓冲区容量为1。
关系分析:
①桌上有组合x 先于 x号消费者取走东西,且缓冲区互斥访问。
②消费者发出完成信号 先于 生产者将下一个组合放到桌上。
Q:轮流操作如何实现?
如右图所示,通过一个if(i==0)的选择分支结构,和结尾的i=(i+1)%3,实现了让i在0,1,2不断循环变化,从而轮流执行三个部分的生产,每次产生对应的offer。补充:如果要随机实现,可以用Random(i)
而如上图每一个消费者每次消耗一个offer的商品P(offer_x),然后再释放一个finish信号给生产者,保证先释放信号,然后生产者再生产下一个产品。
之前介绍过,因为此时缓冲区的容量为1,故不需要互斥信号量mutex也可以实现互斥访问。
读者写者问题(难点)
在系统中有读者、写者两组并发进程,共享一个文件。多个读者可以同时对文件执行读操作,但是只允许一个写者往文件中写入信息,写者对文件的访问必须互斥,也不能边写边读,每个写者在工作时都必须独占该共享文件。
与消费者进程不同,读者进程在读数据时,不会“消耗”数据, 所以读者进程可以同时访问共享数据,互不影响。
不能边读边写是因为,如果允许并发执行,读者可能先读了某数据的一部分,但剩下的部分被写者覆盖了,导致读者所读的数据并不是所期望的。不能同时写的原因同生产者进程互斥原因,会相互覆盖。
关系分析:
①写者进程和所有进程互斥,读者进程之间不互斥。
难点就在于,如何在实现写者进程互斥的同时,让读者进程不互斥。
我们一步一步来:
semaphore rw=1 writer(){ while(1){ P(rw); //写之前上锁 写文件操作... V(rw); //写完解锁 } } reader(){ while(1){ P(rw); //读之前上锁 读文件操作... V(rw); //读完解锁 } }
先写个最简单的,但是显然,这样是每个进程都互斥访问共享区,不能实现同时读操作。
那么,要如何实现“同时读”呢?可以设法让后面的读进程跳过P(rw)这一步。
所以,逻辑代码变成了这样:
semaphore rw=1; int count = 0; //记录当前有几个读进程在访问文件 writer(){ while(1){ P(rw); //写之前上锁 写文件操作... V(rw); //写完解锁 } } reader(){ while(1){ if(count==0) //由第一个读进程负责上锁 P(rw); //读之前上锁 count++; 读文件操作... count--; if(count==0)//由最后一个读进程负责解锁 V(rw); //读完解锁 } }
我们引入了一个新的变量count用来记录当前访问文件的读进程数,当count==0时,访问文件的读进程是第一个,它负责上锁,之后访问的读进程,因为count!=0,就跳过了P(rw)语句,从而可以直接进行读文件操作。同理,当最后一个读进程读完时,count==0,最后一个进程进行V(rw)释放资源,表示没有读进程还在操作了。
但是这样还是有一个问题,因为读进程是并发执行的,如果两个读进程同时开始执行,当第一个读者进程P(rw)以后,还没有来得及count++时,第二个读进程可能已经通过了conunt的判断语句,也进行P(rw),但是此时rw已经为0了,这会导致第二个进程阻塞。
显然,出现上一问题的原因是,我们对count变量的检查和赋值无法一气呵成。无法一气呵成...?我们最初用PV操作就是为了解决进程互斥中,软件实现方法无法一气呵成的问题的,(详见《进程的同步与互斥》)所以可以想到,再加一组互斥标识,保证进程能互斥的访问count。
semaphore rw = 1; semaphore mutex = 1;//用于保证对count变量的互斥访问 int count = 0; //记录当前有几个读进程在访问文件 writer(){ while(1){ P(rw); //写之前上锁 写文件操作... V(rw); //写完解锁 } } reader(){ while(1){ P(mutex); if(count==0) //由第一个读进程负责上锁 P(rw); //读之前上锁 count++; V(mutex); 读文件操作... P(mutex); count--; if(count==0)//由最后一个读进程负责解锁 V(rw); //读完解锁 V(mutex); } }
我们引入了mutex变量,来保证进程能互斥的访问count,此时如果两个读进程同步访问count,第一个进程会P(mutex)阻止其它读进程修改count,并且在自己修改完count之后,其它进程才能进行if判断,这样就不会出现上述问题了。两组对于mutex的PV操作,能够保证进程对count的互斥访问。
但是,还有一个潜在的问题(受不了了),只要有读进程还在读,rw的值就始终是0,写进程就会一直阻塞,如果一直有源源不断的读进程,写进程就会饥饿。在这种算法中,读进程是优先的。那么,该如何避免写进程饥饿呢?就是说,怎样能让写进程在想写入的时候,在有限的等待时间内就能写入呢?
semaphore rw = 1; semaphore mutex = 1;//用于保证对count变量的互斥访问 semaphore w = 1; //用于实现“写优先” int count = 0; //记录当前有几个读进程在访问文件 writer(){ while(1){ P(w); P(rw); //写之前上锁 写文件操作... V(rw); //写完解锁 V(w); } } reader(){ while(1){ P(w); P(mutex); if(count==0) //由第一个读进程负责上锁 P(rw); //读之前上锁 count++; V(mutex); V(w); 读文件操作... P(mutex); count--; if(count==0)//由最后一个读进程负责解锁 V(rw); //读完解锁 V(mutex); } }
我们又引入了一个新的变量w,用于实现写优先。
假如三个进程读者A,写者,读者B并发执行。
当读者进程A通过了上半部分,开始进行读文件操作时,释放了w,占用了rw。此时写者进程可以进行P(w)操作了,但是会被阻塞在P(rw)操作上。当读者进程B开始运行时,因为写进程已经进行过P(w)操作了,读者B会在P(w)处被阻塞。
这样当读者A的读操作结束时,count--后为0,读者A会认为自己是最后一个读进程,从而释放rw,这样写者就可以执行P(rw),实现了写者优先于读者B执行。
此方法并不是真正的“写优先”,只是保证了写进程不会饥饿,相对公平的实现了先来先服务。
此处的w好比一个提供顺序功能的标记,第一个读者最开始先占用,然后在读文件之前释放,第二个读者如果在写者之后来,就会因为写者已经占用了w而不能继续。
小结:
读者-写者问题为我们解决复杂的互斥问题提供了思路,核心思想在于设置一个计数器count来记录当前正在访问共享文件的读进程数,用count的值来判断当前进入的进程是否是第一个/最后一个读进程,从而跳过部分P指令,实现读进程不互斥。
在对count变量的检查和赋值不能一气呵成时,采用mutex变量来确保count部分被互斥访问。
在写进程会饥饿时,引入了w变量来确保可以公平的完成先来先服务。
哲学家进餐问题(避免死锁)
圆桌上坐着5位哲学家,每两个哲学家之间摆着一根筷子,桌子的中间是火锅,哲学家们会思考和干饭。哲学家们在思考时不会影响别人,只有想干饭时,才会拿起左、右各一根筷子(一根一根拿),如果筷子已经在他人手上则需要等待。因为火锅很烫,哲学家必须需要一双筷子才能进餐,进餐完毕后,哲学家放下筷子继续思考。
难点:这个问题中只有互斥关系,没有同步关系,但是在这个模型中,每个哲学家进程都需要同时持有两个临界资源才能开始吃饭。我们可以想到一个很尴尬的情况,那就是每个哲学家都拿了一根筷子,大家都没有办法吃饭,但是都在等待别人吃饭然后让出筷子,这就是死锁。这是临界资源分配不当导致的,我们要设法避免这种情况发生。
关系分析:
①系统中有5个哲学家进程,5位哲学家与左右邻居对其之间的筷子的访问是互斥关系。
②筷子是本模型中的临界资源,哲学家想要吃饭需要获取其左右两根筷子。
我们给哲学家和筷子编号为0~4。显然,编号为i的哲学家需要编号i和i+1的两根筷子来吃饭,考虑到边界,严谨的表述是i和(i+1)%5
我们很容易想到一个简单的写法:
这种写法能实现对筷子的互斥访问,但是不能避免前面提到的死锁问题。那么该怎么解决呢?
①可以要求奇数号哲学家先拿左手的筷子,偶数号哲学家先拿右手的筷子。如图所示,以0、1为例,这样两位就会在一开始争抢1号筷子,谁先拿到,另一个就在不占用任何筷子的情况下直接进入阻塞态,这样就避免了五个人每人占用一根筷子进入阻塞态的死锁。
以代码实现的话,在每个哲学家拿筷子之前判断号码的奇偶,然后确定P操作的顺序即可。
②保证至少有一个哲学家可以一气呵成的拿到完整的一双筷子。
使用mutex互斥变量,保证至少有一个哲学家可以拿到完整的一双筷子。
在拿筷子的部分前后加入了mutex的PV操作。当0号哲学家拿筷子时,先P(mutex),之后依次拿起左右的筷子,即使在这过程中发生了进程调度,别的进程也会因为mutex值已经为0被阻塞,不能拿起筷子,直到0号哲学家把筷子拿完,释放mutex以后,别的哲学家才能拿起筷子。这样能够保证,至少第一个开始拿筷子的进程会一气呵成的拿到一双筷子,避免死锁。
但是,这个方法也有一个问题。如图所示,当0号哲学家拿完一双筷子开始进餐后,4号哲学家开始拿筷子,因为此时mutex已经释放,4号哲学家顺利执行了P(mutex),且拿起了4号筷子,当想拿起0号筷子时,发现被占用,进入阻塞态。
此时,对于2号哲学家来说,他左右两侧的筷子都是可以使用的,没有被占用,但是如果他想进食,因为4号哲学家还在占用着mutex并被阻塞,所以他在执行P(mutex)时会被阻塞。明明有临界资源可以用,但是却不能访问,这违背了空闲让进原则,这种方法的并发度不够高。
③最多允许四个哲学家同时拿起筷子。
只要最多四个哲学家同时拿起筷子,就必然有至少一个哲学家可以拿起一双筷子,避免了死锁。
为了解决这个问题,我们可以把上个方法中,mutex的初始值设置为4。这样,在上一情况中,4号哲学家依然会被右手的筷子阻塞,但2号哲学家不会被mutex阻塞,可以正常进食。
同时,在极端情况,即每个哲学家进程拿起一根筷子就被调度的情况中,最后一个进程想要拿起筷子时,会因为mutex已经为0,在P(mutex)步骤中被阻塞,从而在不占用任何筷子的情况下进入阻塞态,这样就不会发生死锁。
小结:
哲学家进餐问题的关键在于避免死锁,每个进程都需要持有两个临界资源,因此就有了“死锁”的隐患,想避免死锁,就要尽量让进程在不占用临界资源的情况下进入阻塞态。
感谢您看到这里,如果满意的话麻烦您点个赞支持一下,个人主页还有更多内容分享。
个人能力不足,如有错漏部分还请指出,我会尽快修改。
内容总结自王道计算机考研《操作系统》 和 人民邮电出版社《操作系统导论》