基於佇列的鎖:mcs lock簡介

憤怒對抗喳喳發表於2013-06-03

今天在Quora閒逛,看到一個對於MCS Lock的問答。答題的哥們深入淺出的寫了一大篇,感覺非常不錯,特地翻譯出來。

原文翻譯

要理解MCS Locks的本質,必須先知道其產生背景(Why),然後才是其運作原理。就像原論文提到的,我們先從spin-lock說起。spin-lock 是一種基於test-and-set操作的鎖機制。

function Lock(lock){
    while(test_and_set(lock)==1);
}

function Unlock(lock){
    lock = 0;
}

test_and_set是一個原子操作,讀取lock,檢視lock值,如果是0,設定其為1,返回0。如果是lock值為1, 直接返回1。這裡lock的值0和1分別表示無鎖和有鎖。由於test_and_set的原子性,不會同時有兩個程式/執行緒同時進入該方法, 整個方法無須擔心併發操作導致的資料不一致。

一切看來都很完美,但是,有兩個問題:(1) test_and_set操作必須有硬體配合完成,必須在各個硬體(記憶體,二級快取,暫存器)等等之間保持 資料一致性,通訊開銷很大。(2) 他不保證公平性,也就是不會保證等待程式/執行緒按照FIFO的順序獲得鎖,可能有比較倒黴的程式/執行緒等待很長時間 才能獲得鎖。

為了解決上面的問題,出現一種Ticket Lock的演算法,比較類似於Lamport's backery algorithm。就像在麵包店裡排隊買麵包一樣,每個人先付錢,拿 一張票,等待他手中的那張票被叫到。下面是虛擬碼

ticket_lock {
    int now_serving;
    int next_ticket;
};

function Lock(ticket_lock lock){
    //get your ticket atomically
    int my_ticket = fetch_and_increment(lock.next_ticket);
    while(my_ticket != now_serving){};    
}

function Unlock(ticket_lock lock){
    lock.now_serving++;
}

這裡用到了一個原子操作fetch_and_increment(實際上lock.now_serving++也必須保證是原子),這樣保證兩個程式/執行緒無法得到同一個ticket。 那麼上面的演算法解決的是什麼問題呢?只呼叫一次原子操作!!!最原始的那個演算法可是不停的在呼叫。這樣系統在保持一致性上的消耗就小很多。 第二,可以按照先來先得(FIFO)的規則來獲得鎖。沒有插隊,一切都很公平。

但是,這還不夠好。想想多處理器的架構,每個程式/執行緒佔用的處理器都在讀同一個變數,也就是now_serving。為什麼這樣不好呢,從多個CPU快取的 一致性考慮,每一個處理器都在不停的讀取now_serving本身就有不少消耗。最後單個程式/執行緒處理器對now_serving++的操作不但要重新整理到本地快取中,而且 要與其他的CPU快取保持一致。為了達到這個目的,系統會對所有的快取進行一致性處理,讀取新值只能序列讀取,然後再做操作,整個讀取時間是與處理器個數 線性相關。

說到這裡,才會聊到mcs佇列鎖。使用mcs鎖的目的是,讓獲得鎖的時間從O(n)變為O(1)。每個處理器都會有一個本地變數不用與其他處理器同步。虛擬碼如下

mcs_node{
    mcs_node next;
    int is_locked;
}

mcs_lock{
    mcs_node queue;
}

function Lock(mcs_lock,mcs_node my_node){
    my_node.next = NULL;
    mcs_node predecessor = fetch_and_store(lock.queue,my_node);
    if(predecessor!=NULL){
        my_node.is_locked = true;
        predecessor.next = my_node;
        while(my_node.is_locked){};
    }
}

function Unlock(mcs_lock lock,mcs_node my_node){
    if(my_node.next == NULL){
        if(compare_and_swap(lock.queue,my_node,NULL){
            return ;
        }
        else{
            while(my_node.next == NULL){};
        }
    }

    my_node.next.is_locked = false;
}

這次程式碼多了不少。但是隻要記住每一個處理器在佇列裡都代表一個node,就不難理解整個邏輯。當我們試圖獲得鎖時,先將自己加入佇列,然後看看有沒有其他 人(predecessor)在等待,如果有則等待自己的is_lock節點被修改成false。當我們解鎖時,如果有一個後繼的處理器在等待,則設定其is_locked=false,喚醒他。

在Lock方法裡,用fetch_and_store來將本地node加入佇列,該操作是個原子操作。如果發現前面有人在等待,則將本節點加入等待節點的next域中,等待前面的處理器喚醒本節點。 如果前面沒有人,那麼直接獲得該鎖。

在Unlock方法中,首先檢視是否有人排在自己後面。這裡要注意,即使暫時發現後面沒有人,也必須用原子操作compare_and_swap確認自己是最後的一個節點。如果不能確認 必須等待之後節點排(my_node.next == NULL)上來。最後設定my_node.next.is_locked = false喚醒等待者。

最後我們看一下前面的問題是否解決了。原子操作的次數已經減少到最少,大多數時候只需要本地讀寫my_node變數。

註釋

相關文章