《JAVA併發程式設計實戰》原子變數和非阻塞同步機制

sayWhat_sayHello發表於2018-10-31

引言

即使原子變數沒有用於非阻塞演算法的開發,他們也可以用作一種“更好的”volatile型別變數。原子變數提供了與volatile型別變數相同的記憶體語義,此外還支援原子的更新操作,從而使他們更加適用於實現計數器、序列發生器和統計資料收集等,同時還能比基於鎖的方法提供更高的可伸縮性。

鎖的劣勢

通過使用一致的鎖定協議來協調對共享狀態的訪問,可以確保無論哪個執行緒持有守護變數的鎖,都能採用獨佔方式來訪問這些變數,並且對變數的任何修改對隨後獲得這個鎖的其他執行緒都是可見的。

如果多個執行緒同時請求鎖,那麼一些執行緒將被掛起並且在稍後恢復執行。當執行緒恢復執行時,必須等待其他執行緒執行完他們的時間片以後,才能被排程執行。在掛起和恢復執行緒等過程中存在著很大的開銷,並且通常存在著較長時間的中斷。

volatile通過較輕量級的同步機制,因為在使用這些變數時不會發生上下文切換或執行緒排程等操作。然而,volatile變數只能保證可見性,而不能保證原子性。

鎖定還有一些缺點,主要表現在上下文切換,排程延遲等。

硬體對軟體的支援

獨佔式是一項悲觀技術——它假設了最壞的情況(如果你不鎖門,你就會被偷),並且只有在確保其他執行緒不會造成干擾的情況下才能執行下去。

一種樂觀的方法:通過這種方法可以在不發生干擾的情況下完成更新操作。這種方法需要藉助衝突檢查機制來判斷在更新過程中是否存在來自其他執行緒的干擾,如果存在,則這個操作將失敗並且可以重試。

現代處理器支援了一些原子的寫指令:比較並交換(Compare and swap)或者關聯載入/條件儲存。

比較並交換

CAS包含了3個運算元——需要讀寫的記憶體為止V、進行比較的值A、擬寫入的新值B。當且僅當V的值等於A時,CAS才會通過原子方式用新值B來更新V,否則不執行任何操作。

模擬CAS

public class SimulatedCAS{
    private int value;
    public synchronized int get(){
        return value;
    }
    
    public synchronized int compareAndSwap(int expectedValue,int newValue){
        int oldValue = value;
        if(oldValue == newValue){
            value = newValue;
        }
        return oldValue;
    }
    
    public synchronized boolean compareAndSet(int expectedValue,int newValue){
        return (expectedValue == compareAndSwap(expectedValue,newValue);
    }
}

非阻塞的計數器

public class CasCounter{
    private SimulatedCAS value;
    
    public int getValue(){
        return value.get();
    }
    
    public int increment(){
        int v;
        do{
            v = value.get();
        }while(v != value.compareAndSwap(v,v+1));
        return v+1;
    }
}

當競爭不高時,基於CAS的計數器在效能上遠超過了基於鎖的計數器,而在沒有競爭的時候更高。

CAS的主要缺點是,它將使呼叫者處理競爭問題(通過重試、回退、放棄),而在鎖中能自動處理競爭問題(執行緒在獲得鎖之前一直阻塞)。

原子變數類

12個原子變數類,分為4組;

  1. 標量類:AtomicInteger,AtomiceLong,AtomicBoolean,AtomicReference
  2. 更新器類
  3. 陣列類
  4. 複合變數類

原子變數是一種“更好的volatile”

public class CasNumberRange{
    private static class IntPair{
        final int lower;
        final int upper;
        public IntPair(int lower,int upper){
            this.lower = lower;
            this.upper = upper;
        }
        ...
    }
    
    private final AtomicReference<IntPair> values = new AtomicReference<IntPair>(new IntPair(0,0));

    public int getLower(){
        return values.get().lower;
    }
    
    public int getUpper(){
        return values.get().upper;
    }
    
    public void setLower(int i){
        while(true){
            IntPair oldv = values.get();
            if(i > oldv.upper){
                throw new IllegalArgumentException("Can't set lower to "+i+" > upper");
            }
            
            IntPair newv = new IntPair(i,oldv.upper);
            if(values.compareAndSet(oldv,newv)){
                return;
            }
        }
    }
    
    ...
}

非阻塞演算法

如果在某種演算法中,一個執行緒的失敗或掛起不會導致其他執行緒也失敗或掛起,那麼這種演算法被稱為非阻塞演算法。如果在演算法的每個步驟中都存在某個執行緒能夠執行下去,那麼這種演算法也被稱為無鎖演算法。

非阻塞的棧

建立非阻塞演算法的關鍵在於,找出如何將原子修改的範圍縮小到單個變數上,同時還要維護資料一致性。

使用Trebier演算法構造的非阻塞棧:

public class ConcurrentStack<E> {
    AtomicReference<Node<E>> top = new AtomicReference<Node<E>>();
    
    public void push(E item){
        Node<E> newHead = new Node<E>(item);
        Node<E> oldHead;
        
        do{
            oldHead = top.get();
            newHead.next = oldHead;
        } while(!top.compareAndSet(oldHead,newHead));
    }
    
    public E pop(){
        Node<E> oldHead;
        Node<E> newHead;
        do{
            oldHead = top.get();
            if(oldHead == null){
                return null;
            }
            newHead = oldHead.next;
        } while (!top.compareAndSet(oldHead,newHead));
        return oldHead.item;
    }
    
    private static class Node<E> {
        public final E item;
        public Node<E> next;
        
        public Node(E item){
            this.item = item;
        }
    }
}

非阻塞的連結串列

連結需要維護頭指標和尾指標。問題在於如何同時維護這兩個變數,避免一致性遭到破壞。

第一個技巧是,即使在一個包含多個步驟的更新操作中,也要確保資料結構總是處於一致的狀態。第二個技巧是,如果當B到達時發現A正在修改資料結構,那麼資料結構中應該有足夠多的資訊,使得B能完成A的更新操作。

ABA問題

一個值由A變為B,然後在變為A。引入版本號。

相關文章