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

前言

個人上一篇博客的案例中,請求鎖的線程若是發現鎖已經被其餘線程佔用,它是經過自旋的方式來等待的,也就是不斷地嘗試直到成功。本篇就討論一下另外一種方式,那就是掛起以等待喚醒。安全

注:相關代碼都來自《Operating System: Three Easy Pieces》這本書。數據結構

自旋哪裏很差?

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

咱們考慮一個極端的場景,某個電腦只有一個CPU,這時候有2個線程競爭鎖,線程A得到了鎖,進入臨界區,開始執行臨界區的代碼(因爲只有一個CPU,線程A在執行的時候,線程B只能在就緒隊列中等待)。結果線程A還沒執行完臨界區的代碼,時間片就用完了,因而發生上下文切換,線程A被換了出去,如今開始執行線程B,線程B就開始嘗試獲取鎖。函數

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

這還只是2個線程的狀況,若是等待的線程有100多個呢,那在輪詢調度器的場景下,線程A是否是要等到這100多個線程所有空轉完才能運行,這浪費可就大了!線程

用yield()讓出CPU怎麼樣?

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

直接掛起,等待喚醒

前面有之因此還會有過多的上下文切換,就是由於等待的線程仍是會不斷嘗試,只是沒以前那麼頻繁罷了。對象

那不讓這些等待線程執行不就行了?blog

能夠啊,只須要將這些線程移出就緒隊列,它們就不會被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對象咱們訪問不到罷了。

相關文章
相關標籤/搜索