1. 程式人生 > >無鎖資料結構(基礎篇):原子性、原子性原語

無鎖資料結構(基礎篇):原子性、原子性原語

無鎖資料結構基於兩方面——原子性操作以及記憶體訪問控制方法。本文中我話題主要涉及原子性和原子性原語。

在開始之前,我對大家表示感謝,謝謝你們對初識無鎖資料結構的熱愛。看到大家對無鎖話題很感興趣,我感到很開心。我計劃依據學術概念將此做成一個系列,從基礎到演算法,同時以文字的形式展示 libcds 中的程式碼實現。但有些讀者希望避開漫談,儘快展示這些程式碼,以及如何利用這些庫。我同意其中的一些觀點。畢竟,不是所有的人既想知曉 boost 內部構造,也想知道如何應用。

因此,我將系列文章分了三部分:基礎篇、機制篇、番外篇,每篇文章涉及其中之一。

  • 在基礎篇,我會介紹底層的知識,甚至現代CPU構造。
  • 在機制篇,我會介紹無鎖領域有趣的演算法和方法——這一部分更像是無鎖資料結構的理論實現。Libcds會是一個無盡的 C++ 程式碼來源。
  • 在番外篇,大都是關於 libcds 實現實踐為主題的文章,程式設計方法、建議和常見問題;對讀者的提問、評價、提議做出反饋。

本文不會涉及太多 C++ 程式設計,甚至不會涉及太多無鎖知識(儘管沒有原子性,無鎖演算法難以實現。)主要是現代處理器的原子性原語實現,利用這些基本型別時可能遇到的問題。

原子性是兩種底層概念中的前一種。

原子性操作可以簡單地分為讀寫(read and write)、原子性交換操作(read-modify-write,RMW)兩部分。原子操作可認為是一個不可分的操作;要麼發生,要麼沒發生,我們看不到任何執行的中間過程,不存在部分結果(partial effects)。簡單的讀寫操作甚至不具有原子性,例如,沒有記憶體對齊的資料,該資料的讀取不具有原子性。在X86架構的計算機中,這樣的讀操作會導致內部迴避。這樣,處理器讀取資料就被分成了好幾部分。在其它諸如Sparc、Intel Itanium架構中,這樣的讀操作會導致段錯誤,這些操作要能被攔截並處理,而原子性操作不存在這樣的問題。在現代處理器中,原子性的讀寫操作僅僅作用於對齊後的完整型別(整數和指標);而現代編譯器是volatile基本型別正確對齊的保障。

如果你想4到8個位元大小的資料結構具有原子性,那你就應該謹慎行事,藉助編譯器指令確保其正確對齊。每種編譯器都有其獨一無二的資料、型別對齊方法。順便說一下,libcds 庫支援一組備用型別和巨集指令,當你宣告對齊資料時,它們會隱藏編譯器依賴的部分。

Compare-and-swap

即便竭盡全力,設計一個僅僅使用讀寫的無鎖容器演算法依然是困難重重(我不清楚針對執行緒隨機數的資料結構)。這就是為什麼處理器架構開發人員採用 RMW 操作的原因。RMW可以原子性地執行對齊記憶體單元讀操作和針對它的寫操作:compare-and-swap (CAS)、fetch-and-add (FAA)、test-and-set (TAS) 等等。在學術圈,compare-and-swap (CAS)被認為是最基本的一種操作。虛擬碼如下:

C++
123456789 boolCAS(int*pAddr,intnExpected,intnNew)atomically{if(*pAddr==nExpected){*pAddr=nNew;returntrue;}elsereturnfalse;}

從字面意思上看,如果pAddr地址中的當前變數值等於預期的 nExpected,那麼將 nNew 的值賦給此變數,並返回true;否則返回false,變數值不變。所有執行過程都是原子性的、不可分的,不會產生任何可見的部分結果。藉助於CAS,其它的 RMW 操作都可以估值。如下的 fetch-and-add 是這樣的:

C++
12345678 intFAA(int*pAddr,intnIncr){intncur;do{ncur=*pAddr;}while(!CAS(pAddr,ncur,ncur+nIncr);returnncur;}

CAS 操作的學術性型別在實踐中並非那麼得心應手。CAS 失敗後,我們時常想知道記憶體單元中的當前值是多少。這時可以考慮另一個種CAS (所謂的 valued CAS,依然是原子性執行)

C++
123456789 intCAS(int*pAddr,intnExpected,intnNew)atomically{if(*pAddr==nExpected){*pAddr=nNew;returnnExpected;}elsereturn*pAddr}

C++11中的 compare_exchange函式包含了兩種衍生型別(嚴格地說,C++11沒有此類函式,它們是 ompare_exchange_strong 和 compare_exchange_weak,這些我稍後會告知大家):

C++
1 boolcompare_exchange(intvolatile*pAddr,int&nExpected,intnNew);

引數nExpected通過引用傳值,並且包含pAddr地址的預期變數值。在輸出端,返回變化之前的值。(譯者注,其實就是返回pAddr的舊地址。假如函式地址中存在值 nExpected,返回true,加入失敗了則返回false(nExpected 會包含地址 pAddr 的當前變數值)。multipurpose CAS 操作構建涵蓋了學術 CAS定義的兩種衍生型別。但在實際應用中,compare_exchange 會出現一些錯誤,你需要知道 nExpected 引數是傳引用,它是可以改變的,這一點是不能接受的。

但藉助 compare_exchange 可以實現 fetch-and-add 基本型別,程式碼可以寫成下面這樣:

C++
123456 intFAA(int*pAddr,intnIncr){intncur=*pAddr;do{}while(!compare_exchange(pAddr,ncur,ncur+nIncr);returnncur;}

ABA問題

CAS 基本型別適合多種方式不過在應用過程中,可能發生一個嚴重的問題,就是所謂的 ABA 問題。為了描述這個問題,我們需要考慮一種 CAS 操作應用的典型模式:

C++
123456 intnCur=*pAddr;while(true){intnNew=calculating newvalueif(compare_exchange(pAddr,nCur,nNew))break;}

事實上,我們一直在迴圈中,直到CAS執行才跳出迴圈。在讀取 pAddr 地址中的當前變數值和計算新值 nNew,這個在 pAddr 地址中可被其它執行緒改變的變數之間,迴圈是必須的。

ABA 問題可以用下面的方式加以描述假設執行緒A從共享記憶體單元讀取A值,與此同時,該記憶體單元指標指向某些資料;接著執行緒Y將記憶體單元的值改為B,接著再改回 A,但此時指標指向了另一些資料。但執行緒 A 通過 CAS 基本型別試圖更改記憶體單元值時,指標和前面讀取的 A 值比較是成功的,CAS 結果也正確。但此時 A 指向完全不一樣的資料。結果,執行緒就打破了內部物件連線internal object connections),最終導致失敗。

下面是一個無鎖棧的實現,重現了ABA 問題 [Mic04]:

C++
1234567891011121314151617181920 // Shared variablesstaticNodeType*Top=NULL;// Initially nullPush(NodeType*node){do{/*Push1*/NodeType*t=Top;/*Push2*/node->Next=t;/*Push3*/}while(!CAS(&Top,t,node));}NodeType*Pop(){Node*next;do{/*Pop1*/NodeType*t=Top;/*Pop2*/if(t==null)/*Pop3*/returnnull;/*Pop4*/next=t->Next;/*Pop5*/}while(!CAS(&Top,t,next));/*Pop6*/returnt;}

下面一系列活動導致棧結構遭受破壞(需要注意的是,此序列不是引起 ABA 問題的唯一方式)。

Thread X Thread Y
Calls Pop().
Line Pop4 is performed,
variables values: t == A
next == A->next
NodeType * pTop = Pop()
pTop == top of the stack, i.e. A
Pop()
Push( pTop )
Now the top of the stack is A again
Note, that A->next has changed
Pop5 line is being performed.
CAS is successful, but the field Top->next
is assigned with another value,
which doesn’t exist in the stack,
as Y thread has pushed A and A->next out,
of a stack and the local variable next
has the old value of A->next

ABA 問題是所有基於 CAS 基本型別的無鎖容器的一個巨大災難。它會在多執行緒程式碼中出現,當且僅當元素 A 從某個容器中被刪除,接著存入另一個元素 B,然後再改為元素A。即便其它執行緒使該指標指向某一元素,該元素可能正在被刪除。即使該執行緒物理刪除了A,接著呼叫new方法建立了一個新的元素,也不能保證 allocator 返回A的地址。此問題在超過兩個執行緒的場景中經常出現。鑑於此,我們可以討論 ABCBA 問題、ABABA 問題等等。

為了處理 ABA 問題,你應該物理刪除(延遲記憶體單元再分配,或者安全記憶體回收)該元素,並且是在不存在競爭性執行緒區域性,或全域性指向待刪除元素的情況下進行。

因此,無鎖資料結構中元素刪除包含兩個步驟:

  • 第一步,將該元素逐出無鎖容器中;
  • 第二步(延遲),不存在任何連線的情況下,物理移除該元素。

我會在接下來的某篇文章中詳細介紹延遲刪除的不同策略。

Load-Linked / Store-Conditional

我猜測,因為 CAS 中出現的ABA問題,促使處理器開發人員尋找另外一種不受 ABA 問題影響的 RMW 操作。於是找到了load-linked、store-conditional (LL/SC) 這樣的操作對。這樣的操作極其簡單,虛擬碼如下:

C++
123456789101112 wordLL(word*pAddr){return*pAddr;}boolSC(word*pAddr,wordNew){if(data inpAddr has notbeen changed since the LL call){*pAddr=New;returntrue;}elsereturnfalse;}

LL/SC對以括號運算子的形式執行,Load-linked(LL) 運算僅僅返回 pAddr 地址的當前變數值。如果 pAddr 中的資料在讀取之後沒有變化,那麼 Store-conditional(SC) )操作會將LL讀取 pAddr 地址的資料儲存起來。這種變化之下,任何 pAddr 引用的快取行修改都是明確無誤的。為了實現 LL/SC 對,程式設計師不得不更改快取結構。簡而言之,每個快取行必須含有額外的位元狀態值(status bit)。一旦LL執行讀運算,就會關聯此位元值。任何的快取行一旦有寫入,此位元值就會被重置;在儲存之前,SC操作會檢查此位元值是否針對特定的快取行。如果位元值為1,意味著快取行沒有任何改變,pAddr 地址中的值會變更為新值,SC操作成功。否則本操作就會失敗,pAddr 地址中的值不會變更為新值。

CAS通過LL/SC對得以實現,具體如下:

C++
12345 boolCAS(word*pAddr,wordnExpected,wordnNew){if(LL(pAddr)==nExpected)returnSC(pAddr,nNew);returnfalse;}

注意儘管程式碼中存在多個步驟,不過它確實執行原子性的 CAS。目標記憶體單元內容要麼不變,要麼發生原子性變化。框架中實現的 LL/SC 對,僅僅支援 CAS 基本型別是可能的,但不僅限於此種類型。在此,我不打算做進一步討論如果感興趣,可以參考引文[Mic04]。

現代處理器架構分為兩大部分。第一部分支援計算機程式碼中的 CAS 基本型別;第二部分是LL/SC 對。CAS 在X86、Intel Itanium、Sparc框架中有實現。基本型別第一次出現在IBM系統370基本型別中;而PowerPC、MIPS、Alpha、ARM架構中的 LL/SC 對, 最早出現在DEC中。倘若 LL/SC 基本型別在現代架構中沒有完美實現,那它就什麼都不是。比如,採用不同的地址無法呼叫嵌入的 LL/SC ,連線標籤存在錯誤遺棄的可能。

從C++的角度看,C++並沒有考慮 LL/SC 對,僅僅描述了原子性原語 compare_exchange (CAS),以及由此衍生出來的原子性原語——fetch_add、fetch_sub、exchange等等。這個標準意味著通過 LL/SC 可以很容易地實現 CAS;而通過 CAS 對 LL 的向後相容實現絕對沒有那麼簡單。因此,為了不增加 C++ 庫開發人員的難度,標準委員會僅僅引入了C++ compare_exchange。這足以用於無鎖演算法實現。

偽共享(False sharing)

現代處理器中,快取行的長度為64-128位元組,在新的模型中有進一步增加的趨勢。主儲存和快取資料交換在 L 位元組大小的 L 塊中進行。即使快取行中的一個位元組發生變化,所有行都被視為無效,必需和主存進行同步。這些由多處理器多核架構中快取一致性協議負責管理。

假設不同的共享資料相鄰地址的區域存入同一快取行,從處理的角度看,某個資料改變都將導致同一快取行中的其它資料無效。這種場景叫做偽共享。對 LL/SC 基本型別而言,錯誤共享具有破壞性。這些基本型別的執行依賴於快取行。載入連線LL操作連線快取行,而儲存狀態SC))操作在寫之前,會檢查本行中的連線標誌是否被重置。如果標誌被重置,寫就無法執行,SC返回 false。考慮到快取行的長度 L 相當長,那麼任何快取行的變更,即和目標資料不一致,都會導致SC 基本型別返回 false 。結果產生一個活鎖現象:在此場景下,就算多核處理器滿負載執行,依然無用。

為了處理錯誤共享,每個共享變數必須完全處理快取行。通常借用填充(padding)來處理。快取的物理結構影響所有的操作,不僅僅是 LL/SC,也包含CAS。在一些研究中,採用一種特殊的方式建立資料結構,該方式有考慮快取結構(主要是快取行長度)。一旦資料結構被恰當地構建,效能就會有極大的提升。

C++
123456 structFoo{intvolatilenShared1;char_padding1[64];// padding for cache line=64 byteintvolatilenShared2;char_padding2[64];// padding for cache line=64 byte};

CAS衍生型別

同樣,我樂意介紹兩種更有用的基本型別:double-word CAS (dwCAS) 和 double CAS (DCAS)。

Double-word CAS 和通用 CAS 相似,不同的是前者執行在雙倍大小的記憶體單元中:32位體系結構是64位元,64位體系結構是128位元(要求至少96位元)。有鑑於此架構提供 LL/SC 而非CAS,LL/SC 應該執行在 double-word 之上。我瞭解的情況是僅有 X86 支援 dwCAS。那麼為什麼 dwCAS 如此有用呢?藉助它可以組織一種 ABA 問題的解決方案——tagged pointers。此方案依賴於每種相關的共享 tagged pointer 整數。tagged pointer 可以通過以下結構加以描述:

C++
12345678910 template<typenameT>structtagged_pointer{T*ptr;uintptr_t tag;tagged_pointer():ptr(newT),tag(1){}};

為了支援原子性,本型別的變數必須與 double-word 對齊:32位架構是8位元組,64位架構是16位元組。tag 包含 “版本號” 資料,ptr 指向此資料。我會在接下來的某篇文章中詳盡介紹 tagged pointers,集中介紹安全記憶體回收和安全記憶體回收。目前僅討論記憶體,一旦涉及 T-type 資料(以及其對應的tagged_pointer),都不應該物理刪除,而是移入到一個 free—list 中(對每個T-type進行隔離)。未來隨著tag增長,資料得以分散式儲存。ABA問題解決方案:現實中,此指標式很複雜的,tag 包含一個版本號(分散式位置編號)。如果 tagged_pointer 指標型別和 dwCAS 引數相同,但 tag 的值不同,那麼 dwCAS 不會成功執行。

第二種原子性原語——double CAS (DCAS) ,是純理論,沒有在任何現代處理器架構中實現。DCAS 虛擬碼如下:

C++
1234567891011 boolDCAS(int*pAddr1,intnExpected1