[轉]futex 淺析

Futex,Fast Userspace muTEXes,做爲linux下的一種快速同步(互斥)機制,已經存在了很長一段時間了(since linux 2.5.7)。它有什麼優點?又提供了怎樣一些功能,本文就簡單探討一下。node

futex誕生以前

在futex誕生以前,linux下的同步機制能夠歸爲兩類:用戶態的同步機制 和 內核同步機制。 用戶態的同步機制基本上就是利用原子指令實現的spinlock。最簡單的實現就是使用一個整型數,0表示未上鎖,1表示已上鎖。trylock操做就利用原子指令嘗試將0改成1:linux

bool trylock(int lockval) {
    int old;
    atomic { old = lockval; lockval = 1; }  // 如:x86下的xchg指令
    return old == 0;
}

不管spinlock事先有沒有被上鎖,經歷trylock以後,它確定是已經上鎖了。因此lock變量必定被置1。而trylock是否成功,取決於spinlock是事先就被上了鎖的(old==1),仍是此次trylock上鎖的(old==0)。而使用原子指令則能夠避免多個進程同時看到old==0,而且都認爲是本身改它改成1的。服務器

spinlock的lock操做則是一個死循環,不斷嘗試trylock,直到成功。
對於一些很小的臨界區,使用spinlock是很高效的。由於trylock失敗時,能夠預期持有鎖的線程(進程)會很快退出臨界區(釋放鎖)。因此死循環的忙等待極可能要比進程掛起等待更高效。
可是spinlock的應用場景有限,對於大的臨界區,忙等待則是件很恐怖的事情,特別是當同步機制運用於等待某一事件時(好比服務器工做線程等待客戶端發起請求)。因此不少狀況下進程掛起等待是頗有必要的。多線程

內核提供的同步機制,諸如semaphore、等,其實骨子裏也是利用原子指令實現的spinlock,內核在此基礎上實現了進程的睡眠與喚醒。
使用這樣的鎖,能很好的支持進程掛起等待。可是最大的缺點是每次lock與unlock都是一次系統調用,即便沒有鎖衝突,也必需要經過系統調用進入內核以後才能識別。(關於系統調用開銷大的問題,能夠參閱:《從"read"看系統調用的耗時》。)less

理想的同步機制應該是在沒有鎖衝突的狀況下在用戶態利用原子指令就解決問題,而須要掛起等待時再使用內核提供的系統調用進行睡眠與喚醒。換句話說,用戶態的spinlock在trylock失敗時,能不能讓進程掛起,而且由持有鎖的線程在unlock時將其喚醒?
若是你沒有較深刻地考慮過這個問題,極可能想固然的認爲相似於這樣就好了:優化

void lock(int lockval) {
    while (!trylock(lockval)) {
        wait();  // 如:raise(SIGSTOP)
    }
}

可是若是這樣作的話,檢測鎖的trylock操做和掛起進程的wait操做之間會存在一個窗口,若是其間lock發生變化(好比鎖的持有者釋放了鎖),調用者將進入沒必要要的wait,甚至於wait以後再沒有人能將它喚醒。(詳見《linux線程同步淺析》的討論。)atom

在futex誕生以前,要實現咱們理想中的鎖會很是彆扭。好比能夠考慮用sigsuspend系統調用來實現進程掛起:spa

class mutex {
private:
    int lockval;
    spinlocked_set<pid_t> waiters;    // 使用spinlock作保護的set
public:
    void lock() {
        pid_t mypid = getpid();
        waiters.insert(mypid);        // 先將本身加入mutex的等待隊列
        while (!trylock(lockval)) {   // 再嘗試加鎖
            // 進程初始化時須要將SIGUSER1 mask掉,並在此時開啓
            sigsuspend(MASK_WITHOUT_SIGUSER1);
        }
        waiters.remove(mypid)         // 上鎖成功以後將本身從等待隊列移除
    }
    void unlock() {
        lockval = 0;                  // 先釋放鎖
        pid_t waiter = waiters.first();  // 再檢查等待隊列
        if (waiter != 0) {            // 若是有人等待,發送SIGUSER1信號將其喚醒
            kill(waiter, SIGUSER1);
        }
    }
}

注意,這裏的sigsuspend不一樣於簡單的raise(SIGSTOP)之類wait操做。若是unlock時用於喚醒的kill操做先於sigsuspend發生,sigsuspend也同樣能被喚醒。(詳見《linux線程同步淺析》的討論。)
這樣的實現有點相似於老版本的phread_cond,應該仍是能work的。有些不太爽的地方,好比sigsuspend系統調用是全局的,並不僅僅考慮某一把鎖。也就是說,lockA的unlock能夠將等待lockB的進程喚醒。儘管進程被喚醒以後會繼續trylock,並不影響正確性;儘管多數狀況下lockA.unlock也並不會試圖去喚醒等待lockB的進程(除了一些競爭狀況下),由於後者極可能並不在lockA的等待隊列中。
另外一方面,用戶態實現的等待隊列也不太爽。它對進程的生命週期是沒法感知的,極可能進程掛了,pid卻還留在隊列中(甚至於一段時間以後又有另外一個不相干的進程重用了這個pid,以致於它可能會收到莫名其妙的信號)。因此,unlock的時候若是僅僅給隊列中的一個進程發信號,極可能喚醒不了任何等待者。保險的作法只能是所有喚醒,從而引起「驚羣「現象。不過,若是僅僅用在多線程(同一進程內部)倒也不要緊,畢竟多線程不存在某個線程掛掉的狀況(若是線程掛掉,整個進程都會掛掉),而對於線程響應信號而主動退出的狀況也是能夠在主動退出前注意處理一下等待隊列清理的問題。線程

futex來了

如今看來,要實現咱們想要的鎖,對內核就有兩點需求:一、支持一種鎖粒度的睡眠與喚醒操做;二、管理進程掛起時的等待隊列。
因而futex就誕生了。futex主要有futex_wait和futex_wake兩個操做:code

// 在uaddr指向的這個鎖變量上掛起等待(僅當*uaddr==val時)
int futex_wait(int *uaddr, int val);
// 喚醒n個在uaddr指向的鎖變量上掛起等待的進程
int futex_wake(int *uaddr, int n);

內核會動態維護一個跟uaddr指向的鎖變量相關的等待隊列。
注意futex_wait的第二個參數,因爲用戶態trylock與調用futex_wait之間存在一個窗口,其間lockval可能發生變化(好比正好有人unlock了)。因此用戶態應該將本身看到的*uaddr的值做爲第二個參數傳遞進去,futex_wait真正將進程掛起以前必定得檢查lockval是否發生了變化,而且檢查過程跟進程掛起的過程得放在同一個臨界區中。(參見《linux線程同步淺析》的討論。)若是futex_wait發現lockval發生了變化,則會當即返回,由用戶態繼續trylock。

futex實現了鎖粒度的等待隊列,而這個鎖卻並不須要事先向內核申明。任什麼時候候,用戶態調用futex_wait傳入一個uaddr,內核就會維護起與之配對的等待隊列。
這件事情聽上去好像很複雜,實際上卻很簡單。其實它並不須要爲每個uaddr單獨維護一個隊列,futex只維護一個總的隊列就好了,全部掛起的進程都放在裏面。固然,隊列中的節點須要能標識出相應進程在等待的是哪個uaddr。這樣,當用戶態調用futex_wake時,只須要遍歷這個等待隊列,把帶有相同uaddr的節點所對應的進程喚醒就好了。
做爲優化,futex維護的這個等待隊列由若干個帶spinlock的鏈表構成。調用futex_wait掛起的進程,經過其uaddr hash到某一個具體的鏈表上去。這樣一方面能分散對等待隊列的競爭、另外一方面減少單個隊列的長度,便於futex_wake時的查找。每一個鏈表各自持有一把spinlock,將"*uaddr和val的比較操做"與"把進程加入隊列的操做"保護在一個臨界區中。

另外一個問題是關於uaddr參數的比較。futex支持多進程,須要考慮同一個物理內存單元在不一樣進程中的虛擬地址不一樣的問題。那麼不一樣進程傳遞進來的uaddr如何判斷它們是否相等,就不是簡單數值比較的事情。相同的uaddr不必定表明同一個內存,反之亦然。
兩個進程(線程)要想共享同存,無外乎兩種方式:經過文件映射(映射真實的文件或內存文件、ipc shmem,以及有親緣關係的進程經過帶MAP_SHARED標記的匿名映射共享內存)、經過匿名內存映射(好比多線程),這也是進程使用內存的惟二方式。
那麼futex就應該支持這兩種方式下的uaddr比較。匿名映射下,須要比較uaddr所在的地址空間(mm)和uaddr的值自己;文件映射下,須要比較uaddr所在的文件inode和uaddr在該inode中的偏移。注意,上面提到的內存共享方式中,有一種比較特殊:有親緣關係的進程經過帶MAP_SHARED標記的匿名映射共享內存。這種狀況下表面上看使用的是匿名映射,可是內核在暗中卻會轉成到/dev/zero這個特殊文件的文件映射。若非如此,各個進程的地址空間不一樣,匿名映射下的uaddr永遠不可能被futex認爲相等。

futex和它的兄弟姐妹們

futex_wait和futex_wake就是futex的基本。以後,爲了對其餘同步方式作各類優化,futex又增長了若干變種。
futex等待系列的調用通常均可以傳遞timeout參數,支持超時喚醒。這一塊邏輯相對較獨立,本文中再也不展開。

Bitset系列

int futex_wait_bitset(int *uaddr, int val, int bitset);
int futex_wake_bitset(int *uaddr, int n, int bitset);

額外傳遞了一個bitset參數,使用特定bitset進行wait的進程,只能被使用它的bitset超集的wake調用所喚醒。
這個東西給讀寫鎖很好用,進程掛起的時候經過bitset標記本身是在等待讀仍是等待寫。unlock時決定應該喚醒一個寫等待的進程、仍是喚醒所有讀等待的進程。
沒有bitset這個功能的話,要麼只能unlock的時候不區分讀等待和寫等待,所有喚醒;要麼只能搞兩個uaddr,讀寫分別futex_wait其中一個,而後再用spinlock保護一下兩個uaddr的同步。
(參閱:http://locklessinc.com/articles/sleeping_rwlocks/

Requeue系列

int futex_requeue(int *uaddr, int n, int *uaddr2, int n2);
int futex_cmp_requeue(int *uaddr, int n, int *uaddr2, int n2, int val);

功能跟futex_wake有點類似,但不只僅是喚醒n個等待uaddr的進程,而更進一步,將n2個等待uaddr的進程移到uaddr2的等待隊列中(至關於也futex_wake它們,而後強制讓它們futex_wait在uaddr2上面)。
在futex_requeue的基礎上,futex_cmp_requeue多了一個判斷,僅當*uaddr與val相等時才執行操做,不然直接返回,讓用戶態去重試。
這個東西是爲pthread_cond_broadcast準備的。仍是先來回顧一下pthread_cond的邏輯(列一下,後面會屢次用到):

pthread_cond_wait(mutex, cond):
    value = cond->value; /* 1 */
    pthread_mutex_unlock(mutex); /* 2 */
retry:
    pthread_mutex_lock(cond->mutex); /* 10 */
    if (value == cond->value) { /* 11 */
        me->next_cond = cond->waiter;
        cond->waiter = me;
        pthread_mutex_unlock(cond->mutex);
        unable_to_run(me);
        goto retry;
    } else
        pthread_mutex_unlock(cond->mutex); /* 12 */
    pthread_mutex_lock(mutex); /* 13 */
pthread_cond_signal(cond):
    pthread_mutex_lock(cond->mutex); /* 3 */
    cond->value++; /* 4 */
    if (cond->waiter) { /* 5 */
        sleeper = cond->waiter; /* 6 */
        cond->waiter = sleeper->next_cond; /* 7 */
        able_to_run(sleeper); /* 8 */
    }
    pthread_mutex_unlock(cond->mutex); /* 9 */

pthread_cond_broadcast跟pthread_cond_signal相似,不過它會喚醒全部(而不是一個)等待者。注意,pthread_cond_wait在被喚醒以後,第一件事情就是lock(mutex)(第13步)。若是pthread_cond_broadcast一會兒喚醒了N個等待者,它們醒來以後勢必會爭搶mutex,形成千軍萬馬過獨木橋的"驚羣"現象。
做爲一種優化,pthread_cond_broadcast不該該用futex_wake去喚醒全部等待者,而應該用futex_requeue喚醒一個等待者,而後將其餘進程都轉移到mutex的等待隊列上去(隨後再由mutex的unlock來逐個喚醒)。

爲何要有futex_cmp_requeue呢?由於futex_requeue實際上是有問題的,它至關於直接把一批進程拖到uaddr2的等待隊列裏面去了,而沒有在臨界區裏面作狀態檢查(回想一下futex_wait裏面檢查*uaddr==val的重要性)。那麼,在進入futex_requeue和真正將進程移到uaddr2之間就存在一個窗口,這個間隙內可能有其餘線程futex_wake(uaddr2),這將沒法喚醒這些正要移動卻還沒有移動的進程,可能形成這些進程從此再也沒法被喚醒了。
不過儘管futex_requeue並不嚴謹,pthread_cond_broadcast這個case倒是OK的,由於在pthread_cond_broadcast喚醒等待者的時候,不可能有人futex_wake(uaddr2),由於這個鎖正在被pthread_cond_broadcast持有,它將在喚醒操做結束後(第9步)纔會釋放。這也就是爲何futex_requeue有問題,卻冠冕堂皇的被release了。

Wake & Operator

int futex_wake_op(int *uaddr1, int *uaddr2, int n1, int n2, int op);

這個系統調用有點像CISC的思路,一個調用中搞了不少動做。它嘗試在uaddr1的等待隊列中喚醒n1個進程,而後修改uaddr2的值,而且在uaddr2的值知足條件的狀況下,喚醒uaddr2隊列中的n2個進程。uaddr2的值如何修改?又須要知足什麼樣的條件才喚醒uaddr2?這些邏輯都pack在op參數中。
int類型的op參數,實際上是一個struct:

struct op {
    // 修改*uaddr2的方法:SET (*uaddr2=OPARG)、ADD(*uaddr2+=OPARG)、
    // OR(*uaddr2|=OPARG)、ANDN(*uaddr2&=~OPARG)、XOR(*uaddr2^=OPARG)
    int OP : 4;
    // 判斷*uaddr2是否知足條件的方法:EQ(==)、NE(!=)、LT(<)、LE(<=)、GT(>)、GE(>=)
    int CMP : 4;
    int OPARG : 12;// 修改*uaddr2的參數
    int CMPARG : 12;// 判斷*uaddr2是否知足條件的參數
}

futex_wake_op搞這麼一套複雜的邏輯,無非是但願一次系統調用裏面處理兩把鎖,至關於用戶態調用兩次futex_wake。
假設用戶態須要釋放uaddr1和uaddr2兩把鎖(值爲0表明未上鎖、1表明上鎖、2表明上鎖且有進程掛起等待),不使用futex_wake_op的話須要這麼寫:

int old1, old2;
atomic { old1 = *uaddr1; *uaddr1 = 0; }
if (old1 == 2) {
    futex_wake(uaddr1, n1);
}
atomic { old2 = *uaddr2; *uaddr2 = 0; }
if (old2 == 2) {
    futex_wake(uaddr2, n2);
}

而使用futex_wake_op的話,只須要這樣:

int old1;
atomic { old1 = *uaddr1; *uaddr1 = 0; }
if (old1 == 2) {
    futex_wake_op(uaddr1, n1, uaddr2, n2, {
        // op參數的意思:設置*uaddr2=0,而且若是old2==2,則執行喚醒
        OP=SET, OPARG=0, CMP=EQ, CMPARG=2
    } );
}
else {
    ... // 單獨處理uaddr2
}

搞這麼複雜,其實並不只僅是省一次系統調用的問題。由於有可能在unlock(uaddr1)以後,被喚醒的進程立馬會去lock(uaddr2)。而這時若是這邊還沒來得及unlock(uaddr2)的話,被喚醒的進程馬上又將被掛起,而後隨着這邊unlock(uaddr2)又會再度被喚醒。這不折騰麼?
這個場景就可能發生在pthread_cond_wait和pthread_cond_signal之間。當pthread_cond_signal在喚醒等待者以後,會釋放內部的鎖(第9步)。而pthread_cond_wait在被喚醒以後立馬又會嘗試獲取內部的鎖,以從新檢查狀態(第10步)。若不是futex_wake_op將喚醒和釋放鎖兩個動做一筆帶過,這中間一定會有強烈的競爭。
固然,使用前面提到的futex_cmp_requeue也能避免過度競爭,pthread_cond_signal不要直接喚醒等待者,而是將其requeue到內部鎖的等待隊列,等這邊釋放鎖以後才真正將其喚醒。不過既然pthread_cond_signal立馬就會釋放內部鎖,先requeue再wake多少仍是囉嗦了些。

Priority Inheritance系列

int futex_lock_pi(int *uaddr);
int futex_trylock_pi(int *uaddr);
int futex_unlock_pi(int *uaddr);
int futex_wait_requeue_pi(int *uaddr1, int val1, int *uaddr2);
int futex_cmp_requeue_pi(int *uaddr, int n1, int *uaddr2, int n2, int val);

Priority Inheritance,優先級繼承,是解決優先級反轉的一種辦法。 futex_lock_pi/futex_trylock_pi/futex_unlock_pi,是帶優先級繼承的futex鎖操做。 futex_cmp_requeue_pi是帶優先級繼承版本的futex_cmp_requeue,futex_wait_requeue_pi是與之配套使用的,用於替代普通的futex_wait。 這裏面的邏輯很是複雜,稍後能夠看阿里七傷的下篇博文……

相關文章
相關標籤/搜索