互斥鎖mutex的簡單實現

geekartt發表於2019-05-12

mutex一般用於為一段程式碼加鎖,以保證這段程式碼的原子性(atomic)操作,即:要麼不執行這段程式碼,要麼將這段程式碼全部執行完畢。

例如,最簡單的併發衝突問題就是一個變數自增1:

balance = balance + 1;

表面看這是一條語句,可是在背後的彙編中我們可以看到,指令集操作過程中會引入中間變數來儲存右邊的值,進而這個操作至少會被擴充為:

int tmp = balance + 1;
balance = tmp;

這就需要一把互斥鎖(mutual exclusive, mutex)將這段程式碼給鎖住,使其達到任何一個執行緒“要麼全部執行上述程式碼,要麼不執行這段程式碼”的效果。這個用法可以表示為:

lock_t mutex;
...
lock(&mutex)
    balance = balance + 1;
unlock(&mutex);

那麼,一個自然的問題便是,我如何實現上面的這個lock()函式呢?

乍一看這個問題是非常複雜的,特別是考慮到它能夠被適用於各種程式碼的各種情況。但經過各種簡化,這個lock()實現,可以通過幾個test和set的組合得以實現。

例如,

typedef struct __lock_t { int flag; } lock_t;

void init(lock_t *mutex) {
    // 0: lock is available
    // 1: lock is held
    mutex->flag = 0;
}

void lock(lock_t *mutex) {
    while (mutex->flag == 1) {  // Test the flag.
        ;    // Wait the lock
    mutex->flag = 1;  // Set the lock, i.e. start to hold lock
}

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

我第一次看到這個演算法的時候非常驚訝,一個本來極其複雜的問題就這麼優雅地被解決了。它僅僅涉及到對條件的檢驗和變數的複製,然後整個問題就這麼輕而易舉地被攻破了。

當然,我並沒能看到上述程式碼的“坑”,也即是必須依靠指令集級別的支援才能真正做到atomic。這同樣說明了併發程式的困難,稍微不注意便會調入一個萬劫不復的坑裡,並且你還不知道哪裡出錯了。

上述極端優雅的程式碼,有一個隱藏的坑,那便是在lock()函式的實現裡,while迴圈那一段其實是可以被亂入的。

假設thread A是第一個執行到此的執行緒,那麼它得到的mutex->flag就肯定是0,於是它繼續跳出迴圈往下執行,希望通過下面的mutex->flag = 1來持有鎖,使得其它執行緒在檢測while迴圈時為真,進而進入迴圈的等待狀態。

可如果在A執行到這個賦值為1的語句之前,又有另外一個thread B執行到了這個while迴圈部分,由於mutex->flag還未被賦值為1,B同樣可以跳出while,從而跟A一樣拿到這把鎖!這就出現了衝突。

那怎麼辦呢?仔細後可以發現,其實關鍵問題就在於:

  • mutex->flag的檢測
  • mutex->flag的賦值

這兩個操作必須是不被干擾的,也就是它必須是atomic的,要麼這兩段程式碼不被執行,要麼這兩段程式碼被不中斷地完整執行。

這就需要藉助CPU指令集的幫助,來保證上述兩條語句的atomic操作,也即是著名的TestAndSet()操作。

int TestAndSet(int *ptr, int new) {
    int old = *ptr;
    *ptr = new;
    return old;
}

CPU的指令集,並不需要支援繁複的各種atomic操作。僅僅支援上面這個函式,各種互斥加鎖的情形,便都能夠被涵蓋。

此時,在回到我們最開始的那個優雅的lock()實現,就可以將其改造為:

typedef struct __lock_t { int flag; } lock_t;

void init(lock_t *lock) {
    // 0: lock is available
    // 1: lock is held
    mutex->flag = 0;
}

void lock(lock_t *mutex) {
    while (TestAndSet(&lock_t->flag, 1) == 1) {
        ;
}

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

上述程式碼極其精巧。乍一看在lock()實現裡不是還缺少一行mutex->flag = 1;麼?可其實呢,它已經被整合到了TestAndSet()函式中。

這樣的支援TestAndSet()的實現,便是最簡單的spin lock,彈簧鎖。之所以叫彈簧鎖,那是因為在各類鎖當中,彈簧鎖就是最初的被投入工業使用的最簡單的實現技術。

相關文章