CAS原理深度解析

請回答1994發表於2019-03-15

1. CAS

CAS:Compare and Swap, 翻譯成比較並交換。

java.util.concurrent包中藉助CAS實現了區別於synchronouse同步鎖的一種樂觀鎖。

本文先從CAS的應用說起,再深入原理解析。

2. CAS應用

CAS有3個運算元,記憶體值V,舊的預期值A,要修改的新值B。當且僅當預期值A和記憶體值V相同時,將記憶體值V修改為B,否則什麼都不做。

2.1 非阻塞演算法 (nonblocking algorithms)

一個執行緒的失敗或者掛起不應該影響其他執行緒的失敗或掛起的演算法。

現代的CPU提供了特殊的指令,可以自動更新共享資料,而且能夠檢測到其他執行緒的干擾,而 compareAndSet() 就用這些代替了鎖定。

拿出AtomicInteger來研究在沒有鎖的情況下是如何做到資料正確性的。

private volatile int value;
複製程式碼

首先毫無以為,在沒有鎖的機制下可能需要藉助volatile原語,保證執行緒間的資料是可見的(共享的)。

這樣才獲取變數的值的時候才能直接讀取。

public final int get() {
        return value;
    }
複製程式碼

然後來看看++i是怎麼做到的。

public final int incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
            return next;
    }
}
複製程式碼

在這裡採用了CAS操作,每次從記憶體中讀取資料然後將此資料和+1後的結果進行CAS操作,如果成功就返回結果,否則重試直到成功為止。

compareAndSet利用JNI來完成CPU指令的操作。

public final boolean compareAndSet(int expect, int update) {   
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
複製程式碼

整體的過程就是這樣子的,利用CPU的CAS指令,同時藉助JNI來完成Java的非阻塞演算法。其它原子操作都是利用類似的特性完成的。

其中

unsafe.compareAndSwapInt(this, valueOffset, expect, update);

類似:

if (this == expect) {
	this = update
	return true;
} else {
	return false;
}
複製程式碼

那麼問題就來了,成功過程中需要2個步驟:比較this == expect,替換this = update

compareAndSwapInt如何這兩個步驟的原子性呢? 參考CAS的原理。

3. CAS原理

CAS通過呼叫JNI的程式碼實現的。JNI:Java Native Interface為JAVA本地呼叫,允許java呼叫其他語言。

compareAndSwapInt就是藉助C來呼叫CPU底層指令實現的。

下面從分析比較常用的CPU(intel x86)來解釋CAS的實現原理。

下面是sun.misc.Unsafe類的compareAndSwapInt()方法的原始碼:

public final native boolean compareAndSwapInt(Object o, long offset,
                                              int expected,
                                              int x);
複製程式碼

可以看到這是個本地方法呼叫。這個本地方法在openjdk中依次呼叫的c++程式碼為:unsafe.cppatomic.cppatomic*windows*x86.inline.hpp。這個本地方法的最終實現在openjdk的如下位置:openjdk-7-fcs-src-b147-27*jun*2011\openjdk\hotspot\src\os*cpu\windows*x86\vm\ atomic*windows*x86.inline.hpp(對應於windows作業系統,X86處理器)。下面是對應於intel x86處理器的原始碼的片段:

// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line
// so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0  \
                       __asm je L0      \
                       __asm _emit 0xF0 \
                       __asm L0:

inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
  // alternative for InterlockedCompareExchange
  int mp = os::is_MP();
  __asm {
    mov edx, dest
    mov ecx, exchange_value
    mov eax, compare_value
    LOCK_IF_MP(mp)
    cmpxchg dword ptr [edx], ecx
  }
}
複製程式碼

如上面原始碼所示,程式會根據當前處理器的型別來決定是否為cmpxchg指令新增lock字首。如果程式是在多處理器上執行,就為cmpxchg指令加上lock字首(lock cmpxchg)。反之,如果程式是在單處理器上執行,就省略lock字首(單處理器自身會維護單處理器內的順序一致性,不需要lock字首提供的記憶體屏障效果)。

intel的手冊對lock字首的說明如下:

  1. 確保對記憶體的讀-改-寫操作原子執行。在PentiumPentium之前的處理器中,帶有lock字首的指令在執行期間會鎖住匯流排,使得其他處理器暫時無法通過匯流排訪問記憶體。很顯然,這會帶來昂貴的開銷。從Pentium 4,Intel Xeon及P6處理器開始,intel在原有匯流排鎖的基礎上做了一個很有意義的優化:如果要訪問的記憶體區域(area of memory)在lock字首指令執行期間已經在處理器內部的快取中被鎖定(即包含該記憶體區域的快取行當前處於獨佔或以修改狀態),並且該記憶體區域被完全包含在單個快取行(cache line)中,那麼處理器將直接執行該指令。由於在指令執行期間該快取行會一直被鎖定,其它處理器無法讀/寫該指令要訪問的記憶體區域,因此能保證指令執行的原子性。這個操作過程叫做快取鎖定(cache locking),快取鎖定將大大降低lock字首指令的執行開銷,但是當多處理器之間的競爭程度很高或者指令訪問的記憶體地址未對齊時,仍然會鎖住匯流排。
  2. 禁止該指令與之前和之後的讀和寫指令重排序。
  3. 把寫緩衝區中的所有資料重新整理到記憶體中。

備註知識:

關於CPU的鎖有如下3種:

1. 處理器自動保證基本記憶體操作的原子性

  首先處理器會自動保證基本的記憶體操作的原子性。處理器保證從系統記憶體當中讀取或者寫入一個位元組是原子的,意思是當一個處理器讀取一個位元組時,其他處理器不能訪問這個位元組的記憶體地址。奔騰6和最新的處理器能自動保證單處理器對同一個快取行裡進行16/32/64位的操作是原子的,但是複雜的記憶體操作處理器不能自動保證其原子性,比如跨匯流排寬度,跨多個快取行,跨頁表的訪問。但是處理器提供匯流排鎖定和快取鎖定兩個機制來保證複雜記憶體操作的原子性。

2. 使用匯流排鎖保證原子性

  第一個機制是通過匯流排鎖保證原子性。如果多個處理器同時對共享變數進行讀改寫(i++就是經典的讀改寫操作)操作,那麼共享變數就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的,操作完之後共享變數的值會和期望的不一致,舉個例子:如果i=1,我們進行兩次i++操作,我們期望的結果是3,但是有可能結果是2。如下圖

img

原因是有可能多個處理器同時從各自的快取中讀取變數i,分別進行加一操作,然後分別寫入系統記憶體當中。那麼想要保證讀改寫共享變數的操作是原子的,就必須保證CPU1讀改寫共享變數的時候,CPU2不能操作快取了該共享變數記憶體地址的快取。

處理器使用匯流排鎖就是來解決這個問題的。所謂匯流排鎖就是使用處理器提供的一個**LOCK#**訊號,當一個處理器在匯流排上輸出此訊號時,其他處理器的請求將被阻塞住,那麼該處理器可以獨佔使用共享記憶體。

3. 使用快取鎖保證原子性

第二個機制是通過快取鎖定保證原子性。在同一時刻我們只需保證對某個記憶體地址的操作是原子性即可,但匯流排鎖定把CPU和記憶體之間通訊鎖住了,這使得鎖定期間,其他處理器不能操作其他記憶體地址的資料,所以匯流排鎖定的開銷比較大,最近的處理器在某些場合下使用快取鎖定代替匯流排鎖定來進行優化。

頻繁使用的記憶體會快取在處理器的L1,L2和L3快取記憶體裡,那麼原子操作就可以直接在處理器內部快取中進行,並不需要宣告匯流排鎖,在奔騰6和最近的處理器中可以使用“快取鎖定”的方式來實現複雜的原子性。所謂“快取鎖定”就是如果快取在處理器快取行中記憶體區域在LOCK操作期間被鎖定,當它執行鎖操作回寫記憶體時,處理器不在匯流排上聲言**LOCK#**訊號,而是修改內部的記憶體地址,並允許它的快取一致性機制來保證操作的原子性,因為快取一致性機制會阻止同時修改被兩個以上處理器快取的記憶體區域資料,當其他處理器回寫已被鎖定的快取行的資料時會起快取行無效,在例1中,當CPU1修改快取行中的i時使用快取鎖定,那麼CPU2就不能同時快取了i的快取行。

但是有兩種情況下處理器不會使用快取鎖定。第一種情況是:當操作的資料不能被快取在處理器內部,或操作的資料跨多個快取行(cache line),則處理器會呼叫匯流排鎖定。第二種情況是:有些處理器不支援快取鎖定。對於Inter486和奔騰處理器,就算鎖定的記憶體區域在處理器的快取行中也會呼叫匯流排鎖定。

以上兩個機制我們可以通過Inter處理器提供了很多LOCK字首的指令來實現。比如位測試和修改指令BTS,BTR,BTC,交換指令XADD,CMPXCHG和其他一些運算元和邏輯指令,比如ADD(加),OR(或)等,被這些指令操作的記憶體區域就會加鎖,導致其他處理器不能同時訪問它。

4. CAS缺點

CAS雖然很高效的解決原子操作,但是CAS仍然存在三大問題。ABA問題,迴圈時間長開銷大和只能保證一個共享變數的原子操作

4.1 ABA問題

因為CAS需要在操作值的時候檢查下值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變數前面追加上版本號,每次變數更新的時候把版本號加一,那麼A-B-A 就會變成1A-2B-3A。從Java1.5開始JDK的atomic包裡提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設定為給定的更新值。

關於ABA問題參考文件: blog.hesey.net/2011/09/res…

4.2 迴圈時間長開銷大

自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。如果JVM能支援處理器提供的pause指令那麼效率會有一定的提升,pause指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零。第二它可以避免在退出迴圈的時候因記憶體順序衝突(memory order violation)而引起CPU流水線被清空(CPU pipeline flush),從而提高CPU的執行效率。

4.3 只能保證一個共享變數的原子操作

當對一個共享變數執行操作時,我們可以使用迴圈CAS的方式來保證原子操作,但是對多個共享變數操作時,迴圈CAS就無法保證操作的原子性,這個時候就可以用鎖,或者有一個取巧的辦法,就是把多個共享變數合併成一個共享變數來操作。比如有兩個共享變數i=2,j=a,合併一下ij=2a,然後用CAS來操作ij。從Java1.5開始JDK提供了AtomicReference類來保證引用物件之間的原子性,你可以把多個變數放在一個物件裡來進行CAS操作。

5. concurrent包的實現

由於java的CAS同時具有 volatile 讀和volatile寫的記憶體語義,因此Java執行緒之間的通訊現在有了下面四種方式:

  1. A執行緒寫volatile變數,隨後B執行緒讀這個volatile變數。
  2. A執行緒寫volatile變數,隨後B執行緒用CAS更新這個volatile變數。
  3. A執行緒用CAS更新一個volatile變數,隨後B執行緒用CAS更新這個volatile變數。
  4. A執行緒用CAS更新一個volatile變數,隨後B執行緒讀這個volatile變數。

Java的CAS會使用現代處理器上提供的高效機器級別原子指令,這些原子指令以原子方式對記憶體執行讀-改-寫操作,這是在多處理器中實現同步的關鍵(從本質上來說,能夠支援原子性讀-改-寫指令的計算機器,是順序計算圖靈機的非同步等價機器,因此任何現代的多處理器都會去支援某種能對記憶體執行原子性讀-改-寫操作的原子指令)。同時,volatile變數的讀/寫和CAS可以實現執行緒之間的通訊。把這些特性整合在一起,就形成了整個concurrent包得以實現的基石。如果我們仔細分析concurrent包的原始碼實現,會發現一個通用化的實現模式:

  1. 首先,宣告共享變數為volatile
  2. 然後,使用CAS的原子條件更新來實現執行緒之間的同步;
  3. 同時,配合以volatile的讀/寫和CAS所具有的volatile讀和寫的記憶體語義來實現執行緒之間的通訊。

AQS,非阻塞資料結構和原子變數類(java.util.concurrent.atomic包中的類),這些concurrent包中的基礎類都是使用這種模式來實現的,而concurrent包中的高層類又是依賴於這些基礎類來實現的。從整體來看,concurrent包的實現示意圖如下:

img

相關文章