併發程式設計之 CAS 的原理

莫那·魯道發表於2018-04-30

CAS 的原理

前言

在併發程式設計中,鎖是消耗效能的操作,同一時間只能有一個執行緒進入同步塊修改變數的值,比如下面的程式碼


synchronized void function(int b){
  a = a + b;
}


複製程式碼

如果不加 synchronized 的話,多執行緒修改 a 的值就會導致結果不正確,出現執行緒安全問題。但鎖又是要給耗費效能的操作。不論是拿鎖,解鎖,還是等待鎖,阻塞,都是非常耗費效能的。那麼能不能不加鎖呢?

可以。

什麼意思呢?我們看上面的程式碼,分為幾個步驟:

  1. 讀取a
  2. 將 a 和 b 相加
  3. 將計算的值賦值給a。

我們知道,這不是一個原子的操作,多執行緒上面時候會出問題:當兩個執行緒同時訪問 a ,都得到了a 的值,並且通知對a 加 1,然後同時將計算的值賦值給a,這樣就會導致 a 的值只增加了1,但實際上我們想加 2.

問題出在哪裡?第三步,對 a 賦值操作,如果有一種判斷,判斷 a 已經別的執行緒修改,你需要重新計算。比如下面這樣:


void function(int b) {
   int backup = a;
   int c = a + b;
   compareAndSwap(a, backup, c);
}

void compareAndSwap(int backup ,int c ){
       if (a == backup) {
           a = c;
       }
}

複製程式碼

從程式碼中,我們看到,我們備份了 a 的值,並且對 a 進行計算,如果 a 的值和備份的值一致,說明 a 沒有被別的執行緒更改過,這個時候就可以進行修改了。

這裡有個問題:compareAndSwap 方法有多步操作,不是原子的,並且沒有使用鎖,如何保證執行緒安全。其實樓主這裡只是虛擬碼。下面就要好好說說什麼是 CAS (compareAndSwap);

1. 什麼是 CAS

CAS (compareAndSwap),中文叫比較交換,一種無鎖原子演算法。過程是這樣:它包含 3 個引數 CAS(V,E,N),V表示要更新變數的值,E表示預期值,N表示新值。僅當 V值等於E值時,才會將V的值設為N,如果V值和E值不同,則說明已經有其他執行緒做兩個更新,則當前執行緒則什麼都不做。最後,CAS 返回當前V的真實值。CAS 操作時抱著樂觀的態度進行的,它總是認為自己可以成功完成操作。

當多個執行緒同時使用CAS 操作一個變數時,只有一個會勝出,併成功更新,其餘均會失敗。失敗的執行緒不會掛起,僅是被告知失敗,並且允許再次嘗試,當然也允許實現的執行緒放棄操作。基於這樣的原理,CAS 操作即使沒有鎖,也可以發現其他執行緒對當前執行緒的干擾。

與鎖相比,使用CAS會使程式看起來更加複雜一些,但由於其非阻塞的,它對死鎖問題天生免疫,並且,執行緒間的相互影響也非常小。更為重要的是,使用無鎖的方式完全沒有鎖競爭帶來的系統開銷,也沒有執行緒間頻繁排程帶來的開銷,因此,他要比基於鎖的方式擁有更優越的效能。

簡單的說,CAS 需要你額外給出一個期望值,也就是你認為這個變數現在應該是什麼樣子的。如果變數不是你想象的那樣,哪說明它已經被別人修改過了。你就需要重新讀取,再次嘗試修改就好了。

那麼這個CAS 是如何實現的呢?也就是說,比較和交換實際上是兩個操作,如何變成一個原子操作呢?

2. CAS 底層原理

這樣歸功於硬體指令集的發展,實際上,我們可以使用同步將這兩個操作變成原子的,但是這麼做就沒有意義了。所以我們只能靠硬體來完成,硬體保證一個從語義上看起來需要多次操作的行為只通過一條處理器指令就能完成。這類指令常用的有:

  1. 測試並設定(Tetst-and-Set)
  2. 獲取並增加(Fetch-and-Increment)
  3. 交換(Swap)
  4. 比較並交換(Compare-and-Swap)
  5. 載入連結/條件儲存(Load-Linked/Store-Conditional)

其中,前面的3條是20世紀時,大部分處理器已經有了,後面的2條是現代處理器新增的。而且這兩條指令的目的和功能是類似的,在IA64,x86 指令集中有 cmpxchg 指令完成 CAS 功能,在 sparc-TSO 也有 casa 指令實現,而在 ARM 和 PowerPC 架構下,則需要使用一對 ldrex/strex 指令來完成 LL/SC 的功能。

CPU 實現原子指令有2種方式:

  1. 通過匯流排鎖定來保證原子性。 匯流排鎖定其實就是處理器使用了匯流排鎖,所謂匯流排鎖就是使用處理器提供的一個 LOCK# 訊號,當一個處理器咋匯流排上輸出此訊號時,其他處理器的請求將被阻塞住,那麼該處理器可以獨佔共享記憶體。但是該方法成本太大。因此有了下面的方式。

  2. 通過快取鎖定來保證原子性。 所謂 快取鎖定 是指記憶體區域如果被快取在處理器的快取行中,並且在Lock 操作期間被鎖定,那麼當他執行鎖操作寫回到記憶體時,處理器不在匯流排上聲言 LOCK# 訊號,而時修改內部的記憶體地址,並允許他的快取一致性機制來保證操作的原子性,因為快取一致性機制會阻止同時修改兩個以上處理器快取的記憶體區域資料(這裡和 volatile 的可見性原理相同),當其他處理器回寫已被鎖定的快取行的資料時,會使快取行無效。

注意:有兩種情況下處理器不會使用快取鎖定。

  1. 當操作的資料不能被快取在處理器內部,或操作的資料跨多個快取行時,則處理器會呼叫匯流排鎖定
  2. 有些處理器不支援快取鎖定,對於 Intel 486 和 Pentium 處理器,就是鎖定的記憶體區域在處理器的快取行也會呼叫匯流排鎖定。

3. Java 如何實現原子操作

java 在 1.5 版本中提供了 java.util.concurrent.atomic 包,該包下所有的類都是原子操作:

java.util.concurrent.atomic 包

如何使用呢?看程式碼

  public static void main(String[] args) throws InterruptedException {
    AtomicInteger integer = new AtomicInteger();
    System.out.println(integer.get());


    Thread[] threads = new Thread[1000];

    for (int j = 0; j < 1000; j++) {
      threads[j] = new Thread(() ->
          integer.incrementAndGet()
      );
      threads[j].start();
    }

    for (int j = 0; j < 1000; j++) {
      threads[j].join();
    }

    System.out.println(integer.get());
  }
}
複製程式碼

上面的程式碼,我們啟動了1000個執行緒對 AtomicInteger 變數做了自增操作。結果是我們預期的1000,表示沒有發生同步問題。

我們看看他的內部實現,我們找到該類的 compareAndSet 方法,也就是比較並且設定。我們看看該方法實現:

併發程式設計之 CAS 的原理

該方法呼叫了 unsafe 類的 compareAndSwapInt 方法,有幾個引數,一個是該變數的記憶體地址,一個是期望值,一個是更新值,一個是物件自身。完全符合我們之前CAS 的定義。那麼 ,這個 unsafe 又是什麼呢?

該類在 rt.jar 包中,但不在我們熟悉的 java 包下,而是 sun.misc 包下。並且都是 class 檔案,註釋都沒有,符合他的名字:不安全。

我們能構造他嗎?不能,除非反射。

我們看看他的原始碼:

併發程式設計之 CAS 的原理

併發程式設計之 CAS 的原理

getUnsafe 方法中,會檢查呼叫 getUnsafe 方法的類,如果這個類的 ClassLoader 不為null ,就直接丟擲異常,什麼情況下會為null呢?當類載入器是 Bootstrap 載入器的時候,Bootstrap 載入器是沒有物件的,也就是說,載入這個類極有可能是 rt.jar 下的。

而在最新的 Java 9 當中,該類已經被隱藏。因為該類使用了指標。但指標的缺點就是不安全。

4. CAS 的缺點

CAS 看起來非常的吊,但是,他仍然有缺點,最著名的就是 ABA 問題,假設一個變數 A ,修改為 B之後又修改為 A,CAS 的機制是無法察覺的,但實際上已經被修改過了。如果在基本型別上是沒有問題的,但是如果是引用型別呢?這個物件中有多個變數,我怎麼知道有沒有被改過?聰明的你一定想到了,加個版本號啊。每次修改就檢查版本號,如果版本號變了,說明改過,就算你還是 A,也不行。

在 java.util.concurrent.atomic 包中,就有 AtomicReference 來保證引用的原子性,但樓主覺得有點雞肋,不如使用同步加互斥,可能會更加高效。

總結

今天我們從各種角度理解了CAS 的原理,該演算法特別的重要,從CPU 都特別的設計一條指令來實現可見一斑。而JDK的原始碼中,到處都 unSafe 的 CAS 演算法,可以說,如果沒有CAS ,就沒有 1.5 的併發容器。好,今天就到這裡。

good luck !!!

相關文章