實現無鎖的棧與佇列(4)

twoon發表於2013-08-08

現在我們來嘗試解決前一篇文章提到的問題。

(一)

首先是記憶體釋放的問題。

這個問題乍看起來很棘手:我們現在要訪問一段記憶體,但卻不知道這段記憶體是否還合法,是否已被釋放。怎麼辦呢?很直接的一個想法是,看看有沒別的方式可以檢查該記憶體是否還合法,這個想法很單純,但從前面幾篇文章的討論我們得知,任何時候直接去碰佇列上的節點都是不安全的,當前執行緒永遠不知道下一秒後會發生了什麼事情,這就是為什麼 lock free queue 需要引入一個 dummy 頭結點的原因。

既然這樣,那麼我們能不能乾脆簡單點,直接就不允許釋放連結串列的節點呢?

這個方案確實是最直接易用的,所付出的代價也最小,無非就是多費點記憶體,空間換效率,太划算了,boost 的 lock free queue 就採用了這種方法。

(1) 建立佇列的時候,分配好全部的記憶體,比如說,2048 個節點。

(2) 重複實現一套無鎖分配節點的方法。

其中第二條看起來有些為難,這不正是我們現在所要解決的問題嗎?事實上這不大一樣,在這裡我們不需要再分配內部節點!因此,我們不需要擔心記憶體回收的問題!只要處理好 aba 問題就行了!

struct Node
{
    Node* next;   // 用於在 lock free queue 中指向下一個指點
    Node* next2;  // 指向內部佇列
    void* data;
};

Node g_FreeList[N];
Node* head;
void Init() { g_FreeList = (Node*)malloc(sizeof(Node)*N); for (int i = 0; i < N -1; ++i) { g_FreeList[i].next2 = &g_FreeList[i+1]; } g_FreeList[N-1].next2 = NULL; } Node* AllocNode() { Node* old_head; do { old_head = head; if (old_head == NULL) return NULL;
// 下面的一行仍有aba問題,後面再解決。
if (CAS(&head, old_head, old_head->next2)) break; } while(1); return old_head; } void ReleaseNode(Node* node) {
assert(node); // more advance check is necessary Node
* old_head; do { old_head = head; node->next2 = old_head; if (CAS(&head, old_head, node)) break; } while( 1); }

(二)

現在我們來看看  ABA 問題,回過頭仔細觀察一下 ABA 問題, 它的起因簡單來說就在於 dequeue 的時候,無法確認 head 是否還是當初的 head, 也無法確認它的內容是否已經發生變化,因此無法更新當前的頭結點指標。所以解法最直觀的無外乎兩個:

1) 在當前執行緒還在操作該節點時,不允許別的執行緒釋放這個節點。

2) 給節點做標誌,使得每個插入的節點有一個唯一的標記,這樣,就能檢測當前的節點是否已發生變化。

其中第一種做法在 C/C++ 中不容易做到,它們在語言層面上沒有 GC, 對記憶體的操作都得靠程式設計師自己來把控,使得在處理資源的回收時,雖然更靈活,但也更不容易實現一些諸如自動回收這樣的高階功能,不過這難不倒聰明人,2004 年時候,Maged.M.Machel(對,又是他), 在 IEEE 的期刊 Transactions on Parallel and Distributed Systems 上發一發表了一篇論文:Hazard Pointers: Safe Memory Reclamation for Lock-Free Objects

該論文引入一個叫作 hazard pointer 的東西來處理 ABA 問題,關於 Hazard pointer 的介紹可以參考一下 wiki 中的條目。簡而言之,hazard pointer 是實現了一種 reference 的機制,使得連結串列的節點如果還有執行緒在讀,就不允許該節點被釋放,這個方法實現起來有很多的細節要處理,並不是件容易做的事情,維基百科的附錄裡面介紹了好幾種不同的人的實現方案,有興趣的讀者可以自行去研究研究。我在前一篇部落格裡提到過的 Christian Hergert 也在他的部落格中介紹了他自己的 hazard pointer 的實現,程式碼放到了 github上,有興趣的讀者可以去看看。

阻止記憶體過早被釋放這個做法不是件容易的事情,但如果做到了,就連我們上面討論的記憶體訪問的問題都一併解決了。Memory reclamation 是無鎖演算法裡最棘手的兩個問題之一了,Hazard Pointer 在這個難題上是個很完美的解決方案。但是 Hazard Pointer 來頭太大,也太麻煩了,有沒更輕量一點的方法呢?現在我們來看看第二種解法。為了說明第二種方法,我們來回顧一下 lock free queue 中 dequeue 的操作。

 1 gpointer queue_dequeue(Queue *q)
 2     {
 3         Node *node, *tail, *next;
 4 
 5         while (TRUE) {
 6             head = q->head;
 7             tail = q->tail;
 8             next = head->next;
 9             if (head != q->head)
10                 continue;
11 
12             if (next == NULL)
13                 return NULL; // Empty
14 
15             if (head == tail) {
16                 CAS(&q->tail, tail, next);
17                 continue;
18             }
19 
20             data = next->data;
21             if (CAS(&q->head, head, next))
22                 break;
23         }
24 
25         g_slice_free(Node, head); // This isn't safe
26         return data;
27     }

所有的問題歸結起來,就在於第 21 行進行 cas 操作時,head 雖然還是 head,但 head->next 已經發生了變化。那麼,我們應該怎樣來識別這些變化呢?從本質上來說,既然 head 已經發生了變化,那接下來的 CAS 就應該要失敗才是正確的行為。ABA 問題的根源就在於該失敗的 CAS 操作沒有失敗,所以,我們現在的目標就是要糾正 CAS 的這個錯誤行為,讓它在該失敗的時候就徹底的失敗。

回頭來分析一下 cas 操作:

1 bool cas(type*ptr, type old, type new)

這個函式純粹只是比較一下 ptr 與 old 的值,然後決定下一步的操作:如果 *ptr == old,就 *ptr = new,否則什麼也不做(暫且這樣理解)。

在我們的場景下,我們希望在 aba 問題出現了的時候,cas 能夠失敗。為了做到這點,我們自然希望 *ptr != old,但 aba 問題出現時,*ptr 是等於 old 的,因此我們在進行 cas 時不應該只比較 *ptr == old, 而應該想辦法在 *ptr 中加入些不同的東西來加以區別,比如說再多比較幾個位元組,再決定是否更新 *ptr: 我們需要 cas 能比較的位元組數要大於字長 (sizeof(void*)),這個要求顯然是需要 cpu 的支援的。因此,我們現在討論的這個解法並不具備普遍性,是要依賴硬體的。這大概也是為什麼 Maged.M.Machel 花了大心思是去研究出 hazard pointer 的原因。好訊息是,x86 平臺上較新的 cpu 都是支援 double wide cas 的,也就是通常指的 CAS2,具體來說,就是支援cmpxchg8b, cmpxchg16b 這兩條指令。

有了 CAS2 的支援,我們就可以對指向指向節點的指標加一個 tag 作為標記。

1 union DoublePointer
2 {
3     void* vals[2];
4     atomic_longlong val;
5 };

DoublePointer 包含了指向結點的指標,以及一個 tag,每次插入一個節點時,都用一個 DoublePointer 來指向這個新插入的結點,每個 DoublePointer 中包含了唯一的標記符,每次插入新結點或取出結點,都用 CAS2 來更新double pointer,從而就做到區別對待每一個新插入的結點,從根本上去除了 ABA 問題。語言上比較難說得清楚,還好可以用程式碼來說話,有興趣取的讀者可以看看我放在 github 上的程式碼,在 x86-64/32 上都進行了一定的壓力測試,應該是沒問題的(人艱不拆)。

 https://github.com/kmalloc/back-end-facility/blob/master/misc/LockFreeList.h

後話

好了,寫 lock free queue 的目標到此算是基本完成了,花了一個多月的時間,一開始先是讀了很多的文章,從無到有,算是在記憶體模型,cpu 結構方面有了些前所未有的瞭解,不過就算這樣,真正寫起來還是比想像中的困難太多了,尤其是 debug 的過程,剛開始時遇到問題簡直束手無策,事實證明,思路清晰才是解決問題的根本方法,不能一發現問題就掛 gdb,那是沒用的,特別是多執行緒的情況下,必須一點一點的分析程式碼,認真推敲,查詢漏洞,掛gdb 應該只做驗證之用,打 Log 其實更好了。四篇文章寫下來,lock free queue 的實現過程基本是個重造輪子的過程,說到通用性可靠性那是沒法和 boost 相比的,效能的話,也不一定比得上,唯一值得安慰的地方,就是它們是我的親兒子了T_T. 

相關文章