怎樣實現一個無鎖佇列,網路上有很多的介紹,其中流傳最廣,影響最大的恐怕就屬於以下兩篇論文:
a) "Implementing lock free queue" by John.D.Valois
b) "Simple, Fast, and Practical Non-Blocking and Blocking Concurrent Queue Algorithms" by M.M. Michael & M.L. Scott。
很多基於連結串列的無鎖佇列的實現基本參考了該兩篇論文裡的演算法,網路上也充斥著各種各樣的文章,而在眾多的文章中,Christian Hergert 的這篇:Introduction to lock-free/wait-free and the ABA problem(可能需要FQ) 介紹了他基於 M.M.Michael & M.L.Scott 的論文,用 c++ 做的實現,後來被廣泛流傳。本文接下來的討論,也基本來自上述三個出處, 站在巨人的肩膀上,容易看得更遠。
前面兩篇部落格簡單介紹了怎樣基於陣列來實現一個無鎖的棧,雖然最後沒能如願寫出一個滿足理想條件的,但還好它們至少給了我們一些新的思路。現在我們就來探討探討怎樣用連結串列來實現一個無鎖佇列,以下的計論均假設基於強型別的記憶體模型,所有的讀寫操作至少帶有 acquire/release 語義,開始之前,先貼一張以連結串列為基礎的佇列圖。
上面這個佇列應該都不陌生,在 tail 指向的一端插入,在 head 指向的一端取出。有了前兩篇博文的基礎,對於這樣的佇列,理論上我們只要處理好 tail, head 兩個指標及頭尾兩個結點就可以實現無鎖了,事情看起來也比較簡單,那現在我們來嘗試寫一個 Push 操作,先定義結點:
// list based queue
1 struct Node 2 { 3 Node* next; 4 void* data; 5 }; 6 7 static Node* tail = NULL; 8 static Node* head = NULL;
然後準備插入:
1 void Push(void* data) 2 { 3 Node* old_tail; 4 Node* node = new Node(); 5 node->data = data; 6 7 do 8 { 9 old_tail = tail; 10 11 if (!cas(&tail, old_tail, node)) 12 continue 13 14 if (old_tail) 15 old_tail->next = node; 16 else 17 cas(&head, NULL, node); 18 19 break; 20 21 }while (1); 22 23 }
上面一段程式碼乍看起來好像沒什麼大問題,應該能正常工作吧??
--- 很遺憾,它是有問題的:
1) 首先是第 4 行,我們用 new 來分配節點,但是 new 這個操作本身很有可能是有鎖的(如果你沒過載它實現自己的分配方式),至少在標準庫中這個 new 是有鎖的,我們在一個無鎖的操作裡呼叫了有鎖的函式,後面還有必要去展示你精妙的演算法嗎?
2) 第 15 行,我們的原意是想把尾結點指向我們新插入的結點,想法太單純太一廂情願,你怎麼保證當你執行這個操作時,old_tail->next 這個語句中的 old_tail 指向的結點沒有被別的執行緒所釋放掉?設想一下,當你從 11 行執行完 CAS 操作後,當前執行緒就可能會被切換了出去,再被切換回來時,或許又是滄海桑田,或許 old_tail 已經不復存在了。
前面我說過,用連結串列來實現無鎖佇列,有幾個麻煩問題要解決,上面這兩個問題中關於 new 的使用就是其中一個,而這個問題已經有點兒類似於蛋生雞還是雞生蛋的問題了,所以最開始我就想著要不乾脆避開動態記憶體分配的問題,用陣列來試試,只是後面發現這不是一個可以躲避的問題,所以現在又老老實實跑回來。對於這個問題,目前來說,比較直接的解法方法是把分配節點的那套用另外的方法來實現,比如說用 tls(thread local storage),分配記憶體完全線上程內進行,不和別的執行緒競爭,這樣就沒必要用鎖了,缺點是不好寫出有好移值性的程式碼來,因為 tls 與平臺,OS 相關。對於 tls 的使用,不熟悉的讀者可以參考一下這篇文件, 我根據自己的需要,做了一個簡單的封裝,程式碼放這裡。
對於上面提到的第二個問題, 我們再仔細看一下,old_tail 所指向的節點之所以有可能被釋放,原因是它現在是在連結串列上,而任何在連結串列上的節點,都屬於執行緒間的公共變數,隨時有可能被某個執行緒取下來做其它事情,因此一定要避免直接在連結串列上修改某個節點,這種操作不能保證原子性及執行緒獨有(exclusive)? 如果需要修改它,就先把它取下來,確保了它只屬於當前執行緒,再進行相關的修改。
現在回到之前的問題,我們是不是應該像剛剛討論的那樣,直接把節點從連結串列上摘下來,修改完再放回去呢? 很顯然不可行,因為節點一但取出來,我們很難再把它放回原來的位置了,而佇列這個資料結構對結點順序有嚴格要求,不應該因為我們的操作而導致裡面的節點亂序。怎麼辦呢?John.D.Valois 在它的論文裡介紹了一種做法,下圖展示了他設計的連結串列的樣子:
他的做法是引入一個 dummy 的頭,head 指向這個 dummy 的頭,連結串列中真正包含資料的結點由 dummy->next 指向。這個改進保證了,在進行 push 操作的時候,tail 指向的節點永遠都是存在的(空佇列的時候,指向 dummy 頭), 因此也就避免了之前所遇到的問題, 除此之外,這個 dummy header 也使得在處理更空佇列時,更加的容易。
Jobh.D.valois 演算法虛擬碼如下:
1 Enqueue(x) 2 { 3 q = new record 4 q.value = x 5 q.next = NULL 6 7 do 8 { 9 p = tail 10 succ = cas(p.next, NULL, q) 11 if succ != true 12 cas(tail, p, p.next) 13 } while(succ == false) 14 15 cas(tail, p,q) 16 } 17 18 Dequeue 19 { 20 do 21 { 22 p = head 23 if p.next == NULL 24 return NULL 25 }while(false == cas(head, p, p.next) 26 27 return p.next.value 28 }
其中第 10 行是嘗試把新節點掛到佇列的尾巴上,這個操作有可能失敗,因此用一個 while 來反覆嘗試,第 11,12 的程式碼是個很關鍵的兩行,假設當前的執行緒 1 在第 10 行執行失敗了,那證明已經有別的執行緒,假設為執行緒 2,成功把它的結點掛到了連結串列的尾端,正常情況下,執行緒 1 也沒必要執行 11,12 行,但在某些情況下,如果不幸,執行緒 2 在把節點掛到連結串列的尾巴上後,還沒有來得及更新 tail 指標時,就掛了。這時執行緒 1 執行 11,12 行就能幫助執行緒 2 把 tail 更新一下,否則執行緒 1 就只能一直在 while 裡面一直打傳了,這兩行程式碼事實上保證了任何 push 操作都會在有限的時間內能完成。
上面的虛擬碼是演算法的原始版本,John.D.valois 在論文裡指出上面的第 11,12行雖然保證了任何 push 操作不會等太久,但有一個缺陷,在併發比較快的場景下,第 11,12 行可能會被反覆執行,而 cas 操作相對是一個比較費時的操作,因此這裡的效率相對受影響,回頭仔細再想想,第 11,12 行那麼費勁,只是為了更新一下 tail 指標,這個是有必要的嗎?是不是 tail 如果不指向最後的節點,就沒法完成插入了呢?
答案是否定的,tail 不必隨時都指著尾巴,我們之所以固定思維地覺得一定要 tail 指向尾巴,不過是因為我們插入新節點時,總是需要在尾巴的後面,但是我們忘了,tail 即使不指向尾巴,我們可以也是可以找到尾巴節點的:順著 tail->next 往下搜尋不就行了嗎,於是得到如下的改進:
1 Enqueue 2 { 3 q = new record 4 q.value = x 5 q.next = NULL 6 7 p = tail 8 oldp = p 9 10 do 11 { 12 13 while( p.next != NULL) 14 p = p.next 15 16 } while(cas(p.next, NULL, q) == false) 17 18 cas(tail, oldp, q) 19 }
其中第 13,14 行就是為找到最尾巴上的節點,注意此時的 tail 並不一定是指向最後一個節點的。改進的版本減少了對 cas 的使用,而把大部分時間花在找尾巴節點上了,但是有研究表明,這個找節點的迴圈不會太長,假如當前有 p 個執行緒在 enqueue,那 tail 離尾巴最多就隔著 2p-1 個節點,具體的證明可以參看原論文,不難理解。
到目前為止,一切看起來都還好,彷彿實現一個無鎖佇列馬上就能完成了,現在我們來看看別人是怎麼做的,根據 John.D.Valois 的論文,Christian Hergert 在他的部落格中用 c++ 實現了一個版本,程式碼如下:
1 typedef struct _Node Node; 2 typedef struct _Queue Queue; 3 4 struct _Node { 5 void *data; 6 Node *next; 7 }; 8 9 struct _Queue { 10 Node *head; 11 Node *tail; 12 }; 13 14 Queue* 15 queue_new(void) 16 { 17 Queue *q = g_slice_new(sizeof(Queue)); 18 q->head = q->tail = g_slice_new0(sizeof(Node)); 19 return q; 20 } 21 22 void 23 queue_enqueue(Queue *q, gpointer data) 24 { 25 Node *node, *tail, *next; 26 27 node = g_slice_new(Node); 28 node->data = data; 29 node->next = NULL; 30 31 while (TRUE) { 32 tail = q->tail; 33 next = tail->next; // 改為 q->tail->next 會更好 34 if (tail != q->tail) 35 continue; 36 37 if (next != NULL) { 38 CAS(&q->tail, tail, next); 39 continue; 40 } 41 42 if (CAS(&tail->next, null, node) // 應改為 CAS(&q->tail->next, null, node) 43 break; 44 } 45 46 CAS(&q->tail, tail, node); 47 } 48 49 gpointer 50 queue_dequeue(Queue *q) 51 { 52 Node *node, *tail, *next; 53 54 while (TRUE) { 55 head = q->head; 56 tail = q->tail; 57 next = head->next; // 應改為 q->head->next 58 if (head != q->head) 59 continue; 60 61 if (next == NULL) 62 return NULL; // Empty 63 64 if (head == tail) { 65 CAS(&q->tail, tail, next); // next 不空,head == tail, 即 tail 並沒有指向真正的尾巴 66 continue; 67 } 68 69 data = next->data; 70 if (CAS(&q->head, head, next)) 71 break; 72 } 73 74 g_slice_free(Node, head); // This isn't safe 75 return data; 76 }
上面的程式碼看起來和論文裡的虛擬碼是完全一致的,前面我們一直在討論 enqueue,現在我們來看 dequeue.
a) 有人可能注意到 33/57/69 行有問題,tail/head/next 有可能已經被釋放了。確實有可能,33/57 行的問題好解決(純粹只是寫的不對,參看我的註釋),69 行的問題卻非常麻煩了,這個問題是實現 lock free queue 最難解決的問題之一:memory reclamation,但是這個問題在現代的作業系統中,也往往較難觸發,比如在有 virtual memory 的系統中,上面的操作只是讀取可能被 freed 掉的記憶體,而標準庫對記憶體分配常常有緩衝處理,free 掉的記憶體並不一定會立即返回給 OS, 而且即使被返回 OS,只要相應的記憶體沒有真正釋放(如在 linux 中,unmap),哪怕這塊記憶體再次被重新分配了,也是可以去讀的,因此問題不容易出現,但問題仍然是存在的,因此不能存在繞幸的想法,想一想,用著一個明知有問題的東西,然後祈禱問題不會出現,那感覺,彷彿心裡有隻蒼蠅。
b) 還有問題的地方是在第 70 行,這一行想要做什麼呢?它的作用是把 dummy head 指向的節點取下來,看似比較簡單的事情,但上面的程式碼在 c, c++ 中卻是不安全的。。。當然,這個不安全不容易看出來, 現在我們看看這兒究竟怎麼不安全。假設某時刻,佇列如下:
Node1 -> node2 -> node3
假設執行緒 1,開始執行 dequeue, 在執行到 70 行時(還沒開始 cas), 它被停下,切換了出去,此時執行緒 1 來說,第 70 行中,head, next 分別指向 node1, node2。
另一個執行緒 2,開始執行,然後它成功把 node1 pop 出去了,然後第 74 行,free(node1),它運氣比較好,然後又把 node2,node3 也 Pop 出去了,此時佇列為空!
然後又切換到執行緒 3,它要執行 enqueue 操作,此時它會在 27 行分配一個節點,壞事來了,如果十分巧合的情況下,它分配得到了執行緒 1 所 free 掉的 node1,enqueue 之後,佇列成了如下樣子:
Node1-> NULL
執行緒 3 執行完後,如果恰好又切換回執行緒 1,此時,對執行緒 1 來說,第 70 行中的 q->head == head == node1, 因此 cas 會成功!但是 next 呢?next 指向的是被釋放掉的 node2! 嚴重問題!
這個就是無鎖世界裡所謂的 ABA 問題,這個問題就是我為什麼最開始想嘗試用陣列來實現無鎖操作的原因之二,而且,ABA 問題不容易解決。