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

貓毛·波拿巴發表於2019-06-30

前言

我的上一篇部落格的案例中,請求鎖的執行緒如果發現鎖已經被其他執行緒佔用,它是通過自旋的方式來等待的,也就是不斷地嘗試直到成功。本篇就討論一下另一種方式,那就是掛起以等待喚醒。

注:相關程式碼都來自《Operating System: Three Easy Pieces》這本書。

自旋哪裡不好?

先說明一下,自旋也有它的好處,不過這裡先不講,我們先講它可能存在哪些問題。

我們考慮一個極端的場景,某個電腦只有一個CPU,這時候有2個執行緒競爭鎖,執行緒A獲得了鎖,進入臨界區,開始執行臨界區的程式碼(由於只有一個CPU,執行緒A在執行的時候,執行緒B只能在就緒佇列中等待)。結果執行緒A還沒執行完臨界區的程式碼,時間片就用完了,於是發生上下文切換,執行緒A被換了出去,現在開始執行執行緒B,執行緒B就開始嘗試獲取鎖。

這時候尷尬的事情就來了,擁有鎖的執行緒沒在執行,也就不能釋放鎖。而佔據CPU的執行緒由於獲取不到鎖,就只能自旋直到用完它的時間片。

這還只是2個執行緒的情況,如果等待的執行緒有100多個呢,那在輪詢排程器的場景下,執行緒A是不是要等到這100多個執行緒全部空轉完才能執行,這浪費可就大了!

用yield()讓出CPU怎麼樣?

 yield()方法是把呼叫執行緒之間切出,放回就緒佇列。這個方法與前面的不同就在於,當執行緒B恢復執行的時候,它只會嘗試一次,如果失敗,則直接退出,而不會用完它的整個時間片。也就是說被排程的執行緒最多隻會嘗試一次。這樣雖然會比自旋好一點。但是開銷還是不小,對於100多個等待執行緒的情況,每個都要進行一遍run-and-yield操作。上下文切換的開銷也是不容小覷的。

直接掛起,等待喚醒

前面有之所以還會有過多的上下文切換,就是因為等待的執行緒還是會不斷嘗試,只是沒之前那麼頻繁罷了。

那不讓這些等待執行緒執行不就好了?

可以啊,只需要將這些執行緒移出就緒佇列,它們就不會被OS排程,也就不會被執行。

掛起是可以了,還得想想誰來喚醒,怎麼喚醒?

喚醒操作肯定由釋放鎖的執行緒處理。另一方面,我們把執行緒掛起的時候,肯定得用一個資料結構把這個執行緒的資訊記錄下來,不然要喚醒的時候都不知道該喚醒誰。而這個資料結構肯定得跟鎖物件關聯起來,這樣釋放鎖的執行緒也就知道該從哪裡拿這些資料。

typedef struct __lock_t {
    int flag; //標識,鎖是否被佔用
    int guard; //守護欄位
    queue_t *q; //等待佇列,用於儲存等待的執行緒資訊
} lock_t;

void lock_init(lock_t *m) {
    m->flag = 0;
    m->guard = 0;
    queue_init(m->q);
}

void lock(lock_t *m) {
    while(TestAndSet(&m->guard, 1) == 1)
        ;//通過自旋獲得guard
    if (m->flag == 0) {
        m->flag = 1;
        m->guard = 0;
    } else {
        queue_add(m->q, gettid());
        m->guard = 0; //注意:在park()之前呼叫
        park(); //park()呼叫之前,執行緒已經成功加入佇列
    }
}

void unlock(lock_t *m) {
    while(TestAndSet(&m->guard, 1) == 1)
        ;//通過自旋獲取guard
    if(queue_empty(m->q)) //如果沒有等待的執行緒,則將鎖標識為“空閒”
        m->flag = 0; 
    else
        unpark(queue_remove(m->q)); //喚醒一個等待執行緒,此時鎖標識仍為“已佔用”
    m->guard = 0;
}

park()與unpark(threadID)

park()與unpark(threadID)是Solaris系統提供的原語,用於掛起和恢復執行緒。其他系統一般也會提供,但是細節可能有所不同。

park()  => 將當前呼叫執行緒掛起

uppark(threadID)  => 根據執行緒ID喚醒指定執行緒。

guard欄位的用途

我在看這段程式碼的時候有一個疑問,那就是這個queue_t是在哪裡定義的,它到底是什麼樣子?這個佇列內部是不是要做同步操作?不同步的話, 多個執行緒同時訪問,佇列的資料結構就可能被破壞。實際上,仔細看程式碼就會發現,在操作佇列的時候,執行緒需要先獲得guard。也就是說,同一時刻只能有一個執行緒能夠訪問佇列。所以這個佇列是安全的,它自身並不需要提供同步。所以,書上才沒有貼出原始碼。隨便一個佇列實現就可以了。

實際上guard欄位用於控制多執行緒對lock物件的訪問,同一時刻只能有一個執行緒能夠對lock物件的其他資訊(除guard欄位外)進行修改。

上述程式碼存在的問題

由程式碼可知,當guard被釋放的時候,其他執行緒就能訪問Lock物件了。那就可能出現一種情況,即釋放了guard,但還沒來得及執行park()就發生了上下文切換。這個時候存在什麼問題呢,我們來看下圖:

由於上下文切換的緣故,Thread A 已經加入了等待佇列,但並沒有執行掛起操作。結果佔有鎖的執行緒釋放的時候,剛好從佇列中取出Thread A,Thread A被喚醒,放入就緒佇列,等到下次排程的時候執行。Thread A恢復,繼續向下執行,呼叫park()方法。結果就是Thead A被永久地掛起!!!因為這個時候它已經從等待佇列中移除了,誰也不知道它被掛起了。

OS提供的解決方法

OS提供一個setpark()函式來標識某個執行緒將要執行park()操作。如果在這個執行緒(比如Thread A)執行park()操作之前,其他執行緒(如Thread B)對其執行了unpark(threadID)方法,則該執行緒(Thread A)在執行park()會立即返回。更改如下:

...
queue_add(m->q, gettid());
setpark();
m->guard=0;
park();
...

PS:實際上這個setpark()函式應該也只是在底層的Thread物件中設定了一個flag,park()函式內會檢視一下這個flag。只不過這個底層的Thread物件我們訪問不到罷了。

 

相關文章