AQS學習(一)自旋鎖原理介紹(爲何AQS底層使用自旋鎖隊列?)

1.什麼是自旋鎖?

  自旋鎖做爲鎖的一種,和互斥鎖同樣也是爲了在併發環境下保護共享資源的一種鎖機制。在任意時刻,只有一個執行單元可以得到鎖。html

  互斥鎖一般利用操做系統提供的線程阻塞/喚醒機制實現,在爭用鎖失敗時令線程陷入阻塞態而讓出cpu,並在獲取到鎖時再將其喚醒。而自旋鎖則是經過加鎖程序中的無限循環,由當前嘗試加鎖的線程反覆輪訓當前鎖的狀態直到最終獲取到鎖。java

互斥鎖與自旋鎖的優缺點

  互斥鎖的優勢是當加鎖失敗時,線程會及時的讓出cpu,從而提升cpu的利用率,但缺點是若是短期內若是涉及到大量線程的加鎖/解鎖,則頻繁的喚醒/阻塞會由於大量的線程上下文切換而下降系統的性能。所以互斥鎖適用於線程會在較長時間內持有鎖的場景。node

      與互斥鎖相對的,自旋鎖因爲一直處於持續不斷的輪訓中,所以能夠很是迅速的感知到鎖狀態的變化,在兩個線程間可以瞬間完成鎖的釋放與獲取。但若是須要爭用鎖的線程長時間都沒法獲取到鎖,則會形成CPU長時間空轉,形成CPU資源極大的浪費。所以自旋鎖只適用於線程在加鎖成功後會在極短的時間內釋放鎖的場景(須要保護的臨界區很是小)。編程

      自旋鎖和互斥鎖起到了一個互補的做用,在不一樣的需求場景下發揮本身的做用。緩存

2.自旋鎖的多種實現

  本篇博客的重點是自旋鎖的工做原理,因爲存在許多種擁有不一樣特性的自旋鎖,因此這裏只挑選出幾種具備表明性的自旋鎖:原始版本自旋鎖、票鎖TicketLock、CLH鎖和MCS鎖,介紹這幾種自選鎖的實現原理和各有的優缺點。安全

  本篇博客中的自旋鎖是用java實現的。爲了方便測試,先抽象並定義了一個通用的自旋鎖接口SpinLock。 數據結構

public interface SpinLock {

    /**
     * 加鎖
     * */
    void lock();

    /**
     * 解鎖
     * */
    void unlock();
}

2.1 原始自旋鎖架構

  原始版本的自旋鎖很是基礎,實現思路是全部須要加鎖的線程經過cas重試的方式去爭用鎖,若是cas設置lockOwner成功則表明加鎖成功;而解鎖的時候則將lockOwner設置爲null便可,這樣後續須要爭用鎖的某個線程其lock中無限循環的cas操做就能成功獲取到鎖了。併發

 原始自旋鎖實現:app

public class OriginalSpinLock implements SpinLock{
    /**
     * 標識當前自旋鎖的持有線程
     *
     * AtomicReference類型配合cas
     * */
    private final AtomicReference<Thread> lockOwner = new AtomicReference<>();

    @Override
    public void lock() {
        Thread currentThread = Thread.currentThread();

        // cas爭用鎖
        // 只有當加鎖時以前lockOwner爲null,才表明加鎖成功,結束循環
        // 不然說明加鎖時已經有其它線程得到了鎖,無限循環重試
        while (!lockOwner.compareAndSet(null, currentThread)) {
        }
    }

    @Override
    public void unlock() {
        Thread currentThread = Thread.currentThread();

        // cas釋放鎖
        // 只有以前加鎖成功的線程纔可以將其從新cas的設置爲null
        lockOwner.compareAndSet(currentThread, null);
    }
} 

原始自旋鎖的優勢:

  1.簡單:做爲基礎版本的自旋鎖,不管是性能仍是功能上都是最差的,其惟一的優勢就是簡單了。

原始自旋鎖的缺點:

  1.非公平:相比其它類型的自旋鎖,原始自旋鎖的一大缺點是非公平。因爲全部的線程都是監聽lockOwner這一引用,所以後加鎖的線程是極可能比在這以前已經在爭用鎖的線程先加鎖成功的,在大量線程參與加鎖的極端狀況下會致使先加鎖的線程一直沒法加鎖成功。

  2.過多的內存競爭:因爲全部的線程都是監聽、訪問同一內存地址的數據,且AtomicReference中使用volatile關鍵字修飾value來保證線程間可見性。在多核cpu的架構下,操做系統和硬件底層會使用諸如鎖內存總線或者使用緩存一致性協議同步等機制來實現不一樣線程間的內存可見性,這會在必定程度上影響到系統的內存訪問性能。

2.2 票鎖(TicketLock)

  爲了解決原始自旋鎖非公平的缺點,在原始自旋鎖基礎上改進的票鎖TicketLock被髮明瞭出來。

  票鎖在加鎖時當前線程會預先原子性的拿到一個逐步遞增且惟一的排隊服務號,只有當前票鎖的服務票號和本身拿到的排隊服務號一致時才認爲加鎖成功。而在解鎖時則將當前票鎖的服務票號遞增,得以讓下一個加鎖的線程得到鎖。

  因爲服務票號是逐步遞增且惟一的,TicketLock中先來申請加鎖的線程會拿到更小更靠前的服務號,也能較以後申請加鎖的線程更早的得到到鎖,保證了公平性。

票鎖實現:

public class TicketSpinLock implements SpinLock{

    /**
     * 排隊號發號器
     * */
    private AtomicInteger ticketNum = new AtomicInteger();

    /**
     * 當前服務號
     * */
    private AtomicInteger currentServerNum = new AtomicInteger();


    public void lock() {
        // 首先得到一個惟一的排隊號
        int myTicketNum = ticketNum.getAndIncrement();

        // 當前服務號與本身持有的服務號不匹配
        // 一直無限輪訓,直到排隊號與本身的服務號一致(等待排隊排到本身)
        while (currentServerNum.get() != myTicketNum) {
        }
    }

    public void unlock() {
        // 釋放鎖時,表明當前服務已經結束
        // 當前服務號自增,使得拿到下一個服務號的線程可以得到鎖
        currentServerNum.incrementAndGet();
    }
}

票鎖的優勢:

  1.公平:相比於原始自旋鎖,票鎖是一個先來先服務的公平鎖,避免了某些線程被其餘線程搶先而長時間沒法獲取鎖的問題。

票鎖的缺點:

      1.過多的內存競爭:和原始自旋鎖同樣,票鎖中每個線程都會不斷的訪問當前服務票號(currentServerNum)這一volatile關鍵字修飾的變量值,在多核CPU架構下性能會受到必定的影響。

2.3 CLH鎖

      CLH鎖是由Craig, Landin, and Hagersten三位計算機科學家共同發明的,這也是CLH鎖名字的由來(取名字首字母)。

  CLH鎖被髮明出來的主要緣由是爲了解決多核cpu體系中所有加鎖線程都訪問同一內存地址而出現過多內存競爭的問題。CLH鎖和票鎖同樣是先來先服務的公平鎖,但CLH鎖引入了線程節點的概念,須要加鎖的線程不斷的從隊尾加入隊列,構造出了一個邏輯上的單向鏈表隊列;獲取鎖的順序也是從隊列頭部開始,早加入隊列的線程便能更早的得到到CLH鎖,實現先來先服務的公平性。

CLH鎖結構圖:

      CLH鎖中加鎖的線程再也不是統一的監聽同一個標識鎖狀態的內存地址,而是隻監聽隊列中當前線程節點其前驅線程節點的鎖狀態。如此一來,便分散了不一樣線程加鎖時所要訪問的內存變量地址,相比起前面介紹的原始自旋鎖和票鎖減小了大量的內存訪問競爭,減小了底層爲了實現線程間內存數據可見性同步時的性能開銷。

      加鎖時,先cas的入隊獲取前驅節點後,便不斷的循環監聽前驅節點鎖的狀態,當發現前驅節點釋放了鎖時,當前節點便得到了鎖。

      而解鎖時則很簡單,將當前線程本身的鎖狀態更改成已釋放便可。標識爲已釋放時,存在的後繼加鎖節點便能感知到這一變化,從而得到鎖。

CLH鎖實現:

/**
 * 原始版CLH鎖(無顯式prev前驅節點引用,沒法支持取消加鎖等場景)
 */
public class CLHLockV1 implements SpinLock{
    private static class CLHNode {
        /**
         * 獲取到鎖的線程其後繼爭用鎖的節點會持續不斷的查詢isLocked的值
         * 使用volatile修飾,使得釋放鎖修改isLocked時不會出現線程間變量不可見的問題
         * */
        private volatile boolean isLocked;
    }

    private final AtomicReference<CLHNode> tailNode;
    private final ThreadLocal<CLHNode> curNode;

    public CLHLockV1() {
        // 初始化時尾結點指向一個空的CLH節點
        tailNode = new AtomicReference<>(new CLHNode());
        // 設置threadLocal的初始化方法
        curNode = ThreadLocal.withInitial(CLHNode::new);
    }

    @Override
    public void lock() {
        CLHNode currNode = curNode.get();
        currNode.isLocked = true;

        // cas的設置當前節點爲tail尾節點,而且獲取到設置前的老tail節點
        // 老的tail節點是當前加鎖節點的前驅節點(隱式前驅節點),當前節點經過監聽其isLocked狀態來判斷其是否已經解鎖
        CLHNode preNode = tailNode.getAndSet(currNode);
        while (preNode.isLocked) {
            // 無限循環,等待得到鎖
        }

        // 循環結束,說明其前驅已經釋放了鎖,當前線程加鎖成功
    }

    @Override
    public void unlock() {
        CLHNode node = curNode.get();
        // 清除當前threadLocal中的節點,避免再次Lock加鎖時獲取到以前的節點
        curNode.remove();
        node.isLocked = false;
    }
}

CLH鎖的優勢:

  1.公平:FIFO的線程隊列保證了先加鎖的線程能更早一步的得到鎖,不會被後加鎖的線程搶先,保證了公平性。

  2.分散了內存競爭:因爲每一個須要加鎖的線程監聽的是其前驅節點的鎖狀態,因此不一樣線程監聽的是不一樣的內存數據,避免了全部線程都監聽同一內存數據的性能問題。

CLH鎖的缺點:

      1.不支持取消加鎖的場景:當前示例中的CLH鎖是基礎版的,沒有顯式的連接前驅節點,沒法支持超時等取消鎖的場景。

2.4 MCS鎖

  MCS鎖也是得名於其發明者的名字,John Mellor-Crummey和Michael Scott。

  MCS鎖的實現思路和CLH鎖相似,也是經過構建一個單向鏈表來分攤內存競爭的並實現先來先服務的公平性。

MCS鎖結構圖:

MCS鎖實現:

public class MCSLock implements SpinLock{

    private static class MCSNode {
        /**
         * 獲取到鎖的線程其後繼爭用鎖的節點會持續不斷的查詢isLocked的值
         * 使用volatile修飾,使得釋放鎖修改isLocked時不會出現線程間變量不可見的問題
         * */
        private volatile boolean isLocked;

        private MCSNode next;
    }

    private final AtomicReference<MCSNode> tailNode;
    private final ThreadLocal<MCSNode> curNode;

    public MCSLock() {
        // MCS鎖的tailNode初始化時爲空,表明初始化時沒有任何線程持有鎖
        tailNode = new AtomicReference<>();
        // 設置threadLocal的初始化方法
        curNode = ThreadLocal.withInitial(MCSNode::new);
    }

    @Override
    public void lock() {
        MCSNode currNode = curNode.get();
        currNode.isLocked = true;

        MCSNode preNode = tailNode.getAndSet(currNode);
        if(preNode == null){
            // 當前線程加鎖以前並不存在tail節點,則表明當前線程爲最新的節點,直接認爲是加鎖成功
            currNode.isLocked = false;
        }else{
            // 以前的節點存在,令前驅節點next指向當前節點,以便後續前驅節點釋放鎖時可以找到currNode
            // 前驅節點釋放鎖時,會主動的更新currNode.isLocked(令currNode.isLocked=false)
            preNode.next = currNode;

            while (currNode.isLocked) {
                // 自旋等待當前節點本身的isLocked變爲false
            }
        }
    }

    @Override
    public void unlock() {
        MCSNode currNode = curNode.get();
        if (currNode == null || currNode.isLocked) {
            // 前置防護性校驗,若是當前線程節點爲空或者當前線程自身沒有成功得到鎖,則直接返回,加鎖失敗
            return;
        }

        if(currNode.next == null){
            // 當前節點的next爲空,說明其是MCS的最後一個節點
            // 以cas的形式將tailNode設置爲null(防止此時有線程併發加鎖 => lock方法中的tailNode.getAndSet())
            boolean casSuccess = tailNode.compareAndSet(currNode,null);
            if(casSuccess){
                // 若是cas設置tailNode成功爲null成功,則釋放鎖結束
                return;
            }else{
                // 若是cas設置失敗,說明此時又有了新的線程節點入隊了
                while (currNode.next == null) {
                    // 自旋等待,併發lock的線程執行(preNode.next = currNode),設置currNode的next引用
                }
            }
        }

        // 若是currNode.next存在,按照約定則釋放鎖時須要將其next的isLocked修改,令next節點線程結束自旋從而得到鎖
        currNode.next.isLocked = false;
        // 方便GC,斷開next引用
        currNode.next = null;
    }
}

MCS鎖的優勢:

  1.公平:FIFO的線程隊列保證了先加鎖的線程能更早一步的得到鎖,不會被後加鎖的線程搶先,保證了公平性。

  2.分散了內存競爭:因爲每一個須要加鎖的線程監聽的是其前驅節點的鎖狀態,因此不一樣線程監聽的是不一樣的內存數據,避免了全部線程都監聽同一內存數據的性能問題。

  3.NUMA架構下性能更好NUMA架構下MCS鎖的性能略優於CLH鎖。

MCS鎖的缺點:

      1..不支持取消加鎖的場景:和基礎版的CLH鎖同樣,沒法支持超時等取消鎖的場景。

3.爲何CLH鎖在NUMA的CPU架構下性能會略低於MCS鎖?

  從上述CLH鎖和MCS鎖的實現中能夠看到,MCS鎖的鏈表隊列方向和CLH鎖是相反的,CLH鎖是一個從尾節點出發經過prev關聯前驅節點的隊列,後繼節點經過無限循環監聽並感知其前驅節點的鎖狀態變化;而MCS鎖是一個從頭節點出發經過next關聯後繼節點的單向隊列,在前驅節點釋放鎖時經過修改後繼節點的鎖狀態來通知後繼節點。

  兩種鎖的實現方式看起來大同小異,但細小的區別卻使得MCS鎖在NUMA架構下的性能要高於CLH鎖。

3.1 什麼是NUMA架構?

  NUMA架構是多核CPU體系架構的一種,與之相對的則是SMP架構。

  SMP(Sysmmetric Multi-Processor System,對稱多處理器系統)架構顧名思義,多個cpu核心經過統一的方式共享訪問同一個集中式的存儲器,每一個cpu並沒有主從之分被分配一樣大小的時間片平均的訪問存儲器。

  SMP架構比起NUMA架構簡單,早期的多核CPU都是採用這種架構。但SMP架構受限於存儲器總線的帶寬,核心數過多容易致使部分核心沒法獲得足夠的訪問時間片而陷入飢餓,所以SMP架構其所能支持的CPU核心數受到了很大的限制。  

SMP結構示意圖:

  爲了解決SMP架構下存儲器帶寬有限的問題,計算機科學家提出了一種新的CPU體系架構即NUMA架構(Non-Uniform Memory Access,非一致性存儲器訪問與SMP架構不一樣,NUMA架構下的存儲器是分佈式的,其將cpu核心和存儲器平均分割爲了多個NUMA節點(NUMA Node),不一樣節點之間經過QPI總線等機制進行互聯。

  NUMA架構下任意的cpu核心依然能夠訪問全量的存儲器,cpu核心若是訪問的是位於同一節點內的存儲器會很快,但訪問其它NUMA節點內的存儲器則因爲須要經過QPI總線訪問而會有必定的延遲,這也是其被稱爲非一致存儲器訪問的緣由。

  NUMA架構下cpu核心訪問本地節點內存快,訪問遠程節點內存慢。理想狀況下,每一個節點若是都只訪問本地節點的內存,那麼理論上其數據吞吐量將會是SMP架構的N倍(N爲節點數)。

NUMA結構示意圖: 

3.2 CLH鎖在NUMA架構下低於MCS鎖的緣由

  在簡單瞭解了NUMA架構後,下面開始說明CLH鎖在NUMA架構下低於MCS鎖的緣由。

  因爲NUMA架構下訪問本地節點內存和遠程節點內存性能存在差別,操做系統在爲線程分配內存時須要儘量的讓線程訪問的內存與執行線程的cpu分配到同一個NUMA節點中,一個簡單的策略即是將對應線程所申請的內存分配到對應線程cpu所在的同一NUMA節點中。(實際的NUMA結構下內存分配與平衡機制很複雜,由於內存分配後若是線程發生上下文切換後可能就在其它節點的cpu核心上了,這裏舉的例子極大的簡化了複雜度)

  觀察CLH鎖和MCS鎖在NUMA架構下的行爲:CLH鎖和MCS鎖爲線程節點分配的內存一般都會分配到與對應線程執行cpu核心綁定的NUMA節點的存儲器中,而不一樣線程對應的cpu則可能位於不一樣的NUMA節點中。CLH鎖會無限輪訓前驅節點的isLocked狀態,這一操做在前驅節點線程與當前線程不位於同一NUMA節點時,會不斷的進行遠程節點訪問,性能較低;而MCS鎖中,當前線程則是無限輪訓本身線程節點的isLocked,這種狀況下都是本地NUMA節點內存的訪問。

  當前驅節點線程與當前節點線程不在同一NUMA節點內時,CLH鎖在lock時會進行N次遠程節點訪問,在unLock時進行一次本地節點訪問;而MCS鎖則在lock時會進行N次本地節點訪問,並在unLock時進行一次遠程節點訪問。

  綜上所述,因爲NUMA節點下本地節點訪問性能是優於遠程節點訪問的,所以MCS鎖的性能在NUMA架構下會略優於CLH鎖。

4.爲何AQS框架底層使用CLH隊列結構做爲基礎?

  這個問題能夠被分解爲兩個更細節的問題,即爲何AQS底層使用自旋鎖隊列做爲基礎以及爲何在自旋鎖隊列中選擇了CLH鎖隊列而不是MCS鎖隊列做爲基礎

4.1 爲何AQS底層使用自旋鎖隊列做爲基礎?

  AQS是jdk的juc併發工具包下提供的抽象同步器框架,可做爲可重入鎖、信號量等各種型同步器的實現基礎。

  因爲AQS中須要讓大量併發爭用鎖的線程頻繁的被阻塞和喚醒,出於性能的考慮,爲避免過多的線程上下文切換,AQS自己沒有再利用操做系統底層提供的線程阻塞/喚醒機制經過互斥鎖來保證同步隊列的併發安全,而是使用基於CAS的樂觀重試機制來構造一個無鎖,併發安全的同步隊列。

  AQS論文原文:These days, there is little controversy that the most appropriate choices for synchronization queues are non-blocking data structures that do not themselves need to be constructed using lower-level locks.(現在,構造同步隊列最合適的選擇是使用自身構造不依賴底層鎖的無鎖數據結構,這在業界是幾乎沒有爭議的)

    從AQS的做者Doug Lea的論文中能夠看到,AQS做爲一個用於控制線程併發的底層框架,爲避免互斥鎖同步機制下過多的線程上下文切換而影響性能,因此才使用不須要阻塞線程的自旋鎖隊列做爲基礎來實現線程安全的。

4.2 爲何AQS使用CLH鎖隊列而不是MCS鎖隊列做爲基礎呢?

  AQS做爲一個通用的同步器框架,是須要支持超時、中斷等取消加鎖的,而前面提到基礎版的CLH鎖和MCS鎖都存在一個缺陷,即沒法很好的支持超時、中斷等取消加鎖的場景。

  引入了顯式前驅節點引用的CLH鎖比起MCS鎖能夠更加簡單的實現超時、中斷等加鎖過程當中臨時退出加鎖的場景。而因爲AQS中的線程在徵用鎖失敗時不會佔用CPU一直自旋等待,而是被設置爲阻塞態讓出CPU(LockSupport.park),所以MCS鎖在NUMA架構下性能略高的優勢也就不是那麼重要了。

  AQS論文原文:Historically, CLH locks have been used only in spinlocks. However, they appeared more amenable than MCS for use in the synchronizer framework because they are more easily adapted to handle cancellation and timeouts, so were chosen as a basis. The resulting design is far enough removed from the original CLH structure to require explanation.一直以來,CLH鎖僅被用於自旋鎖。然而,在這個框架中,CLH鎖顯然比MCS鎖更合適。由於CLH鎖能夠更容易地去實現「取消」和「超時」功能,所以咱們選擇了CLH鎖做爲實現的基礎。可是最終的設計已經與原來的CLH鎖結構有較大的出入)

引入顯示前驅節點的CLH鎖實現:

public class CLHLockV2 implements SpinLock{

    private static class CLHNode {
        private volatile CLHNode prev;
        private volatile boolean isLocked;

        public CLHNode() {
        }

        public CLHNode(CLHNode prev, boolean isLocked) {
            this.prev = prev;
            this.isLocked = isLocked;
        }
    }

    private static final CLHNode DUMMY_NODE = new CLHNode(null,false);

    private final CLHNode head;
    private final AtomicReference<CLHNode> tail;
    private final ThreadLocal<CLHNode> curNode;

    public CLHLockV2() {
        head = DUMMY_NODE;
        tail = new AtomicReference<>(DUMMY_NODE);
        curNode = ThreadLocal.withInitial(CLHNode::new);
    }

    @Override
    public void lock() {
        CLHNode currentNode = curNode.get();
        currentNode.isLocked = true;

        // cas的設置爲當前tail爲新的tail節點
        currentNode.prev = tail.getAndSet(currentNode);

        while(true){
            while(currentNode.prev.isLocked){
            }

            // 內層while循環結束,說明前驅節點已經釋放了鎖
            CLHNode prevNode = currentNode.prev;
            if(prevNode == head){
                // 若是前驅節點爲head(Dummy節點)
                return;
            }else{
                currentNode.prev = prevNode.prev;
            }
        }
    }

    @Override
    public void unlock() {
        CLHNode currentNode = curNode.get();
        curNode.remove();
        currentNode.isLocked = false;
    }
}

  熟悉AQS實現的讀者能夠看到,引入了顯式前驅節點引用的CLH鎖改良版和AQS的同步隊列實現已經有了幾分類似。不過因爲AQS中加鎖失敗的節點不是經過自旋來感知可否獲取到鎖,而是依賴其同步隊列的前驅節點來喚醒它,所以AQS和用於自旋鎖的CLH鎖在最終實現上存在必定差別。(The wait queue is a variant of a "CLH" (Craig, Landin, and Hagersten) lock queue. CLH locks are normally used for spinlocks)。

5.總結

  做爲AQS框架學習的第一篇博客,之因此決定從自旋鎖的工做原理開始展開,是由於AQS框架的底層是CLH鎖隊列的一個變種。若是能理解CLH鎖隊列的工做模式,能夠爲AQS的學習提供很大的幫助。因爲AQS須要可以爲互斥鎖、共享鎖和條件變量等多種不一樣類型的同步器提供基礎支持,代碼量較多;且其基於樂觀鎖重試的特色,使得代碼中存在着很多處理臨界區無鎖併發的晦澀邏輯,源碼讀起來比較吃力。所以在學習的過程當中轉換思惟,嘗試着站在AQS設計者的角度來理解其工做原理,參考着AQS的實現思路本身動手寫一個簡易版的AQS,在這個過程當中將本身的實現和AQS的源碼進行比較,結合網上關於AQS原理解析的博客,反覆琢磨和體會做者Doug Lea實現的巧妙之處。 

  本身實現的AQS會按照順序經過支持互斥鎖、支持共享鎖、支持取消加鎖(中斷、超時退出)和支持條件變量這幾個功能模塊爲基礎逐步完成,後續會以博客的形式分享出來。

  但願這篇博客能幫助到對自旋鎖、AQS工做原理感興趣的人,若有錯誤,還請多多指教。

參考書籍:

  《多處理器編程的藝術》

參考博客:

  https://www.cnblogs.com/dennyzhangdd/p/7218510.html AQS框架論文翻譯

  https://www.cnblogs.com/stevenczp/p/7136416.html 多種自旋鎖實現

  https://felord.blog.csdn.net/article/details/108313803 CLH鎖的實現

  https://javazhiyin.blog.csdn.net/article/details/108332477 CLH鎖詳解

  http://www.javashuo.com/article/p-xngesqab-wu.html NUMA架構介紹 

相關文章
相關標籤/搜索