这几天正在学PV,觉得网上的总结都不全面,还是自己归纳一下吧。
一、解决互斥
1、软件方案
纯用编程来解决互斥是非常难的事,有很多错误的解法,非常考验编程技巧。直到1965年,被提出的Dekker算法才被认为是的正确的纯软件解法。而1981年,Peterson算法才可以算正确的且简洁的解决。这里只介绍Peterson算法。其它算法和Dekker算法请看教科书。
#define FALSE 0
#define TRUE 1
#define N 2 // 进程的个数
int turn; // 轮到谁?
int interested[N];// 兴趣数组,初始值均为FALSE
void 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};// 兴趣阶段数组,每个进程一个,数组内容表示这个进程的兴趣阶段,初始值均为-1
int 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、硬件方案
enter_region:
TSL REGISTER, LOCK
CMP REGISTER, #0
JNE enter_region
RET
leave_region:
MOVE LOCK, #0
RET
c. 交换指令 XCHG
enter_region:
MOVE REGISTER, #1
XCHG REGISTER, LOCK
CMP REGISTER, #0
JNE enter_region
RET
leave_region:
MOVE LOCK, #0
RET