理解CAS

pidanhub發表於2024-06-29

在日常開發中,難免會使用到併發,如對一個計數器自增。考慮如下場景:

public class DoAndRecord {
  private int cot = 0;
  
  public void doFunc () {
    // Do something...
    ++cot;
  }
}

如果是併發的場景,很容易造成兩個執行緒操作結束後,最後值只自增了1,出現了執行緒安全問題。因此,在實際開發中,對於這樣的簡單的操作,我們可能會用到如下的類,如AtomicInteger

public class DoAndRecord {
  private AtomicInteger cot = new AtomicInteger(0);
  
  public void doFunc () {
    // Do something...
    cot.getAndIncrement();
  }
}

這是一種無鎖的程式設計方式,這個類的底層就透過Unsafe類用到了CAS,即Compare And Swap。實現的原理其實很簡單:如需要自增一個變數v,在更新前有一箇舊值e,期望更新成n。這個過程會經歷讀和寫,在寫之前,驗證現在變數的值還是不是e,如果還是e則更新為n,不是則失敗。

這裡需要注意的是,無鎖並不代表真的無鎖,只是軟體層面沒有加任何的鎖。只是,我們在程式設計的過程中沒有使用到鎖,但是考慮紅色部分的描述,可能會考慮一個問題,用一個變數保護另一個變數,誰來保護保護變數的變數呢?在這裡,比較與寫之間,會不會有其他執行緒在這個空擋更新這個值?答案是不會,硬體工程師將CAS設計為一條原子的指令,只是,實現這種方式必須保證讀和寫之間是原子的,鎖被加在了底層硬體的位置,至於如何加鎖,鎖住匯流排或是緩衝行,則依賴於具體CPU的實現,不同的處理器體系結構可能不同,軟體工程師不需要考慮這一點。

誠然程式設計變得簡單,在上面的demo中,自增操作已經無需使用synchronized去鎖住臨界區的程式碼,但是簡單的雖然是優雅的,不過未必是完美的,CAS依然會帶來問題。


ABA問題

場景

兩個執行緒都希望將100變為50(或者考慮為兩個使用者有共同的願望),那麼考慮正常結束的情況下,應該有一個執行緒是修改失敗結束:當一個執行緒修改成功了以後,已經是50了,而不是100,不應該修改。這是正常的情況,但是過程中,第三個執行緒僅希望將這個值加上50(而不追究原來是多少)。這個時候,有了第三個執行緒的參與,最終的值應該是100才對。但是,應該失敗的執行緒阻塞住了,偏等一個“將100變成50”的執行緒,和“把值加50”的執行緒全部執行完畢後,才繼續執行,於是,讀到100,變成50,三個執行緒都成功退出了。

這顯然與預期結果是不符的,我們期望有一個執行緒失敗,最終結果為100,但是實際情況三個執行緒都成功結束,值卻是50。如果涉及到對併發場景的資料一致性要求非常高的情況,這種資料的丟失會引發嚴重的線上問題!這個就是老生常談的ABA問題,透過場景來理解這個問題帶來的代價,則是我作這篇部落格的重點。

解決的方式

依然是上面這個場景,造成這個問題的原因,是那個本應失敗的執行緒根本不知道100與100還有區別,它比較的時候所看見的100,與一開始的100根本就不是同一個100,所以,解決的方式就是為100打上版本號,讓這個執行緒可以區分兩個100,問題就可以解決了。可以參考AtomicStampedReference的實現,原理與上述相同。

自旋等待問題

這個要深入到原始碼中去發現問題,我們進入到Java的JUC包的基石——Unsafe類中去檢視:

與普通的CAS不同,CAS如果在比較的階段發現讀到的值與預期不同,指令就會執行失敗,這裡所做的事是:如果失敗了,就把新的預期值拿出來,再去比較,直到成功。可以預見,當併發量特別高的時候,這個方法會經常失敗,代價是空轉CPU。試想在高併發的情況下還如此浪費資源,情況是非常糟糕。解決方法可以有減少自旋的次數,如失敗次數達到一定的閾值就放棄或阻塞,待喚醒之後再繼續,提高成功率。CAS的目的是減少執行緒阻塞、喚醒的過程以加快執行速度,當情況非常糟糕,樂觀鎖的效率並非很高的時候,可以考慮將二者達到一個平衡。

AtomicReference

這個是JUC包提供的另一個類,CAS能做到的只是更新一個值,但是如果是一個結構(例項),可能會需要同時更新多個值。這裡的實現方法也是CAS,只不過更新的是地址,使用的Unsafe類中的compareAndSwapObject方法。這是一個native方法,底層C++原始碼實現的時候,將原來的值的地址更新成新的值的地址,以實現更新多個值的目的。

class N {
	Integer a;
	Integer b;
	Double d;
	
	public N (Integer a, Integer b, Double d) {
		this.a = a;
		this.b = b;
		this.d = d;
	}
	
	public N (Integer a, Double d) {
		this.a = a;
		this.d = d;
	}
	
	@Override
	public String toString () {
		return "N{" +
				"a=" + a +
				", b=" + b +
				", d=" + d +
				'}';
	}
}

然後使用AtomicReference更新:

AtomicReference<N> reference = new AtomicReference<>(new N(1, 2, 2.5));
System.out.println(reference.get());
reference.getAndSet(new N(1, 1.5));
System.out.println(reference.get());
/*
	N{a=1, b=2, d=2.5}
	N{a=1, b=null, d=1.5}
 */

可以看見,更新時是整體的替換,可以印證剛剛的說法,透過將原來的引用指向新的地址以完成透過一次CAS更新全部的屬性的方法。具體可以參考openjdk的C++實現compareAndSwapObject的程式碼,博主目前無暇去搜尋具體的程式碼,下面的程式碼摘抄自其他博主——原始碼解析 Java 的 compareAndSwapObject 到底比較的是什麼?

// Unsafe.h
virtual jboolean compareAndSwapObject(::java::lang::Object *, jlong, ::java::lang::Object *, ::java::lang::Object *);

// natUnsafe.cc
static inline bool compareAndSwap (volatile jobject *addr, jobject old, jobject new_val)
{
	jboolean result = false;
	spinlock lock;
  
  	// 如果欄位的地址與期望的地址相等則將欄位的地址更新
	if ((result = (*addr == old)))
    	*addr = new_val;
	return result;
}

// natUnsafe.cc
jboolean sun::misc::Unsafe::compareAndSwapObject (jobject obj, jlong offset,jobject expect, jobject update) {
	// 獲取欄位地址並轉換為字串
	jobject *addr = (jobject*)((char *) obj + offset);
	// 呼叫 compareAndSwap 方法進行比較
    return compareAndSwap (addr, expect, update);
}

相關文章