1. 程式人生 > >【雜談】從底層看鎖的實現

【雜談】從底層看鎖的實現

以下內容針對互斥鎖。

為什麼需要鎖?

鎖代表著對臨界區的訪問許可權。只有獲得鎖的操作物件,才能進入臨界區。

鎖的本質是什麼?

鎖的本質是一個數據結構(或者說是一個物件),這個物件內保留著描述鎖所需要的必要資訊。如當前鎖是否已被佔用,被哪個執行緒佔用。而鎖的一些工具,函式庫,實際上就是對一個鎖物件的資訊進行變更。

上鎖操作    =>  嘗試對鎖物件的資訊進行修改,如果修改成功,則程式繼續向下執行,否則將暫時停留在此。(停留的方式有兩種,一種是自旋反覆嘗試,另一種是掛起等待喚醒)

解鎖操作    =>  重置鎖物件的資訊。

類似下面這樣(注:這個例子不準確,後面會講)

typedef struct __lock_t {
    int flag;              //鎖的狀態 0-空閒, 1-被佔用
} lock_t; 

void init(lock_t *mutex) { //初始化鎖物件
    mutex->flag = 0;
}

void lock(lock_t *mutex) {
    while(mutex->flag == 1)
        ;// 自旋等待
    mutex->flag = 1;
}

void unlock(lock_t *mutex) {
    mutex->flag = 0;
}

鎖資訊的儲存位置

一種是保留在程序內,由於作業系統提供的記憶體虛擬化,所以這個鎖物件的記憶體空間,只能被當前程序訪問。並且同一程序的執行緒可以共享記憶體資源。所以,這個鎖物件只能被當前程序的執行緒所訪問。

另一種是將鎖的資訊儲存在本機的其他應用中。例如本機沒有開啟外部訪問的Redis。這樣本機的多個應用就可以通過Redis中的這個鎖的資訊進行排程管理。

還有一種就是將鎖的資訊儲存在其他機器中(或者本機開啟外部訪問的Redis中),這樣其他電腦的應用也可以對這個鎖進行訪問,這就是分散式鎖。

對鎖資訊進行修改

存在的問題

前面有提到,前面的lock函式對鎖資訊的修改操作存在問題,我們來看看問題到底出在哪裡。假設,我們的電腦只有一個CPU,這個時候有兩個執行緒開始嘗試獲取鎖。

 

這個程式的結果是,線上程B已經佔用鎖的時候,執行緒A還能獲取到鎖。這就不能滿足"互斥鎖"的定義,這段程式碼就不滿足正確性。那麼問題出在哪裡呢?問題就在於判斷和修改這兩個操作沒有原子性。

正如上面的例子那樣,執行緒A剛執行完判斷,還沒來得及做修改操作,就發生了上下文切換,轉而執行執行緒B的程式碼。切換回執行緒A的時候,實際上條件已經發生了變更。

硬體的支援

這個問題顯然不是應用的程式碼能夠解決的,因為上下文切換是OS決定的,普通應用無權干涉。但是硬體提供了一些指令原語,可以幫助我們解決這個問題。這些原語有test-and-set、compare-and-swap、fetch-and-add等等,我們可以基於這些原語來實現鎖資訊修改的原子操作。例如,我們可以基於test-and-set進行實現:

//test-and-set的C程式碼表示
int TestAndSet(int *ptr, int new) {
    int old = *ptr; //抓取舊值
    *ptr = new; //設定新值
    return old; //返回舊值
}

typedef struct __lock_t {
    int flag;
} lock_t;

void init (lock_t *lock) {
    lock->flag = 0;
}

void lock(lock_t *lock) {
    //如果為1,說明原來就有人在用
    //如果不為1,說明原來沒人在用,同時設定1,表面鎖現在歸我使用了
    while (TestAndSet(&lock->flag, 1) == 1) 
        ; //spin-wait (do noting)
}

void unlock (lock_t *lock) {
    lock->flag = 0;
}

為什麼這些指令不會被上下文切換所打斷?

上下文切換實際上也是執行切換的指令。CPU執行指令是一條一條執行的,test-and-set對於CPU來說就是一個指令,所以就算需要進行上下文切換,它也會先執行完當前的指令,然後再執行上下文切換的指