我在《無鎖資料結構(基礎篇):記憶體模型》已經提到,實現無鎖資料結構最大的兩個困難,一是ABA問題,二是記憶體回收。即便它們之間有聯絡,卻鮮有兩全其美的辦法,同時解決這兩大難題,因此我將其分為兩個問題進行討論。
本文中我將論述無鎖容器幾種流行的記憶體安全回收方法,並在Michael-Scott經典的無鎖佇列中展示其中的幾種。
標籤指標(Tagged pointers)
標籤指標作為一種規範由IBM引入,旨在解決ABA問題,它可能是解決此類問題最流行的演算法。依據此規則,每個指標代表一組原子性的記憶體單元地址和標籤(32位元的整數)
1 2 3 4 5 6 7 8 9 |
template <typename T> struct tagged_ptr { T * ptr ; unsigned int tag ; tagged_ptr(): ptr(nullptr), tag(0) {} tagged_ptr( T * p ): ptr(p), tag(0) {} tagged_ptr( T * p, unsigned int n ): ptr(p), tag(n) {} T * operator->() const { return ptr; } }; |
標籤作為一個版本號,隨著標籤指標上的每一次CAS運算而增加,並且只增不減。一旦需要從容器中非物理地移除某個元素,就應將其放入一個放置空閒元素的列表中。在空閒元素列表中,邏輯刪除的元素完全有可能被再次呼叫。因為是無鎖資料結構,一個執行緒刪除X元素,另外一個執行緒依然可以持有標籤指標的本地副本,並指向元素欄位。因此需要一個針對每種T型別的空閒元素列表。多數情況下,將元素放入空閒列表中,意味著呼叫這個T型別資料的解構函式是非法的(考慮到並行訪問,在解構函式運算的過程中,其它執行緒是可以讀到此元素的資料)。
當然,標籤指標規則還有以下缺陷:
- 此規則由平臺實現,因此該平臺必須擁有一個基於dwCAS的原子性CAS原語。需要指出的是,32位的現代作業系統支援dwCAS 64位元的字運算,而所有的現代計算機架構都一套完整的64位指令集。在64位元的操作模式中,dwCAS需要128位元,至少96位元。但不是所有的架構中都實現了dwCAS。
簡直是胡說八道,一派胡言!
有經驗的無鎖程式設計人員可能認為,沒有必要用一個128位元或96位元的CAS去實現標籤指標。完全可以用64位元完成,因為現代處理器只採用48位元定址,還有16位元閒置,完全可以用它來做標籤計數器,例如boost.lockfree庫
但是本方法存在兩個問題:
- 問題一,誰能保證剩餘的16位地址將來不會被用到?一旦記憶體晶片領域取得一個大的突破,即記憶體容量徒增,供應商可能會馬上提供64位元完整的定址處理器。
- 問題二,16位元足夠儲存標籤嗎?相關研究表明,16位元是不夠的。在此情況下,記憶體溢位的可能性很大,這也增加了ABA問題發生的可能。不過32位元是足夠了。
確實如此,16位元標籤的取值範圍0-65535。現代作業系統,單個執行緒的時間片執行大約30萬到50萬條彙編指令(來自Linux開發人員的資料)。然而,當處理器效能增加時,時間片也會跟著增加;因此6.5萬個難度較大的CAS運算也是可以執行的(即使現在不可以,未來絕對沒問題)。所有采用16位元的標籤,就有面對ABA問題的風險。
- 空閒列表通常以無鎖棧或者無鎖佇列的方式實現,同樣也會引起效能問題:無論是空閒列表中元素移除或者是新增,至少有一個CAS會被呼叫。不過,空閒列表的在某些方面卻在提高效能。即便空閒列表不為空,也沒有必要引用系統函式,此類函式通常執行很慢,並且需要同步分配記憶體。
- 針對每種資料型別提供單獨的空閒列表(free list),這樣做太過奢侈難以被大眾所接收,一些應用使用記憶體太過低效。例如,無鎖佇列通常包含10個元素,但可以擴充套件到百萬,比如在一次阻塞後,空閒列表擴充套件至百萬。這樣的行為通常是非法的。
由此可見,標籤指標規則是解決ABA問題的諸多演算法中的一種,但它不能解決記憶體回收的問題。
截止目前,libcds庫中的無鎖容器沒有使用標籤指標。儘管實現起來相對簡單,此規則依然可能會使已使用的記憶體增長變得難以管控,因為無鎖適用於任何一種容器型別。libcds庫中,無鎖演算法採用可預測記憶體使用方式,而非dwCAS。而boost.lockfree庫在標籤指標規則方面有很好的應用。
標籤指標應用案例
對那些喜歡桌布的人來說,如果可能的話,帶有標籤指標的MSQueue偽碼桌布也是可以的。不可否認,無鎖演算法確實健壯。使用std:atomic簡單地做一個應用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
template <typename T> struct node { tagged_ptr next; T data; } ; template <typename T> class MSQueue { tagged_ptr<T> volatile m_Head; tagged_ptr<T> volatile m_Tail; FreeList m_FreeList; public: MSQueue() { // Allocate dummy node // Head & Tail point to dummy node m_Head.ptr = m_Tail.ptr = new node(); } void enqueue( T const& value ) { E1: node * pNode = m_FreeList.newNode(); E2: pNode–>data = value; E3: pNode–>next.ptr = nullptr; E4: for (;;) { E5: tagged_ptr<T> tail = m_Tail; E6: tagged_ptr<T> next = tail.ptr–>next; E7: if tail == Q–>Tail { // Does Tail point to the last element? E8: if next.ptr == nullptr { // Trying to add the element in the end of the list E9: if CAS(&tail.ptr–>next, next, tagged_ptr<T>(node, next.tag+1)) { // Success, leave the loop E10: break; } E11: } else { // Tail doesn’t point to the last element // Trying to relocate tail to the last element E12: CAS(&m_Tail, tail, tagged_ptr<T>(next.ptr, tail.tag+1)); } } } // end loop // Trying to relocate tail to the inserted element E13: CAS(&m_Tail, tail, tagged_ptr<T>(pNode, tail.tag+1)); } bool dequeue( T& dest ) { D1: for (;;) { D2: tagged_ptr<T> head = m_Head; D3: tagged_ptr<T> tail = m_Tail; D4: tagged_ptr<T> next = head–>next; // Head, tail and next consistent? D5: if ( head == m_Head ) { // Is queue empty or isn’t tail the last? D6: if ( head.ptr == tail.ptr ) { // Is the queue empty? D7: if (next.ptr == nullptr ) { // The queue is empty D8: return false; } // Tail isn’t at the last element // Trying to move tail forward D9: CAS(&m_Tail, tail, tagged_ptr<T>(next.ptr, tail.tag+1>)); D10: } else { // Tail is in position // Read the value before CAS, as otherwise // another dequeue can deallocate next D11: dest = next.ptr–>data; // Trying to move head forward D12: if (CAS(&m_Head, head, tagged_ptr<T>(next.ptr, head.tag+1)) D13: break // Success, leave the loop } } } // end of loop // Deallocate the old dummy node D14: m_FreeList.add(head.ptr); D15: return true; // the result is in dest } |
讓我們仔細觀察位於入隊和出隊前面的演算法,通過這些例子,你可以看到幾種標準的無鎖資料結構構建方式。
請注意這兩種方法都包含迴圈—運算上下文不斷的在重複,直到成功執行為止(也有可能無法成功執行,比如從一個空佇列中進行出佇列運算)。這種重複迴圈方式是一種典型的無鎖程式設計方式。
佇列首個元素,即m_Head指向的元素為啞節點,確保指向佇列起始和結束的指標永遠都不為空。判斷一個空佇列的條件是 m_Head == m_Tail且m_Tail->next == NULL,見D6到D8行 。條件m_Tail->next == NULL尤為重要,這樣往佇列里加資料,並不會改變m_Tail。第E9行僅僅改變 m_Tail->next,眨眼一看,enqueue()執行break跳出迴圈。實際上,任何方法或者執行緒m_Tail均可以被改變。入隊新增元素時,E8行必須檢查m_Tail是否指向末尾元素即m_Tail->next == NULL;如若不然,如本例,執行E12行,嘗試先將指標指向末尾元素。同樣,在元素出隊時,倘若m_Tail並未指向末尾元素,執行D9行使其指向末尾元素。本段程式碼是一種廣為流行的無鎖程式設計方法:執行緒互助法。某個運算的演算法可以擴充套件到容器的其它所有運算中,這樣,該運算剩餘的工作,就可以藉由其它執行緒所呼叫的運算加以完成。
進一步觀察,E5-E6行和D2-D4行,運算所需的指標值存於區域性變數中。接著,E7、D5行比較計算值和原值。這是一個典型的無鎖方法,僅限於併發程式設計,此刻讀到的原值是可以被改變的。倘若不禁止編譯器優化某些共享資料佇列訪問,一些“聰明”的編譯器會刪除E7或者D5比較行,因此需將m_Head以及m_Tail定義為C++原子型別,而在本偽碼中為volatile型別。
此外,大家記住CAS原語是將目標地址值和某個既定值進行比較,若這倆值相等,CAS則依據目標記憶體地址設定新值。對CAS原語來說,推斷本地拷貝是否為當前值是必需的。CAS(&val, val, newVal) 通常都會成功執行。
現在,我們設想這樣的場景,在出對方法中,D11行復制資料,之後,執行D12行,在佇列中移除該元素。不過元素的刪除即D12行m_Head前移有可能失敗,在此情況下,D11資料複製會被反覆執行。從C++的角度看,佇列中的資料儲存,其實現不宜太過複雜,否則賦值運算負載會很大。令人擔心的是,高負載情況下,CAS原語失敗的可能會很大。
人們自然想到了優化,將D11移到迴圈外邊,但這會導致一個錯誤:next元素很可能會被另一執行緒刪除。因為遵循標籤指標規範,其中的元素並沒有被刪除,因此優化最終會導致這樣一個結果,返回一個錯誤資料;儘管D12行執行成功,但返回的資料並不在佇列中。
Peculiarities of M&S queue MS 佇列的特點
MSQueue有趣的地方就在於 m_Head一直會指向啞節點,即非空佇列的首個元素為m_Head元素的下一個元素。非空佇列第一個元素出佇列,即讀取m_Head的下一個元素。倘若啞元素被刪除,接下來的元素便接替成為啞元素,即佇列的頭,最後返回後者的值。因此只有在下一次出對運算結束之後,才可以新增新元素。開發者試圖採用cds::intrusive::MSQueue的侵入式變數,這些特點會引發很多問題。
基於週期的記憶體回收(Epoch-based reclamation)
Fraser [Fra03]引入週期規則,採用延遲刪除,即在安全時刻再刪除,也即確信任何執行緒的引用不再指向待刪除元素時再刪除。週期規則採取如下的保護策略:擁有一個全域性週期nGlobalEpoch,並且單個執行緒執行於對應的區域性週期nThreadEpoch中。某個執行緒進入週期規則保護的程式碼中,此時若該執行緒區域性週期小於等於全域性週期,區域性週期的值便相應增加。而所有的執行緒進入全域性週期,nGlobalEpoch的值便自增。
該規則偽碼如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// global epoch static atomic<unsigned int> m_nGlobalEpoch := 1 ; const EPOCH_COUNT = 3 ; // TLS data struct ThreadEpoch { // global epoch of the thread unsigned int m_nThreadEpoch ; // the list of retired elements List<void *> m_arrRetired[ EPOCH_COUNT ] ; ThreadEpoch(): m_nThreadEpoch(1) {} void enter() { if ( m_nThreadEpoch <= m_nGlobalEpoch ) m_nThreadEpoch = m_nGlobalEpoch + 1 ; } void exit() { if ( all threads are in the epoch which m_nGlobalEpoch ) { ++m_nGlobalEpoch ; empty (delete) the elements m_arrRetired[ (m_nGlobalEpoch – 2) % EPOCH_COUNT ] of all threads ; } } } ; |
無鎖容器中被清空的元素放入區域性執行緒列表m_arrRetired中,而該列表中有m_nThreadEpoch % EPOCH_COUNT個等待刪除的元素。一旦m所有執行緒通過全域性週期m_nGlobalEpoch,此時便可以清空週期m_nGlobalEpoch — 1的所有執行緒列表,同時m_nGlobalEpoch也會自增。
無鎖容器的每個運算囊括在ThreadEpoch::enter()和ThreadEpoch::exit()方法中;類似於臨界區。
1 2 3 4 5 6 7 8 9 |
lock_free_op( … ) { get_current_thread()->ThreadEpoch.enter() ; . . . // lock-free operation of the container. // we’re inside “the critical section” of the epoch-based scheme, // so we can be sure that no one will delete the data we’re working with. . . . get_current_thread()->ThreadEpoch.exit() ; } |
此規則相當簡單,旨在保護容器運算中的區域性引用,該引用指向無鎖容器元素;但本規則不能保護容器運算以外的全域性引用。因此,無法採用週期規則實現無鎖容器的元素迭代器。此規則的缺點是,程式的所有執行緒需進入接下來的週期中(following epoch ),譬如,這些執行緒須指向某些無鎖容器。倘若至少有一個執行緒未能進入接下來的週期,已廢棄的元素就不能被刪除。倘若執行緒存在不同的優先順序,優先順序低的執行緒會導致優先順序高的執行緒延遲待刪除元素增長變得不可控。一旦某個執行緒失敗,週期規則會導致無限的記憶體消耗。
然而libcds庫沒有采用週期規則,因為我無法建立有效的演算法,來判定所有執行緒是否抵達全域性週期。也許,讀者朋友可以給些好的建議!
險象指標(Hazard pointer)
本規則由Michael [Mic02a, Mic03]建立,旨在保護區域性引用,同樣該引用指向無鎖資料結構元素。這也許是當今世界最流行、研究最多的延遲刪除規則了。此規則的實現僅依賴原子性讀寫,而未採用任何重量級的CAS同步原語。
此規則的核心職責是,宣告一個指向無鎖容器元素的指標,將其作為無鎖資料結構運算的內部險象指標。在呼叫元素前,先將其放入當前執行緒險象指標所在的HP陣列中,HP陣列是執行緒私有的,即只有擁護該陣列的執行緒才能寫入HP陣列,而所有執行緒通過Scan過程都可讀取HP陣列。(譯者注:C++中有返回值的為函式,沒有的稱之為過程)。仔細分析各類無鎖容器的運算之後,你會發現HP陣列大小,即單個執行緒險象指標的數目,最多為三或四。因此可以說,此規則下的負載不高。
大型資料結構
這些“大型”資料結構需要不止64個險象指標。譬如,skip-list (cds::container::SkipListMap),這是一個隨機資料結構。實際上,它是一個巢狀的列表,儲存不同級別的元素。此類容器並不適合險象指標規則,即使libcds中實現了基於此規則的skip-list。
險象指標規則偽碼 [Mic02]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
// Constants // P : number of threads // K : number of hazard pointers in one thread // N : the total number of hazard pointers = K*P // R : batch size, R-N=Ω(N), for example, R=2*N // Per-thread variables: // the array of Hazard Pointer thread // Owner-thread only can write in it // all threads can read it void * HP[N] // the current size of dlist (values 0..R) unsigned dcount = 0; // an array of data ready for deletion void* dlist[R]; // Data deletion // Places data to dlist array void RetireNode( void * node ) { dlist[dcount++] = node; // If the array is filled we call the basic Scan function if (dcount == R) Scan(); } // The basic function // deletes all elements of dlist array, which haven’t been declared // as Hazard Pointer void Scan() { unsigned i; unsigned p=0; unsigned new_dcount = 0; // 0 .. N void * hptr, plist[N], new_dlist[N]; // Stage 1 – traverse all HP of all threads // collect the total plist array of protected pointers for (unsigned t=0; t < P; ++t) { void ** pHPThread = get_thread_data(t)->HP ; for (i = 0; i < N; ++i) { hptr = pHPThread[i]; if ( hptr != nullptr ) plist[p++] = hptr; } } // Stage 2 – sorting hazard pointers // The sorting is necessary for the following binary search sort(plist); // Stage 3 – deleting the elements that haven’t been declared as hazard for ( i = 0; i < R; ++i ) { // if dlist[i] conforms in plist list of all Hazard Pointers // dlist[i] can be deleted if ( binary_search(dlist[i], plist)) new_dlist[new_dcount++] = dlist[i]; else free(dlist[i]); } // Stage 4 – forming a new array of retired elements. for (i = 0; i < new_dcount; ++i ) dlist[i] = new_dlist[i]; dcount = new_dcount; } |
呼叫RetireNode(pNode),刪除無鎖容器元素pNode時,此刻執行緒將pNode放入其區域性陣列dlist中,該陣列用來儲存待刪除的廢棄元素。陣列dlist大小為R時,呼叫Scan()儲存過程,刪除廢棄元素;R和N做比較,須大於N,比如R = 2N,而N = P*K。R > P*K這個條件很重要,若滿足此條件, Scan()會刪除陣列中的廢棄元素;而此條件一旦被打破,Scan()則無法刪除任何元素,演算法在此情況下出現錯誤,陣列全部填滿資料,卻無法降低陣列本身的大小。
Scan()過程分四個步驟:
- 第一步, 宣告用於儲存險象指標的陣列plist,儲存所有執行緒的非空險象指標。此步驟僅能讀取共享資料,即HP執行緒陣列,而其它的步驟僅作用於區域性資料。
- 第二步,陣列plist進行排序,為接下來的檢索進行優化。同時,刪除plist中的記賬元素
- 第三步,刪除運算,遍歷當前執行緒的陣列dlist,倘若dlist[i]的元素在plist中,則說明某些執行緒正在呼叫此指標,此刻還不能刪除該指標,該指標會留著在dlist中。倘若dlist[i]的元素不在plist中,說明沒有執行緒呼叫該指標,可以進行刪除。
- 第四步,將new_dlist中未刪除元素重新被放入dlist中,當R>N,Scan()儲存會被呼叫,來降低陣列dlist大小,某些元素會被成功刪除。
通常來說,宣告一個HP指標,程式碼實現如下:
1 2 3 4 5 6 7 |
std::atomic<T *> atomicPtr ; … T * localPtr ; do { localPtr = atomicPtr.load(std::memory_order_relaxed); HP[i] = localPtr ; } while ( localPtr != atomicPtr.load(std::memory_order_acquire)); |
首先,讀取指向區域性變數localPtr的原子性指標atomicPtr,將其放入當前執行緒險象指標陣列HP的槽點HP[i]中。接下來,需要檢查已讀取的atomicPtr值是否被其它執行緒更改。為了方便檢查,我們再次讀取atomicPtr,並與此前已讀取的localPtr值進行比較。檢查會一直持續下去,直至將atomicPtr的真實值放入陣列HP中。一旦此指標存入險象指標陣列中,就意味著不能被任何執行緒物理刪除。因此,該指標引用無法進行空閒記憶體區域的雜湊讀取或寫入。
險象指標規則與C++原子性運算以及記憶體序列化相關的分析,細節參見文章 [Tor08]
MSQueue performed by Hazard Pointer 險象指標的 MSQueue實現
無鎖佇列的險象指標由Michael Scott實現,這裡我提供一個純粹的偽碼,不涉及libcds庫。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
template <typename T> class MSQueue { struct node { std::atomic<node *> next ; T data; node(): next(nullptr) {} node( T const& v): next(nullptr), data(v) {} }; std::atomic<node *> m_Head; std::atomic<node *> m_Tail; public: MSQueue() { node * p = new node; m_Head.store( p, std::memory_order_release ); m_Tail.store( p, std::memory_order_release ); } void enqueue( T const& data ) { node * pNew = new node( data ); while (true) { node * t = m_Tail.load(std::memory_order_relaxed); // declaring the pointer as hazard. HP – thread-private array HP[0] = t; // necessarily verify that m_Tail hasn’t changed! if (t != m_Tail.load(std::memory_order_acquire) continue; node * next = t->next.load(std::memory_order_acquire); if (t != m_Tail) continue; if (next != nullptr) { // m_Tail points to the last element // move m_Tail forward m_Tail.compare_exchange_weak( t, next, std::memory_order_release); continue; } node * tmp = nullptr; if ( t->next.compare_exchange_strong( tmp, pNew, std::memory_order_release)) break; } m_Tail.compare_exchange_strong( t, pNew, std::memory_order_acq_rel ); HP[0] = nullptr; // zero the hazard pointer } bool dequeue(T& dest) { while true { node * h = m_Head.load(std::memory_order_relaxed); // Setup the Hazard Pointer HP[0] = h; // Verify that m_Head hasn’t changed if (h != m_Head.load(std::memory_order_acquire)) continue; node * t = m_Tail.load(std::memory_order_relaxed); node * next = h->next.load(std::memory_order_acquire); // head->next also mark as Hazard Pointer HP[1] = next; // If m_Head hasn’t changed – start everything anew if (h != m_Head.load(std::memory_order_relaxed)) continue; if (next == nullptr) { // The queue is empty HP[0] = nullptr; return false; } if (h == t) { // Help enqueue method by moving m_Tail forward m_Tail.compare_exchange_strong( t, next, std::memory_order_release); continue; } dest = next->data; if ( m_Head.compare_exchange_strong(h, next, std::memory_order_release)) break; } // Zero the Hazard Pointers HP[0] = nullptr; HP[1] = nullptr; // Place the old queue head into the array of data ready for deletion. RetireNode(h); } }; |
險象指標是否有多個用途?是否適用於所有的資料結構?事實上,並非上節描述的那樣,險象指標數數目被限制在常數K以內。對大多數資料結構,有限的險象指標是滿足要求的,陣列HP通常很小。但估算併發所需險象指標數目的演算法,難以實現。 排序的Harris列表[Har01]就是一個例子。在此演算法中從列表中移除元素,無限長的連結亦會被刪除,這導致HP規則變得不可用。
嚴格來說,HP規則是用來防止險象指標數量的無限增多。對於此規則,其作者提供了詳盡的實現指南。在libcds庫中,我將精力集中在經典演算法中,避免將HP規則複雜化,不然實現起來會更加困難。同險象指標類似,但不那麼流行的規則-踢皮球(Pass the Buck)亦是如此。在本規則中,採用險象指標不限數目的方式,稍後我會介紹這些。
libcds中險象指標實現
本圖展示了libcds庫的險象指標演算法的內部實現,核心演算法-險象指標管理器-作為一個單例放入.dll或.so動態連結庫中。每個執行緒擁有一個物件-Thread HP Manager,持有K大小的HP陣列,R大小的廢棄指標陣列。所有的Thread HP Manager放入列表中。執行緒的最大值為P。在libcds中的預設值如下:
- 險象指標陣列的大小K為8
- 執行緒的數目P為100
- 廢棄待刪除資料所在陣列大小R為2 * K * P = 1600
libcds中HP規則的實現方式分三步:
- 核心-一個獨立的基於HP規則的資料型別底層實現,名稱空間為cds::gc::hzp。然而核心沒有型別,因為資料型別T會被刪除,無法依賴,因此核心被移入動態庫中。資料型別資訊缺失,無法呼叫該資料解構函式,準確地說,標記為刪除的資料不一定被物理刪除。比如,侵入式容器,呼叫處理器仿函式,模仿資料安全刪除事件。但我們不知道,事件背後的處理器。
- 實現級別,為一個典型的規則實現,位於cds::gc::hzp名稱空間內部。此級別代表一組核心shell結構模板,用來儲存資料型別,有點類似型別擦除。當然此級別不應放在程式中。
- 介面級別,cds::gc::HP類,應用於libcds的無鎖容器中。實際上是GC容器模板的引數值。從程式碼的角度看,cds::gc::HP類為一個輕量級的包裝類,包裝了實現級別的叢多小類。
重建缺失的資料型別
如果核心中資料型別缺失,解構函式該如何被呼叫,更確切地說,型別如何重建?其實很簡單,陣列日誌為核心刪除做好了準備,程式碼如下:
1 2 3 4 5 6 |
struct retired_ptr { typedef void (* fnDisposer )( void * ); void * ptr ; // Retiredpointer fnDisposer pDisposer; // Disposer function retired_ptr( void * p, fnDisposer d): ptr(p), pDisposer(d) {} }; |
由此,廢棄指標及其刪除函式一起被儲存了一下來。
Scan()方法呼叫基於HP規則的pDisposer(ptr)函式進行元素刪除,pDisposer函式知道其引數型別。實現級別負責“透明”地生成此函式。譬如,物理刪除做如下實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
template <typename T> struct make_disposer { static void dispose( void * p ) { delete reinterpret_cast<T *>(p); } }; template <typename T> void retire_ptr( T * p ) { // Place p into arrRetired array of ready for deletion data // Note that arrRetired are private data of the thread arrRetired.push( retired_ptr( p, make_disposer<T>::dispose )); // we call scan if the array is filled if ( arrRetired.full() ) scan(); } |
方法是簡單了些,不過點子確實不錯。
假如使用libcds庫中基於HP規則的容器,在main()方法中宣告cds::gc::HP型別的物件即可,採用HP規則的容器,就能將其與每個執行緒連線。假如基於cds::gc::HP實現自己的容器,就有必要了解HP規則API。
cds::gc::HP類的API
cds::gc::HP類的所有方法都是靜態的,需要強調的是,此類為一個單例包裝類。
- 建構函式
1 2 3 4 |
HP(size_t nHazardPtrCount = 0, size_t nMaxThreadCount = 0, size_t nMaxRetiredPtrCount = 0, cds::gc::hzp::scan_type nScanType = cds::gc::hzp::inplace); |
- nHazardPtrCount,險象指標的最大數目,即規則常數K的大小
nMaxThreadCount ,為執行緒的最大數目,即規則常數P
nMaxRetiredPtrCount,廢棄指標陣列維度,即規則常數R=2K*P
nScanType,小部分優化
cds::gc::hzp::classic的值表明,非常有必要檢視Scan演算法偽碼,cds::gc::hzp::inplace值允許Scan()中選擇陣列選擇dlist棄用new_dlist。
應明確一點,只存在一個cds::gc::HP物件。
事實上,建構函式呼叫靜態方法就是在初始化核心,雖然宣告兩個cds::gc::HP物件,不會生成兩個險象指標規則,重新初始化是安全的,但也沒有必要。 - 將指標放入當前執行緒的廢棄陣列中,即準備延遲刪除。
1 2 3 4 |
template <class Disposer, typename T> static void retire( T * p ) ; template <typename T> static void retire( T * p, void (* pFunc)(T *) ) |
Disposer引數pFunc定義了刪除仿函式disposer
1 2 3 4 5 6 7 8 |
In the first case the call is quite pretentious: struct Foo { … }; struct fooDisposer { void operator()( Foo * p ) const { delete p; } }; // Calling myDisposer disposer for the pointer at Foo Foo * p = new Foo ; cds::gc::HP::retire<fooDisposer>( p ); |
1 |
static void force_dispose(); |
對險象指標規則Scan()演算法的強制呼叫,我不太確定在實際開發中是否有用,不過在libcds中有時很有必要。
另外,cds::gc::HP宣告瞭三個重要的子類:
- thread_gc ,包裝類,含有初始化私有執行緒資料程式碼,該程式碼指向險象指標規則。本類的建構函式,負責HP規則連線執行緒,而解構函式負責將執行緒同規則斷開。
- Guard,險象指標
- template <size_t Count> GuardArray,險象指標陣列。在應用HP規則時,往往需要一次性地宣告一些險象指標。最好是一次性地在此類陣列中宣告這些指標,而不是在幾個Guard型別的物件中進行宣告。
Guard類以及GuardArray類均是基於內部險象指標陣列的超級資料結構,作為內部險象指標陣列的分配器,此陣列為單個執行緒所私有。
Guard類是一個很重要的險象指標槽口,具體介面如下:
1 2 3 4 |
template <typename T> T protect( CDS_ATOMIC::atomic<T> const& toGuard ); template <typename T, class Func> T protect( CDS_ATOMIC::atomic<T> const& toGuard, Func f ); |
宣告一個原子性指標為冒險型別,通常T型別為指標。
我早前已描述過了,這些方法內部暗含一個迴圈。首先,讀取原子性指標toGuard,並將其值賦給險象指標,接著檢查該指標是否被其它執行緒更改過。第二個Func functor引數是必要的,因為在某些場景中,宣告的險象指標並不指向T*的指標,而是由此衍生的指標型別。尤其是在侵入式容器中,該容器管理節點指標,而真實資料指標可能有別於節點指標,譬如,節點可能只是真是資料的某個欄位。
functor宣告如下:
1 2 3 |
struct functor { value_type * operator()( T * p ) ; }; |
呼叫下面這兩個方法,均返回險象指標:
1 2 3 4 |
template <typename T> T * assign( T * p ); template <typename T, int Bitmask> T * assign( cds::details::marked_ptr<T, Bitmask> p ); |
這些方法將p宣告為險象指標,和保護型別不同的是,此方法沒有迴圈體,僅僅將p分配給冒險槽口。
第二個語法引數cds::details::marked_ptr為標籤指標。標籤指標中,低位的2到3位元用來儲存標籤,這是一種非常流行的 無鎖程式設計方式。該函式借助位掩碼將攜帶標籤的指標放入冒險槽口。
呼叫該方法,返回險象指標
1 2 |
template <typename T> T * get() const; |
讀取當前冒險槽口的值有時顯得很有必要的
1 |
void copy( Guard const& src ); |
將源冒險槽的值拷貝一份給當前物件,結果,兩個冒險槽擁有相同的值
1 |
void clear(); |
清空冒險槽的值,功能與Guard類的解構函式一樣。
GuardArray類擁有相似的介面,通過陣列下標獲取險象指標
1 2 3 4 5 6 7 8 9 10 11 12 13 |
template <typename T> T protect(size_t nIndex, CDS_ATOMIC::atomic<T> const& toGuard ); template <typename T, class Func> T protect(size_t nIndex, CDS_ATOMIC::atomic<T> const& toGuard, Func f ) template <typename T> T * assign( size_t nIndex, T * p ); template <typename T, int Bitmask> T * assign( size_t nIndex, cds::details::marked_ptr<T, Bitmask> p ); void copy( size_t nDestIndex, size_t nSrcIndex ); void copy( size_t nIndex, Guard const& src ); template <typename T> T * get( size_t nIndex) const; void clear( size_t nIndex); |
細心的讀者大概已經發現一個未知的字CDS_ATOMIC,這是什麼呢?
這是一個巨集,為std::atomic宣告恰當的名稱空間。
編譯器若支援C++11 atomic,則CDS_ATOMIC為std;若不支援,則CDS_ATOMIC為名稱空間cds::cxx11_atomics。libcds接下來的一版,很用可能採用boost.atomic,屆時CDS_ATOMIC則為boost。
帶有引用計數器的險象指標
險象指標規則的缺陷,就是僅能保護無鎖容器節點的區域性引用,而無法對全域性引用作出保護。需要說明的是,對迭代器僅僅做了概念實現。而對於一個不限大小的險象指標陣列,迭代器的存在是很有必要的。
特別宣告
事實上,我們可以採用HP規則實現迭代器,即迭代器物件需持有一個HP槽口,用來保護迭代器指標。最終,我們得到一個非常特殊的,與執行緒繫結的迭代器。記住,冒險槽口存放執行緒私有資料。另外,考慮到險象指標集合的大小有限,可以說,基於HP規則的迭代器實現幾乎難以完成。
以往,程式設計人員認為引用計數技術可以作為多用途工具,處理所有錯誤。此刻,我們知道這些觀點是錯誤的。
判定物件是否被呼叫,最流行的做法是,引用計數方法,即RefCount。Valois為無鎖方法的發起者之一,在他的工作中,引用計數技術用於容器元素的安全刪除。但RefCount規則存在一些缺陷,其中最大的問題是,迴圈資料結構,元素你引用我,我引用你。另外,許多研究者認為RefCount規則效率低下,無鎖實現太過頻繁地使用fetch-and-add原語。確實如此,指標每一次使用前,引用計數器數目需要增加,而每一次使用後,計數器數目需要減少。
2005年,哥德堡大學的研究團隊發表了他們的論文 [GPST05] 。此論文將險象指標和引用計數技術結合起來,險象指標規則有效地保護無鎖資料結構運算內部的區域性引用,而引用計數技術保護全域性引用,確保資料結構完整。
姑且將此規則命名為HRC(Hazard pointer RefCounting)。
採用險象指標可以避免過於困難的運算,比如,對元素引用數目的增加或減少。總的來說,就是增加了引用計數技術規則的有效性。然而,同時呼叫這兩種方法,某種程度上會增加聯合規則演算法的複雜度。不過在這方面有很多技術實現,我就不提供完整的偽碼了,詳細的細節參看[GPST05]。另外,險象指標規則無需任何來自無鎖容器元素的特殊支援,HRC僅依賴兩個幫助方法:
1 2 |
void CleanUpNode( Node * pNode); void TerminateNode( Node * pNode); |
TerminateNode過程清空pNode內部元素,即指向容器元素的所有指標。而呼叫CleanUpNode過程則是確保pNode元素僅指向資料結構“活”的元素,必要時改變其引用。引用計數容器中的每一次引用,都伴隨著元素引用計數器數目的增加,而CleanUpNode則會在元素刪除時減少計數器數目:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
void CleanUpNode(Node * pNode) { for (all x where pNode->link[x] of node is reference-counted) { retry: node1 = DeRefLink(&pNode->link[x]); // set HP if (node1 != NULL and !is_deleted( node1 )) { node2 = DeRefLink(node1->link[x]); // set HP // Change the reference and at once increment the reference counter // to the old node1 element CompareAndSwapRef(&pNode->link[x],node1,node2); ReleaseRef(node2); // clears HP ReleaseRef(node1); // clears HP goto retry; // a new reference also can be deleted, so we repeat } ReleaseRef(node1); // clears HP } } |
正是這種改變強化了無鎖容器管理,從規則核心到容器元素本身。HRC規則元素本身獨立於特定的無鎖資料結構。需要注意的是,CleanUpNode 演算法在短期內會破壞資料結構的完整性,逐一地改變元素內部引用,這在某些場景中是難以被接收的。例如,編寫MultiCAS模擬,無鎖容器元素中所有連線的原子性應用無法接受這樣的違例。
同險象指標規則相似,頂層的廢棄元素數量有限,而且其物理刪除演算法與險象指標規則的Scan演算法極其相似。最大的不同在於:若採用HP規則的保護機制,即R > N = P * K時,Scan過程必定會刪除一些東西。而HRC規則中Scan過程呼叫,會因為彼此引用而失敗,每個引用就是一個自增的計數器。倘若Scan執行失敗,須呼叫CleanUpAll對此過程進行支援。遍歷所有執行緒的廢棄指標陣列,然後呼叫CleanUpNode過程,促成Scan二次成功呼叫。
libcds庫中的HRC規則實現
libcds庫中的HRC規則實現方式與HP規則相似,主要實現類為cds::gc::HRC,該API與cds::gc::HP API非常相似。HRC規則最大的優勢是可以支援迭代器,但libcds庫中並沒有對其做出實現。主要是開始建庫那會,私以為通用迭代器不適用無鎖容器。前提是不僅物件迭代器引用是安全的,而且整個容器的迭代亦是安全的。然而一般情況下,無鎖資料結構無法進行最後一輪迭代。總有一個併發執行緒試圖刪除迭代器的依賴元素,這就導致無法安全地引用節點欄位。而且,節點因為被HP規則所保護,無法被物理刪除。不僅如此,由於本節點已被移出無鎖容器中,接下來的元素變得難以獲取。
因此,libcds中的HRC規則可以看做是HP規則的特殊實現。比如,新增額外的條件即引用計數器使得HP規則更加複雜。測試結果顯示,HRC容器比HP容器慢好幾倍,還可能面對成熟垃圾回收器常見的高負載。同時,在Scan呼叫期間不能進行刪除操作時,比如,迴圈引用的原因,應啟動CleanUpAll過程遍歷所有廢棄節點。
在libcds庫中,HRC規則作為HP規則的一種變換形式而存在,這就要求我們在構建時必須考慮泛型。由於HRC特殊的內部結構,基於HRC以及基於HP容器的泛型化處理,往往非常有趣。
Pass the Buck 踢皮球
Herlihy和al,致力於無鎖資料結構記憶體回收問題,提出Pass-the-Buck演算法[HLM02, HLM05]。PTB演算法和Michael M的HP規則非常相似,不同的地方在於其具體實現。
同HP規則一樣,PTB規則也需要宣告一個指標保護,類似於HP規則險象指標。初始化PTB規則意味著,提前準備了無數保護,即險象指標。一旦存在相當多的廢棄資料,呼叫Liberate過程,類似於HP規則中的Scan過程,返回一個可以安全刪除的指標陣列。與HP規則不同的是,HRC規則中,廢棄指標陣列為單個執行緒私有,而這些廢棄資料陣列為所有執行緒所共享。
保護即險象指標的結構,不僅包含保護指標,而且包含廢棄資料指標,稱之為傳遞隊友(hand-off)。在刪除的過程中,若Liberate過程發現一些被保護的廢棄資料,會將這些廢棄的記錄放入傳遞隊友的保護槽口中。在下一次Liberate呼叫時,若傳遞隊友的資料連線其保護被改變,則此資料可以被刪除,也意味著此保護指向了其它被保護的資料。
在文章HLM05中,作者為Liberate提供了兩種演算法:非等待和無鎖。非等待需要dwCAS,即基於雙字的CAS,這使得演算法依賴平臺對dwCAS的支援。而無鎖演算法僅在資料發生變化時起作用。倘若在無鎖版本的Liberate兩次呼叫期間,資料、即保護和廢棄指標,保持不變,迴圈就很有可能發生。因為演算法無法刪除所有可能廢棄的資料,不得不更密集地呼叫Liberate。在程式執行的最後階段,資料依然沒有變化,特別是當PTB單例解構函式中的脫離被執行時,或者Liberate被呼叫時。
這個迴圈困擾我很久,為此我決定改變PTB規則的Liberate演算法,並借鑑HP規則演算法。結果,libcds的PTB實現越來越像HP規則的變體,擁有任意數量的險象指標,整個廢棄元素陣列。而容量卻沒有什麼大的影響,“聰明”的HP規則比PTB快很多,但PTB不限保護數量的特性更受人們喜愛。
libcds中的踢皮球規則實現
在libcds庫中PTB規則實現類為cds::gc::PTB,實現細節參見cds::gc::ptb。cds::gc::PTB API和cds::gc:::HP API非常相似,唯一不同的是建構函式引數。建構函式接收的引數如下:
1 PTB( size_t nLiberateThreshold = 1024, size_t nInitialThreadGuardCount = 8 );
- nLiberateThreshold,Liberate呼叫的閾值,一旦廢棄資料的整個陣列達到這個值,便會呼叫Liberate。
- nInitialThreadGuardCount,某一執行緒建立時,初始化時的guard數目,倘若guard不夠用,新的保護會被自動建立出來。
全文總結
本文中,我們集中討論了險象指標規則的記憶體安全回收演算法。HP規則及其各種變體,為我們提供了一種很好的無鎖資料結構記憶體安全控制方式。
本文提到的任何規則,不侷限於無鎖資料結構領域。倘若你僅對libcds感興趣,以下這些操作就夠了,初始化選定的規則,為其關聯相應的執行緒,並將規則類作為GC容器的首個形參。引用保護、Scan()、Liberate()等等的呼叫,容器中均有實現。
還剩一個極其有意思的RCU演算法,此演算法不同於HP型別的規則,我會在接下來的文章中,單獨介紹它。
參考文獻
- [Fra03] Keir Fraser Practical Lock Freedom, 2004; technical report is based on a dissertation submitted September 2003 by K.Fraser for the degree of Doctor of Philosophy to the University of Cambridge, King’s College
- [GPST05] Anders Gidenstam, Marina Papatriantafilou, Hakan Sundell, Philippas Tsigas Practical and Efficient Lock-Free Garbage Collection Based on Reference Counting, Technical Report no. 2005-04 in Computer Science and Engineering at Chalmers University of Technology and Goteborg University, 2005
- [Har01] Timothy Harris A pragmatic implementation of Non-Blocking Linked List, 2001
- [HLM02] M. Herlihy, V. Luchangco, and M. Moir The repeat offender problem: A mechanism for supporting
dynamic-sized lockfree data structures Technical Report TR-2002-112, Sun Microsystems - Laboratories, 2002.
- [HLM05] M.Herlihy, V.Luchangco, P.Martin, and M.Moir Nonblocing Memory Management Support for Dynamic-Sized Data Structure, ACM Transactions on Computer Systems, Vol. 23, No. 2, May 2005, Pages 146–196.
- [Mic02] Maged Michael Safe Memory Reclamation for Dynamic Lock-Free Objects Using Atomic Reads and Writes, 2002
- [Mic03] Maged Michael Hazard Pointers: Safe Memory Reclamation for Lock-Free Objects, 2003
- [MS98] Maged Michael, Michael Scott Simple, Fast and Practical Non-Bloking and Blocking Concurrent Queue Algorithms, 1998
- [Tor08] Johan Torp The parallelism shift and C++’s memory model, chapter 13, 2008
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!
任選一種支付方式