執行人: yuyuan0625
專題解說錄影
Petakuo
"具有零成本的 rcu_read_lock()
和 rcu_read_unlock()
" 能否解釋這句話中零成本指的是甚麼?不占記憶體空間還是花費時間極少?又是怎麼做到的呢?
感謝你的疑問,零成本的用詞不夠精確,我已將敘述修改為低成本。
在 QSBR 中,rcu_read_lock() 和 rcu_read_unlock() 的實現是通過每個 thread 的本地變數來達成的。具體的方式如下:
- Thread-Local Storage (TLS):每個 thread 都有自己的本地變數來追蹤它是否處於 RCU 讀臨界區域內。這些變數存儲在 Thread-Local Storage (TLS) 中。
- 簡單標記:
rcu_read_lock()
和rcu_read_unlock()
操作僅僅是設置或清除這些本地變數。例如:
rcu_read_lock()
只是設置一個本地變數,表示該 thread 進入了讀臨界區域。
rcu_read_unlock()
則清除這個本地變數,表示該 thread 離開了讀臨界區域。
eleanorLYJ
想請問五種 URCU 是如何做 memory reclamination?
我在 Performance of memory reclamation for lockless synchronization 看到 4 種 memory reclamation 策略 (quiescent-state-based reclamation (QSBR), epoch-based recla-mation (EBR), hazard-pointer-based reclamation (HPBR), and lock-free reference counting (LFRC)),想請問 5 種 urcu 中是否使用了這些策略,還是用其他策略?
此 5 種 flavor 都是使用 QSBR 策略來進行 memory reclamation。不同 flavor 的差異主要在於它們實現 quiescent states 和同步的方法。
stevend543
筆記內容有提到 QSBR 需要再最後一個 onQuiescentState
thread 做判別,請問這是如何做到的,需要額外的資料結構維護才有辦法讓所有 thread 對彼此的狀態都是 visiable?
每個 reader 一開始都要透過
urcu_qsbr_register_thread
將 reader 串連在一個全域雙向鏈結串列 registry , 藉由 registry 看每個 reader 的狀態判斷 GP 能否結束
程式碼如下:
cds_list_add(&URCU_TLS(urcu_qsbr_reader).node, ®istry)
是將當前 thread 的 reader 加入到全域的 registry
雙向鏈結串列中。
如此一來全域雙向鏈結串列 registry
就包含了所有已註冊的 readers。藉由檢查這個 registry
,系統可以判斷每個 reader 的狀態,從而決定是否能夠結束 grace period (GP)。
重現 2023 年專題: RCU 研究 實驗並針對近期實作予以討論。
可重用 2023 年專題: RCU 研究 素材,但要清楚解釋其設計和實作考量,中間你可能會遇到若干問題,記錄下來並討論。
RCU (Read-Copy Update) 是一種資料同步機制,在 Linux 核心扮演重要作用。在 What is RCU, Fundamentally? 一文敘述:
RCU achieves scalability improvements by allowing reads to occur concurrently with updates.
RCU 適用於頻繁的讀取 (即多個 readers)、但資料寫入 (即少量的 updaters/writers) 卻不頻繁的情境。例如檔案系統經常需要搜尋特定目錄,但對目錄的修改卻相對少,這就是 RCU 理想應用場景。RCU 最大化讀取操作,犧牲寫入的效能。
下圖即為 RCU 在不同更新需求場景和不同資料一致性下的適用性,圖中藍色區域即為讀多寫少且可以接受資料不一致性的場景,即為 RCU 最適合的場景。反之,紅色區域為寫入頻繁且不允許資料不一致的情形,就不適合使用 RCU。
出處: User-space RCU
RCU 模型包含 3 個重要概念:
rcu_read_lock()
成對程式碼範例:
程式碼範例:
synchronize_rcu()
對應的非同步函式,會在所有已存在的 RCU 讀取操作完成後呼叫指定的函式程式碼範例:
程式碼範例:
Category | Primitive | Purpose |
---|---|---|
Readers | rcu_read_lock() |
Start an RCU read-side critical section. |
rcu_read_unlock() |
End an RCU read-side critical section. | |
rcu_dereference() |
Safely load an RCU-protected pointer. | |
Updaters | synchronize_rcu() |
Wait for all pre-existing RCU read-side critical sections to complete. |
call_rcu() |
Invoke the specified function after all pre-existing RCU read-side critical sections complete. | |
rcu_assign_pointer() |
Safely update an RCU-protected pointer. |
此表格擷取自 Is Parallel Programming Hard, And, If So, What Can You Do About It?
Updater 更新內容後,呼叫指定介面進行發布,reader 呼叫特定介面來讀取已發佈的內容。
由於現代編譯器會對程式碼進行一系列最佳化處理,而 CPU 在執行指令時也會進行重排序。儘管它們的手段不同,目標都是為了改進程式碼的執行效率。然而這些最佳化有時可能會導致非預期的結果。在 RCU 中,由於沒有 lock,因此我們需要自行處理 memory ordering 議題。
考慮以下程式碼:
我們預期程式碼的第 12 到 14 行會在第 15 行之前進行,但因為編譯器最佳化後無法保證程式碼的執行順序,因此有可能在a
, b
, c
變數的數值指派完成之前就已經指派 gp
,後續讀取端執行 do_something_with(p->a, p->b, p->c)
時造成非預期的結果。
你如何得知上方陳述是成立的?用計算機結構的認知來解說。
注意:某些材料存在謬誤,即使是 Linux 核心內附的文件也可能出錯。
搭配 並行程式設計: 執行順序,設計實驗來解說。
TODO
為了解決此問題, Linux 核心提供對 memory barrier 進行包裝的 rcu_assign_pointer()
和 rcu_dereference()
等巨集來確保執行順序。
於是上述程式碼的第 15 行應改為:
而第 18 行應改為:
另外,Linux 核心也基於 rcu_assign_pointer()
和 rcu_dereference()
進行更高層次的包裝, 如 list
和 hlist
。
以下表格顯示了 RCU 的發布和訂閱的 API,以及“取消發布”或撤回的附加API
Category | Publish | Retract | Subscribe |
---|---|---|---|
Pointers | rcu_assign_pointer() | rcu_assign_pointer(…, NULL) | rcu_dereference() |
Lists | list_add_rcu() list_add_tail_rcu() list_replace_rcu() |
list_del_rcu() | list_for_each_entry_rcu() |
Hlists | hlist_add_after_rcu() hlist_add_before_rcu() hlist_add_head_rcu() hlist_replace_rcu() |
hlist_del_rcu() | hlist_for_each_entry_rcu() |
RCU 讀取端的 CS 以 rcu_read_lock()
開始,並且以對應的rcu_read_unlock()
結束。
RCU 使用寬限期的概念,共用資料的處理期間,寫入端執行緒發布共用資料的更新作為寬限期的起點,直到諸多讀取端執行緒得以達成取得更新資料的操作為止。 RCU 必須等待之前的讀取端都已經讀取完成(退出臨界區後)才能對已更變的資料進行回收處理。
維護最近更新物件的多個版本,適用於讀取端執行緒可接受並行地新增和刪除新物件的多個版本
QSBR 的關鍵概念就是決定執行緒的 quiescent state,該狀態和 CS 相對,執行緒一旦離開 CS 就會歸類在 quiescent state。每個 reader 在離開 CS 時記錄自身狀態,writer 會檢查這個狀態,當所有執行緒都都離開 CS 時,就可釋放舊節點所在的記憶體。
識別出靜默狀態,還需要通知狀態資訊給其他執行緒,整個過程可用下圖描述:
圖中有 4 個執行緒,執行緒 1
執行完更新操作後,增添釋放記憶體的 callback
,此時執行緒 2
, 3
, 4
讀取的都是之前的內容。需要等他們執行完成後分別呼叫 onQuiescentState
來表明自身已是靜默狀態,等到最後一個執行緒呼叫 onQuiescentState 時,就可呼叫之前註冊的 callback。實作過程中要考量的點就是如何選擇適合的位置執行 onQuiescentState
,以及如何得知哪個才是最後一個執行onQuiescentState
的執行緒。
URCU 針對不同的應用場景提供五種不同的 RCU flavors:
相關程式碼可見 src/urcu-qsbr.c、include/urcu/static/urcu-qsbr.h
類似於“經典”的 RCU 實作:
rcu_read_lock()
和 rcu_read_unlock()
定期呼叫 rcu_quiescent_state()
:
qsbr
flavor 在reader 結束 CS 之後並不會像其他 4 種 flavor 一樣會自動進入 quiescent state,因此 grace period 會比較長rcu_quiescent_state()
,讓 reader 進入 quiescent state,類似於 kernel 中必須定期呼叫的 schedule()
執行緒註冊和取消註冊:
rcu_register_thread()
rcu_unregister_thread()
性能優勢:
除了「搬運」既有的素材,難道你不會好奇,QSBR 何以有更好的性能?又,既然有 QSBR,為何要探討 EBR 呢?
相關程式碼可見 src/urcu.c、include/urcu/static/urcu-mb.h
類似於早期的可搶佔 RCU 實現:
-rt patchset
中的可搶佔 RCU 實作rcu_read_lock()
和 rcu_read_unlock()
包含 memory barrier (但不包含重量級的 atomics 指令)讀取端成本較高
不需要定期呼叫
rcu_quiescent_state()
,因此能用於函式庫中實作上 mb
、signal
、MEMBARRIER
類似,後面兩者即是對讀取端的 memory barrier 進行改善
相關程式碼可見 src/urcu.c、include/urcu/static/urcu-bp.h
rcu_register_thread()
/ rcu_unregister_thread()
rcu_register_thread()
,進一步增加了成本rcu_read_lock()
和 rcu_read_unlock()
中移除 memory barriers,因此擁有最接近 qsbr flavor 的讀取端效能synchronize_rcu()
用於取代讀取端的 memory barriersPhase 1 of deprecating liburcu-signal
Complete removal of urcu-signal flavor
簡單來說,Signal-based RCU 減少了primitives 的成本,但需要使用者放棄一個 POSIX 訊號,並且所有執行緒在使用 RCU 之前必須註冊。
sys_membarrier()
system call (qsbr-memb)相關程式碼可見 src/urcu.c
、include/urcu/static/urcu-memb.h
sys_membarrier()
系統呼叫取代 Signal-based RCU 使用的 POSIX 訊號,而 sys_membarrier()
因為可以只通知正在執行的執行緒,所以相較於 POSIX 訊號更輕量化簡單來說,memb flavor 透過使用處理器間中斷而非 POSIX 信號減少對系統的負擔,同時只中斷當前運行的執行緒來進一步優化性能。
rcu_init
:
rcu_sys_membarrier_init
: 檢查作業系統核心是否支援 sys_membarrier
,沒有則退回 mb flavorsmp_mb_master
: 若 kernel 支援 sys_membarrier
, writer 透過它將 reader 的 compiler barrier 提升成 memory barrierurcu_memb_smp_mb_slave
: 若 kernel 支援 sys_membarrier
, 將 memory barrier 換成 compiler barrier除了「搬運」,你能說出個別 RCU flavor 對應到哪些真實世界的應用場景嗎?
qsbr
版本: 若使用者非常在意效能且能清楚知道讀取端的每個執行緒如何進入 quiescent state 以避免寫入端被 block 太久或寬限期無法結束。
反之則適合使用另外三種 flavor,使用者就可以不用考慮何時讓 reader 進入 quiescent state
mb
版本: 適合於無法讓出 POSIX 信號或不支援 membarrier 且需要最佳寫入端效能的情況signal
版本: 適合於可以讓出 POSIX 信號且最在意讀取端效能的情況memb
版本: 適合於支援 membarrier 並希望平衡讀取端和寫入端效能的情況還是沒解釋「真實世界」中的應用,例如 Linux 核心和 LTT-ng 怎麼運用?
不要刪除這些標注的訊息,除非你獲得充分的檢閱。
本表格擷取自 User-space RCU
比較不同 flavor 的讀寫效能,解讀其表現。注意:近期程式碼可能會有不同的表現,而且你可能會遇到某些程式碼無法運作,試圖修正,並貢獻回 Userspace RCU 專案。
其中 ./bootstrap
需要使用 Autoconf
和 libtool
另外雖然官方教學沒有寫,但make install
和 ldconfig
都需要利用 sudo
提高權限才能成功執行。
使用 tests/benchmark 目錄下的測試檔來比較以上 4 種 (signal flavor 已棄用,故不討論)flavor 的讀寫效能。
下表為 6 reader, 2 writer, 1 second 的結果
6 reader/ 2 writer | read | write |
---|---|---|
qsbr | 1735352485 | 235117 |
signal | - | - |
memb | 138286044 | 754509 |
mb | 136154230 | 721500 |
bp | 673107223 | 122460 |
可以觀察到 bp flavor 的 read 次數比 memb 高, 推測是因為 bp flavor 的 write 次數較少,因此產生的 cache-line exchanges 也較少,所以 reader 受到 cache 的效能影響降低。
至於 bp flavor write 次數較少的原因是因為在 urcu_bp_synchronize_rcu
要將 signal block ,來確保 signal safe,透過 perf 可以發現它佔整體的 90%,產生的 overhead 非常高。
解釋其設計考量,要能充分對應到你的實驗結果。
以第一手的材料為主,特別是 Paul E. McKenney 博士撰寫的論文和技術報告。
好的,因為此網頁有出現在Linux 核心設計: RCU 同步機制的延伸閱讀中,所以學生才會也納入參考。會再多參考 Paul E. McKenney 博士的第一手材料!