1. 引言
1. 引言
現代計算機,即使很小的智慧機亦或者平板電腦,都是一個多核(多CPU)處理裝置,如何充分利用多核CPU資源,以達到單機效能的極大化成為我們碼農進行軟體開發的痛點和難點。在多核伺服器中,採用多程式或多執行緒來並行處理任務,儼然成為了大家效能調優的標準解決方案。多程式(多執行緒)的並行程式設計方式,必然要面對共享資料的訪問問題,如何併發、高效、安全地訪問共享資料資源,成為並行程式設計的一個重點和難點。
傳統的共享資料訪問方式是採用同步原語(臨界區、鎖、條件變數等)來達到共享資料的安全訪問,然而,同步恰恰和並行程式設計是對立的,很容易成為並行程式中的瓶頸。一方面,有些同步原語是作業系統的核心物件,呼叫該原語會帶來昂貴的上下文切換(使用者態切換到核心態)代價,同時,核心物件是一個比較有限的資源。另一方面,同步杜絕了並行操作,一個執行緒在訪問共享資料的時候,其他的多個執行緒必須在排隊空閒等待,同時,同步可擴充套件性很弱,隨著並行執行緒的增加,很容易成為程式的一個瓶頸,甚至出現,服務效能吞吐量並沒隨CPU核數增加或併發執行緒的增加呈現線性增長,相反出現下降的情況。
於是,人們開始研究對共享資料進行併發訪問的資料結構和演算法,通常有以下幾方面:
```
1. Transactional memory --- 事務性記憶體
2. Fine-grained algorithms --- 細粒度(鎖)演算法
3. Lock-free data structures --- 無鎖資料結構
```
(1) 事務記憶體(Transactional memory)TM是一個軟體技術,簡化了併發程式的編寫。 TM借鑑了在資料庫社群中首先建立和發展起來的概念, 基本的想法是要申明一個程式碼區域作為一個事務。一個事務(transaction ) 執行並原子地提交所有結果到記憶體(如果事務成功),或中止並取消所有的結果(如果事務失敗)。 TM的關鍵是提供原子性(Atomicity),一致性(Consistency )和隔離性(Isolation )這些要素。 事務可以安全地並行執行,以取代現有的痛苦和容易犯錯誤(下面幾點)的技術,如鎖和訊號量。 還有一個潛在的效能優勢。 我們知道鎖是悲觀的(pessimistic ),並假設上鎖的執行緒將寫入資料,因此,其他執行緒的進展被阻塞。 然而訪問鎖定值的兩個事務可以並行地進行,且回滾只發生在當事務之一寫入資料的時候。但是,目前還沒有嵌入式的事務記憶體,比較難和傳統程式碼整合,需要軟體做出比較大的變化,同時,軟體TM效能開銷極大,2-10倍的速度下降是常見的,這也限制了軟體TM的廣泛使用
```
1. 因為忘記使用鎖而導致條件競爭(race condition)
2. 因為不正確的加鎖順序而導致死鎖(deadlock)
3. 因為未被捕捉的異常而造成程式崩潰(corruption)
4. 因為錯誤地忽略了通知,造成執行緒無法正常喚醒(lost wakeup)
```
(2) 細粒度(鎖)演算法是一種基於另類的同步方法的演算法,它通常基於“輕量級的”原子性原語(比如自旋鎖),而不是基於系統提供的昂貴消耗的同步原語。細粒度(鎖)演算法適用於任何鎖持有時間少於將一個執行緒阻塞和喚醒所需要的時間的場合,由於鎖粒度極小,在此類原語之上構建的資料結構,可以並行讀取,甚至併發寫入。Linux 4.4以前的核心就是採用_spin_lock自旋鎖這種細粒度鎖演算法來安全訪問共享的listen socket,在併發連線相對輕量的情況下,其效能和無鎖效能相媲美。然而,在高併發連線的場景下,細粒度(鎖)演算法就會成為併發程式的瓶頸所在。
(3) 無鎖資料結構,為解決在高併發場景下,細粒度鎖無法避免的效能瓶頸,將共享資料放入無鎖的資料結構中,採用原子修改的方式來訪問共享資料。
目前,常見的無鎖資料結構主要有:無鎖佇列(lock free queue)、無鎖容器(b+tree、list、hashmap等)。
本文以一個無鎖佇列實現片段為藍本,來談談無鎖程式設計中的那些事。下面是一個開源C++併發資料結構lib中的無鎖佇列的實現片段
上面是一個普通單向連結串列佇列的無鎖實現,對比普通的連結串列佇列實現,無鎖實現複雜了很多,多出了很多獨有的特徵操作:
```
1. C++11 標準的原子性操作: load、store、compare_exchange_weak、compare_exchange_strong
2. 一個無限迴圈: while ( true ) { ... }
3. 區域性變數的安全性(guards):t = guard.protect( m_pTail, node_to_value() );
4. 補償策略(functor bkoff):這不是必須的,但可以在連線很多的情況下緩解處理器的壓力,尤其是多個執行緒逐個地呼叫佇列時。
5. helping方法:本例中,dequeue中幫助enqueue將m_pTail設定正確。
// It is needed to help enqueue
m_pTail.compare_exchange_strong( t, pNext, memory_model::memory_order_release,
memory_model::memory_order_relaxed );
6. 標準原子操作中使用的記憶體模型(memory model),也就是記憶體柵欄(屏障):memory_order_release、memory_order_acquire等
```
下面分別講一下上面提到無鎖佇列實現中的6個特徵。
我們知道無論是何種情況,只要有共享的地方,就離不開同步,也就是concurrency。對共享資源的安全訪問,在不使用鎖、同步原語的情況下,只能依賴於硬體支援的原子性操作,離開原子操作的保證,無鎖程式設計(lock-free programming)將變得不可能。
留意本例的無鎖佇列的實現例子,我們發現原子性操作可以簡單劃分為兩部分:
```
1. 原子性讀寫(atomic read and write):本例中的原子load(讀)、原子store(寫)
2. 原子性交換(Atomic Read-Modify-Write -- RMW):本例中的compare_exchange_weak、compare_exchange_strong
```
原子操作可認為是一個不可分的操作;要麼發生,要麼沒發生,我們看不到任何執行的中間過程,不存在部分結果(partial effects)。可以想象的到,原子操作要保證要麼全部發生,要麼全部沒發生,這樣原子操作絕對不是一個廉價的消耗低的指令,相反,原子操作是一個較為昂貴的指令。那麼在無鎖程式設計中,我們要避免濫用原子操作,那麼什麼情況下,我們需要對共享變數的操作採用原子操作呢?對變數的普通的讀取賦值操作是原子的嗎?
通常情況下,我們有一個對共享變數必須使用原子操作的規則:
```
任何時刻,只要存在兩個或多個執行緒併發地對同一個共享變數進行操作,並且這些操作中的其中一個是執行了寫操作,那麼所有的執行緒都必須使用原子操作。
```
如果違反上面的規則,即存在某個執行緒使用了非原子操作,那麼你將會陷入一個在C++11標準中稱之為資料競爭(data race)(這裡的資料競爭和Java中的data race概念,以及更通用的race condition是不一樣的)的情形。如果你引發了資料競爭,那麼就會得到一個"未定義行為(undefined behavior)"的結果,它們會導致torn reads(撕裂讀)和torn writes(撕裂寫),也就是一個非完整的讀寫。
什麼樣的記憶體操作是原子的呢?通常情況下,如果一個記憶體操作使用了多條CPU指令,那麼這個記憶體操作是非原子的。那麼只使用一條CPU指令的記憶體操作是不是就一定是原子的呢?答案是不一定,某些僅僅使用一條CPU的記憶體操作,在絕大多數CPU架構上是原子,但是,在個別CPU架構上是非原子的。如果,我們想寫出可移植的程式碼,就不能做出使用一條CPU指令的記憶體操作一定是原子的假設。
在C/C++中,所有的記憶體操作都被假定為非原子性的,即使是普通的32位整形賦值,除非編譯器或硬體廠商有特殊說明這個賦值操作是原子的。在所有的現代x86,x64,Itanium,SPARC,ARM和PowerPC處理器中,普通的32位整形,只要記憶體地址是對齊的,那麼賦值操作就是原子操作,這個保證是特定平臺下編譯器和處理器做出的保證。由於C/C++語言標準並沒對整型賦值是原子操作做出保證,於是,要想寫出真正可移植的C和C++程式碼時,我們只能使用C++11提供的原子庫( C++11 atomic library)來保證對變數的load(讀)和store(寫)是原子的。
2.1 不能不說的關鍵字:volatile
透過上面我們知道,在現代處理器中,對於一個對齊的整形型別(整形或指標),其讀寫操作是原子的,而對於現代編譯器,用volatile修飾的基本型別正確對齊的保障,並且限制了編譯器對其最佳化。這樣透過對int變數加上volatile修飾,我們就能對該變數進行原子性讀寫。
```
volatile int i=10;//用volatile修飾變數i
......//something happened
int b = i;//atomic read
```
由於volatile 在某種程度上限制了編譯器的最佳化,而很多時候,對於同一個變數,我們在某些地方有原子性讀寫的需求,在某些地方我們又不需要原子性讀寫,這個時候希望編譯器該最佳化的時候就最佳化。然而,不加volatile修飾,那麼就做不到前面一點。加了volatile,後面這一方面就無從談起,怎麼辦?其實,這裡有個小技巧可以達到這個目的:
```
int i = 2; //變數i還是不用加volatile修飾
#define ACCESS_ONCE(x) (*(volatile typeof(x) *)&(x))
#define READ_ONCE(x) ACCESS_ONCE(x)
#define WRITE_ONCE(x, val) ({ ACCESS_ONCE(x) = (val); })
a = READ_ONCE(i);
WRITE_ONCE(i, 2);
```
透過上面我們知道,用volatile修飾的int在現代處理器中,能夠做到原子性的讀寫,並且限制編譯器的最佳化,每次都是從記憶體中讀取最新的值,很多同學就誤以為volatile能夠保證原子性並且具有Memery Barrier的作用。其實vloatile既不能保證原子性,也不會有任何的Memery Barrier(記憶體柵欄)的保證。上面例子中,volatile僅僅是保證int的地址對齊,而對齊後的整形在現代處理器中,是能夠做到原子性讀寫的。在C++中volatile具有以下特性:
```
1. 易變性:所謂的易變性,在彙編層面反映出來,就是兩條語句,下一條語句不會直接使用上一條語句對應的volatile變數的暫存器內容,而是重新從記憶體中讀取。
2. "不可最佳化"性:volatile告訴編譯器,不要對我這個變數進行各種激進的最佳化,甚至將變數直接消除,保證程式設計師寫在程式碼中的指令,一定會被執行。
3. "順序性":能夠保證Volatile變數間的順序性,編譯器不會進行亂序最佳化。Volatile變數與非Volatile變數的順序,編譯器不保證順序,可能會進行亂序最佳化。
```
2.2 Compare-And-Swap(CAS)
對於CAS相信大家都不陌生,在學術圈,compare-and-swap (CAS)被認為是最基礎的一種原子性RMW操作,其虛擬碼如下:
```
bool CAS( int * pAddr, int nExpected, int nNew )
atomically {
if ( *pAddr == nExpected ) {
*pAddr = nNew ;
return true ;
}
else
return false ;
}
```
上面的CAS返回bool告知原子性交換是否成功,然而在有些應用場景中,我們希望CAS 失敗後,能夠返回記憶體單元中的當前值,於是就有一個稱為 valued CAS的變種,虛擬碼如下:
```
int CAS( int * pAddr, int nExpected, int nNew )
atomically {
if ( *pAddr == nExpected ) {
*pAddr = nNew ;
return nExpected ;
}
else
return *pAddr;
}
```
CAS作為最基礎的RMW操作,其他所有RMW操作都可以透過CAS來實現,例如 fetch-and-add(FAA),虛擬碼如下:
```
int FAA( int * pAddr, int nIncr )
{
int ncur = *pAddr;
do {} while ( !compare_exchange( pAddr, ncur, ncur + nIncr ) ;//compare_exchange失敗會返回當前值於ncur
return ncur ;
}
```
在C++11的原子lib中,主要有以下RMW操作:
```
std::atomic<>::fetch_add()
std::atomic<>::fetch_sub()
std::atomic<>::fetch_and()
std::atomic<>::fetch_or()
std::atomic<>::fetch_xor()
std::atomic<>::exchange()
std::atomic<>::compare_exchange_strong()
std::atomic<>::compare_exchange_weak()
```
其中compare_exchange_weak()就是最基礎的CAS,使用compare_exchange_weak()我們可以實現其他所有的RMW操作,C++11 atomic library中的原子RMW操作有點少,不能滿足我們實際需求,我們可以自己動手實現自己需要的原子RMW操作。
例如:我們需要一個原子對記憶體中值執行乘法,也就是 atomic fetch_multiply,實現虛擬碼如下:
```
uint32_t fetch_multiply(std::atomic<uint32_t>& shared, uint32_t multiplier)
{
uint32_t oldValue = shared.load();
while (!shared.compare_exchange_weak(oldValue, oldValue * multiplier))
{
}
return oldValue;
}
```
以上的原子RMW操作都是隻能對一個integer變數進行原子修改操作,如果我們想同時對兩個integer變數進行原子操作,怎麼實現呢?我們知道C++11的原子庫std::atomic<>是一個模版,這樣我們可以用一個結構體來包含兩個integer變數,來對結構體進行原子修改,實現如下:
```
struct Terms
{
uint32_t x;
uint32_t y;
};
std::atomic<Terms> terms;
void atomicFibonacciStep()
{
Terms oldTerms = terms.load();
Terms newTerms;
do
{
newTerms.x = oldTerms.y;
newTerms.y = oldTerms.x + oldTerms.y;
}
while (!terms.compare_exchange_weak(oldTerms, newTerms));
}
```
到這裡,可能大家會有疑問了,是不是terms.compare_exchange_weak(oldTerms, newTerms)在內部加了鎖,要不怎麼能夠原子修改呢?
C++11的原子庫std::atomic<> template可以是任何型別(int、bool等buil-in type,或user-defined type),但並不是所有的型別的原子操作是lock-free的。C++11 標準庫 std::atomic 提供了針對整形(integral)和指標型別的特化實現,其中 integal 代表瞭如下型別char, signed char, unsigned char, short, unsigned short, int, unsigned int, long, unsigned long, long long, unsigned long long, char16_t, char32_t, wchar_t,這些特化實現,都包含了一個is_lock_free()成員來用於判斷該原子型別是原子操作是否是lock-free的。
上面的例子中,在X64平臺下,用GCC4.9.2編譯出來的程式碼terms.compare_exchange_weak(oldTerms, newTerms)是lock-free的,在其他平臺下就不能保證了。在實際應用中,通常情況下,同時滿足以下條件的原子類的原子操作才能做出是lock-free的保證:
```
1. The compiler is a recent version MSVC, GCC or Clang.
2. The target processor is x86, x64 or ARMv7 (and possibly others).
3. The atomic type is std::atomic<uint32_t>, std::atomic<uint64_t> or std::atomic<T*> for some type T.
```
2.3 Weak and Strong CAS
相信大家看到C++11的CAS操作有兩個compare_exchange_weak和compare_exchange_strong,CAS怎麼還有強弱之分呢?現代處理器架構對CAS的實現分成兩大陣營:(1)實現了原子性CAS原語 -- X86、Intel Itanium、Sparc等處理器架構,最早實現於IBM System 370。(2)實現LL/SC對(load-linked/store-conditional) -- PowerPC, MIPS, Alpha, ARM 等處理器架構,最早實現於DEC,透過LL/SC對可以實現原子性CAS,但在一些情況下它並不具有原子性。為什麼會存在LL/SC對的使用,而不直接實現CAS原語呢?要說明LL/SC對存在的原因,不得不說一下無鎖程式設計中的一個棘手問題:ABA問題。
2.3.1 ABA問題
下面無鎖堆疊的實現片段:
```
// Shared variables
static NodeType * Top = NULL; // Initially null
Push(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*/ return null;
/*Pop4*/ next = t->Next;
/*Pop5*/ } while ( !CAS(&Top,t,next) );
/*Pop6*/ return t;
}
```
假設當前堆疊有4個成員是:A-->B-->C-->D,A位於棧頂。下面的一個執行時序會導致一個棧被破壞的ABA問題:
```
1. Thread X執行Pop()操作,並在執行完/*Pop4*/這行程式碼後Thread X被切出去,這個時候對於Thread X來說, t == A; next == A->next == B;Top == A;當前棧:A-->B-->C-->D
2. Thread Y 執行NodeType * pTop=Pop()操作,接著又執行Pop(),最後執行Push(pTop)。這個時候當前棧變成了:A-->C-->D。
3. 這個時候Thread X被排程執行/*Pop5*/這行程式碼,由於棧頂元素依然是A,於是CAS(&Top,t,next)執行成功,Top變成指向了B,棧頂指標指向了一個已經不再棧中的元素B,整個棧被破壞了。
```
透過這個例子,我們知道ABA問題是所有基於CAS的無鎖容器的一個災難問題,要解決ABA問題有兩個思路:
```
1. 不要重用容器中的元素,本例中,Pop出來的A不要直接Push進容器,應該new一個新的元素A_n出來然後在push進容器中。當然new一個新的元素也不絕對安全,如果是A先被delete了,接著呼叫new來new一個新的元素有可能會返回A的地址,這樣還是存在ABA的風險。一般對於無鎖程式設計中的記憶體回收採用延遲迴收的方式,在確保被回收記憶體沒有被其他執行緒使用的情況下安全回收記憶體。
2. 允許記憶體重用,對指向的記憶體採用標籤指標(Tagged Pointers)的方式,標籤作為一個版本號,隨著標籤指標上的每一次CAS運算而增加,並且只增不減。本例中,如果採用標籤指標方式,Tread X的t指向Top的時候Top的標籤為T1,這個時候t == A並且標籤是T1。隨後Tread Y執行Pop(),Pop(),Push(),Top至少進過了3次CAS,標籤變成了T1+3,於是Top == A並且標籤是T1+3,這樣在Thread X被排程執行/*Pop5*/這行程式碼的時候,雖然t == Top == A,但是標籤不一樣,於是CAS會失敗,這樣棧就不會被破壞了。
```
2.3.2 Load-Linked / Store-Conditional -- LL/SC對
透過上面我們知道,ABA問題的本質在於,CAS進行比較的是指標指向的記憶體地址,雖然在/\*Pop1\*/行讀取Top指向的記憶體地址,到/\*Pop5\*/行的CAS,t和Top都是指向A的記憶體地址,但是A記憶體裡面的內容已經發生過變化了(A的next變成了C)。如果處理器能夠感知得到在進行CAS的記憶體地址的內如發生了變化,讓CAS失敗的話,那麼就能從源頭上解決ABA問題。於是PowerPC, MIPS, Alpha, ARM 等處理器架構的開發人員找到了load-linked、store-conditional (LL/SC) 這樣的操作對來徹底解決ABA問題,虛擬碼如下:
```
word LL( word * pAddr ) {
return *pAddr ;
}
bool SC( word * pAddr, word New ) {
if ( data in pAddr has not been changed since the LL call) {
*pAddr = New ;
return true ;
}
else
return false ;
}
```
LL/SC對以括號運算子的形式執行,Load-linked(LL) 運算僅僅返回 pAddr 地址的當前變數值。如果 pAddr 中的記憶體資料在讀取之後沒有變化,那麼 Store-conditional(SC)操作將會成功,它將LL讀取 pAddr 地址的儲存新的值,否則,SC將執行失敗。這裡的pAddr中的記憶體資料是否變化指的是pAddr地址所在的Cache Line是否發生變化。在實現上,處理器開發者給每個Cahce Line新增額外的位元狀態值(status bit)。一旦LL執行讀運算,就會關聯此位元值。任何的快取行一旦有寫入,此位元值就會被重置;在儲存之前,SC操作會檢查此位元值是否針對特定的快取行。如果位元值為1,意味著快取行沒有任何改變,pAddr 地址中的值會變更為新值,SC操作成功。否則本操作就會失敗,pAddr 地址中的值不會變更為新值。
CAS透過LL/SC對得以實現,虛擬碼如下:
```
bool CAS( word * pAddr, word nExpected, word nNew ) {
if ( LL( pAddr ) == nExpected )
return SC( pAddr, nNew ) ;
return false ;
}
```
可以看到透過LL/SC對實現的CAS並不是一個原子性操作,但是它確實執行了原子性的CAS,目標記憶體單元內容要麼不變,要麼發生原子性變化。由於透過LL/SC對實現的CAS並不是一個原子性操作,於是,該CAS在執行過程中,可能會被中斷,例如:執行緒X在執行LL行後,OS決定將X排程出去,等OS重新排程恢復X之後,SC將不再響應,這時CAS將返回false,CAS失敗的原因不在資料本身(資料沒變化),而是其他外部事件(執行緒被中斷了)。
正是因為如此,C++11標準中添入兩個compare_exchange原語-弱的和強的。也因此這兩原語分別被命名為compare_exchange_weak和compare_exchange_strong。即使當前的變數值等於預期值,這個弱的版本也可能失敗,比如返回false。可見任何weak CAS都能破壞CAS語義,並返回false,而它本應返回true。而Strong CAS會嚴格遵循CAS語義。
那麼,何種情形下使用Weak CAS,何種情形下使用Strong CAS呢?通常執行以下原則:
```
倘若CAS在迴圈中(這是一種基本的CAS應用模式),迴圈中不存在成千上萬的運算(迴圈體是輕量級和簡單的,本例的無鎖堆疊),使用compare_exchange_weak。否則,採用強型別的compare_exchange_strong。
```
2.3.3 False sharing(偽共享)
現代處理器中,cache是以cache line為單位的,一個cache line長度L為64-128位元組,並且cache line呈現長度進一步增加的趨勢。主儲存和cache資料交換在 L 位元組大小的 L 塊中進行,即使快取行中的一個位元組發生變化,所有行都被視為無效,必需和主存進行同步。存在這麼一個場景,有兩個變數share_1和share_2,兩個變數記憶體地址比較相近被載入到同一cahe line中,cpu core1 對變數share_1進行操作,cpu core2對變數share_2進行操作,從cpu core2的角度看,cpu core1對share_1的修改,會使得cpu core2的cahe line中的share_2無效,這種場景叫做False sharing(偽共享)。
由於LL/SC對比較依賴於cache line,當出現False sharing的時候可能會造成比較大的效能損失。載入連線(LL)操作連線快取行,而儲存狀態(SC))操作在寫之前,會檢查本行中的連線標誌是否被重置。如果標誌被重置,寫就無法執行,SC返回 false。考慮到cache line比較長,在多核cpu中,cpu core1在一個while迴圈中變數share_1執行CAS修改,而其他cpu corei在對同一cache line中的變數share_i進行修改。在極端情況下會出現這樣的一個livelock(活鎖)現象:每次cpu core1在LL(share_1)後,在準備進行SC的時候,其他cpu core修改了同一cache line的其他變數share_i,這樣使得cache line發生了改變,SC返回false,於是cpu core1又進入下一個CAS迴圈,考慮到cache line比較長,cache line的任何變更都會導致SC返回false,這樣使得cup core1在一段時間內一直在進行一個CAS迴圈,cpu core1都跑到100%了,但是實際上沒做什麼有用功。
為了杜絕這樣的False sharing情況,我們應該使得不同的共享變數處於不同cache line中,一般情況下,如果變數的記憶體地址相差住夠遠,那麼就會處於不同的cache line,於是我們可以採用填充(padding)來隔離不同共享變數,如下:
```
struct Foo {
int volatile nShared1;
char _padding1[64]; // padding for cache line=64 byte
int volatile nShared2;
char _padding2[64]; // padding for cache line=64 byte
};
```
上面,nShared1和nShared2就會處於不同的cache line,cpu core1對nShared1的CAS操作就不會被其他core對nShared2的修改所影響了。
上面提到的cpu core1對share_1的修改會使得cpu core2的share_2變數的cache line失效,造成cpu core2需重新載入同步share_2;同樣,cpu core2對share_2變數的修改,也會使得cpu core1所在的cache line實現,造成cpu core1需要重新載入同步share_1。這樣cpu core1的一個修改造成cpu core2的一個cache miss,cpu core2的一個修改造成cpu core1的一個cache miss的反覆現象就是所謂的Cache ping-pong問題,出現大量Cache ping-pong意味著大量的cache miss,會造成巨大的效能損失。我們同樣可以採用填充(padding)來隔離不同共享變數來解決cache ping-pong。
透過上面,我們知道實現無鎖資料結構在記憶體使用上存在兩個棘手的問題:一是ABA問題,二是記憶體安全回收問題。這兩個問題之間聯絡比較密切,但是鮮有兩全其美的辦法,同時解決這兩大難題,通常採用各個擊破,分別予以解決。
有種從根源上解決這個問題的方法,那就是不產生這兩個問題,對於無鎖佇列來說,我們可以實現一個定長無鎖佇列,佇列在初始化的時候確定好佇列的大小n,這樣一次性分配好所需的記憶體(n * sizeof(node))。
定長無鎖佇列將一塊連續的記憶體分割成n個小記憶體塊block,每個記憶體塊block可以儲存一個佇列node(當然在佇列node過大的情況下,可以用連續的幾個記憶體塊來儲存一個佇列node)。透過head和tail兩個指標來進行佇列node的入隊和出隊,從head到tail是已經被使用的記憶體block(被已入隊的佇列node佔用),從tail到head之間是空閒記憶體block。入隊的時候,首先原子修改tail指標(tail指標向後移動若干block),佔據需要使用的block,然後往blcok中寫入佇列node。出隊的時候,首先原子修改head指標(head指標向後移動若干block),佔據需要讀取的block,然後從block中讀取佇列node。
定長無鎖佇列不存在記憶體的分配和回收問題,同時記憶體block的位置固定,像一個環形buf一直在迴圈讀寫使用,不存在ABA問題。定長無鎖佇列存在一個佇列元素讀寫完整性問題,由於入隊採用的是先入隊在寫入內容的方式,於是存在佇列node內容還沒寫入完畢就會被出隊讀取了,讀取到一個不完整的node。同樣,出隊採用先出隊,在讀取佇列node內容,於是也存在內容還沒讀取的時候,被新的佇列node入隊的內容給覆蓋了。要解決這個問題不復雜,只要給每個佇列node加上一個tag標記是否已經寫入完畢、是否已經讀取完畢即可。
定長無鎖佇列雖然不存在ABA問題和記憶體安全回收問題,但是由於其佇列是定長的,擴充套件性比較差。對於ABA問題的解決方案,前面已經介紹了標籤指標(Tagged pointers)和LL/SC對兩種解決方案。下面著重介紹以下記憶體安全回收的解決方案。
記憶體安全回收問題根源上是待回收的記憶體還被其他執行緒引用中,此時如果delete該記憶體,那麼引用該記憶體的執行緒就會出現使用非法記憶體的問題,那麼我們只能延遲迴收該記憶體,即在安全時刻再delete。目前用於lock free程式碼的記憶體回收的經典方法有:Lock Free Reference Counting、Hazard Pointer、Epoch Based Reclamation、Quiescent State Based Reclamation等。
3.1 Epoch Based Reclamation(基於週期的記憶體回收)
Epoch Based方法採用遞增的方式來維護記錄當前正在被引用的記憶體版本ver_i,如果能知道當前被引用的的記憶體的最小版本ver_min,那麼我們就可以安全回收所有記憶體版本小於ver_min的記憶體了。通常不會給一個記憶體物件一個版本,這樣版本太多,難以管理,一個折中方案是一個週期內的記憶體物件都是分配同一個版本ver_p。那麼,最少需要幾個不同版本呢?一個版本是肯定不可以的,這樣就無法區分哪些記憶體物件是可以安全回收的,哪些是暫時不能回收的。兩個版本ver_0、ver_1是否OK呢?假設當前的記憶體物件都被分配版本號ver_0,在某一個時刻t1,我們決定變更版本號為ver_1,這樣新的記憶體物件就被分配版本ver_1。這樣才t1後,在我們再次變更版本號為ver_0前,版本號為ver_0的記憶體物件就不再增加了。那麼,在所有使用版本號為ver_0的記憶體物件的執行緒都不再使用這些記憶體物件後,假設這個時候是t2,這時我們就可以開始回收版本號ver_0的記憶體物件,回收耗時k*n(n是待回收的記憶體物件)。很明顯,我們再次變更版本號為ver_0的時刻t3是一定要大於等於t2+k*n時刻的,因為,如果t3<t2+k*n,那麼在t2+k*n至t3間產生的版本號為的物件就會存在非安全回收的風險。
可以看出採用兩個版本是ok的,但是細心的同學會發現,這樣的回收粒度有點粗,版本號為ver_1的記憶體物件在t1至t2+k*n這段時間內一直在增長,整個時間長度依賴於記憶體物件被引用的時間和ver_0的記憶體物件被回收的時間,這樣可能會引起滾雪球效應,越往後面回收時間會越長。
透過上面的分析,我們知道,如果想版本號變更的時間點不依賴ver_0的記憶體物件被回收的時間,我們需要增加一個版本號ver_2,那麼在t2時刻,我們就可以切換版本號為ver_2,同時可以啟動回收ver_0的記憶體物件。
透過上面的分析,Epoch Based演算法維護了一個全域性的epoch(取值為0、1、2)和三個全域性的retire_list(每個全域性的epoch對應一個retire list, retire list 存放邏輯刪除後待回收的節點指標)。除此之外我們為每個執行緒維護一個區域性的thread_active flag(這個用來標識thread時候已經不再引用該epoch值的記憶體物件)和thread_epoch(取值自然也為0、1、2)。演算法如下:
```
#define N_THREADS 4 //假設一共4個執行緒
const EPOCH_COUNT = 3 ;
bool active[N_THREADS] = {false};
int epoches[N_THREADS] = {0};
int global_epoch = 0;
vector<int*> retire_list[3];
void read(int thread_id)
{
active[thread_id] = true;
epoches[thread_id] = global_epoch;
//進入臨界區了。可以安全的讀取
//......
//讀取完畢,離開臨界區
active[thread_id] = false;
}
void logical_deletion(int thread_id)
{
active[thread_id] = true;
epoches[thread_id] = global_epoch;
//進入臨界區了,這裡,我們可以安全的讀取
//好了,假如說我們現在要刪除它了。先邏輯刪除。
//而被邏輯刪除的tmp指向的節點還不能馬上被回收,因此把它加入到對應的retire list
retire_list[global_epoch].push_back(tmp);
//離開臨界區
active[thread_id] = false;
//看看能不能物理刪除
try_gc();
}
bool try_gc()
{
int &e = global_epoch;
for (int i = 0; i < N_THREADS; i++) {
if (active[i] && epoches[i] != e) {
//還有部分執行緒沒有更新到最新的全域性的epoch值
//這時候可以回收(e + 1) % EPOCH_COUNT對應的retire list。
free((e + 1) % EPOCH_COUNT);//不是free(e),也不是free(e-1)。
return false;
}
}
//更新global epoch
e = (e + 1) % EPOCH_COUNT;
//更新之後,那些active執行緒中,部分執行緒的epoch值可能還是e - 1(模EPOCH_COUNT)
//那些inactive的執行緒,之後將讀到最新的值,也就是e。
//不管如何,(e + 1) % EPOCH_COUNT對應的retire list的那些記憶體,不會有人再訪問到了,可以回收它們了
//因此epoch的取值需要有三種,僅僅兩種是不夠的。
free((e + 1) % EPOCH_COUNT);//不是free(e),也不是free(e-1)。
}
bool free(int epoch)
{
for each pointer in retire_list[epoch]
if (pointer is not NULL)
delete pointer;
}
```
Epoch Based Reclamation演算法規則比較簡單明瞭,該演算法規則有個重要的缺陷是,它依賴於所有使用ver_0的記憶體物件的執行緒都進入到下個週期ver_1後,ver_0的記憶體物件才能被回收。只要有一個執行緒未能進入到下個週期ver_1,那麼那些大多數已經沒有引用的ver_0記憶體物件就不能被刪除回收。這個線上程存在不同的優先順序時候,優先順序低的執行緒會導致優先順序高的執行緒延遲待刪除元素增長變得不可控,一旦某個執行緒一直無法進入下一個週期,會導致無限的記憶體消耗。
3.2 險象指標(Hazard pointer)
Hazard Pointer由Maged M. Michael在論文"Hazard Pointers: Safe Memory Reclamation for Lock-Free Objects"中提出,基本思路是將可能要被訪問到的共享物件指標(成為hazard pointer)先儲存到執行緒區域性,然後再訪問,訪問完成後從執行緒區域性移除。而要釋放一個共享物件時,則要先遍歷查詢所有執行緒的區域性資訊,如果尚有執行緒區域性儲存有這個共享物件的指標,說明這個執行緒有可能將要訪問這個物件,因此不能釋放,只有所有執行緒的區域性資訊中都沒有儲存這個共享物件的指標情況下,才能將其釋放。
我們知道Hazard Pointer封裝了原始指標,那麼Hazard Pointer的記憶體和生命週期本身如何管理呢?以下是常見的策略:
```
1,Hazard Pointer本身的記憶體只分配,不釋放。在stack、queue等資料結構裡,需要的Hazard Pointer數量一般為1或者2,所以不釋放問題不大。對於skip list這種資料結構又有遍歷需求的,那麼Hazard Pointer可能就不是非常適用了,可以考慮使用Epoch Based Reclamation技術。據我所知,這也是memsql使用的記憶體回收策略。
2,每個執行緒擁有、管理自己的retire list和hazard pointer list ,而不是所有執行緒共享一個retire list,這樣可以避免維護retire list和hazard pointer list的開銷,否則我們可能又得想盡腦汁去設計另外一套lock free的策略來管理這些list,先有雞先有蛋,無窮無盡。所謂retire list就是指邏輯刪除後待物理回收的指標列表。
3,每個執行緒負責回收自己的retire list中記錄維護的記憶體。這樣,retire list是一個執行緒區域性的資料結構,自己寫,自己讀,吃自己的狗糧。
4,只有當retire list的大小(數量)達到一定的閾值時,才進行GC。這樣,可以把GC的開銷進行分攤,同時,應該儘可能使用Jemalloc或者TCmalloc這些高效的、帶執行緒區域性快取的記憶體分配器。
```
3.3 Hazard Version
HazardPointer的實現簡單,但是其有個不足:需要為每個共享變數維護一個執行緒的hazard pointer,這樣使用者需要仔細分析演算法以儘量減少同時存在的hazard pointer,Hazard Pointer機制也與具體資料結構的實現比較緊的耦合在一起,對於skip list這樣的有遍歷需求的資料結構同時存在的hazard pointer很容易膨脹比較多,記憶體使用是個問題。
因此在Hazard Pointer基礎上發展出了被稱為Hazard Version技術,它提供類似lock一樣的acquire/release介面,支援無限制個數共享物件的管理。
與Hazard Pointer的實現不同:首先全域性要維護一個int64_t型別的GlobalVersion;要訪問共享物件前,先將當時的GlobalVersion儲存到執行緒區域性,稱為hazard version;而每次要釋放共享物件的時候先將當前GlobalVersion儲存在共享物件,然後將GlobalVersion原子的加1,然後遍歷所有執行緒的區域性資訊,找到最小的version稱為reclaim version,判斷如果待釋放的物件中儲存的version小於reclaim version則可以釋放。hazard version就類似於給每個記憶體物件分配一個單調遞增的version的Epoch Based方法,是更細粒度的記憶體回收。
4 補償策略(functor bkoff)
補償策略通常作為避免大量CAS競爭的一種退避策略,在大併發修改同一變數的情況下,能有效緩解CPU壓力。考慮這麼一個場景,N個執行緒同時對一個無鎖佇列進行入隊操作,於是同時進行的N個CAS操作,最終只有一個執行緒返回成功。於是,失敗的N-1個執行緒在下一個迴圈中繼續重試同時進行N-1個CAS操作,最終還是隻有一個執行緒返回成功。這樣一直下去,大量的CPU被消耗在無用的CAS操作上,我們知道CAS操作是一個很重的一個操作,伺服器效能會急劇下降。這好比來自四面八方的車輛匯聚到一個出口,大家都比較自私的想要最快透過的話,那麼這個路口會被堵的水洩不通,理想的情況是將車輛流水線化,這樣大家都能較快透過出口。然而實際情況是比較難流水線化的,於是,我們採用禮讓的方式,在嘗試透過路口發現堵塞的時候,就delay一小會在嘗試透過。一個簡單的實現如下:
```
bkoof()
{
static const int64_t INIT_LOOP = 1000000;
static const int64_t MAX_LOOP = 8000000;
static __thread int64_t delay = 0;
if (delay <= 0) {
delay = INIT_LOOP;
}
for (int64_t i = 0; i < delay; i++) {
CPU_RELAX();
}
int64_t new_delay = delay << 1LL;
if (new_delay <= 0 || new_delay >= MAX_LOOP) {
new_delay = INIT_LOOP;
}
delay = new_delay;
}
}
```
```
m_pTail.compare_exchange_strong( t, pNew, memory_model::memory_order_acq_rel,
memory_model::memory_order_relaxed );
```
這裡為什麼不重試讓m_pTail指向正確的位置呢?這裡主要是實現策略和成本開銷的問題,考慮這麼一個場景:
```
1. 當前時刻佇列有3個節點(A-->B-->C),佇列狀態:m_pHead->m_pNext == A,m_pTail == C
2. 這時執行緒1執行入隊enqueue(D),執行緒2執行入隊enqueue(E)。
3. 執行緒1執行enqueue(D)進行到最後一步,這時佇列狀態:(A-->B-->C-->D),m_pHead->m_pNext == A,m_pTail == C
4. 執行緒2執行enqueue(E),這時它發現m_pTail->m_pNext != NULL,m_pTail位置不正確了。
```
這個時候,執行緒2有兩個選擇:(1) 不斷重試等待執行緒1將m_pTail設定正確後,自己在進行下面的操作步驟。(2) 順路幫執行緒1一把,自己將m_pTail調整到正確位置,然後在進行下面的操作步驟。如果採用(1),執行緒2可能會進入一個較漫長的等待來等執行緒1完成m_pTail 的設定。採用(2)則是一個雙贏的局面,執行緒2不在需要等待和依賴執行緒1,執行緒1也不再需要在m_pTail設定失敗的時候進行重試了。
第6章的內容將在本次推送的第二條圖文《說說無鎖(Lock-Free)程式設計那些事(下)》中闡述。
參考資料
http://chonghw.github.io/
http://chonghw.github.io/blog/2016/08/11/memoryreorder/
http://chonghw.github.io/blog/2016/09/19/sourcecontrol/
http://chonghw.github.io/blog/2016/09/28/acquireandrelease/
http://www.wowotech.net/kernel_synchronization/Why-Memory-Barriers.html
http://www.wowotech.net/kernel_synchronization/why-memory-barrier-2.html
http://www.wowotech.net/kernel_synchronization/memory-barrier-1.html
http://www.wowotech.net/kernel_synchronization/perfbook-memory-barrier-2.html
https://kukuruku.co/post/lock-free-data-structures-introduction/
https://kukuruku.co/post/lock-free-data-structures-basics-atomicity-and-atomic-primitives/
https://kukuruku.co/post/lock-free-data-structures-the-inside-memory-management-schemes/