執行人: Shiang1212
專題解說錄影
allenliao666
"最後,再執行 free_pool 將 retire list 裡的所有節點 free 掉。"這句話中英夾雜,應修正為正體中文。
Shiang1212
已修改,感謝建議。
Petakuo
"首先會判斷該節點是否能被 free 且不在任何消費者的 hazard pointer 裡的話" 這句話不太通順,能再描述清楚一點嗎?
Shiang1212
原本打算直翻程式碼的意思,沒想到會不便於閱讀,已修改,感謝建議。
weihsinyeh
問題 1 : 執行時間中的 hazard pointer 的時間測量是在 lfq_dequeue_tid
中加入 usleep(10)
並等待free_pool
節點數量到達 20 個以上才釋放,即在「提出效能改進方案中的情況二」測量出來的實驗結果嗎?
Shiang1212
不是,是沒有加usleep
的版本,但確實可以測量一下「提出效能改進方案中的情況二」的執行時間。
問題 2 : 延續問題 1 ,因為如果是用原本的程式碼,依據仍可改進的地方的描述:「但該何時釋放 retire list 裡的節點呢?」,這個意思是指從程式開始執行到程式結束,retire list
節點中的節點都不會被釋放嗎?
Shiang1212
第十週測驗的測驗二中,每次讀取端結束共享資料的存取時,就會執行一次safe_free
釋放 retire list 中節點記憶體。原本的描述確實容易讓人混淆,已修改,感謝建議。
Ackerman666
我覺得這句話能再嚴謹一點,應是寫入端修改資料完畢後,一直到等待讀取端結束存取舊資料,之間所經歷的時間為寬限期。
如果讀取端存取共享資料的同時,寫入端也想修改這份資料的話,就會進入寬限期 (Grace Period)
Shiang1212
已修改,感謝建議。
yuyuan0625
想請問為何 hazard pointer 相較於 RCU 會對於並行佇列能有更好的效能呢,是因為其回收機制嗎?
Shiang1212
從執行結果來看,當生產者多、消費者多的情況下,RCU 的執行時間比 hazard pointer 短,我認為 RCU 的回收機制確實比 hazard pointer 好,畢竟省去了將節點丟進 retire list 等待釋放的這個步驟。但實際上為何 RCU 的表現比 hazard pointer 好,可能還要進行相關實驗才能驗證。
'線程運行結束前使用', thread 是「執行緒」而非線程。參見: https://hackmd.io/@sysprog/it-vocabulary
Shiang1212
已修改,感謝建議。
從並行佇列的實作出發,探討 hazard pointer 和 RCU 對其整合的影響,評估執行效益,過程中參照經典論文,並予以落實,最終體會 Linux 核心相關的設計考量。
重做第 10 週測驗的測驗 2 (lfq: 精簡的並行佇列),包含延伸問題,務必探究其理論分析。
比較 hazard pointer 和 RCU 對其整合的影響,評估執行效益。
在並行程式設計中,假設今天執行緒 A 正在存取某個共享的記憶體內容,但同時執行緒 B 也正在修改這份共享資料,那這個情況下,可能就會導致執行緒 A 資訊不同步,或是共享資料的記憶體已被釋放等等狀況,因而導致程式運行錯誤,例如 dangling pointer。因此,本專題採用 hazard pointer 以及 RCU 同步機制來解決上述問題,並針對兩者的性能進行評估。
hazard pointer 是一種可以控管共享資料的記憶體修改與釋放的方法。其基本的概念是允許共享資料可以被一個寫入端修改而多個讀取端存取,當存在讀取端正在存取該共享資料時,hazard pointer 會將其標註,於是寫入端可得知此訊息並延遲對其釋放。
根據 〈Lock-Free Data Structures with Hazard Pointers〉 這篇論文提出的架構,在 hazard pointer 架構中,每個執行緒需要維護的兩個關鍵集合:
此外,hazard pointer 架構會在特定條件下 (請見呼叫 free_pool 的時機) 檢查 retire list,計算執行緒的 retire list 與其他執行緒的 hazard pointer 之差集,找出實際上能釋放的共享資料並真正釋放其記憶體。
因此 hazard pointer 架構能安全的釋放記憶體的基本想法就是:
而第十週測驗的測驗二對 hazard pointer 的實作做了改良,透過一個全域的管理者,僅使用指標的指標紀錄每個執行緒正在使用的共享資料,與一個 retire list 紀錄所有執行緒要求釋放的共享資料。
main thread 使用 lfq_init
初始化 handler ctx
,用來管理上述的 hazard pointer 與 retire list。前者使用指標的指標 HP
來紀錄每個讀取端正在使用的節點。後者使用 fph
和 fpt
將每個寫入端要求釋放的節點串起來形成一個鏈結串列,等某個特定時機再一次釋放。再按照輸入的參數產生 MAX_PRODUCER
個生產者以及 MAX_CONSUMER
個消費者。
其中,在 ctx
使用 head
和 tail
指標維護用來存放資料的佇列,生產者執行 lfq_enqueue
將東西放進佇列,消費者執行 lfq_dequeue_tid
將東西從佇列移除。
在消費者執行 lfq_dequeue_tid
時,最後會使用 safe_free
釋放節點的記憶體,此時必須考慮到是否還有其他 thread 正在使用該節點,這裡採用 hazard pointer 解決存取共用記憶體可能導致的問題。使用 ctx->HP
紀錄每個消費者正在使用的指標,讓每個 thread 在執行 safe_free
釋放節點記憶體前,先透過 ctx->HP
檢查是否還有其他 thread 正在存取該節點,藉此避免不小心釋放掉別人正在使用的節點,導致程式運行發生錯誤。
在使用 safe_free
釋放節點記憶體時,會先判斷是否能釋放該節點的記憶體,如果可以,就直接釋放;如果不行,就執行 insert_pool
將該節點放進 retire list,等待後續呼叫 free_pool
再一口氣釋放 retire list 裡所有節點的記憶體。
等到生產者結束生產,消費者也結束運行了,程式最後會執行 lfq_release
將還存在佇列裡的節點、retire list 裡的節點、ctx
本身的記憶體全部釋放。
lfq_node
lock-free queue node 的結構,也就是佇列裡的基本元素,包含資料、是否能釋放,以及一個 union 裝著指向下一個元素的指標 next
與 指向 retire list 的下一個節點的指標 free_next
。
next
,將節點串連起來形成佇列free_next
,將每個消費者要求釋放的節點串連起來形成一個鏈結串列lfq_ctx
lock-free queue 的 handler,用來儲存佇列及 retire list 的頭尾,及 tid 的 map,還有個 bool 型態的變數用來確認是否正在執行釋放,此外,也儲存 hazard pointers 和他的最大長度。
head
/ tail
:指向 queue 中的首個 / 末個元素HP
:使用指標的指標,紀錄每個消費者的 hazard pointerfph
/ fpt
:retire list 的首個 / 末個元素示意圖:
使用 Graphviz 或其他向量製圖語法來繪製。
lfq_node
透過 next
與其他節點串連起來,形成佇列free_next
將該節點串在 ctx->fpt
後面,形成一條 retire listlfq_enqueue
生產者透過 lfq_enqueue
將資料 push 進佇列中。
宣告欲新增的節點內容 insert_head
,讓 ctx->tail
指向這個節點,並將 old_tail
的 next
指向 insert_head
。
lfq_dequeue_tid
消費者透過 lfq_dequeue_tid
將資料從佇列中 pop 掉。
從佇列的前端讀取一個節點 old_head
,並存入 ctx->HP[tid]
用來告知其他 thread 該節點目前有人使用。再用 ctx_head
指向 new_head
(也就是 old_head->next
),最後將 ctx_HP[tid]
重新設為 0,再用 safe_free
釋放 old_head
的記憶體。
safe_free
lfq_dequeue_tid
裡使用 safe_free
嘗試釋放記憶體。首先判斷該節點是否存在 hazard pointer 裡,如果是,就代表有其他執行緒正在存取這個節點,執行 insert_pool
將該節點串連進 retire list 等待後續釋放;如果不是,則代表可以毫無顧慮的使用 free
,將該節點的記憶體釋放。最後,執行 free_pool
將 retire list 裡的所有節點的記憶體釋放掉。
其中,可以看到呼叫 safe_free
嘗試釋放節點時,「每次」都會執行 free_pool
掃描一次 retire list 裡的節點並將其釋放,這樣做能確保被要求釋放的節點能很快獲得釋放,但過於頻繁的呼叫 safe_free
也有可能導致效能下降(明明 retire list 是空的但卻呼叫 free_pool
,白呼叫了!)。
避免只是張貼程式碼而沒有對等的解說和討論。HackMD 不該是張貼完整程式碼的地方,GitHub 才是。
改進你的漢語表達。
insert_pool
& free_pool
insert_pool
將尚有人使用的節點存進 retire list,以待後續使用 free_pool
釋放 retire list 裡節點的記憶體。
其中,程式碼第 16 行的 if
判斷,我們使用這個判斷式推斷這個節點是否能正常釋放,如果判斷通過(也就是該節點現在不能被釋放),就會用 break
跳出迴圈,結束 retire list 的掃描與釋放,但這樣貿然結束掃描會導致 retire list 中的節點進行無謂的等待。
考慮到以下例子:retire list 中有 7 個節點,其中節點 3 被 hazard pointer 標注。
這個情況下呼叫 free_pool
的話,free_pool
釋放完節點 1 和節點 2 的記憶體後,在判斷節點 3 是否能被釋放時,會因為該節點當前被 hazard pointer 標注而停止掃描,導致節點 3 後面的節點無法被釋放。
lfq_init
& lfq_release
lfq_init
將 lfq_ctx
初始化,lfq_release
將還存在佇列裡的節點、retire list 裡的節點、ctx 本身的記憶體全部釋放。
評估上述程式碼的正確性 (correctness) 和資料處理吞吐量 (throughput)
lfq_dequeue_tid
可以看到,每次讀取端結束共享資料的存取時,會呼叫 safe_free
將 retire list 裡的節點記憶體釋放掉。但過於頻繁的呼叫 safe_free
,可能會導致程式運行效率下降,所以該何時釋放 retire list 裡的節點比較好呢?接下來這裡使用 RCU (Read-Copy-Update) 機制來實現 lock-free queue 的實作,RCU 同步機制是另一種 lock-free 程式設計演算法和記憶體回收機制,透過其特有的「寬限期」,確保所有執行緒能正確且安全的使用共享記憶體內容。
RCU 同步機制於 2002 年新增至 Linux 核心,RCU 允許讀取與更新同時進行,提高並行程式的 Scalability。適合使用在多讀取少寫入,且對資料沒有 strong consistency 需求的場景。
讀取端紀錄讀取資料的開始與結束,以便寫入端能夠確定該資料可以被安全釋放的時機。一旦所有讀取端都傳送讀取結束的訊號給寫入端時,此時寫入端就能安全的釋放該資料的記憶體,在不使用鎖的情況下達成執行緒的同步。
在 RCU 架構下,寫入端打算修改資料時,會需要等待所有讀取端結束讀取,才能對其修改,而從寫入端打算修改資料,直到等待讀取端結束存取該資料,之間所經歷的時間為寬限期 (Grace Period)。在寬限期,寫入端會先將要寫入的資料修改在副本裡,等到所有讀取端結束存取時,再將用副本將共享資料覆蓋過去,這樣就可以在確保讀取端運行正確的同時,達成寫入端的安全修改。相比於 hazard pointer,使用 RCU 進行實作可以精確的得知何時能安全的釋放共享資料。
這裡使用 Userspace RCU library 來實作,執行前請先確認系統已完成下載。
這份程式碼參考 sample code,如同 hazard pointer 版本,改用 RCU 進行 lock-free queue 的實作。main thread 使用 urcu_memb_register_thread
初始化一個 userspace RCU ,然後按照輸入的參數產生 MAX_PRODUCER
個生產者以及 MAX_CONSUMER
個消費者,並宣告佇列的開頭 queue
。
其中,在 cds_lfq_queue_rcu
裡面存放受 RCU 保護的節點 node
,透過 node
裡的 cds_lfq_node_rcu
將節點串成一個佇列。
所有打算存取受 RCU 保護資料的 thread,都要先使用 urcu_memb_register_thread
將該 thread 註冊到 RCU 中,這樣這個 thread 就可以使用 RCU 的讀取和更新操作。運行結束前再用 urcu_memb_unregister_thread
註銷該 thread。
生產者透過 cds_lfq_queue_rcu
將資料 push 進入佇列,消費者使用 cds_lfq_dequeue_rcu
將資料從佇列移除且釋放該資料的記憶體。而兩者在執行前後都要使用 urcu_memb_read_lock
和 urcu_memb_read_unlock
聲明該執行緒已進入臨界區間的開始與結束,以確保受 RCU 保護的資料的一致性。
最後,main thread 使用 urcu_memb_unregister_thread
註銷 RCU。
選擇 Userspace RCU 裡頭 flavor 的考慮因素是什麼?針對並行佇列,該怎麼選擇?
待完成:聽完 yeh-sudo 的報告,有五種 flavor。
這份程式碼的讀寫行為接近 1:1
node
佇列中的基本元素,value
用來儲存資料,next
用來指向佇列中的下一個元素。
cds_lfq_node_rcu
cds_lfq_queue_rcu
示意圖:
urcu_memb_register_thread
& urcu_memb_unregister_thread
在 RCU 同步機制下,打算存取受 RCU 保護的資料的 thread,需要使用 urcu_memb_register_thread
向 RCU 註冊,當前 thread 被註冊之後,該 thread 就能夠安全地進行 RCU 讀取操作,並且 RCU 會追蹤這個 thread 的狀態,以便在進行更新操作時確保資料的一致性。
如下方範例,thread 使用 urcu_memb_register_thread
向 RCU 註冊自己的 thread,執行緒運行結束前使用 urcu_memb_unregister_thread
向 RCU 註銷。
urcu_memb_read_lock
& urcu_memb_read_unlock
除了向 RCU 註冊自己的 thread,在存取 RCU 保護的資料前,還需要使用 urcu_memb_read_lock
以及 urcu_memb_read_unlock
告知 RCU 該 thread 進行讀取操作的時間區段,以便資料能在安全的時機被修改。
如下方範例,在進入 critical section 前使用 urcu_memb_read_lock
,在離開 critical section 前使用 urcu_memb_read_unlock
。
cds_lfq_enqueue_rcu
& cds_lfq_dequeue_rcu
生產者使用 cds_lfq_enqueue_rcu
將資料 push 進佇列,消費者使用 cds_lfq_denqueue_rcu
將資料從佇列中 pop 掉。兩者在使用前都要先用 urcu_memb_read_lock
以及 urcu_memb_read_unlock
告知 RCU,確保已進入 critical section 才能繼續執行。其他細節請見 urcu/static/rculfqueue.h。
採用第 12 週測驗 3 提到的效能分析方法,量化實作表現 (without RCU vs. with RCU)。
務必確認執行結果的驗證,善用 ThreadSanitizer 一類的工具
編譯:
hazard pointer | RCU |
---|---|
117647 | 108108 |
hazard pointer | RCU |
---|---|
833333 | 909090 |
hazard pointer | RCU |
---|---|
1538461 | 2352941 |
以上述的結果來看,可以看到當生產者與消費者的數量小,hazard pointer 在執行時間與記憶體需求上的表現勝過 RCU,這是因為 hazard pointer 的架構較簡單輕便,不必嚴格紀錄每個執行緒的讀取時段的開端與結束,在執行緒數量小的情況能有比較好的效能。
隨著生產者與消費者的數量越來越大,RCU 在執行時間與記憶體需求上的表現逐漸勝過 hazard pointer,這是因為當執行緒數量上升,共享資料的存取情況會更加複雜,而在 RCU 架構下,透過寬限期我們可以更精確的得知何時能釋放共享記憶體,減少節點等待釋放的時間,進而降低執行時間與程式對於記憶體的需求。
使用 Valgrind 查看記憶體釋放狀況:producer:2、consumer:10
可以看到所有從 heap 請求配置的記憶體空間都成功釋放掉了。
可以看到程式配置了 216 個 block,直到程式結束運行,仍有 3 個 block 沒有被釋放,造成記憶體的浪費。我觀察程式碼,發現原來宣告的佇列的開頭 queue
沒有釋放,於是我做了以下改動:
結果:
相比原本,又多釋放一個 block,但仍然有兩個 block 還沒釋放。
使 ThreadSanitizer 工具檢測 hazard pointer 與 RCU 兩個實作中,是否存在 data race。
一開始執行時會跳出錯誤訊息:
後來把 ASLR 關掉,就可以正常運行了。
hazard pointer 與 RCU 皆正常執行,沒有跳出錯誤訊息
後續使用 Helgrind 進行測試,檢測結果顯示有 data race 的情況發生。
free_pool
的時機前面提到,hazard pointer 機制會將 thread 要求釋放的節點存放於 retire list 裡,等待後續使用 free_pool
釋放。那麼,使用 free_pool
的時機是什麼呢?
很直觀地,我們會希望 retire list 裡的節點個數超過某個 threshold 時,再呼叫 free_pool
來清除所有節點的記憶體。為此我們需要先實作如何計算 retire list 裡的節點個數:增加下面兩行程式來追蹤 retire list 的節點個數。
為了配合實驗,我在 lfq_dequeue_tid
中加入 usleep(10)
,延長消費者存取該節點的時間,讓該節點被紀錄在 ctx->HP[tid]
的時間久一點,這樣做也變相讓 safe_free
在執行時,比較傾向將待釋放的節點放進 retire list,而不是直接被釋放掉。
接下來,我修改 safe_free
函式,讓他可以依照 retire list 裡的節點個數來決定要不要執行 free_pool
。
使用 ThreadSanitizer 檢測修改過後的程式,發現加了這兩行判斷會導致 data race。
最後,分別比較 free_pool
在以下時機使用的效能:
safe_free
都執行一次 free_pool
safe_free
裡執行 free_pool
,等待程式運行結束才呼叫。可以看到,情況一所需要的記憶體是最少的,因為執行 free_pool
的頻率最高,被要求釋放的節點很快就能獲得釋放,但也因為 free_pool
使用頻率最高,當 free_pool
執行成本很大時,這個情況下的程式運行成本也會隨之上升。反過來說,雖然情況三所需要的記憶體是最多的,但當 free_pool
執行成本很大時,這個情況下的程式運行成本反而不會那麼大。
而在〈Lock-Free Data Structures with Hazard Pointers〉提到:
A good choice for is , where is the number of hazard pointers and is some small positive constant, say . In that case, each scan is guaranteed to delete node.
選擇一個比讀取端個數還大的值作為 threshold,能有效提高程式運行效率。假設今天有 100 個讀取端執行緒,如論文範例,將 threshold 設為 125。假設所有執行緒的 hazard pointer 各自為互斥集且其元素皆存在於 retire list,那此時呼叫 safe_free
,就能確保每次都有 25 個共享資料能獲得釋放。這樣的設計在減少 safe_free
呼叫次數的同時,也提高 safe_free
的使用效率。
換言之,你需要引入符合應用場景的實驗,分類探討。