從軟體(Java/hotspot/Linux)到硬體(硬體架構)分析互斥操作的本質

執生發表於2021-01-26

先上結論:

一切互斥操作的依賴是 自旋鎖(spin_lock),互斥量(semaphore)等其他需要佇列的實現均需要自選鎖保證臨界區互斥訪問。

而自旋鎖需要xcmpchg等類似的可提供CAS操作的硬體指令提供原子性 和 可見性,(xcmpchg會鎖匯流排或快取行,一切會鎖匯流排或快取行的操作都會刷StoreBuffer,起到寫屏障的操作)

所以,任意的互斥操作,無論是 java 層面,hotspot層面,linux層面 的根本依賴都是 xcmpchg 等硬體指令。java算是上層,需要依賴hotspot和linux嵌入的彙編完成xcmpchg的呼叫。

所有同步手段的根本是硬體,軟體是輔助手段,軟體和硬體的交介面是用於併發控制的硬體指令(如 cmpchg, 帶lock字首的指令,lwsync, sfence 等)

整個依賴鏈條:

1. Java 的併發工具包 JUC 中大部分同步工具類依賴 AQS 為他們提供佇列服務和資源控制服務。

2. AQS 依賴 LockSupport 的 park 和 unpark 為他提供執行緒休眠喚醒操作

3. LockSupport 的 park 和 unpark 是依賴 JVM(此處語境討論 Hotspot)呼叫作業系統的  pthread_mutex_lock 和 pthread_cond_wait , 前者是保護後者和 counter 變數的互斥鎖,保證只有一個執行緒操作 counter 變數和 condtion 上的等待佇列

4. pthread_mutex_wait 依賴於 作業系統的 futex 機制,多個使用者態的執行緒(Java執行緒,即Mutator)通過使用者空間相同,物理頁共享,共同爭搶受寫屏障增強,執行緒可見性強的資源變數。如果搶不到,需要用 futex_wait 系統呼叫,具體是委託核心檢視該變數是否還是 futex_wait 的入參(爭搶失敗後的值),如果是,則讓核心將自己從 runqueue(Linux下的就緒程式佇列)摘下來,並且狀態設為 TASK_INTERRUABLE,表示不需要繼續執行,但是可以用訊號喚醒,如果不是,返回使用者空間,再次爭搶

5. futex_wait 和 futex_wakeup 依賴 spin_lock保護桶bucket,其實保護bucket上的一整條連結串列

6. 作業系統的 down , up 依賴 spin_lock 保護等待佇列和資源變數


 硬體層

預備知識:

寫屏障

簡化微機架構(Intel X86):

 

 

 無寫屏障:

  1.假設有變數var

  

 

 

   2. CPU A(程式/執行緒A) 修改 var = 1

  

 

  3. CPU C(程式/執行緒C) 讀取到 var = 3, 無法立刻得到 A 的修改

  

 

有寫屏障(A,B,C任意CPU在修改完某個變數後均使用寫屏障):

  上面的微機架構可以簡化成:

  

 

  1.A修改var

  

 

  2.C立刻可見

       

 

使用寫屏障類似:

  var = 1;

  write_fence_here(); // 寫屏障

作用只是將 storeBuffer中的內容馬上刷出到 自己的快取記憶體中,因為快取記憶體有MESI快取一致性協議,所以其他CPU讀取該變數,將是一直的新值(即使穿透快取直接讀取記憶體也是一樣一致)

 


 

 作業系統層

 自旋鎖和佇列鎖(一般互斥量是佇列鎖)

1.自旋鎖簡化:

    while (true) {
            if (compareAndSet(期望的舊值, 新值)) {
                return;
            }
        }

自旋鎖在Linux中寫作 spin_lock ,spin 本身有“連軸轉”的意思。自旋鎖的本質是獲取不到資源就一直空轉。

compareAndSet : 類似下面程式碼,但是被包裝成 一條硬體指令,所以是原子的,在他執行的中間,不能有別的CPU插手這個記憶體的操作。

並且CAS要麼全部完成,要麼不執行,不能只執行一半,因為他是一條鎖了匯流排或快取行的硬體指令。在SMP條件下,如果不鎖匯流排或快取行,指令也不是原子的,比如ADD(read-write-read),只有微操作是原子的。

比如將某個值打入某個暫存器中(write)。

    boolean compareAndSet (期望的舊值, 新值) {
        if (變數值 == 期望的舊值) {
             變數 = 新值;
             return true;
        }
        return false;
    }

 

2. 佇列鎖簡化:

addToQueue: 將執行緒/程式的TCB/PCB(在linux是task_struct),放入等待佇列,當持有資源的執行緒釋放資源的時候會喚醒等待佇列中執行緒(PCB/TCP就是代表程式/執行緒的結構)。

          並且將程式/執行緒的 狀態設為非執行狀態(linux中一般使用TASK_INTERRUPTABLE), 並從就緒佇列上摘下來(Linux上是runqueue)

schedule :當前執行緒已設定為非執行狀態,所以會選擇其他執行緒佔用CPU, 當前執行緒在此點睡眠

   while (true) {
        if (!compareAndSet(期望舊值,新值)) { // 嘗試獲取資源,如:compareAndSet(原資源數,原資源數 - 1)
            addToQueue(當前執行緒PCB/TCB); // 獲取不到就進入等待佇列
            schedule();// 睡眠,讓出CPU
        }
    }

 

為什麼說互斥量(佇列鎖)依賴自旋鎖?

  假設有以下情況:(互斥量對應資源初始值=1)

 

 

 如此一來,明明有資源,但是執行緒B卻無法被喚醒。

   究其原因,是因為B的 檢測資源-掛入等待佇列-睡眠 這三個階段,不是原子的。執行緒A 可以修改資源,讓資源變成1。

 執行緒A對資源的操作插入到了執行緒B的操作之中,使得B的操作集合中語句前後所處的狀態不一致,即非原子的,受干擾的(區別於事物原子性)。

 

   可以使用自旋鎖保護 資源,在讀取資源時,其他執行緒不能修改資源,那麼釋放操作就會被放到睡眠之後:

 

 

 

 

 

 

 

   為何可以使用自旋鎖? 因為自旋鎖不涉及佇列,如果執行緒無法獲取自旋鎖,就在CPU 上空轉,直到獲取為止,不需要佇列去儲存他們,所以不會出現多個執行緒修改一個佇列的情況。

也不會睡眠,所以也不會出現因為睡眠而錯過資源的情況,像上二張圖就是錯過資源的情況,自選鎖一直都在爭搶。

  但是自旋鎖的侷限性也很大,空轉,無意義的CPU時間被浪費。所以只有競爭不是很激烈,以及佔用鎖時間不長的情況,才使用自旋鎖。

  這裡的對佇列操作,只是簡單地讀取一下變數,和在連結串列上掛一個節點,很快。

  在Linux(3.0.7)下的實現:

  up 操作是釋放互斥量資源,down 操作是獲取互斥量資源

    

 

 

 

futex(fast user mutex):之所以稱為 user mutex,是因為多個使用者態執行緒通過一塊共享記憶體儲存代表資源的變數,多個使用者態執行緒對這個資源的操作是原子性的,這是在使用者態的操作。

當使用者執行緒發現自己爭搶不到資源,才委託系統呼叫幫自己檢查一下這個變數還是不是剛才讀到的變數,如果是就當前執行緒休眠,所以是在使用者態判斷是否可以獲取資源,不行再使用系統呼叫陷入核心態。比如說,我有一塊記憶體頁,被A,B兩個執行緒共享,這個記憶體頁裡有個變數 var ,表示資源的個數,一開始是1。執行緒A和B都是通過CAS型的硬體指令去設定這個資源,即操作是原子性的。假如一開始A,CAS 搶奪成功,資源var 變成 0。資源B 直接通過自己的頁面對映表去到這個共享的物理頁,讀取一下,發現是0,那麼當前表示無資源可用。B將會使用系統呼叫,委託作業系統檢查,這個資源是不是還是0,如果是就將自己休眠,否則B退出核心態回到使用者態。為什麼要委託作業系統再檢查一次呢?因為有可能A已經釋放資源了,B只要再CAS一次就能獲得資源。

 

 

 

 

futex 機制的實現比較簡單,基於雜湊表:

每一個futex_key代表一個共享變數,即資源。

每一個節點包裹著 futex_key

每一個futex_bucket代表一個hash桶,也就是hash表中的某個位置

一個 futex_bucket 的連結串列中,有不同節點,說明有不同資源。比如說,“螢石” 是一種資源,“紅石”也是一種資源,他們的數量所代表的變數(地址)的節點會存在於下圖的同一個連結串列上

每個bucket都有一個 鎖 可以被自旋鎖 鎖定,鎖的單位是 一個 bucket上的連結串列,所以當一種資源需要加鎖,會鎖到連結串列上的其他資源。

設計者這麼做其實並不過分,因為一個桶中的連結串列長度並不是很長,而且spin_lock是短時間鎖,將鎖粒度控制在整個雜湊表一個鎖和每個節點一個鎖之間,是對空間和時間的權衡。

 

 

futex在 執行緒處於核心態 ,讀取資源 之前,會用 spin_lock 鎖住 bucket,讀取資源後發現沒有資源會把自己掛入等待佇列,然後釋放spin_lock 。

持有資源的執行緒在喚醒等待佇列中執行緒之前,同樣要用 spin_lock 鎖住同樣位置的 bucket。

下圖是 futex 的互斥機制,可能會有疑問:獲取資源不用算進去嗎?

這和程式順序有關,釋放資源肯定在喚醒之前的,這是必須遵循的,因為釋放完資源才會去喚醒程式去爭奪

那麼喚醒等待佇列這個操作可能在 被自旋鎖保護區域的上面或者下面。

如果在上面,那麼資源在喚醒之前就釋放了,保護區裡肯定可以得到資源,免於睡眠。

如果在下面,那麼無論資源在喚醒之前的哪個位置,就算是在保護區裡也好,只要是釋放了就行。因為喚醒操作在保護區之後,而保護區裡,要休眠程式已經掛到等待佇列。

所以喚醒操作必能喚醒要休眠程式,因為他在 入隊操作之後,他能找到那些休眠的程式,從而喚醒他們。

 

再向上一層看, pthread_mutex_wait 和 pthread_cond_wait,這兩個函式是 Hotspot 實現 park 函式依賴的作業系統層面介面。而park函式是 LockSupport.park 方法的本地方法實現。

其中 pthread_cond_wait 是把 Java執行緒(java應用執行緒,即Mutator)放入到一個等待佇列,這個佇列稱為條件佇列。對應LockSupport.park 方法。

還有一個與之對應的解鎖方法,pthread_cond_signal ,是喚醒這個佇列上的執行緒。那麼怎麼保證對這個等待佇列的操作是互斥的呢?如果不互斥,就可能發生下面這鐘典型的寫覆蓋併發問題:

 

 

依賴的是 pthread_mutex_lock, 要操作佇列之前先獲取互斥量,操作完釋放互斥量

pthread_mutex_lock(&mutex);
pthread_cond_wait(&queue);
pthread_mutex_unlock(&mutex);

pthread_mutex_lock 依賴的是上面所說的,futex, 所以 pthread_mutex_lock 就是上面說的,先在使用者態讀取資源,如果沒資源了,就呼叫 SYS futex 系統呼叫

 


 

jvm(hotspot)層

 

到這裡,作業系統和java層面差不多要連起來了,我們再通過LockSupport向上走。

在 呼叫LockSupport.unpark 之後呼叫LockSupport.park 的話,執行緒不會休眠。這個點很重要,沒有這個點 ,JUC中的AQS無法正常工作。

虛擬碼:xchg相比xcmpchg不會比較,而是直接原子設定相應記憶體單元的值。

park () {
        // 之前有資源,直接返回,並且把資源消耗掉
        if (xchg(&counter ,1, 0) == 1) {
            return;
        }
        // 準備操作 票據和佇列
        pthread_mutex_lock();
        // 可能之前 獲取 mutex 的執行緒給予了 資源
        // 必須要有這一句,否則可能錯過釋放了的資源,永遠無法被喚醒
        if (counter == 1) {
            counter = 0;
            pthread_mutex_unlock();
            return;
        }
        pthread_cond_wait();
        // 這句為什麼在 pthread_cond_wait 之後呢?
        // 因為這裡是執行緒被喚醒之後的地方,其他執行緒給了一個資源,當前執行緒才被喚醒
        // 既然被喚醒了,就要去消耗這個資源,這樣一喚醒(資源+1),一睡眠(資源-1)。
        // 扯平之後就是當前執行緒的 繼續執行狀態
        counter = 0;
        pthread_mutex_unlock();
    }

    unpark () {
        pthread_mutex_lock();
        counter = 1;
        writeBarrierHere();
        pthread_cond_signal();
        pthread_mutex_unlock();
    }

回到剛才的問題:為什麼unpark 之後 park 不會休眠在 AQS 中起到關鍵作用?


 

java層

 

假設執行緒A是已經獲取資源,要釋放資源的執行緒

B是嘗試獲取資源的執行緒

 

 

 

 

執行緒A對應下面兩處程式碼: 

 

執行緒B對應下面兩處程式碼。

 

 

 

 

 極端一段假設:當執行緒B執行到下面的綠色處,A執行完成他 release 方法中的兩處程式碼

 

 

 

雖然A釋放了資源,但是B還是判斷要休眠,於是呼叫LockSupport.park。於是雖然有資源但是B還是呼叫了park

B真的就這樣休眠了嗎?不會,奧祕在unparkSuccessor。

 

他會unpark 頭節點的後繼。B在呼叫 acquireQueued之前已經在佇列中,所以B的執行緒會被呼叫 LockSupport.unpark(B);

 

於是B在下次呼叫 LockSupport.park 的時候不會休眠,可以接著爭搶資源!

 

最後,JUC中的絕大多是同步工具,如Semaphore 和 CountDownLatch 都是依賴AQS的。整個JAVA應用層面到硬體原理層面的同步體系至此介紹完畢。

 

 

 

 

 

 

 

 

 

 

 

 

  

 

 

 

 

  

 

相關文章