转:并发编程中ABA问题

什么是ABA问题

转发地址:http://www.lantaozi.com/article/521b199f0ff2456b5b000001

 

ABA并不是一个缩写,更像是一个形象的描述。ABA问题出现在多线程或多进程计算环境中。该问题Wiki上有详细的介绍,本文将进行更通俗的演绎该问题。

首先描述ABA。假设两个线程T1T2访问同一个变量V,当T1访问变量V时,读取到V的值为A;此时线程T1被抢占了,T2开始执行,T2先将变量V的值从A变成了B,然后又将变量VB变回了A;此时T1又抢占了主动权,继续执行,它发现变量V的值还是A,以为没有发生变化,所以就继续执行了。这个过程中,变量VA变为B,再由B变为A就被形象地称为ABA问题了。

上面的描述看上去并不会导致什么问题。T1中的判断V的值是A就不应该有问题的,无论是开始的A,还是ABA后面的A,判断的结果应该是一样的才对。

不容易看出问题的主要还是因为:值是一样的等同于没有发生变化(就算被改回去了,那也是变化)的认知。毕竟在大多数程序代码中,我们只需要知道值是不是一样的,并不关心它在之前的过程中有没有发生变化;所以,当我需要知道之前的过程中有没有发生变化的时候,ABA就是问题了。

现实ABA问题

Wiki百科上有个形象的现实的例子,这里可以简单用两个字来概括下:掉包。

警匪剧看多了人应该可以快速反应到发生了什么。应用到ABA问题,首先,这里的AB并不表示被掉的包这个实物,而是掉包过程中的状态的变化。假设一个装有10000W箱子(别管它有多大)放在一个房间里,10分钟后再进去拿出来赎人去。但是,有个贼在这10分钟内进去(别管他是怎么进去的)用一个同样大小的空箱子,把我的箱子掉包了。当我再进去看的时候,发现箱子还在,自然也就以为没有问题了的,就继续拿着桌子上的箱子去赎人了(别管重量对不对)。现在只要知道这里有问题就行了,拿着没钱的箱子去赎人还没有问题么?

这里的变量V就是桌子上是否有箱子的状态。A,是桌子上有箱子的状态;B是箱子在掉包过程中,离开桌子,桌子上没有箱子的状态;最后一个A也是桌子上有箱子的状态。但是箱子里面的东西是什么就不不知道了。

程序世界的ABA问题

在程序的世界里,ABA是什么样呢?讨论ABA问题之前,先来讨论一种操作——CAS操作。

在多线程操作环境中,如果某些操作不是原子的,那么一般如果想让它变成原子的,那么就需要采用一些同步的方法,加个锁什么的。比如说,++就不是一个原子的操作(虽然只有一行)。我们还知道,有一种Atomic原子类,它可以原子地实现我们直观上认为的非原子操作。而且观察它的源码你会发现,它并没有使用锁这样的方式。那么Atomic是怎么原子地来进行操作的呢?看下面的代码:

    public final int getAndAdd( int delta) {

        for (;;) {

            int current = get();

            int next = current + delta;

            if (compareAndSet(current, next))

                return current;

        }

    }

这是AtomicInteger中的一个方法:获取并增加delta值量。这里用来保证线程安全的是compareAndSet操作,也就是传说中的CAS操作了。如果CAS操作没有成功地话,那么会重新去获取最新的valuevolatile用来保证线程可见性),重新执行一遍,直到成功为止。这里使用CAS乐观锁,而不是synchronized悲观锁。

1.    CAS乐观锁算法相对于synchronized的操作显然有不少的优点,不会出现死锁等活跃性问题

2.    为什么CAS操作能保证原子性?这得益于底层硬件的支持,硬件上保证了操作是线程安全的。

大多数乐观锁算法都是基于CAS操作的,因为没有锁定操作,当进行一些重要操作的时候,就需要检查相关的状态有没有被其他线程改变,比如JUC中并发集合(Collections)等。在状态进行检查比较过程中,就可以出现ABA问题。不过,上面的CAS显然不会存在ABA问题。

在实现一个复杂的数据结构的时候,比如说并发集合(初始一个元素A)进行操作的时候,如push一个新元素B。在并发中,加锁实现的时候,可以保证push过程中,栈的元素不会被别的线程改变。但是,CAS实现的时候,可能会存在如下的代码片段:

           oldHead = top .get();           //1

           newHead = oldHead .next()       //2

           top.CAS(oldHead , newHead);//3

如果执行到第1行结束的时候,另一个线程pop了原来的topA,然后push一个新的元素C,然后又把A push进去了。结构变成了A->C->A。但是这个线程还以为是top->A,继续执行后变成了top->B->A->C。至于之后会发生什么问题,就要根据实际情况查看了。

实际上,CAS对一个地址的操作更容易出现ABA问题。也就是现实ABA例子中,对箱子操作,而不是箱子里的内容进行操作。在C这些需要自己管理内存的语言中,容易诱发严重的问题。有一定C基础的,也可以参考[3]。其实ABA问题不一定只与CAS操作有关系。

Java中的例子

Jdkconcurrent包中就有对ABA问题的处理,比如说,CocurrentHashMap中的处理。ConcurrentHashMap中的某些操作使用了半无锁的处理方式。简单来说,就是先用无锁的算法理念进行处理,如果有问题,那么就强制锁定每个切片(ConcurrentHashMap将元素hash到不同的切片中),从而确保操作正确性。

比如isEmpty的实现,这里并没有锁定segments里的每个切片:

    public boolean isEmpty() {

        final Segment<K,V>[] segments = this. segments;

        /*

         * We keep track of per-segment modCounts to avoid ABA

         * problems in which an element in one segment was added and

         * in another removed during traversal, in which case the

         * table was never actually empty at any point. Note the

         * similar use of modCounts in the size() and containsValue()

         * methods, which are the only other methods also susceptible

         * to ABA problems.

         */

        int[] mc = new int[segments.length];

        int mcsum = 0;

        for (int i = 0; i < segments. length; ++i) {

            if (segments[i]. count != 0)

                return false;

            else

                mcsum += mc[i] = segments[i]. modCount;

        }

        // If mcsum happens to be zero, then we know we got a snapshot

        // before any modifications at all were made.  This is

        // probably common enough to bother tracking.

        if (mcsum != 0) {

            for ( int i = 0; i < segments. length; ++i) {

                if (segments[i]. count != 0 ||

                    mc[i] != segments[i]. modCount)

                    return false;

            }

        }

        return true;

    }

这里的ABA问题是什么?就是在并发的环境中,当执行到if (mcsum != 0) { 行,现在的状态是A(所有的切片中都是空的),在线程环境中,如果不加锁,这是不靠谱的,我们还是再判断一遍,保证这段时间内,切片中还是空的。如果我们不考虑mode检查(忽略所有与modCount相关的代码),那么在第二遍检查的时候,如果所有的切片中还是空的(A),那么我们是否可以直接返回true呢?这是不对的,我们不知道有没有别的线程在我们检查同一切片的过程中插入了一个元素(B),然后又删除了(A)。那么如果存在这样的情况,我们是不是应该返回true呢?好吧,并发环境中,实在不好严格地判断。实际上,这样是被判false的。

它是怎么解决ABA问题的呢?第一个循环中,分别检查每个切片是否为空的,如果不是,直接返回false,如果是,那么就记录每个切片的modCount(每次改变元素数量的时候+1)并做一个检查和mcsum。如果执行到判断mcsum是否为0,就表示在之前的循环中,所有的切片中的元素在该线程中被判断的时候,是为空的。至于在检查前后是否被修改未知(多线程环境中,即使同一时间点不是所有的切片中都为空的,最后的结果也可能是空的,因为检查不是在同一个时间点进行的)。

如果最后的mcsum0modCount的初始值为0),表示还没有操作过这个map,就为空的,这个没有问题。

mcsum不为0的时候,再次检查每个切片中元素的数量,如果不为0,显然就不为空了,否则,看modCount是否和之前记录的mc的是否相等,不等表示有改变(添加或删除元素),如果有改变,那么也就返回false了。

按照加锁的思维,我们判断isEmpty的时候只要把所有是切片都锁定,然后挨个统计数量就好了(事实上,size方法中有这样的操作,就是在尝试不加锁的情况下,有问题的时候,就会进行锁定),但是这样的话,判断是否为空的时候,其他的线程就可能阻塞。不加锁是对性能上的优化。

modCount就是解决ABA问题中思路的体现。每次操作元素的时候,不止更新切片中的元素列表,同时维护一个修改的版本(modCount),如果修改就加1,那么,通过检查这个版本就可以知道是否有被修改过。

思考:Segment中为什么countvolatile的,但是modCount不是呢?

Java中解决ABA

Java中对于ABA问题的解决,提供下面两种方法(主要是跟踪每次修改,并记录变化):AtomicMarkableReference/AtomicStampedReference具体可以查看相关API

总结

简单说来,在多线程环境中,为了进行性能上的优化,可以设计一些无锁的算法。这里面会需要进行较多的判断,有些判断是十分关键的(比如说CAS中的判断),ABA主要存在这些判断中。有的时候,我们并不只是需要判断变量是不是我们看到的那个值,还需要在执行操作的过程中,判断这个变量是否已经发生了改变。

得益于垃圾回收机制,用Java设计无锁算法的时候,可能出现ABA问题的情况还是相对比较少的。

[参考]

1.    Java并发编程实践 Ch15

2.    wiki百科ABA 

3.    无锁队列的 

你可能感兴趣的:(cas,ABA)