無鎖隊列的實現

本文轉自:http://coolshell.cn/articles/8239.html

陳大師的文章微笑,核心就是利用__sync_bool_compare_and_swap來嘗試無鎖操作,在組裏的svr狀態統計的代碼看裏看到類似用法。

可以有效的避免用鎖,但是感覺有死循環的風險,所以一般__sync_bool_compare_and_swap只嘗試有限次數比較合適。

__sync_bool_compare_and_swap有限次失敗後,需要增加異常處理分支。


關於無鎖隊列的實現,網上有很多文章,雖然本文可能和那些文章有所重複,但是我還是想以我自己的方式把這些文章中的重要的知識點串起來和大家講一講這個技術。下面開始正文。

關於CAS等原子操作

在開始說無鎖隊列之前,我們需要知道一個很重要的技術就是CAS操作——Compare & Set,或是 Compare & Swap,現在幾乎所有的CPU指令都支持CAS的原子操作,X86下對應的是 CMPXCHG 彙編指令。有了這個原子操作,我們就可以用其來實現各種無鎖(lock free)的數據結構。

這個操作用C語言來描述就是下面這個樣子:(代碼來自Wikipedia的Compare And Swap詞條)意思就是說,看一看內存*reg裏的值是不是oldval,如果是的話,則對其賦值newval。

1
2
3
4
5
6
7
intcompare_and_swap (int* reg, intoldval,intnewval)
{
  intold_reg_val = *reg;
  if(old_reg_val == oldval)
     *reg = newval;
  returnold_reg_val;
}

這個操作可以變種爲返回bool值的形式(返回 bool值的好處在於,可以調用者知道有沒有更新成功):

1
2
3
4
5
6
7
8
boolcompare_and_swap (int*accum,int*dest,intnewval)
{
  if( *accum == *dest ) {
      *dest = newval;
      returntrue;
  }
  returnfalse;
}

與CAS相似的還有下面的原子操作:(這些東西大家自己看Wikipedia吧)

注:在實際的C/C++程序中,CAS的各種實現版本如下:

1)GCC的CAS

GCC4.1+版本中支持CAS的原子操作(完整的原子操作可參看 GCC Atomic Builtins

1
2
bool__sync_bool_compare_and_swap (type *ptr, type oldval type newval, ...)
type __sync_val_compare_and_swap (type *ptr, type oldval type newval, ...)

2)Windows的CAS

在Windows下,你可以使用下面的Windows API來完成CAS:(完整的Windows原子操作可參看MSDN的InterLocked Functions

1
2
3
InterlockedCompareExchange ( __inoutLONGvolatile*Target,
                                __inLONGExchange,
                                __inLONGComperand);

3) C++11中的CAS

C++11中的STL中的atomic類的函數可以讓你跨平臺。(完整的C++11的原子操作可參看 Atomic Operation Library

1
2
3
4
5
6
template<classT >
boolatomic_compare_exchange_weak( std::atomic<T>* obj,
                                   T* expected, T desired );
template<classT >
boolatomic_compare_exchange_weak(volatilestd::atomic<T>* obj,
                                   T* expected, T desired );

無鎖隊列的鏈表實現

下面的東西主要來自John D. Valois 1994年10月在拉斯維加斯的並行和分佈系統系統國際大會上的一篇論文——《Implementing Lock-Free Queues》。

我們先來看一下進隊列用CAS實現的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
EnQueue(x)//進隊列
{
    //準備新加入的結點數據
    q = newrecord();
    q->value = x;
    q->next = NULL;
 
    do{
        p = tail; //取鏈表尾指針的快照
    }while( CAS(p->next, NULL, q) != TRUE); //如果沒有把結點鏈在尾指針上,再試
 
    CAS(tail, p, q); //置尾結點
}

我們可以看到,程序中的那個 do- while 的 Re-Try-Loop。就是說,很有可能我在準備在隊列尾加入結點時,別的線程已經加成功了,於是tail指針就變了,於是我的CAS返回了false,於是程序再試,直到試成功爲止。這個很像我們的搶電話熱線的不停重播的情況。

你會看到,爲什麼我們的“置尾結點”的操作(第12行)不判斷是否成功,因爲:

  1. 如果有一個線程T1,它的while中的CAS如果成功的話,那麼其它所有的 隨後線程的CAS都會失敗,然後就會再循環,
  2. 此時,如果T1 線程還沒有更新tail指針,其它的線程繼續失敗,因爲tail->next不是NULL了。
  3. 直到T1線程更新完tail指針,於是其它的線程中的某個線程就可以得到新的tail指針,繼續往下走了。

這裏有一個潛在的問題——如果T1線程在用CAS更新tail指針的之前,線程停掉或是掛掉了,那麼其它線程就進入死循環了。下面是改良版的EnQueue()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
EnQueue(x)//進隊列改良版
{
    q = newrecord();
    q->value = x;
    q->next = NULL;
 
    p = tail;
    oldp = p
    do{
        while(p->next != NULL)
            p = p->next;
    }while( CAS(p.next, NULL, q) != TRUE); //如果沒有把結點鏈在尾上,再試
 
    CAS(tail, oldp, q); //置尾結點
}

我們讓每個線程,自己fetch 指針 p 到鏈表尾。但是這樣的fetch會很影響性能。而通實際情況看下來,99.9%的情況不會有線程停轉的情況,所以,更好的做法是,你可以接合上述的這兩個版本,如果retry的次數超了一個值的話(比如說3次),那麼,就自己fetch指針。

好了,我們解決了EnQueue,我們再來看看DeQueue的代碼:(很簡單,我就不解釋了)

1
2
3
4
5
6
7
8
9
10
DeQueue()//出隊列
{
    do{
        p = head;
        if(p->next == NULL){
            returnERR_EMPTY_QUEUE;
        }
    while( CAS(head, p, p->next) != TRUE );
    returnp->next->value;
}

我們可以看到,DeQueue的代碼操作的是 head->next,而不是head本身。這樣考慮是因爲一個邊界條件,我們需要一個dummy的頭指針來解決鏈表中如果只有一個元素,head和tail都指向同一個結點的問題,這樣EnQueue和DeQueue要互相排斥了

注:上圖的tail正處於更新之前的裝態。

CAS的ABA問題

所謂ABA(見維基百科的ABA詞條),問題基本是這個樣子:

  1. 進程P1在共享變量中讀到值爲A
  2. P1被搶佔了,進程P2執行
  3. P2把共享變量裏的值從A改成了B,再改回到A,此時被P1搶佔。
  4. P1回來看到共享變量裏的值沒有被改變,於是繼續執行。

雖然P1以爲變量值沒有改變,繼續執行了,但是這個會引發一些潛在的問題。ABA問題最容易發生在lock free 的算法中的,CAS首當其衝,因爲CAS判斷的是指針的地址。如果這個地址被重用了呢,問題就很大了。(地址被重用是很經常發生的,一個內存分配後釋放了,再分配,很有可能還是原來的地址)

比如上述的DeQueue()函數,因爲我們要讓head和tail分開,所以我們引入了一個dummy指針給head,當我們做CAS的之前,如果head的那塊內存被回收並被重用了,而重用的內存又被EnQueue()進來了,這會有很大的問題。(內存管理中重用內存基本上是一種很常見的行爲

這個例子你可能沒有看懂,維基百科上給了一個活生生的例子——

你拿着一個裝滿錢的手提箱在飛機場,此時過來了一個火辣性感的美女,然後她很暖昧地挑逗着你,並趁你不注意的時候,把用一個一模一樣的手提箱和你那裝滿錢的箱子調了個包,然後就離開了,你看到你的手提箱還在那,於是就提着手提箱去趕飛機去了。

這就是ABA的問題。

解決ABA的問題

維基百科上給了一個解——使用double-CAS(雙保險的CAS),例如,在32位系統上,我們要檢查64位的內容

1)一次用CAS檢查雙倍長度的值,前半部是指針,後半部分是一個計數器。

2)只有這兩個都一樣,纔算通過檢查,要吧賦新的值。並把計數器累加1。

這樣一來,ABA發生時,雖然值一樣,但是計數器就不一樣(但是在32位的系統上,這個計數器會溢出回來又從1開始的,這還是會有ABA的問題)

當然,我們這個隊列的問題就是不想讓那個內存重用,這樣明確的業務問題比較好解決,論文《Implementing Lock-Free Queues》給出一這麼一個方法——使用結點內存引用計數refcnt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SafeRead(q)
{
    loop:
        p = q->next;
        if(p == NULL){
            returnp;
        }
 
        Fetch&Add(p->refcnt, 1);
 
        if(p == q->next){
            returnp;
        }else{
            Release(p);
        }
    gotoloop;
}

其中的 Fetch&Add和Release分是是加引用計數和減引用計數,都是原子操作,這樣就可以阻止內存被回收了。

用數組實現無鎖隊列

本實現來自論文《Implementing Lock-Free Queues

使用數組來實現隊列是很常見的方法,因爲沒有內存的分部和釋放,一切都會變得簡單,實現的思路如下:

1)數組隊列應該是一個ring buffer形式的數組(環形數組)

2)數組的元素應該有三個可能的值:HEAD,TAIL,EMPTY(當然,還有實際的數據)

3)數組一開始全部初始化成EMPTY,有兩個相鄰的元素要初始化成HEAD和TAIL,這代表空隊列。

4)EnQueue操作。假設數據x要入隊列,定位TAIL的位置,使用double-CAS方法把(TAIL, EMPTY) 更新成 (x, TAIL)。需要注意,如果找不到(TAIL, EMPTY),則說明隊列滿了。

5)DeQueue操作。定位HEAD的位置,把(HEAD, x)更新成(EMPTY, HEAD),並把x返回。同樣需要注意,如果x是TAIL,則說明隊列爲空。

算法的一個關鍵是——如何定位HEAD或TAIL?

1)我們可以聲明兩個計數器,一個用來計數EnQueue的次數,一個用來計數DeQueue的次數。

2)這兩個計算器使用使用Fetch&ADD來進行原子累加,在EnQueue或DeQueue完成的時候累加就好了。

3)累加後求個模什麼的就可以知道TAIL和HEAD的位置了。

如下圖所示:

 小結

以上基本上就是所有的無鎖隊列的技術細節,這些技術都可以用在其它的無鎖數據結構上。

1)無鎖隊列主要是通過CAS、FAA這些原子操作,和Retry-Loop實現。

2)對於Retry-Loop,我個人感覺其實和鎖什麼什麼兩樣。只是這種“鎖”的粒度變小了,主要是“鎖”HEAD和TAIL這兩個關鍵資源。而不是整個數據結構。

還有一些和Lock Free的文章你可以去看看:

注:我配了一張look-free的自行車,寓意爲——如果不用專門的車鎖,那麼自行得自己鎖自己!

 (全文完)

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