你應該知道的樂觀鎖-高效控制線程安全的手段

1.背景

最近在修改Seata線程併發的一些問題,把其中一些經驗總結給你們。先簡單描述一下這個問題,在Seata這個分佈式事務框架中有個全局事務的概念,在大多數狀況下,全局事務的流程基本是順序推動不會出現併發問題,可是當一些極端的狀況下,會出現多線程訪問致使咱們全局事務處理不正確。 以下面代碼所示: 在咱們全局事務commit階段,有一個以下代碼:數據庫

if (status == GlobalStatus.Begin) {
        globalSession.changeStatus(GlobalStatus.Committing);
    }

代碼有些省略,就是先判斷status狀態是否Begin狀態,而後改變狀態爲Committing。安全

在咱們全局事務rollback階段,有一個以下代碼:多線程

if (status == GlobalStatus.Begin) {
            globalSession.changeStatus(GlobalStatus.Rollbacking);
        }

一樣的也省略了部分代碼,這裏先判斷status狀態是否爲begin,而後改變爲Rollbacking。這裏再Seata的代碼中並無作一些線程同步的手段,若是這兩個邏輯同時執行(通常狀況下不會,可是極端狀況下可能會出現),會讓咱們的結果出現不可預料的錯誤。而咱們所要作的就是解決這種極端狀況下來的併發出現的問題。併發

2.悲觀鎖

對於這種併發出現問題我相信你們第一時間想到的確定是加鎖,在Java中咱們咱們通常採用下面兩個手段進行加鎖:框架

Synchronized
ReentrantLock
咱們能夠利用Synchronized 或者 ReentrantLock進行加鎖,能夠將代碼修改爲下面的邏輯:分佈式

synchronized:ide

synchronized(globalSession){
            if (status == GlobalStatus.Begin) {
                globalSession.changeStatus(GlobalStatus.Rollbacking);
            }
        }

ReentrantLock進行加鎖:性能

reentrantLock.lock();
 try {
    if  (status == GlobalStatus.Begin) {
    globalSession.changeStatus(GlobalStatus.Rollbacking);
        }
    }finally {
            reentrantLock.unlock();
    }

對於這種加鎖比較簡單,在Seata的Go-Server中目前是這樣實現的。可是這種實現場景忽略了咱們上面所說的一種狀況,就是極端狀況下,也就是有可能99.9%的狀況下可能不會出現併發問題,只有%0.1的狀況可能致使這個併發問題。雖然咱們悲觀鎖一次加鎖的時間也比較短,可是在這種高性能的中間件中仍是不夠,那麼就引入了咱們的樂觀鎖。學習

3.樂觀鎖

一提起樂觀鎖,不少朋友都會想到數據庫中樂觀鎖,想象一下上面的邏輯若是在數據庫中,而且沒有利用樂觀鎖去作,咱們會有以下的僞代碼邏輯:優化

select * from table where id = xxx for update;
if(status == begin){
    //do other thing
    update table set status = rollbacking;
}

上述代碼在咱們不少的業務邏輯中都能看見,這段代碼有兩個小問題:

1,事務較大,因爲咱們一上來就對咱們數據加鎖,那麼一定在一個事務中,咱們的查詢和更新之間若是穿插了一些比較耗時的邏輯那麼咱們的事務就會致使較大。因爲咱們的每個事務都會佔據一個數據庫鏈接,那麼在流量較高的時會很容易出現數據庫鏈接池不夠的狀況。

2,鎖定數據時間較長,在咱們整個事務中都是對這條數據加了行鎖,若是有其餘事務想對這個數據進行修改那麼會長時間阻塞等待。

因此爲了解決上面的問題,在不少若是競爭不大的場景下,咱們就採用了樂觀鎖的方法,咱們在數據庫中加一個字段version表明着版本號,咱們將代碼修改爲以下所示:

select * from table where id = xxx ;
if(status == begin){
    //do other thing
    int result = (update table set status = rollbacking where version = xxx);
    if(result == 0){
        throw new someException();
    }
}

這裏咱們的查詢語句再也不有for update,咱們的事務也只縮小到update一句,咱們經過咱們第一句查詢出來的version來進行判斷,若是咱們的更新的更新的行數爲0,那麼就證實其餘事務對他進行了修改。這裏能夠拋出異常或者作一些其餘的事。

從這裏能夠看出咱們使用樂觀鎖將事務較大,鎖定較長這兩個問題都解決,可是對應而來的成本就是若是更新失敗咱們可能就會拋出異常或者作一些其餘補救的措施,而咱們的悲觀鎖在執行業務以前都已經限制住了。因此咱們這裏使用樂觀鎖必定只能在對某條數據併發處理的狀況比較小的狀況下。

3.1 代碼中的樂觀鎖

咱們上面講述了在數據庫中的樂觀鎖,不少人就在問,沒有數據庫,在咱們代碼中怎麼去實現樂觀鎖呢?熟悉synchronized的同窗確定知道synchronized在Jdk1.6以後對其進行了優化,引入了鎖膨脹的一個模型:

1,偏向鎖:顧名思義偏向某個線程的鎖,適用於某個線程能長期獲取到該鎖。

2,輕量級鎖:若是偏向鎖獲取失敗,那麼會使用CAS自旋來完成,輕量級鎖適用於線程交替進入臨界區。

3,重量級鎖:自旋失敗以後,會採起重量級鎖策略咱們線程會阻塞掛起。

上面的級種鎖模型中輕量級鎖所適用的線程交替進入臨界區很適合咱們的場景,由於咱們的全局事務通常來講不會是某個單線程一直在處理該事務(固然也能夠優化成這個模型,只是設計會比較複雜),咱們的全局事務再大多數狀況下都會是不一樣線程交替進入處理這個事務邏輯,因此咱們能夠借鑑輕量級鎖CAS自旋的思想,完成咱們代碼級別的自旋鎖。這裏也有朋友可能會問爲何不用synchronized呢?這裏通過實測在交替進入臨界區咱們本身實現的CAS自旋性能是最高的,而且synchronized沒有超時機制,不方便咱們處理異常狀況。

class GlobalSessionSpinLock {

        private AtomicBoolean globalSessionSpinLock = new AtomicBoolean(true);

        public void lock() throws TransactionException {
            boolean flag;
            do {
                flag = this.globalSessionSpinLock.compareAndSet(true, false);
            }
            while (!flag);
        }

        public void unlock() {
            this.globalSessionSpinLock.compareAndSet(false, true);
        }
    }
  // method rollback  
  void rollback(){
    globalSessionSpinLock.lock();
    try {
        if  (status == GlobalStatus.Begin) {
        globalSession.changeStatus(GlobalStatus.Rollbacking);
            }
    }finally {
        globalSessionSpinLock.unlock();
    }
  }

上面咱們用CAS簡單的實現了一個樂觀鎖,可是這個樂觀鎖有個小缺點就是一旦出現競爭不能膨脹爲悲觀鎖阻塞等待,而且也沒有過時超時,有可能大量佔用咱們的CPU,咱們又繼續進一步優化:

public void lock() throws TransactionException {
            boolean flag;
            int times = 1;
            long beginTime = System.currentTimeMillis();
            long restTime = GLOBAL_SESSOION_LOCK_TIME_OUT_MILLS ;
            do {
                restTime -= (System.currentTimeMillis() - beginTime);
                if (restTime <= 0){
                    throw new TransactionException(TransactionExceptionCode.FailedLockGlobalTranscation);
                }
                // Pause every PARK_TIMES_BASE times,yield the CPU
                if (times % PARK_TIMES_BASE == 0){
                    // Exponential Backoff
                    long backOffTime =  PARK_TIMES_BASE_NANOS << (times/PARK_TIMES_BASE);
                    long parkTime = backOffTime < restTime ? backOffTime : restTime;
                    LockSupport.parkNanos(parkTime);
                }
                flag = this.globalSessionSpinLock.compareAndSet(true, false);
                times++;
            }
            while (!flag);
        }

上面的代碼作了以下幾個優化:

引入了超時機制,通常來講一個要作好這種對臨界區域加鎖必定要作好超時機制,尤爲是在這種對性能要求較高的中間件中。

引入了鎖膨脹機制,這裏沒循環必定次數若是獲取不到鎖,那麼會線程掛起parkTime時間,掛起以後又繼續循環獲取,若是再次獲取不到,此時咱們會對咱們的parkTime進行指數退避形式的掛起,將咱們的掛起時間逐漸增加,直到超時。

總結

從咱們對併發控制的處理來看,想要達到一個目的,要實現它方法是有多種多樣的,咱們須要根據不一樣的場景,不一樣的條件,選擇合適的方法,選擇最高效的手段來完成咱們的目的。本文沒有對悲觀鎖的原理作太多的闡述,這裏有興趣的能夠下來自行查閱資料,讀完本文若是你只能記住一件事,那麼請記住實現線程併發安全的時候別忘記考慮樂觀鎖。

學習更多Java基礎知識能夠加入個人:Java學習園地,更適合小白。

相關文章
相關標籤/搜索