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


先上結論:

一切互斥操作的依賴是 自旋鎖(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讀取該變量,將是一直的新值(即使穿透緩存直接讀取內存也是一樣一致)

讀屏障本文不討論,其本身作用是將 Invalidate Queue 中的無效化請求應用到自己獨享的緩存中,以便不去讀之前的舊數據。而是因為緩存行已經 Invalidated,而去 從其他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應用層面到硬件原理層面的同步體系至此介紹完畢。

 

 

 

 

 

 

 

 

 

 

 

 

  

 

 

 

 

  

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM