C++:07.無鎖數據結構

看了很多博客,大多講的很高深,看起來很費勁,能力有限只能簡單總結一下。

什麼是無鎖數據結構:

先說一下鎖是幹嘛的,在多線程環境下,由於很多操作不是原子操作,導致多個線程同時做了一個工作,爲了防止這種情況的發生,我們通過對執行代碼前上鎖,讓其他進程無法執行該步驟,再執行完後解鎖,其他線程才能完成該步驟。

再說一下爲什麼要無鎖數據結構:上鎖解鎖的過程是很消耗資源的,因爲要從用戶態切到內核態。簡單的步驟加大量的鎖會造成頻繁切入切出,爲了解決這一自相矛盾的問題,就有了無鎖數據結構。

舉個栗子:

比如像++count(count是整型變量)這樣的簡單操作也得加鎖,因爲即便是增量操作,實際上也是分三步進行的:讀、改、寫。

movl x, %eax

addl $1, %eax

movl %eax, x

只要再寫入之前,切換了另一個進程,另一個進程完成了寫入的操作。再切回來,原進程還是會繼續執行,最後的結果導致+2。

爲了解決這種問題,我們第一時間肯定想到了加上互斥鎖控制同一時刻只能有一個線程可以對隊列進行寫操作,但是加鎖的操作太消耗系統資源了。因爲對臨界區的操作只有一步 就是對隊列的尾節點進行更新,所以只要讓這一步進行的是原子操作就可以了。解決方法就使用到了CAS操作。

原子操作(瞭解):

原子操作的實現機制,是在硬件層面上,CPU會默認保證基本的內存操作的原子性,CPU保證從系統內存當中讀取或者寫入一個字節的行爲肯定是原子的,當一個CPU讀取一個字節時,其他CPU處理器不能訪問這個字節的內存地址。但是對於複雜的內存操作CPU處理器不能自動保證其原子性,比如跨總線寬度或者跨多個緩存行(Cache Line),跨頁表的訪問等。這個時候就需要用到CPU指令集中設計的原子操作指令,現在大部分CPU指令集都會支持一系列的原子操作。而在無鎖編程中經常用到的原子操作是Read-Modify-Write  (RMW)這種類型的,這其中最常用的原子操作又是 COMPARE AND SWAP(CAS),幾乎所有的CPU指令集都支持CAS的原子操作。

CAS操作:

CAS:Compare and Swap, 比較並交換。

CAS 靠CPU硬件實現。 通過總線枷鎖的方式。該進程沒執行完,其他指令無法使用總線。

僞代碼如下:
bool CAS( int * pAddr, int nOld, int nNew )
{
    if ( *pAddr == nOld )   如果pAddr地址中值還等於原先的nOld
    {
        *pAddr = nNew ;   那麼將 nNew 的值賦給此變量,
        return true ;     並返回true;
    }
    else  否則說明pAddr中的值已經不是nOld中的值了,那就不交換了。
    {
        return false ;
    }    
}

CAS所有執行過程都是原子性的、不可分的,不會產生任何可見的部分結果。

當然上面這個返回的是bool。如果想知道之前內存單元中的當前值是多少,改下返回值即可。

int CAS( int * pAddr, int nOld, int nNew )
{
    if ( *pAddr == nOld ) 
    {
        *pAddr = nNew ;
        return nOld;
    }
    else
    {
        return *pAddr;
    }   
}

 在gcc中提供了對應的這倆個函數:

bool __sync_bool_compare_and_swap (type *ptr, type oldval, type newval, ...)
type __sync_val_compare_and_swap (type *ptr, type oldval, type newval, ...)

舉第二個栗子:

多線程環境下, 對同一鏈隊列進行入隊操作時,一個線程A正在將新的隊列節點掛載到隊尾節點的next上,可是還沒來的及更新隊尾節點,同一時刻另一個線程B也在進行入隊操作將新的隊列節點也掛在到了沒更新的隊尾節點那麼A線程掛載的節點就丟失了。

EnQueue(x) //入隊列方法
{ 
    q = new record();
    q->value = x; //隊列節點的值
    q->next = NULL;//下一個節點
    p = tail; //保存尾節點指針
    oldp = p;
    do { //開始 loop  cas
         while (p->next != NULL) //用來防止進行cas(tail,oldp,q)操作的線程掛掉引起死循環
            p = p->next;
    } while( CAS(p.next, NULL, q) != TRUE); 
CAS(tail, oldp, q); 
}

DeQueue() //出隊列方法
{
    do{
        p = head;
        if (p->next == NULL)
        {
            return ERR_EMPTY_QUEUE;
        }
    }while( CAS(head, p, p->next) != TRUE );
    return p->next->value;
}

具體實現:

gcc從4.1.2提供了__sync_*系列的built-in函數,用於提供加減和邏輯運算的原子操作。

其聲明如下:

原子操作的 後置加加:
type __sync_fetch_and_add (type *ptr, type value, ...)

原子操作的 前置加加:
type __sync_add_and_fetch (type *ptr, type value, ...)

其他類比
type __sync_fetch_and_sub (type *ptr, type value, ...)
type __sync_fetch_and_or (type *ptr, type value, ...)
type __sync_fetch_and_and (type *ptr, type value, ...)
type __sync_fetch_and_xor (type *ptr, type value, ...)
type __sync_fetch_and_nand (type *ptr, type value, ...)


type __sync_sub_and_fetch (type *ptr, type value, ...)
type __sync_or_and_fetch (type *ptr, type value, ...)
type __sync_and_and_fetch (type *ptr, type value, ...)
type __sync_xor_and_fetch (type *ptr, type value, ...)
type __sync_nand_and_fetch (type *ptr, type value, ...)

這兩組函數的區別在於第一組返回更新前的值,第二組返回更新後的值。

CAS缺點:

1、ABA問題:

因爲CAS需要在操作值的時候檢查下值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。

ABA問題的解決思路就是使用版本號。在變量前面追加上版本號,每次變量更新的時候把版本號加一,那麼A-B-A 就會變成1A - 2B-3A。

2、循環時間長開銷大:

自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。

3、多個共享變量操作時,CAS無法保證操作的原子性。

只能保證一個共享變量的原子操作對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖,或者有一個取巧的辦法,就是把多個共享變量合併成一個共享變量來操作。比如有兩個共享變量i=2,j=a,合併一下ij=2a,然後用CAS來操作ij。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章