1. 程式人生 > >看完你就明白的鎖系列之自旋鎖

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

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

自旋鎖的提出背景

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

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

什麼是自旋鎖

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

自旋鎖的原理

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

因為自旋鎖避免了作業系統程序排程和執行緒切換,所以自旋鎖通常適用在時間比較短的情況下。由於這個原因,作業系統的核心經常使用自旋鎖。但是,如果長時間上鎖的話,自旋鎖會非常耗費效能,它阻止了其他執行緒的執行和排程。執行緒持有鎖的時間越長,則持有該鎖的執行緒將被 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_34337272/article/details/81252853

http://www.blogjava.net/jinfeng_wang/archive/2016/12/14/432088.html

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

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

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

本文由部落格一文多發平臺 OpenWrite 釋出!