原子變量, 無鎖定且無等待算法

Java 理論與實踐: 流行的原子

新原子類是 java.util.concurrent 的隱藏精華html

developerWorks
文檔選項
<>

討論java

將此頁做爲電子郵件發送

將此頁做爲電子郵件發送算法


級別: 初級編程

Brian Goetz (brian@quiotix.com), 首席顧問, Quiotix
api

2004 年 11 月 23 日數組

在 JDK 5.0 以前,若是不使用本機代碼,就不能用 Java 語言編寫無等待、無鎖定的算法。在 java.util.concurrent 中添加原子變量類以後,這種狀況發生了變化。請跟隨並行專家 Brian Goetz 一塊兒,瞭解這些新類如何使用 Java 語言開發高度可伸縮的無阻塞算法。您能夠在本文的 論壇中與做者或其餘讀者共享您對本文的見解。(也能夠經過單擊文章頂部或者底部的 討論連接來訪問討論。)

十五年前,多處理器系統是高度專用系統,要花費數十萬美圓(大多數具備兩個到四個處理器)。如今,多處理器系統很便宜,並且數量不少,幾乎每一個主要微處理器都內置了多處理支持,其中許多系統支持數十個或數百個處理器。 安全

要使用多處理器系統的功能,一般須要使用多線程構造應用程序。可是正如任何編寫併發應用程序的人能夠告訴你的那樣,要得到好的硬件利用率,只是簡單地在多個線程中分割工做是不夠的,還必須確保線程確實大部分時間都在工做,而不是在等待更多的工做,或等待鎖定共享數據結構。 數據結構

問題:線程之間的協調多線程

若是線程之間 須要協調,那麼幾乎沒有任務能夠真正地並行。以線程池爲例,其中執行的任務一般相互獨立。若是線程池利用公共工做隊列,則從工做隊列中刪除元素或向工做隊列添加元素的過程必須是線程安全的,而且這意味着要協調對頭、尾或節點間連接指針所進行的訪問。正是這種協調致使了全部問題。 併發

標準方法:鎖定

在 Java 語言中,協調對共享字段的訪問的傳統方法是使用同步,確保完成對共享字段的全部訪問,同時具備適當的鎖定。經過同步,能夠肯定(假設類編寫正確)具備保護一組給定變量的鎖定的全部線程都將擁有對這些變量的獨佔訪問權,而且之後其餘線程得到該鎖定時,將能夠看到對這些變量進行的更改。弊端是若是鎖定競爭太厲害(線程經常在其餘線程具備鎖定時要求得到該鎖定),會損害吞吐量,由於競爭的同步很是昂貴。(Public Service Announcement:對於現代 JVM 而言,無競爭的同步如今很是便宜。

基於鎖定的算法的另外一個問題是:若是延遲具備鎖定的線程(由於頁面錯誤、計劃延遲或其餘意料以外的延遲),則 沒有要求得到該鎖定的線程能夠繼續運行。

還可使用可變變量來以比同步更低的成本存儲共享變量,但它們有侷限性。雖然能夠保證其餘變量能夠當即看到對可變變量的寫入,但沒法呈現原子操做的讀-修改-寫順序,這意味着(好比說)可變變量沒法用來可靠地實現互斥(互斥鎖定)或計數器。

使用鎖定實現計數器和互斥

假如開發線程安全的計數器類,那麼這將暴露 get()increment()decrement() 操做。清單 1 顯示瞭如何使用鎖定(同步)實現該類的例子。注意全部方法,甚至須要同步 get(),使類成爲線程安全的類,從而確保沒有任何更新信息丟失,全部線程都看到計數器的最新值。


清單 1. 同步的計數器類
public class SynchronizedCounter {
    private int value;
    public synchronized int getValue() { return value; }
    public synchronized int increment() { return ++value; }
    public synchronized int decrement() { return --value; }
}

increment()decrement() 操做是原子的讀-修改-寫操做,爲了安全實現計數器,必須使用當前值,併爲其添加一個值,或寫出新值,全部這些均視爲一項操做,其餘線程不能打斷它。不然,若是兩個線程試圖同時執行增長,操做的不幸交叉將致使計數器只被實現了一次,而不是被實現兩次。(注意,經過使值實例變量成爲可變變量並不能可靠地完成這項操做。)

許多併發算法中都顯示了原子的讀-修改-寫組合。清單 2 中的代碼實現了簡單的互斥, acquire() 方法也是原子的讀-修改-寫操做。要得到互斥,必須確保沒有其餘人具備該互斥( curOwner = Thread.currentThread()),而後記錄您擁有該互斥的事實( curOwner = Thread.currentThread()),全部這些使其餘線程不可能在中間出現以及修改 curOwner field


清單 2. 同步的互斥類
public class SynchronizedMutex {
    private Thread curOwner = null;
    public synchronized void acquire() throws InterruptedException {
        if (Thread.interrupted()) throw new InterruptedException();
        while (curOwner != null) 
            wait();
        curOwner = Thread.currentThread();
    }
    public synchronized void release() {
        if (curOwner == Thread.currentThread()) {
            curOwner = null;
            notify();
        } else
            throw new IllegalStateException("not owner of mutex");
    }
}

清單 1 中的計數器類能夠可靠地工做,在競爭很小或沒有競爭時均可以很好地執行。然而,在競爭激烈時,這將大大損害性能,由於 JVM 用了更多的時間來調度線程,管理競爭和等待線程隊列,而實際工做(如增長計數器)的時間卻不多。您能夠回想 上月專欄中的圖,該圖顯示了一旦多個線程使用同步競爭一個內置監視器,吞吐量將如何大幅度降低。雖然該專欄說明了新的 ReentrantLock 類如何能夠更可伸縮地替代同步,可是對於一些問題,還有更好的解決方法。

鎖定問題

使用鎖定,若是一個線程試圖獲取其餘線程已經具備的鎖定,那麼該線程將被阻塞,直到該鎖定可用。此方法具備一些明顯的缺點,其中包括當線程被阻塞來等待鎖定時,它沒法進行其餘任何操做。若是阻塞的線程是高優先級的任務,那麼該方案可能形成很是很差的結果(稱爲 優先級倒置的危險)。

使用鎖定還有一些其餘危險,如死鎖(當以不一致的順序得到多個鎖定時會發生死鎖)。甚至沒有這種危險,鎖定也僅是相對的粗粒度協調機制,一樣很是適合管理簡單操做,如增長計數器或更新互斥擁有者。若是有更細粒度的機制來可靠管理對單獨變量的併發更新,則會更好一些;在大多數現代處理器都有這種機制。





回頁首


硬件同步原語

如前所述,大多數現代處理器都包含對多處理的支持。固然這種支持包括多處理器能夠共享外部設備和主內存,同時它一般還包括對指令系統的增長來支持多處理的特殊要求。特別是,幾乎每一個現代處理器都有經過能夠檢測或阻止其餘處理器的併發訪問的方式來更新共享變量的指令。

比較並交換 (CAS)

支持併發的第一個處理器提供原子的測試並設置操做,一般在單位上運行這項操做。如今的處理器(包括 Intel 和 Sparc 處理器)使用的最通用的方法是實現名爲 比較並轉換或 CAS 的原語。(在 Intel 處理器中,比較並交換經過指令的 cmpxchg 系列實現。PowerPC 處理器有一對名爲「加載並保留」和「條件存儲」的指令,它們實現相同的目地;MIPS 與 PowerPC 處理器類似,除了第一個指令稱爲「加載連接」。)

CAS 操做包含三個操做數 —— 內存位置(V)、預期原值(A)和新值(B)。若是內存位置的值與預期原值相匹配,那麼處理器會自動將該位置值更新爲新值。不然,處理器不作任何操做。不管哪一種狀況,它都會在 CAS 指令以前返回該位置的值。(在 CAS 的一些特殊狀況下將僅返回 CAS 是否成功,而不提取當前值。)CAS 有效地說明了「我認爲位置 V 應該包含值 A;若是包含該值,則將 B 放到這個位置;不然,不要更改該位置,只告訴我這個位置如今的值便可。」

一般將 CAS 用於同步的方式是從地址 V 讀取值 A,執行多步計算來得到新值 B,而後使用 CAS 將 V 的值從 A 改成 B。若是 V 處的值還沒有同時更改,則 CAS 操做成功。

相似於 CAS 的指令容許算法執行讀-修改-寫操做,而無需懼怕其餘線程同時修改變量,由於若是其餘線程修改變量,那麼 CAS 會檢測它(並失敗),算法能夠對該操做從新計算。清單 3 說明了 CAS 操做的行爲(而不是性能特徵),可是 CAS 的價值是它能夠在硬件中實現,而且是極輕量級的(在大多數處理器中):


清單 3. 說明比較並交換的行爲(而不是性能)的代碼
public class SimulatedCAS {
    private int value;
    
    public synchronized int getValue() { return value; }
    
    public synchronized int compareAndSwap(int expectedValue, int newValue) {
        if (value == expectedValue) 
            value = newValue;
        return value;
    }
}

使用 CAS 實現計數器

基於 CAS 的併發算法稱爲 無鎖定算法,由於線程沒必要再等待鎖定(有時稱爲互斥或關鍵部分,這取決於線程平臺的術語)。不管 CAS 操做成功仍是失敗,在任何一種狀況中,它都在可預知的時間內完成。若是 CAS 失敗,調用者能夠重試 CAS 操做或採起其餘適合的操做。清單 4 顯示了從新編寫的計數器類來使用 CAS 替代鎖定:


清單 4. 使用比較並交換實現計數器
public class CasCounter {
    private SimulatedCAS value;
    public int getValue() {
        return value.getValue();
    }
    public int increment() {
        int oldValue = value.getValue();
        while (value.compareAndSwap(oldValue, oldValue + 1) != oldValue)
            oldValue = value.getValue();
        return oldValue + 1;
    }
}





回頁首


無鎖定且無等待算法

若是每一個線程在其餘線程任意延遲(或甚至失敗)時都將持續進行操做,就能夠說該算法是 無等待的。與此造成對比的是, 無鎖定算法要求僅 某個線程老是執行操做。(無等待的另外一種定義是保證每一個線程在其有限的步驟中正確計算本身的操做,而無論其餘線程的操做、計時、交叉或速度。這一限制能夠是系統中線程數的函數;例如,若是有 10 個線程,每一個線程都執行一次 CasCounter.increment() 操做,最壞的狀況下,每一個線程將必須重試最多九次,才能完成增長。)

再過去的 15 年裏,人們已經對無等待且無鎖定算法(也稱爲 無阻塞算法)進行了大量研究,許多人通用數據結構已經發現了無阻塞算法。無阻塞算法被普遍用於操做系統和 JVM 級別,進行諸如線程和進程調度等任務。雖然它們的實現比較複雜,但相對於基於鎖定的備選算法,它們有許多優勢:能夠避免優先級倒置和死鎖等危險,競爭比較便宜,協調發生在更細的粒度級別,容許更高程度的並行機制等等。

原子變量類

在 JDK 5.0 以前,若是不使用本機代碼,就不能用 Java 語言編寫無等待、無鎖定的算法。在 java.util.concurrent.atomic 包中添加原子變量類以後,這種狀況才發生了改變。全部原子變量類都公開比較並設置原語(與比較並交換相似),這些原語都是使用平臺上可用的最快本機結構(比較並交換、加載連接/條件存儲,最壞的狀況下是旋轉鎖)來實現的。 java.util.concurrent.atomic 包中提供了原子變量的 9 種風格( AtomicIntegerAtomicLongAtomicReferenceAtomicBoolean;原子整型;長型;引用;及原子標記引用和戳記引用類的數組形式,其原子地更新一對值)。

原子變量類能夠認爲是 volatile 變量的泛化,它擴展了可變變量的概念,來支持原子條件的比較並設置更新。讀取和寫入原子變量與讀取和寫入對可變變量的訪問具備相同的存取語義。

雖然原子變量類表面看起來與清單 1 中的 SynchronizedCounter 例子同樣,但類似僅是表面的。在表面之下,原子變量的操做會變爲平臺提供的用於併發訪問的硬件原語,好比比較並交換。

更細粒度意味着更輕量級

調整具備競爭的併發應用程序的可伸縮性的通用技術是下降使用的鎖定對象的粒度,但願更多的鎖定請求從競爭變爲不競爭。從鎖定轉換爲原子變量能夠得到相同的結果,經過切換爲更細粒度的協調機制,競爭的操做就更少,從而提升了吞吐量。

ABA 問題
由於在更改 V 以前,CAS 主要詢問「V 的值是否仍爲 A」,因此在第一次讀取 V 以及對 V 執行 CAS 操做以前,若是將值從 A 改成 B,而後再改回 A,會使基於 CAS 的算法混亂。在這種狀況下,CAS 操做會成功,可是在一些狀況下,結果可能不是您所預期的。(注意, 清單 1清單 2 中的計數器和互斥例子不存在這個問題,但不是全部算法都這樣。)這類問題稱爲 ABA 問題,一般經過將標記或版本編號與要進行 CAS 操做的每一個值相關聯,並原子地更新值和標記,來處理這類問題。 AtomicStampedReference 類支持這種方法。

java.util.concurrent 中的原子變量

不管是直接的仍是間接的,幾乎 java.util.concurrent 包中的全部類都使用原子變量,而不使用同步。相似 ConcurrentLinkedQueue 的類也使用原子變量直接實現無等待算法,而相似 ConcurrentHashMap 的類使用 ReentrantLock 在須要時進行鎖定。而後, ReentrantLock 使用原子變量來維護等待鎖定的線程隊列。

若是沒有 JDK 5.0 中的 JVM 改進,將沒法構造這些類,這些改進暴露了(向類庫,而不是用戶類)接口來訪問硬件級的同步原語。而後,java.util.concurrent 中的原子變量類和其餘類向用戶類公開這些功能。





回頁首


使用原子變量得到更高的吞吐量

上月,我介紹了 ReentrantLock 如何相對於同步提供可伸縮性優點,以及構造經過僞隨機數生成器模擬旋轉骰子的簡單、高競爭示例基準。我向您顯示了經過同步、 ReentrantLock 和公平 ReentrantLock 來進行協調的實現,並顯示告終果。本月,我將向該基準添加其餘實現,使用 AtomicLong 更新 PRNG 狀態的實現。

清單 5 顯示了使用同步的 PRNG 實現和使用 CAS 備選實現。注意,要在循環中執行 CAS,由於它可能會失敗一次或屢次才能得到成功,使用 CAS 的代碼老是這樣。


清單 5. 使用同步和原子變量實現線程安全 PRNG
public class PseudoRandomUsingSynch implements PseudoRandom {
    private int seed;
    public PseudoRandomUsingSynch(int s) { seed = s; }
    public synchronized int nextInt(int n) {
        int s = seed;
        seed = Util.calculateNext(seed);
        return s % n;
    }
}
public class PseudoRandomUsingAtomic implements PseudoRandom {
    private final AtomicInteger seed;
    public PseudoRandomUsingAtomic(int s) {
        seed = new AtomicInteger(s);
    }
    public int nextInt(int n) {
        for (;;) {
            int s = seed.get();
            int nexts = Util.calculateNext(s);
            if (seed.compareAndSet(s, nexts))
                return s % n;
        }
    }
}

下面圖 1 和圖 2 中的圖與上月那些圖類似,只是爲基於原子的方法多添加了一行。這些圖顯示了在 8-way Ultrasparc3 和單處理器 Pentium 4 上使用不一樣數量線程的隨機發生的吞吐量(以每秒轉數爲單位)。測試中的線程數不是真實的;這些線程所表現的競爭比一般多得多,因此它們以比實際程序中低得多的線程數顯示了 ReentrantLock 與原子變量之間的平衡。您將看到,雖然 ReentrantLock 擁有比同步更多的優勢,但相對於 ReentrantLock,原子變量提供了其餘改進。(由於在每一個工做單元中完成的工做不多,因此下圖可能沒法徹底地說明與 ReentrantLock 相比,原子變量具備哪些可伸縮性優勢。)


圖 1. 8-way Ultrasparc3 中同步、ReentrantLock、公平 Lock 和 AtomicLong 的基準吞吐量
8-way Ultrasparc3 吞吐量

圖 2. 單處理器 Pentium 4 中的同步、ReentrantLock、公平 Lock 和 AtomicLong 的基準吞吐量
Uniprocessor Pentium4 吞吐量

大多數用戶都不太可能使用原子變量本身開發無阻塞算法 — 他們更可能使用 java.util.concurrent 中提供的版本,如 ConcurrentLinkedQueue。可是萬一您想知道對比之前 JDK 中的相相似的功能,這些類的性能是如何改進的,可使用經過原子變量類公開的細粒度、硬件級別的併發原語。

開發人員能夠直接將原子變量用做共享計數器、序號生成器和其餘獨立共享變量的高性能替代,不然必須經過同步保護這些變量。





回頁首


結束語

JDK 5.0 是開發高性能併發類的巨大進步。經過內部公開新的低級協調原語,和提供一組公共原子變量類,如今用 Java 語言開發無等待、無鎖定算法首次變爲可行。而後, java.util.concurrent 中的類基於這些低級原子變量工具構建,爲它們提供比之前執行類似功能的類更顯著的可伸縮性優勢。雖然您可能永遠不會直接使用原子變量,仍是應該爲它們的存在而歡呼。



參考資料



關於做者

Brian Goetz 在過去 17 年多的時間裏一直從事專業軟件開發。他是 Quiotix 的首席顧問,這是一家位於加利福尼亞洛斯拉圖斯的軟件開發和諮詢公司,他服務於好幾個 JCP 專家組。請參閱 Brian 在流行的業界出版物上 已發表和即將發表的文章

相關文章
相關標籤/搜索