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

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

前面寫的那幾篇筆記都來源於 Maged Michael 的學術論文,Hazard pointer 也是他的創想,academic paper 的特色之一就是常常有些美好的假設,關於 hazard pointer 也一樣如此,如下的討論均假設內存模型是 sequential consistent 的,不然仍是問題多多。node

核心問題

Hazard Pointer(如下簡稱爲 HP) 要解決的核心問題是怎樣安全地釋放內存,該問題的解決在實現無鎖算法時有兩個關鍵的影響:c++

  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,吶吶,看這裏

相關文章
相關標籤/搜索