所谓lock-free和wait-free算法是指对于共享的数据并非对其加锁来控制访问,而是多个线程并行的访问。通过该算法可以达到对共享对象并发的读写而不会破坏对象本身。所谓lock-free是指对于线程不加锁,让系统执行所有的步骤。lock-free提到的不加锁是指不使用类似于互斥锁或者信号量之类的排他机制。因为一旦对线程加锁的话,当线程执行中断时,那么对于这个系统来说运行也中断了。所谓wait-free是指,不管其他线程执行什么操作,线程无论有什么操作都能在有限的步骤里面完成。所以对于算法来说达到lock-free不一定能达到wait-free,但是达到wait-free的算法一定是lock-free的。
意义
在多线程编程中,对于共享资源的访问最传统的做法就是加锁。互斥锁和信号量本质上都是在代码层面的某一段逻辑上加上排他机制,从而达到对于共享资源的访问不造成破坏性的结果。假如某个线程需要获得已经被其他线程先占有的锁,那么在那个锁释放之前,这个线程的工作会陷入停止状态。
很多情况下,我们都不希望看到线程的运行停止。首先,阻塞中的线程无法做任何事情。其次,如果线程要处理的事务优先级很高乃至要实时处理的话,我们也不希望看到线程被阻塞。再者,当多个资源被锁的时候,就容易出现死锁、活锁或者优先顺序颠倒等问题。最后,使用锁的地方,如果对加锁的逻辑颗粒度很大的话会导致并行处理的机会会减少,如果加锁颗粒度太细又容易产生bug而不得不小心设计,最后陷入死胡同。
wait-free的数据结构
使用wait-free的数据结构的应用程序中,与其将原来使用互斥的算法改造为wait-free的算法,不如直接使用基于wait-free算法开发的stack、queue、set和map。例如,在Java 5以后,java.util.concurrent包中就引入了wait-free的数据结构。通过直接使用这些wait-free的数据结构,编写线程的异步数据访问也将变得很容易。
举个银行存钱的例子
例如,在银行的柜台有个存钱的程序。每个线程相当于一个ATM。当金钱存入的时候,需要将当前余额读出来,然后加上要存入的金额算出新的余额。如果通过锁来实现的话,当一台ATM在计算的时候,为了让其他ATM不能同时变更余额,需要加锁。否则的话,如果同时更新将导致数据错误。如果通过lock-free来实现的话,需要一个管理所有存入请求的独立线程,然后创建一个wait-free的队列。所有ATM异步的将存入金额的请求放入队列中而无需加任何的锁。管理所有存入请求的独立线程从队列中依次取出请求,更新账户余额。通过以上方式,无需单独实现lock-free的存钱算法,编程也更加便捷。同时,该方法因为队列是wait-free的,所以不仅仅实现了lock-free也实现了wait-free。对于余额的更新如果需要N并发的话,只需要创建N个wait-free的队列,然后根据账号对N取余放入对应队列中即可。
CAS Compare and Swap
在实现lock-free和wait-free算法时,需要CPU专用的管理指令来完成。其中最重要的就是Compare and Swap(简称CAS)。在Java中,在java.util.concurrent.atomic包中类方法compareAndSet来实现。其中使用到了内存地址、旧值和新值。如果该地址所保存的值和旧值相同则替换为新值,如果不是的话则什么都不做。然后将处理成功与否的结果返回。所以需要CPU支持该方法。目前Intel的芯片中有该功能。通过该功能实现了从内存中读出数据,进行更新后写入的时候其他线程无法同时更新的算法。
继续拿前面的银行柜台来举例,我们换个算法来实现。ATM将当前余额读出、计算再写入这三个步骤可以类比为CPU的CAP操作。进行这三个步骤的时候,没有其他线程更新该值的话则认为操作成功。如果在进行这三步的时候,第一步读出的金额和第三步更新要用新值去替换的金额不一致的情况下,命令直接失败,然后操作回滚到第一步重新执行。所有的ATM都是遵循这个方法,在成功之前都反复执行这三步。这个算法是lock-free的但是不是wait-free的。因为当其他ATM进行操作的时候,会影响当前ATM的操作,导致可能要反复执行步骤。
ABA问题
CAS实现的过程是先取出内存中某时刻的数据,在下一时刻比较并替换。
- 1号线程从内存位置 α 中取出值 A
- 2号线程也从内存位置 α 中取出值 A
- 2号线程进行了一些操作将 α 位置的数据从 A 变成了 B
- 2号线程又进行了一些操作将 α 位置的数据变成A
- 这时候1号线程进行 CAS 操作发现内存中仍然是A,然后操作成功。
尽管1号线程的CAS操作成功,但是不代表这个过程就是没有问题的。
我们来看个具体例子:
现有一个用单向链表实现的堆栈,栈顶为A,这时线程T1已经知道A.next为B:
head→A→B
后希望用CAS将栈顶替换为B
head.compareAndSet(A,B);
在T1执行上面这条指令之前,线程T2介入,将A、B出栈,再pushD、C、A,此时堆栈结构如下,而对象B此时处于游离状态:
head→A→C→D
B
此时轮到线程T1执行CAS操作,检测发现栈顶仍为A,所以CAS成功,栈顶变为B,但实际上B.next为null,所以此时的情况变为:
head→B
A→C→D
其中堆栈中只有B一个元素,C和D组成的链表不再存在于堆栈中,平白无故就把C、D丢掉了。
AtomicStampedReference/AtomicMarkableReference
以上就是由于ABA问题带来的隐患,各种乐观锁的实现中通常都会用版本戳version来对记录或对象标记,避免并发操作带来的问题,在Java中,AtomicStampedReference<E>也实现了这个作用,它通过包装[E,Integer]的元组来对对象标记版本戳stamp,从而避免ABA问题。
下面的代码用AtomicStampedReference来对初始值为100的原子整型变量进行更新,而加上版本戳的AtomicStampedReference对于ABA问题会执行CAS失败:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicStampedReference;
public class ABA {
private static AtomicStampedReference<Integer> atomicStampedRef =
new AtomicStampedReference<Integer>(100, 0);
public static void main(String[] args) throws InterruptedException {
Thread refT1 = new Thread(new Runnable() {
@Override
public void run() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
atomicStampedRef.compareAndSet(100, 101,
atomicStampedRef.getStamp(), atomicStampedRef.getStamp()+1);
atomicStampedRef.compareAndSet(101, 100,
atomicStampedRef.getStamp(), atomicStampedRef.getStamp()+1);
}
});
Thread refT2 = new Thread(new Runnable() {
@Override
public void run() {
int stamp = atomicStampedRef.getStamp();
System.out.println("before sleep : stamp = " + stamp); // stamp = 0
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("after sleep : stamp = " + atomicStampedRef.getStamp());//stamp = 2
boolean c3 = atomicStampedRef.compareAndSet(100, 101, stamp, stamp+1);
System.out.println(c3); //false
}
});
refT1.start();
refT2.start();
}
}
作者:宋雾代
链接:https://www.jianshu.com/p/baaf53d69b51
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。