實現無鎖的棧與佇列(5):Hazard Pointer

twoon發表於2016-03-04

兩年多以前隨手寫了點與 lock free 相關的筆記:1,2,3,4,質量都不是很高其實(讀者見諒),但兩年來陸陸續續竟也有些閱讀量了(可見劍走偏鋒的技巧是多容易吸引眼球)。筆記當中在解決記憶體釋放和 ABA 問題時提到了 Hazard Pointer 這個東西,有兩三個讀者來信問這是什麼,讓詳細講一下,我想了想,反正以前在看這東西的時候也記了些東西,乾脆整理一下發出來。

前面寫的那幾篇筆記都來源於 Maged Michael 的學術論文,Hazard pointer 也是他的創想,academic paper 的特點之一就是經常有些美好的假設,關於 hazard pointer 也同樣如此,以下的討論均假設記憶體模型是 sequential consistent 的,否則還是問題多多。

核心問題

Hazard Pointer(以下簡稱為 HP) 要解決的核心問題是怎樣安全地釋放記憶體,該問題的解決在實現無鎖演算法時有兩個關鍵的影響:

  1. 保證了關鍵節點的訪問是合法的,不會導致程式嘗試去讀取已經釋放了的記憶體。
  2. 保證了 ABA 問題不會出現,程式邏輯正確的前提。

這兩個問題在寫無鎖程式碼時基本是無法避免的,走這條路終會遇上,多少人因此費盡心力窮盡技巧各種花樣,只為把這問題徹底解決。HP 就是這眾多花樣各種技巧中的一種,它的做法以我的愚見也不是很完美,但實現上比較簡單,不依賴具體系統,也不對硬體有特殊要求(當然 CAS 操作還是要的),從效果上看也湊和,因此無論怎樣是值得參考學習的。

具體實現

在無鎖演算法中釋放記憶體之所以難,主要原因在於,當一個執行緒準備釋放一塊記憶體時,它無法知道是否另有別的執行緒也同時持有該塊記憶體的指標並需要訪問,因此解決這個難點的一個直接想法就是,在每個執行緒獲取了一個關鍵記憶體的指標後,該執行緒將設定一個標誌,表明"我正在操作這個關鍵資料,你們誰都別給我隨便就釋放了"。當然,這個標誌需要放在一個公共區域,使得任何執行緒都可以去讀。當另一個執行緒想要釋放一塊記憶體時,它就去把每個執行緒的標誌都看一下,看看是否有別的執行緒也在操作這塊記憶體,從而決定是否馬上釋放該記憶體:如果有別的執行緒在操作該記憶體,則暫時不釋放,等下次。具體實現如下:

  1. 建立一個全域性陣列 HP hp[N],陣列中的元素為指標,稱為 Hazard pointer,陣列的大小為執行緒的數目,即每個執行緒擁有一個 HP。
  2. 約定每個執行緒只能修改自己的 HP,而不允許修改別的執行緒的 HP,但可以去讀別的執行緒的 HP 值。
  3. 當執行緒嘗試去訪問一個關鍵資料節點時,它得先把該節點的指標賦給自己的 HP,即告訴別人不要釋放這個節點。
  4. 每個執行緒維護一個私有連結串列(free list),當該執行緒準備釋放一個節點時,把該節點放入自己的連結串列中,當連結串列數目達到一個設定數目 R 後,遍歷該連結串列把能釋放的節點通通釋放。
  5. 當一個執行緒要釋放某個節點時,它需要檢查全域性的 HP 陣列,確定如果沒有任何一個執行緒的 HP 值與當前節點的指標相同,則釋放之,否則不釋放,仍舊把該節點放回自己的連結串列中。

HP 演算法主要用在實現無鎖的佇列上,因此前面的具體步驟其實基於以下幾個假設:

  1. 佇列上的元素任何時候,只可能被其中一個執行緒成功地從佇列上取下來,因此每個執行緒的 free list 中的元素肯定是唯一的。
  2. 執行緒在操作無鎖佇列時,任何時候基本只需要處理一個節點,因此每個執行緒只需要一個 HP 就夠了,如果有特殊需求,當然 HP 的數目也可以相應擴充套件。
  3. 對於某個節點來說,多個執行緒同時持有該節點的指標這個現象,在時間上是非常短暫有限的,只有當這幾個執行緒同時嘗試去取下該節點,它們才可能同時持有該節點的指標,一旦某個執行緒成功地將節點取下,其它執行緒很快就會發現,並嘗試繼續去操作下一下節點,而後續再來取節點的執行緒則不再可能獲得已經不在無瑣佇列上的節點的指標,因此:當某個執行緒嘗試去檢查其它執行緒的 HP 時,它只需要將 HP 陣列遍歷一遍就夠了,不用擔心各執行緒 HP 值的變化。

以下為我從論文裡翻譯過來的虛擬碼,入佇列的函式不涉及刪除節點因此不會操作 HP,難點都在處理出佇列的函式上:

using hp_t = void*;

hp_t hp[N] = {0};

// 以下為佇列的頭指標。
node_t* top;

data_t* Pop()
{
   node_t* t = null;
   while (true)
   {
      t = top;
      if (t == null) break;

      // 設定當前執行緒的 HP
      hp[this_thread] = t;

      // 以下這步是必須的,確認了當前 HP 在 t 被釋放前已經被設定到當前執行緒的 HP 中。
      if (t != top) continue;

      node_t* next = t->next;
      if (CAS(&top, t, next)) break;
   }

   // 已經不再持有任何節點,需將自己的 HP 設為空.
   hp[this_thread] = null;

   if (t == null) return null

   data_t* data = t->data;
   
   // 嘗試釋放節點
   DeleteNode(t);

   return data;
}

以上是出佇列的程式碼,顯然,所做的事情非常直白:執行緒拿到一個節點後將資料取出,並嘗試釋放節點。釋放節點是另一個關鍵點,具體實現參看如下虛擬碼:

thread_local vector<hp_t> free_list;

void DeleteNode(node_t* t)
{
   free_list.push_back(t);
   if (free_list.size() > R) FreeNode();
}

void FreeNode()
{
  vector<hp_t> hp_list;
  hp_list.reserve(N);

  // 獲取所有執行緒的 HP,如非空則儲存到 hp_list 中。
  for (int i = 0; i < N; ++i)
  {
    if (hp[i] == null) continue;

    hp_list.push_back(hp[i]);
  }

  std::sort(hp_list);

  vector<hp_t> not_free;  
  not_free.reserve(free_list.size());

  // 把當前執行緒的 free_list 遍歷遂一進行釋放。
  for (int i = 0;i < free_list.size(); ++i)
  {
    if (std::binary_search(hp_list.begin(), hp_list.end(), free_list[i]))
    {
      // 某個執行緒持有當前節點,故不能刪除,還是保留在佇列裡。
      not_free.push_back(free_list[i]);
      continue;
    }

    // 確認沒有任何執行緒持有該節點,刪除之。
    delete free_list[i];
  }

  free_list.swap(not_free);
}

存在的問題

看到這裡相信讀者對 Hazard Pointer 的原理已經大概瞭解了,那麼我們來簡單總結一下上面的實現。

首先是效率問題,它夠快嗎?根據前面的虛擬碼,顯然影響效率的關鍵點在FreeNode()這個函式上,該函式有一個雙重迴圈,但還好第二重迴圈用了二分查詢,因此刪除 R 個節點總的時間效率理論上是 O(R*logN),R 可以設定, N 是執行緒數目,通常也不會太大,因此均攤下來應該還好?我只能說不知道,看使用場景吧,用無瑣一般有很高的效率需求,這裡加個這樣複雜度的處理是否會比加瑣更快呢?也說不準,實現上覆雜了是肯定的,想用的話得好好測試測試看看劃不划得來。

其次是易用性,HP 釋放節點是累進位制的,只有當一個執行緒積累到了一定數量的節點才批量進行釋放,而生產環境裡通常情況複雜,會不會某個執行緒積累了幾個節點後,就不再去佇列裡 pop 資料了呢?豈不是總有些節點不能釋放?心裡有些疙瘩。。除此,現代作業系統裡執行緒建立銷燬其實很頻繁,某個執行緒如果要退出了,還得記得把自己頭上的節點釋放一下,也是件麻煩事。有人可能會覺得為什麼刪除節點時要把節點放到佇列裡再刪?多此一舉!直接遍歷 HP 陣列直到沒有執行緒持有該節點不就好了 --- 放到佇列裡其實是為效率,否則每 pop 一次就遍歷一遍 HP list,而且搞不好還要反覆等待某個執行緒釋放節點,均攤下來效率太低。

最後,還有一個問題,相信讀者忍了很久了,HP 陣列那裡,各個執行緒怎麼 index 進去取出自己的 HP 呢? thread id 嗎?那這個陣列不得很大很大很大?

一點改進

關於 HP 陣列的實現上,作者其實也看到了問題,提出可以用 list 來管理 HP,因為不是每個執行緒都必須固定分配一個 HP,事實上只有當該執行緒正在進行 pop 操作的時候它才需要,pop 完了馬上就可以把 HP 還回去了,因此陣列可以用連結串列來替換,當然這個連結串列也得是 Lock free 的,但這個連結串列可以不用考慮回收和釋放實現上容易多了,和我在本系列文章的第四篇裡提到的思路是一致的。

但這樣用 List 來代替陣列在一定程度也增加了效率負擔,因為每個執行緒取出 HP 變得更慢了(首先是很容易引起多個執行緒衝突,其次用到了 CAS 以及函式呼叫的開銷),當然具體有多少效率損失還得看使用場景,需要好好測量一下---寫無瑣程式碼不能少做的事情。

無瑣程式設計很難,但這並不代表它們因此只能是理論遊戲,Maged Michael 的無瑣系列文章啟發了很多人,這其中也包括 c++ 裡的大腕 Andrei Alexandrescu,吶吶,看這裡

相關文章