看完你就明白的鎖系列之自旋鎖

看完你就明白的鎖系列之自旋鎖

在上一篇文章 看完你就應該能明白的悲觀鎖和樂觀鎖 中我們已經學習到了什麼是悲觀鎖和樂觀鎖、悲觀鎖和樂觀鎖的實現、優缺點分別是什麼。其中樂觀鎖的實現之一 CAS 算法中提到了一個自旋鎖的概念,爲了全面理解 CAS 算法就首先需要了解一下自旋鎖 是什麼,自旋鎖的適用場景和優缺點分別是什麼,彆着急,下面爲你一一列舉。

自旋鎖的提出背景

由於在多處理器環境中某些資源的有限性,有時需要互斥訪問(mutual exclusion),這時候就需要引入鎖的概念,只有獲取了鎖的線程才能夠對資源進行訪問,由於多線程的核心是CPU的時間分片,所以同一時刻只能有一個線程獲取到鎖。那麼就面臨一個問題,那麼沒有獲取到鎖的線程應該怎麼辦?

通常有兩種處理方式:一種是沒有獲取到鎖的線程就一直循環等待判斷該資源是否已經釋放鎖,這種鎖叫做自旋鎖,它不用將線程阻塞起來(NON-BLOCKING);還有一種處理方式就是把自己阻塞起來,等待重新調度請求,這種叫做互斥鎖

什麼是自旋鎖

自旋鎖的定義:當一個線程嘗試去獲取某一把鎖的時候,如果這個鎖此時已經被別人獲取(佔用),那麼此線程就無法獲取到這把鎖,該線程將會等待,間隔一段時間後會再次嘗試獲取。這種採用循環加鎖 -> 等待的機制被稱爲自旋鎖(spinlock)

file

自旋鎖的原理

自旋鎖的原理比較簡單,如果持有鎖的線程能在短時間內釋放鎖資源,那麼那些等待競爭鎖的線程就不需要做內核態和用戶態之間的切換進入阻塞狀態,它們只需要等一等(自旋),等到持有鎖的線程釋放鎖之後即可獲取,這樣就避免了用戶進程和內核切換的消耗。

因爲自旋鎖避免了操作系統進程調度和線程切換,所以自旋鎖通常適用在時間比較短的情況下。由於這個原因,操作系統的內核經常使用自旋鎖。但是,如果長時間上鎖的話,自旋鎖會非常耗費性能,它阻止了其他線程的運行和調度。線程持有鎖的時間越長,則持有該鎖的線程將被 OS(Operating System) 調度程序中斷的風險越大。如果發生中斷情況,那麼其他線程將保持旋轉狀態(反覆嘗試獲取鎖),而持有該鎖的線程並不打算釋放鎖,這樣導致的是結果是無限期推遲,直到持有鎖的線程可以完成並釋放它爲止。

解決上面這種情況一個很好的方式是給自旋鎖設定一個自旋時間,等時間一到立即釋放自旋鎖。自旋鎖的目的是佔着CPU資源不進行釋放,等到獲取鎖立即進行處理。但是如何去選擇自旋時間呢?如果自旋執行時間太長,會有大量的線程處於自旋狀態佔用 CPU 資源,進而會影響整體系統的性能。因此自旋的週期選的額外重要!JDK在1.6 引入了適應性自旋鎖,適應性自旋鎖意味着自旋時間不是固定的了,而是由前一次在同一個鎖上的自旋時間以及鎖擁有的狀態來決定,基本認爲一個線程上下文切換的時間是最佳的一個時間。

自旋鎖的優缺點

自旋鎖儘可能的減少線程的阻塞,這對於鎖的競爭不激烈,且佔用鎖時間非常短的代碼塊來說性能能大幅度的提升,因爲自旋的消耗會小於線程阻塞掛起再喚醒的操作的消耗,這些操作會導致線程發生兩次上下文切換!

但是如果鎖的競爭激烈,或者持有鎖的線程需要長時間佔用鎖執行同步塊,這時候就不適合使用自旋鎖了,因爲自旋鎖在獲取鎖前一直都是佔用 cpu 做無用功,佔着 XX 不 XX,同時有大量線程在競爭一個鎖,會導致獲取鎖的時間很長,線程自旋的消耗大於線程阻塞掛起操作的消耗,其它需要 cpu 的線程又不能獲取到 cpu,造成 cpu 的浪費。所以這種情況下我們要關閉自旋鎖。

自旋鎖的實現

下面我們用Java 代碼來實現一個簡單的自旋鎖

public class SpinLockTest {

    private AtomicBoolean available = new AtomicBoolean(false);

    public void lock(){

        // 循環檢測嘗試獲取鎖
        while (!tryLock()){
            // doSomething...
        }

    }

    public boolean tryLock(){
        // 嘗試獲取鎖,成功返回true,失敗返回false
        return available.compareAndSet(false,true);
    }

    public void unLock(){
        if(!available.compareAndSet(true,false)){
            throw new RuntimeException("釋放鎖失敗");
        }
    }

}

這種簡單的自旋鎖有一個問題:無法保證多線程競爭的公平性。對於上面的SpinlockTest,當多個線程想要獲取鎖時,誰最先將available設爲false誰就能最先獲得鎖,這可能會造成某些線程一直都未獲取到鎖造成線程飢餓。就像我們下課後蜂擁的跑向食堂,下班後蜂擁地擠向地鐵,通常我們會採取排隊的方式解決這樣的問題,類似地,我們把這種鎖叫排隊自旋鎖(QueuedSpinlock)。計算機科學家們使用了各種方式來實現排隊自旋鎖,如TicketLock,MCSLock,CLHLock。接下來我們分別對這幾種鎖做個大致的介紹。

TicketLock

在計算機科學領域中,TicketLock 是一種同步機制或鎖定算法,它是一種自旋鎖,它使用ticket 來控制線程執行順序。

就像票據隊列管理系統一樣。麪包店或者服務機構(例如銀行)都會使用這種方式來爲每個先到達的顧客記錄其到達的順序,而不用每次都進行排隊。通常,這種地點都會有一個分配器(叫號器,掛號器等等都行),先到的人需要在這個機器上取出自己現在排隊的號碼,這個號碼是按照自增的順序進行的,旁邊還會有一個標牌顯示的是正在服務的標誌,這通常是代表目前正在服務的隊列號,當前的號碼完成服務後,標誌牌會顯示下一個號碼可以去服務了。

像上面系統一樣,TicketLock 是基於先進先出(FIFO) 隊列的機制。它增加了鎖的公平性,其設計原則如下:TicketLock 中有兩個 int 類型的數值,開始都是0,第一個值是隊列ticket(隊列票據), 第二個值是 出隊(票據)。隊列票據是線程在隊列中的位置,而出隊票據是現在持有鎖的票證的隊列位置。可能有點模糊不清,簡單來說,就是隊列票據是你取票號的位置,出隊票據是你距離叫號的位置。現在應該明白一些了吧。

當叫號叫到你的時候,不能有相同的號碼同時辦業務,必須只有一個人可以去辦,辦完後,叫號機叫到下一個人,這就叫做原子性。你在辦業務的時候不能被其他人所幹擾,而且不可能會有兩個持有相同號碼的人去同時辦業務。然後,下一個人看自己的號是否和叫到的號碼保持一致,如果一致的話,那麼就輪到你去辦業務,否則只能繼續等待。上面這個流程的關鍵點在於,每個辦業務的人在辦完業務之後,他必須丟棄自己的號碼,叫號機才能繼續叫到下面的人,如果這個人沒有丟棄這個號碼,那麼其他人只能繼續等待。下面來實現一下這個票據排隊方案

public class TicketLock {

    // 隊列票據(當前排隊號碼)
    private AtomicInteger queueNum = new AtomicInteger();

    // 出隊票據(當前需等待號碼)
    private AtomicInteger dueueNum = new AtomicInteger();

    // 獲取鎖:如果獲取成功,返回當前線程的排隊號
    public int lock(){
        int currentTicketNum = dueueNum.incrementAndGet();
        while (currentTicketNum != queueNum.get()){
            // doSomething...
        }
        return currentTicketNum;
    }

    // 釋放鎖:傳入當前排隊的號碼
    public void unLock(int ticketNum){
        queueNum.compareAndSet(ticketNum,ticketNum + 1);
    }

}

每次叫號機在叫號的時候,都會判斷自己是不是被叫的號,並且每個人在辦完業務的時候,叫號機根據在當前號碼的基礎上 + 1,讓隊列繼續往前走。

但是上面這個設計是有問題的,因爲獲得自己的號碼之後,是可以對號碼進行更改的,這就造成系統紊亂,鎖不能及時釋放。這時候就需要有一個能確保每個人按會着自己號碼排隊辦業務的角色,在得知這一點之後,我們重新設計一下這個邏輯

public class TicketLock2 {

    // 隊列票據(當前排隊號碼)
    private AtomicInteger queueNum = new AtomicInteger();

    // 出隊票據(當前需等待號碼)
    private AtomicInteger dueueNum = new AtomicInteger();

    private ThreadLocal<Integer> ticketLocal = new ThreadLocal<>();

    public void lock(){
        int currentTicketNum = dueueNum.incrementAndGet();

        // 獲取鎖的時候,將當前線程的排隊號保存起來
        ticketLocal.set(currentTicketNum);
        while (currentTicketNum != queueNum.get()){
            // doSomething...
        }
    }

    // 釋放鎖:從排隊緩衝池中取
    public void unLock(){
        Integer currentTicket = ticketLocal.get();
        queueNum.compareAndSet(currentTicket,currentTicket + 1);
    }

}

這次就不再需要返回值,辦業務的時候,要將當前的這一個號碼緩存起來,在辦完業務後,需要釋放緩存的這條票據。

缺點

TicketLock 雖然解決了公平性的問題,但是多處理器系統上,每個進程/線程佔用的處理器都在讀寫同一個變量queueNum ,每次讀寫操作都必須在多個處理器緩存之間進行緩存同步,這會導致繁重的系統總線和內存的流量,大大降低系統整體的性能。

爲了解決這個問題,MCSLock 和 CLHLock 應運而生。

CLHLock

上面說到TicketLock 是基於隊列的,那麼 CLHLock 就是基於鏈表設計的,CLH的發明人是:Craig,Landin and Hagersten,用它們各自的字母開頭命名。CLH 是一種基於鏈表的可擴展,高性能,公平的自旋鎖,申請線程只能在本地變量上自旋,它會不斷輪詢前驅的狀態,如果發現前驅釋放了鎖就結束自旋。

public class CLHLock {

    public static class CLHNode{
        private volatile boolean isLocked = true;
    }

    // 尾部節點
    private volatile CLHNode tail;
    private static final ThreadLocal<CLHNode> LOCAL = new ThreadLocal<>();
    private static final AtomicReferenceFieldUpdater<CLHLock,CLHNode> UPDATER =
            AtomicReferenceFieldUpdater.newUpdater(CLHLock.class,CLHNode.class,"tail");


    public void lock(){
        // 新建節點並將節點與當前線程保存起來
        CLHNode node = new CLHNode();
        LOCAL.set(node);

        // 將新建的節點設置爲尾部節點,並返回舊的節點(原子操作),這裏舊的節點實際上就是當前節點的前驅節點
        CLHNode preNode = UPDATER.getAndSet(this,node);
        if(preNode != null){
            // 前驅節點不爲null表示當鎖被其他線程佔用,通過不斷輪詢判斷前驅節點的鎖標誌位等待前驅節點釋放鎖
            while (preNode.isLocked){

            }
            preNode = null;
            LOCAL.set(node);
        }
        // 如果不存在前驅節點,表示該鎖沒有被其他線程佔用,則當前線程獲得鎖
    }

    public void unlock() {
        // 獲取當前線程對應的節點
        CLHNode node = LOCAL.get();
        // 如果tail節點等於node,則將tail節點更新爲null,同時將node的lock狀態職位false,表示當前線程釋放了鎖
        if (!UPDATER.compareAndSet(this, node, null)) {
            node.isLocked = false;
        }
        node = null;
    }
}

MCSLock

MCS Spinlock 是一種基於鏈表的可擴展、高性能、公平的自旋鎖,申請線程只在本地變量上自旋,直接前驅負責通知其結束自旋,從而極大地減少了不必要的處理器緩存同步的次數,降低了總線和內存的開銷。MCS 來自於其發明人名字的首字母: John Mellor-Crummey和Michael Scott。

public class MCSLock {

    public static class MCSNode {
        volatile MCSNode next;
        volatile boolean isLocked = true;
    }

    private static final ThreadLocal<MCSNode> NODE = new ThreadLocal<>();

    // 隊列
    @SuppressWarnings("unused")
    private volatile MCSNode queue;

    private static final AtomicReferenceFieldUpdater<MCSLock,MCSNode> UPDATE =
            AtomicReferenceFieldUpdater.newUpdater(MCSLock.class,MCSNode.class,"queue");


    public void lock(){
        // 創建節點並保存到ThreadLocal中
        MCSNode currentNode = new MCSNode();
        NODE.set(currentNode);

        // 將queue設置爲當前節點,並且返回之前的節點
        MCSNode preNode = UPDATE.getAndSet(this, currentNode);
        if (preNode != null) {
            // 如果之前節點不爲null,表示鎖已經被其他線程持有
            preNode.next = currentNode;
            // 循環判斷,直到當前節點的鎖標誌位爲false
            while (currentNode.isLocked) {
            }
        }
    }

    public void unlock() {
        MCSNode currentNode = NODE.get();
        // next爲null表示沒有正在等待獲取鎖的線程
        if (currentNode.next == null) {
            // 更新狀態並設置queue爲null
            if (UPDATE.compareAndSet(this, currentNode, null)) {
                // 如果成功了,表示queue==currentNode,即當前節點後面沒有節點了
                return;
            } else {
                // 如果不成功,表示queue!=currentNode,即當前節點後面多了一個節點,表示有線程在等待
                // 如果當前節點的後續節點爲null,則需要等待其不爲null(參考加鎖方法)
                while (currentNode.next == null) {
                }
            }
        } else {
            // 如果不爲null,表示有線程在等待獲取鎖,此時將等待線程對應的節點鎖狀態更新爲false,同時將當前線程的後繼節點設爲null
            currentNode.next.isLocked = false;
            currentNode.next = null;
        }
    }
}

CLHLock 和 MCSLock

  • 都是基於鏈表,不同的是CLHLock是基於隱式鏈表,沒有真正的後續節點屬性,MCSLock是顯示鏈表,有一個指向後續節點的屬性。
  • 將獲取鎖的線程狀態藉助節點(node)保存,每個線程都有一份獨立的節點,這樣就解決了TicketLock多處理器緩存同步的問題。

總結

此篇文章我們主要講述了自旋鎖的提出背景,自旋鎖是爲了提高資源的使用頻率而出現的一種鎖,自旋鎖說的是線程獲取鎖的時候,如果鎖被其他線程持有,則當前線程將循環等待,直到獲取到鎖。

自旋鎖在等待期間不會睡眠或者釋放自己的線程。自旋鎖不適用於長時間持有CPU的情況,這會加劇系統的負擔,爲了解決這種情況,需要設定自旋週期,那麼自旋週期的設定也是一門學問。

還提到了自旋鎖本身無法保證公平性,那麼爲了保證公平性又引出了TicketLock ,TicketLock 是採用排隊叫號的機制來實現的一種公平鎖,但是它每次讀寫操作都必須在多個處理器緩存之間進行緩存同步,這會導致繁重的系統總線和內存的流量,大大降低系統整體的性能。

所以我們又引出了CLHLock和MCSLock,CLHLock和MCSLock通過鏈表的方式避免了減少了處理器緩存同步,極大的提高了性能,區別在於CLHLock是通過輪詢其前驅節點的狀態,而MCS則是查看當前節點的鎖狀態。

文章參考:

https://blog.csdn.net/qq_3433...

http://www.blogjava.net/jinfe...

https://blog.hufeifei.cn/ 關於自旋鎖的文章

https://en.wikipedia.org/wiki...

下面爲自己做個宣傳,歡迎關注公衆號 Java建設者,號主是Java技術棧,熱愛技術,喜歡閱讀,熱衷於分享和總結,希望能把每一篇好文章分享給成長道路上的你。
關注公衆號回覆 002 領取爲你特意準備的大禮包,你一定會喜歡並收藏的。

file

本文由博客一文多發平臺 OpenWrite 發佈!
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章