兩年多以前隨手寫了點與 lock free 相關的筆記:1,2,3,4,質量都不是很高其實(讀者見諒),但兩年來陸陸續續竟也有些閱讀量了(可見劍走偏鋒的技巧是多容易吸引眼球)。筆記當中在解決內存釋放和 ABA 問題時提到了 Hazard Pointer 這個東西,有兩三個讀者來信問這是什么,讓詳細講一下,我想了想,反正以前在看這東西的時候也記了些東西,干脆整理一下發出來。
前面寫的那幾篇筆記都來源於 Maged Michael 的學術論文,Hazard pointer 也是他的創想,academic paper 的特點之一就是經常有些美好的假設,關於 hazard pointer 也同樣如此,以下的討論均假設內存模型是 sequential consistent 的,否則還是問題多多。
核心問題
Hazard Pointer(以下簡稱為 HP) 要解決的核心問題是怎樣安全地釋放內存,該問題的解決在實現無鎖算法時有兩個關鍵的影響:
- 保證了關鍵節點的訪問是合法的,不會導致程序嘗試去讀取已經釋放了的內存。
- 保證了 ABA 問題不會出現,程序邏輯正確的前提。
這兩個問題在寫無鎖代碼時基本是無法避免的,走這條路終會遇上,多少人因此費盡心力窮盡技巧各種花樣,只為把這問題徹底解決。HP 就是這眾多花樣各種技巧中的一種,它的做法以我的愚見也不是很完美,但實現上比較簡單,不依賴具體系統,也不對硬件有特殊要求(當然 CAS 操作還是要的),從效果上看也湊和,因此無論怎樣是值得參考學習的。
具體實現
在無鎖算法中釋放內存之所以難,主要原因在於,當一個線程准備釋放一塊內存時,它無法知道是否另有別的線程也同時持有該塊內存的指針並需要訪問,因此解決這個難點的一個直接想法就是,在每個線程獲取了一個關鍵內存的指針后,該線程將設置一個標志,表明"我正在操作這個關鍵數據,你們誰都別給我隨便就釋放了"。當然,這個標志需要放在一個公共區域,使得任何線程都可以去讀。當另一個線程想要釋放一塊內存時,它就去把每個線程的標志都看一下,看看是否有別的線程也在操作這塊內存,從而決定是否馬上釋放該內存:如果有別的線程在操作該內存,則暫時不釋放,等下次。具體實現如下:
- 建立一個全局數組 HP hp[N],數組中的元素為指針,稱為 Hazard pointer,數組的大小為線程的數目,即每個線程擁有一個 HP。
- 約定每個線程只能修改自己的 HP,而不允許修改別的線程的 HP,但可以去讀別的線程的 HP 值。
- 當線程嘗試去訪問一個關鍵數據節點時,它得先把該節點的指針賦給自己的 HP,即告訴別人不要釋放這個節點。
- 每個線程維護一個私有鏈表(free list),當該線程准備釋放一個節點時,把該節點放入自己的鏈表中,當鏈表數目達到一個設定數目 R 后,遍歷該鏈表把能釋放的節點通通釋放。
- 當一個線程要釋放某個節點時,它需要檢查全局的 HP 數組,確定如果沒有任何一個線程的 HP 值與當前節點的指針相同,則釋放之,否則不釋放,仍舊把該節點放回自己的鏈表中。
HP 算法主要用在實現無鎖的隊列上,因此前面的具體步驟其實基於以下幾個假設:
- 隊列上的元素任何時候,只可能被其中一個線程成功地從隊列上取下來,因此每個線程的 free list 中的元素肯定是唯一的。
- 線程在操作無鎖隊列時,任何時候基本只需要處理一個節點,因此每個線程只需要一個 HP 就夠了,如果有特殊需求,當然 HP 的數目也可以相應擴展。
- 對於某個節點來說,多個線程同時持有該節點的指針這個現象,在時間上是非常短暫有限的,只有當這幾個線程同時嘗試去取下該節點,它們才可能同時持有該節點的指針,一旦某個線程成功地將節點取下,其它線程很快就會發現,並嘗試繼續去操作下一下節點,而后續再來取節點的線程則不再可能獲得已經不在無瑣隊列上的節點的指針,因此:當某個線程嘗試去檢查其它線程的 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,吶吶,看這里。