经典PV问题系列一:解决互斥

来源:互联网 发布:照片审核工具 mac 编辑:程序博客网 时间:2024/05/29 14:55

这几天正在学PV,觉得网上的总结都不全面,还是自己归纳一下吧。

一、解决互斥

PV是用来解决互斥和同步问题的。从编程角度来看,PV只是两个函数的实现,其实不用它们也可以达到互斥的目的。要理解PV,应先理解互斥。这里细致的分析了一些解决互斥的经典方法,可以帮助你深入理解互斥。当然啦,如果你连什么是互斥、什么是临界区、什么是竞争都不知道的话,建议你不要先看这篇博文,先读读教科书吧。
实现进程互斥的方案可基本分为软件方案和硬件方案。

1、软件方案

纯用编程来解决互斥是非常难的事,有很多错误的解法,非常考验编程技巧。直到1965年,被提出的Dekker算法才被认为是的正确的纯软件解法。而1981年,Peterson算法才可以算正确的且简洁的解决。这里只介绍Peterson算法。其它算法和Dekker算法请看教科书。

#define FALSE 0#define TRUE 1#define N 2 // 进程的个数int turn; // 轮到谁? int interested[N];// 兴趣数组,初始值均为FALSEvoid enter_region ( int process)// process = 0 或 1 { int other;// 另外一个进程的进程号 other = 1 - process; interested[process] = TRUE; // 表明本进程感兴趣 turn = process; // 设置标志位 while( turn == process && interested[other] == TRUE);}void leave_region ( int process) { interested[process] = FALSE; // 本进程已离开临界区 }

这里有两个变量:interested数组和turn。interested[i]指明进程i想要进入临界区,每个进程i拥有自己的interested项,所以真正的竞争应该是从turn=process开始。如果P0(进程0)先执行完了turn=process这一语句(注意这一语句并不只对应一句汇编,我这里指明了“运行完”),那么它一定会进入临界区,下面对P1分情况讨论:

1、P1没有运行完interested[process]=TRUE,那么对P0 turn==process为真而interested[other]为假,必进入临界区。

2、P1运行完interested[process]=TRUE,但没有运行完turn=process,那么P0两条件皆为真,开始循环,不过这没有关系;P1在之后某一时刻被调度上CPU,一定会执行完trun=process一句,这时P0的trun=process条件为假,退出循环进入用户的临界区。

3、P1运行完turn=process一句,对P0 trun=process条件为假,必进入临界区。

而对P1来说,无论它与P0怎样相对执行,只要P0已经抢先执行完turn=process,而P1后对turn赋值,那么当P1执行while判断时,turn一定等于1,而对方的interested也必已经置位,所以P1一定会陷入循环,直到对方退出临界区时修改interested数组。

总结一下:谁先抢占完成turn=process,谁先进临界区,先抢先进,后抢等待。

如果我们调换interested[process]=TRUE和trun=process语句的顺序呢?那么程序就无法工作了:P0刚好执行完turn=process而被调度下CPU,转而执行P1,P1一路执行,到while循环时发现对方没有置interested数组,所以P1进入临界区;而当P0再执行时,turn已经被P1修改过,所以while循环中turn==process条件为假,P0也会进入临界区。

如果我们调换while循环内turn==process和interested[other]==TRUE的顺序呢?程序依然正确,因为这几条语句都没有修改内存,虽然&&有“短路执行”的特点,但是逻辑上依然满足两者有一假就可进入的性质。

上述代码中的问题我们就都解决了。但是看到这里,我们不仅问:如果有多于两个进程怎么办?其实推广也并不容易,Peterson在1981年也只给出了解决两个进程互斥的方案,而后人又花了些心思才做出了推广。

#define N 100 // 进程的个数int interested[N] = {-1};// 兴趣阶段数组,每个进程一个,数组内容表示这个进程的兴趣阶段,初始值均为-1int turn[N-1] = {-1}; // 表示0..N-2个阶段下,哪一个进程在这个阶段void enter_region ( int process)// process = 0..N {for(int i=0 ; i<N-1 ; i++) // 指明该进程处在哪一个阶段{interested[process] = i; // 本进程对临界区的兴趣又到了一个更高的阶段turn[i] = process; // 目前为止哪一个进程在这个阶段while(turn[i] == process && (interested[0] >= i || interested[1] >= i || …… interested[k] >= i (k!=process) …… || interested[N-1] >= i));// 上述语句的简洁语义是:while(turn[i] == process && there exists k ≠ process, such that interest[k] >= i));}}void leave_region ( int process) { interested[process] = -1; // 本进程已离开临界区 }
每个进程必须要经过n-1个阶段(值为0..N-2)才能到达临界区,变量i指明了这个阶段。interested[p]表示进程p目前在哪一个阶段,turn[i]表示最近哪一个进程进入了阶段i。

等待条件:Pi进程等待,如果有其他任何一个进程处在更高的阶段,或者即使其他进程在同一个阶段pi也是后进入这个阶段的。


如图所示,对第0个阶段,最多有N个进程同时可以进入这个阶段,但最多有N-1个进程可以同时通过这个阶段,被卡住的进程是最后一个拿到turn[0]的进程,它最后一个置turn[0]位,而其他interested[k]的阶段一定大于等于它;对第1个阶段,由上一层的限制,最多有N-1个进程同时在这个阶段,但最多有N-2个进程可以同时通过这个阶段。递归推理:对第i个阶段,最多可以容纳N-i个进程,而最多有N-i-1个进程可以通过它。可得:对第N-2个阶段,最多有N-(N-2)-1=1个进程可以通过它,通过它即进入了临界区(CS Critical Section)。

这种思想在互斥和同步的算法里是非常经典的:建立一个N-1层的井,每层容量减少1,先进抢占容量,无空则在上一层等待。即使是PV操作,也会用到这类思想。

小小几行代码就暗藏无数玄机,不是吗?所以并发编程是非常复杂的事,千万不要小看它。要知道,直到1981年Peterson才为两个进程提出了简洁的正确的解法。那么之前人们难道不用并发编程了吗?不!其实有简单得多的实现,只需要硬件支持一条原语就可以了。

我们之所以费这么大劲解决互斥的原因是:没法用一句汇编完成:读、判断、写的功能,如果这几步操作中间被中断,那么其它进程就可能修改你读来的数值;如果硬件保证这几步之间不被中断,那一切会简单得多,实际上计算机也是这么做的。上述的Peterson解法只是让你知道只用软件也是可以解决这个问题的以及给你一波智商上的碾压。

2、硬件方案

如果你看前面的软件解法看得头晕脑胀,看到这里已经快放弃理解互斥时,那么恭喜你,你已经通过了大部分的智商碾压,接下来的部分会非常简单,而且比上面的用处更广。纯软件解法没什么实际用处,它效率低下,编程复杂。也许可以用在某次面试考验你的智商,但是你不会自虐的把它用在你自己的工程中。建议如果你接触到一个指令集没有提供硬件保障互斥时,你要做的第一件事不是去用软件解决它,而是先写封吐槽信。

a. 关中断
比如x86体系中的cli指令。我们理解一下为什么会出现竞争现象:比如对于cnt++这条命令,当你把cnt从内存里用mov指令取到寄存器之后,还没等加,操作系统就把你调度下CPU了。
等等,操作系统怎么把你调度下CPU的呢?是通过定时器中断。定时器每隔一段时间就会发一个中断脉冲,用户进程只能被中断陷入到系统态来处理这个中断,操作权自然也就到了操作系统手中。谨记一句话:操作系统是中断驱动的,中断相当于操作系统的齿轮,中断在计算机中的重要程度可见一斑。如果进程的时间片到了,操作系统就会把你调度下CPU。
被调度下CPU后,原来进程以为拿到了正确的cnt的值,但是如果有其他进程在其时间片内修改了cnt值,原有进程再放回cnt值就会引起逻辑上cnt值的错误。这种情况叫做“竞争”,即两个进程都竞争cnt的值。“竞争”导致错误的原因是操作系统在没有完成更新cnt内存值之前就插手调度。插手调度是由中断驱动的,如果我们屏蔽了中断,那么就可以保证这条命令的原语执行。

cli(关中断)指令确实可以一定程度上达到互斥的作用,它简单、高效。在临界区前你使用cli关中断,临界区后再打开中断。但是这种方法有两个非常致命的问题:1、它只适用于操作系统本身,cli指令必须在系统态下执行,如果用户态可以执行关中断指令,那么一旦关中断就可以不再打开,操作系统就再也奈何不了它了。2、它只适用于单CPU的情况,这点非常重要!每个CPU都有一个独立的中断系统,cli后只关闭了进程所在CPU的中断。对单CPU的处理器而言,互斥只是逻辑上的。在同一时刻,只有一个程序在运行,关中断就可以保证这一个程序可以持续运行。而对多CPU而言,同一时刻有多个程序在运行,即使关闭了一个CPU的中断,其他CPU依然可以修改内存,关中断指令并不能解决互斥。

总结一下:关中断简单方便,但有个致命缺点:只能用于单CPU下,用于保证操作系统不被中断干扰(操作系统与中断处理程序之间的竞争)。虽然有着缺点,但这种方法被操作系统广泛采用,在操作系统代码中,你可以看到大量开关中断的操作。

b. 测试并加锁 TSL指令
非常简单,不多说。有一个小问题:对多CPU系统有效吗?答案是有效。我们要理解:即使是在多CPU系统中,如果一个CPU要访问内存,它总会使用总线,而总线在同一时刻只能由一个CPU操作,总线这一个隐含的“锁”保证了对多个CPU每条指令访问内存的互斥。
enter_region:TSL REGISTER, LOCKCMP REGISTER, #0JNE enter_regionRETleave_region:MOVE LOCK, #0RET

c. 交换指令 XCHG
原理同上,交换一个寄存器和一片内存的值,可以用于多CPU系统。
enter_region:MOVE REGISTER, #1XCHG REGISTER, LOCKCMP REGISTER, #0JNE enter_regionRETleave_region:MOVE LOCK, #0RET

3、思考

到目前为止,我们已经明白如何保证一段语句的“原语”执行。如果我们希望对一个临界区给予“原语”的性质,那么只需要在进入临界区之前调用上述方法的任一enter_region,退出时调用leave_region,这就是我们所谓的“加锁”、“解锁”,这样即可保证临界区内的互斥。我们说PV是原语,或者sleep,wakeup是原语,都是采用了上述方法或其变形来保障。

对于上述的所有解法,我们在发现竞争时,都采取了“忙等待”的处理:在进程得到临界区访问权之前,持续测试而不做其他事情。也许你觉得这种方法太low了,为什么我们不sleep一下呢?拜托,我们是在学操作系统,在你面前除了丑陋的硬件指令外没什么选择。除非你先写出一个sleep函数来,否则就别做空中楼阁。

而且其实这种“忙等待”在某些情况下甚至比sleep更高效。在多CPU的环境中,假设P1发现P2已经进入了临界区,所以P1开始忙等待。而当P2出临界区时,它会放开内存锁(MOVE LOCK, #0),P1在忙等待时会突然发现它可以进入临界区了。这种忙等待锁通常叫做“自旋锁”(spin lock),它也被广泛用在操作系统编程中,尤其是多CPU环境且临界区范围比较小时,稍微“自旋”忙等待一下,要比请求sleep()进行调度快速高效的多。但在单CPU环境下,自旋锁是一定比带sleep()的锁低效的。

在正式讨论PV操作之前,我们需要先理解如何用现有的手段搭建PV操作的机制,然后再理解如何用PV解决问题。理解Peterson算法是有益的,尤其你会在之后看到那个类似“井漏”模型的用处。“凡有所学,皆成性格”,对此我一直很赞同。
1 0
原创粉丝点击