导读
分析并发计算的实质就是分析时间。有时候希望事件同时发生,有时候希望事件在不同时间发生。— 《多处理器编程的艺术》
本章内容会介绍一种互斥锁算法也就是标题中所说的过滤锁算法,虽然它在实际应用中可能并不实用,但却是一种经典算法。掌握这些算法将会有助于你对并发,互斥,死锁,饥饿的理解。
互斥 mutual exclusion
在多线程环境中所有线程共享一个共同的时间。一个线程是一个状态机,而它状态的转换称为事件。事件是瞬时的,它们在单个瞬间发生。在并发编程中互斥是指在同一时刻,至多只能有一个线程进入临界区内,这样的特性称为互斥。如果并发执行的程序没有互斥特性,将会无法保障计算结果的正确性。所以说互斥是一种安全特性。为了直观的便于大家理解,下面会举一个生活中的例子说明互斥特性。
上图是一辆和谐号动车正在一条铁轨上行驶,很显然在同一时刻这条铁轨的某一段中最多只能允许一辆列车在上面行驶,这就是互斥特性的体现。如果说在某一时刻这条铁轨的同一段中出现了两辆列车都在行驶,那么就会产生安全问题,这体现了互斥的安全特性。对上面这个模型进行抽象,时间是永远不会改变且不会受外界影响的,行驶的和谐号列车可以看作是一个运行的线程,而这条铁轨的某一段可以看作临界区
在并发编程当中与互斥特性密切相关的还有无死锁,无饥饿两个重要特性。聪明的读者应该已经想到了,为什么会有无死锁和无饥饿这两个重要特性。当然是因为互斥有时候会引发死锁和饥饿,如果没有这两个问题那么我们根本都不需要谈论无死锁和无饥饿了。
死锁
首先我们先来解释死锁,先让大家了解什么是死锁,才会更深刻的体会到为什么我们期望无死锁。还是通过一个现实中的例子说明,有两只羊都要通过一个独木桥到对岸去,它们两个都走到了桥上希望到对岸去。但是这个独木桥太小了同时只能允许一只羊通过。于是两只羊开始僵持不下,它们都迫切且极度固执的希望立刻能通过桥到对岸去,谁也不肯让步。于是它们开始在桥上等待对方先退后让步。不知道这样僵持过了多久,它们都死在了桥上只剩下骨头架子了。故事的结局略显悲伤,但是却形象的说明了什么是“死锁”。
然后我们再用计算机语言的表述方式抽象上面这个故事。当两个以上的计算单元,双方都在等待对方让出某个共享资源以供自己使用,但是却没有一方提前让出该资源时,就称为死锁。死锁造成的后果会使得我们的程序“冻结”,无法继续执行。
饥饿
接下来我们来解释饥饿,这个概念应该是最好理解的一个了,我们还是通过一些例子进行说明。这次我们先不讲故事了,先看图我估计看完图不用看文字都明白了。
这下大家明白什么是饥饿特性了吧,因为真的很饿啊
,不开玩笑了噢好好说 。所谓饥饿其实就是等待,从上面两个图中我们可以观察到,当我们很饿需要吃饭的时候,首先需要排队进行等待,排队结束后如果没有位子坐,还需要等待位子。当等待结束的时候就可以吃饭了,这时候饥饿状态就结束了。
无死锁、无饥饿
再次回到计算机的世界中来,当我们的程序中发生了死锁,饥饿状况的时候是非常糟糕的,会导致程序停滞,冻结。无死锁要求,当一个线程正在尝试获取一个锁,那么总会成功获得这个锁。如果无法获取到这个锁,则一定存在其他线程正在进行无穷次的执行临界区。而无饥饿则要求每一个试图获取锁的线程最终都能成功。注意无饥饿也就意味着无死锁。但是无死锁并不一定无饥饿。比如一个线程在临界区中进行死循环的时候,其他所有线程都处于饥饿状态。在了解了互斥特性后接下来我们进入到互斥算法的内容。
经典双线程互斥算法
遵循由易而难得规则,我们先来看双线程环境下的互斥算法。双线程互斥算法遵循以下两点约定:
-
线程标识为 0 或 1;
-
若当前线程为 i ,另一个线程为 j = 1 - i ;
LockOne 算法
class LockOne implements Lock {
private boolean[] flag = new boolean[2];
public void lock() {
int i = ThreadId.get();
int j = 1 - i;
flag[i] = true;
while(flag[j]) {}
}
public void unlock() {
int i = ThreadID.get();
flag[i] = false;
}
}
这个算法会将第二个访问 lock() 函数的线程阻塞在 while(true) 的死循环当中。这个算法满足了互斥的特性,但是会产生死锁,因为 lock() 自身并不是互斥的,所以两个线程可能同时都将对方的 flag 标识设置为 true ,导致两个线程都被阻塞在 while(true) {} 。
LockTwo 算法
class LockTwo implements Lock {
private volatile int victim;
public void lock() {
int i = ThreadId.get();
victim = i;
while(victim == i) {}
}
public void unlock() {
}
}
LockTwo 算法的特点是阻塞率先执行 victim = i ;的线程,而让另外一个线程优先执行。这个算法也满足互斥特性。但是却存在死锁:当一个线程先执行,而另一个线程迟迟不执行,这时候第一个线程就被锁住无法进行向下执行。
Peterson 算法
将 LockOne 和 LockTwo 算法结合起来,构造出一种无饥饿的锁算法。该算法无疑是最简洁、最完美的双线程互斥算法,按照其发明者的名字命名为Peterson 算法。
class PetersonLock implements Lock {
private boolean[] flag = new boolean[2];
private volatile int victim;
public void lock() {
int i = ThreadId.get();
int j = 1 - i;
flag[i] = true;
victim = i;
while(flag[j] && victim == i) {}
}
public void unlock() {
int i = ThreadID.get();
flag[i] = false;
}
}
过滤锁互斥算法
最后终于到了我们今天的主题过滤锁互斥算法。它是Peterson锁算法在多线程上的直接一般化。我们假设有n个线程,过滤锁会建立一个 n - 1 个等待"层级",每个线程必须穿过每一层最终才可以进入临界区。这种层级结构中每一层都满足两个特性:
1. 至少有一个进入层 a 的线程会成功;
2. 如果有一个以上的线程要进入层 a ,则至少有一个线程会在层 a 进行等待。
class FilterLock implements Lock {
int[] level;
int[] victim;
int n;
public FilterLock(int n) {
level = new int[n];
victim = new int[n];
this.n = n;
for (int i = 0;i < n; i++) {
level[i] = 0;
}
}
public void lock() {
int me = ThreadID.get();
for (int i = 0; i < n; i++) {
level[me] = i;
victim[i] = me;
for (int k = 0; k < n;k++) {
while ((k != me) && (level[k] >= i && victim[i] == me))) {
}
}
}
}
public void unlock() {
int me = ThreadID.get();
level[me] = 0;
}
}
第一层 for 循环用来控制线程所处的层级。
第二层 for 循环和 while 循环用来检查除了自己外的其他线程是否是在本层级或是下一层级中。以及本线程是否要在目前层级中阻塞等待。
如果没有其他线程占用下个层级,本线程会被进入到下个层级别再次重复上述判断过程。
过下面这个图示说明也许会帮助进行理解:
好了到这里过滤锁算法就讲完了,希望对你有帮助,如果觉得有收获还请点击"再看"自持作者。本人才疏学浅,如果文中有不当之处还请留言指正。
看完本文有收获?请分享给更多人
微信关注「黑帽子技术」加星标,看精选 IT 技术文章
来源:oschina
链接:https://my.oschina.net/j4love/blog/3198371