無鎖佇列的實現
耗子叔曾經寫過一篇同名的部落格,主要參考了John D. Valois 1994年10月在拉斯維加斯的並行和分散式系統國際大會上的一篇論文——《Implementing Lock-Free Queues》。但是從目前現狀來看,這篇論文中提到的演算法是有問題的,並沒有在實際中被採用。現在被廣泛採用的無鎖佇列實現都是基於Maged M. Michael和Michael L. Scott 95年的論文《Simple, Fast, and Practical Non-Blocking and Blocking ConcurrentQueue Algorithms》,例如C++ Boost庫中的lockfree模組和Java concurrent包中的ConcurrentLinkedQueue類。在本部落格中,首先介紹Valois方法存在的問題,然後介紹Michael和Scott所做的改進,最後簡單介紹一下ABA問題。如果大家對無鎖佇列不甚瞭解,可以先閱讀耗子叔的部落格,然後再閱讀本部落格。
1. Valois無鎖佇列
1.1 版本1
Valois的方法雖然存在問題,但是卻是後續改進的基礎,我們看一下他論文中給出的無鎖佇列實現方法。
Initialize()
head=new node();
head->next=NULL;
tail=head;
end
Enqueue(data)
q=new node();
q->value=data;
q->next=NULL;
repeat
p=tail;
succ=CAS(&p->next,NULL ,q);
if not succ
CAS(&tail,p,p->next);
until succ
CAS(&tail,p,q);
end
Dequeue(value)
repeat
p=head;
if p->next==NULL
return false;
until CAS(&head,p,p->next)
*value=p->next->value;
return true;
end
在初始化過程中,head和tail都指向一個dummy節點,這樣就避免了佇列在為空或者只有一個元素的時候操作出現問題。更重要的是,這個dummy節點避免了佇列只有一個元素時插入和刪除操作之間的競爭(如何避免我也不清楚)。後續的無鎖佇列實現都採用了這種方式。
Enqueue操作有三個CAS操作,第一個CAS操作嘗試把新節點新增到佇列末尾,第二個CAS操作在其他執行緒新增節點成功導致該執行緒新增失敗的情況下嘗試修改tail指標,使其指向其他執行緒成功新增的節點。第三個CAS操作在新增成功之後嘗試修改tail指標。通過三個CAS操作實現的Enqueue可以保證tail指標始終指向末尾節點或者末尾節點的前一個節點。但是存在的問題是第二個CAS會帶來過多的競爭。
上述無鎖佇列最大的問題在於Dequeue。由於在出隊的過程中只判斷了head指標,所以可能會出現head指標跑到tail指標前面的情況,這樣就破壞了佇列的結構。後續的修改也主要是針對Dequeue進行的。
1.2 版本2
針對版本1中Enqueue帶來的過多競爭,我們可以將tail的定義放寬,只是作為末尾節點的“提示”,只要距離真正末尾的位置能夠預測即可。按照這樣的原則可以將版本1中的Enqueue修改如下:
Enqueue(data)
q=new node();
q->value=data;
q->next=NULL;
p=tail;
oldp=p;
repeat
while p->next!=NULL
p=p->next;
until CAS(&p->next,NULL,q)
CAS(&tail,oldp,q);
end
上述程式碼減少了一個CAS操作,避免了失敗執行緒修改tail指標的操作。但是會引入新的問題,導致執行緒花費大量時間在遍歷佇列上,因為tail指標可能離真正的末尾很遠,每個執行緒都需要從tail指標位置遍歷到真正的末尾進行新增操作。
2. Michael和Scott的改進
初始化操作是一樣的,Enqueue操作主要還是仿照Valois的版本一實現的,不同於他們原始論文中的方法,我們下面給出Java ConcurrentLinkedQueue類中的實現:
Enqueue(data)
n=new node();
n->value=data;
n->next=NULL;
repeat
t=tail;
s=t->next;
if t==tail
if s==NULL
if CAS(&t->next,s,n)
CAS(&tail,t,n);
return true;
else
CAS(&tail,t,s);
end
上述程式碼雖然和Volois的版本一類似,但在執行起來有較大不同。Valois的版本一總是會先執行一個CAS操作嘗試在佇列末尾新增新元素,而改進的程式碼則通過引入一個新的臨時變數s來避免執行CAS操作,相比之下會少執行一次CAS操作。
相比Valois的程式碼,改進的程式碼最大的不同在Dequeue上,為了避免head指標跑到tail指標前面,我們必須在Dequeue操作中考慮修改tail指標,具體實現如下:
Dequeue(value)
repeat
h=head;
t=tail;
first=h->next;
if h==head
if h==t
if first==NULL
return false;
CAS(&tail,t,first);
else if CAS(&head,h,first)
*value=first->value;
break;
free(h);
return true;
end
上述程式碼核心部分分成兩部分:1. 判斷head指標和tail指標是否指向同一個位置,若是表明此刻head指標追上tail指標了,我們需要讓head指標等待tail指標往前走一步;2. 否則表示我們可以刪除佇列頭元素,若執行緒競爭失敗則重複競爭直到成功,因為Dequeue要麼刪除一個元素要麼佇列為空。
Java ConcurrentLinkedQueue類的poll方法即是仿照上面的程式碼實現的,但是與Michael和Scott的原始程式碼與稍有不同。Michael和Scott的原始程式碼在執行刪除操作時,會先獲得隊首元素的值再執行CAS操作,我感覺這種方式效率略差,讀取了隊首值之後CAS操作還是可能會失敗,從而做了一次無效的寫操作。相比之下 ConcurrentLinkedQueue類的poll函式是先執行CAS操作獲得這個要刪除的隊首節點,然後再獲取值,這種方式更高效也更安全。
3. ABA問題
Java的ConcurrentLinkedQueue類就是按照上面介紹的改進實現的,所以不太可能有其他問題了。但是,Java的垃圾回收機制掩蓋了無鎖操作的一個缺陷—ABA問題。如果在演算法中採用自己的方式來管理節點物件的記憶體,那麼可能出現ABA問題。在這種情況下,即使佇列的頭結點仍然只像之前觀察到的節點,那麼也不足以說明佇列的內容沒有發生變化。
關於ABA問題,維基百科上有一個經典的解釋:“Natalie正在車裡和兩個孩子等紅燈。孩子比較鬧騰,她回頭斥責他倆。當她回頭的時候發現還是紅燈所以繼續等待。但實際上,在她回頭教育孩子的時候訊號燈已經由紅燈變為綠燈又變為紅燈,但是她卻不知道以為什麼都沒有發生。”簡單來說,ABA問題就是指在無鎖競爭環境下,由於每個執行緒都可能被打斷,所以對記憶體的訪問也是不可靠的。由於CAS操作只檢查某個位置的內容是否等於給定的值,這就導致某個位置的值A被修改為B然後又修改為A也會通過CAS操作。
不要認為這個問題無關痛癢,在多執行緒環境下可能會導致非常嚴重的錯誤。舉個例子,執行緒A在執行Dequeue的CAS前被打斷,此時該執行緒的區域性變數h=head,first=h->next;後續有另一個執行緒B也執行Dequeue操作成功,此時A執行緒認為的head空間就被釋放;接著一系列執行緒執行Enqueue操作和Dequeue操作之後,如果之前被釋放的空間被重複利用,則可能會出現 A認為的head又恰巧出現在佇列的開頭;這時A再繼續執行CAS操作就會成功。雖然head都是相同的記憶體,但是內容可能不一樣,next指標指向的位置也不一樣,first指標早已失效。但是成功執行CAS操作就會將隊首元素刪除,同時指向一個可能不存在的位置,從而導致佇列的結構被破壞。
ABA問題的根本原因在於記憶體的重複利用。在具有垃圾回收功能的語言中,不存在這個問題。(原因不明,在ConcurrentLinkedQueue類的註釋中有這樣一句話:Also note that like most non-blocking algorithms in this package, this implementation relies on the fact that in garbage collected systems, there is no possibility of ABA problems due to recycled nodes, so there is no need to use “counted pointers” or related techniques seen in versions used in non-GC’ed settings.)
解決ABA問題的方法比較容易理解,對每一個要訪問的記憶體加版本號,每次操作都更新版本號。不是隻是更新某個引用的值,而是更新兩個值,包含一個引用和一個版本號。即使這個值由A變成B,然後又變為A,版本號也將是不同的。Java的AtomicStampedReference以及AtomicMarkableReference支援在兩個變數上執行原子的條件更新。AtomicStampedReference將更新一個“物件—-引用”二元組,通過在引用上加上“版本號”,從而避免ABA問題。在Michael和Scott的原始程式碼中給出的即是有版本號的實現。
4. 總結
通過對兩篇論文中的無鎖佇列實現進行分析,我們可以瞭解其實現的細節和可能遇到的問題。可以看出,要實現一個沒有問題且高效的無鎖佇列還是非常困難的,尤其在設計ABA問題上,如果沒有十足的把握請不要自己實現,直接採用執行緒的庫即可。Java中是concurrent包,C++中有boost庫^_^。