現在我們來嘗試解決前一篇文章提到的問題。
(一)
首先是記憶體釋放的問題。
這個問題乍看起來很棘手:我們現在要訪問一段記憶體,但卻不知道這段記憶體是否還合法,是否已被釋放。怎麼辦呢?很直接的一個想法是,看看有沒別的方式可以檢查該記憶體是否還合法,這個想法很單純,但從前面幾篇文章的討論我們得知,任何時候直接去碰佇列上的節點都是不安全的,當前執行緒永遠不知道下一秒後會發生了什麼事情,這就是為什麼 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.