那個過去由鎖替你回答的問題
到了第三篇,你已經能在從不持有鎖的情況下,從 Treiber 堆疊彈出一個節點、或從 Michael-Scott 佇列出列。CAS 把頭指標越過那個節點甩過去,就結構而言這個節點已經不見了。於是你對它呼叫 free()——而地板就在這裡塌了。在鎖之下這根本不是個問題:當你持有互斥鎖時沒有人能在結構裡面,所以一旦你解開一個節點的連結,它就可證明地歸你銷毀。把鎖移走,那個證明就蒸發了。
把這幅畫一格一格看。執行緒 A 正要彈出。它讀取 head、拿到節點 N,然後——就在讀指標與讀 N 的 next 欄位之間——排程器把它暫停了。執行緒 B 此時彈出 N、把 head 越過它甩過去、並呼叫 free(N)。配置器把那塊記憶體交還給系統、或回收給別的東西用。執行緒 A 醒來時握著一個指向 N 的指標,並對它解參考以讀取 N 的 next。那次解參考是教科書級的釋放後使用:指標底下的位元組已經不是那個節點,而是垃圾、或別人的物件、或一觸碰就引發區段錯誤的未映射記憶體。
這就是記憶體回收問題,而請注意它正是我們在第二篇 ABA 問題底下找到的同一個根源:一個節點被釋放、它的記憶體被重用,而另一個執行緒卻仍握著指進它的指標。深層的困難在於:沒有一個中央的地方能讓你問「還有人在看 N 嗎?」只有執行緒自己知道它們在讀哪些節點,而且每個都只知道自己的。所以整個安全回收的把戲就是:讓每個執行緒公佈足夠多關於它正在讀什麼的資訊,使得一個想釋放某節點的執行緒能證明沒有人在碰它。兩個經典設計的差別只在於公佈什麼、以及粗到什麼程度。
危險指標:宣告你即將碰觸什麼
第一個答案爽快地直接。在一個執行緒對某個節點解參考之前,它大聲宣告「我即將要用這一個——先別釋放它」。那個宣告就是一個危險指標:一個單寫者、多讀者的槽位,由一個執行緒擁有,那個執行緒在裡面公佈它目前正在保護的節點的位址。每個執行緒有少量固定數目的這種槽位——通常一兩個,因為大多數操作在任一時刻也只握著指向一兩個節點的指標。所有執行緒的危險槽位合起來,構成一張全域、可掃描的清單,列出每一個目前禁止被 free() 的位址。
微妙之處在於:公佈與讀取會競爭,所以協定必須分毫不差。你不能只是寫了危險槽位就往前衝——在你寫入與你解參考之間,那個節點可能已經被解開連結並釋放掉了。所以讀取端要跳一支先公佈、再驗證的舞步:把節點的位址存進你的危險槽位,然後重新讀一次來源指標(比方說 head),確認它仍指向同一個節點。如果它變了,那節點可能正在被釋放的途中,於是你放掉它、重試。只有在槽位已設好而且指標已重新確認之後,解參考才安全。這個重新檢查就是整個正確性論證,而且很容易做錯。
// READER: protect a node before touching it (one hazard slot 'hp')
for (;;) {
node *n = atomic_load(&head); // 1. read the pointer
atomic_store(&hp, n); // 2. publish: "protecting n"
if (atomic_load(&head) == n) // 3. re-check it didn't move
break; // confirmed safe to use n
// else: n may be getting freed; loop and try again
}
// ... safe to dereference n here ...
atomic_store(&hp, NULL); // done: clear the slot
// RETIRER: thread that unlinked a node
retire(node *n) {
add_to_my_retired_list(n); // do NOT free yet
if (retired_count >= THRESHOLD) // amortize the scan
scan_and_reclaim(); // free only nodes in NO hazard slot
}現在看另一半。一個解開節點連結的執行緒不會立刻釋放它。它把節點放進一張私有的已退役清單,然後走開。週期性地——當它的已退役清單長過一個門檻時,藉此把成本攤平——它執行一次掃描:把目前坐在任何執行緒危險槽位裡的每一個位址收集成一個集合,然後對它已退役清單上的每個節點,只有在它的位址不在那個集合裡時才釋放它。仍被標為危險的節點留在退役狀態,下一輪再檢查。因為一個節點只有在沒有任何危險槽位指名它時才會被回收,釋放後使用就在構造上不可能發生。
為什麼危險指標上的記憶體順序不是可有可無
你很容易把這個協定讀成單純的儲存與載入,並假設你寫下的順序就是它們執行的順序。並非如此,而這正是上一級的 取得/釋放順序不再抽象、變成承重結構的地方。讀取端的公佈(對危險槽位的儲存)與重新檢查(對 head 的載入)絕不能被編譯器或 CPU 重排——如果那次重新載入飄到了公佈之上,那麼在某個退役者掃描的瞬間槽位可能仍是空的,於是節點可能被從讀取者腳下釋放掉。對稱地,退役者對危險槽位的掃描必須看見讀取者已公佈的那次寫入。把記憶體屏障弄錯,演算法就會悄無聲息、時有時無地壞在你恰恰沒去測試的那種硬體上。
退一步,誠實地掂量危險指標。代價付在讀取端:每一次受保護的存取都要在熱路徑上、每一次都多做一次原子儲存與一次帶記憶體屏障的載入。對讀取繁重的結構,那份額外開銷是真實且可量測的。好處同樣真實:未回收的(「漂浮」)垃圾量被緊緊地界定住——每執行緒每危險槽位至多一兩個節點——所以記憶體使用維持可預測,而一個停滯或被換下的執行緒只擋住它實際指名的那寥寥幾個節點。危險指標也順帶免費修好了 ABA,因為一個被標為危險的節點不能被釋放、它的位址也不能在你握著它時被回收再利用。
紀元:保護一段時間,而非一個節點
基於紀元的回收把問題翻轉過來。它不問「每個執行緒在保護哪些節點?」,而是問那個較粗、較便宜的問題「還有任何執行緒待在一個開始於現在之前的操作裡嗎?」結構保有一個單一的全域計數器——紀元(epoch)。當一個執行緒開始一次操作時,它進入一個臨界區:它讀取目前的全域紀元,並在自己的槽位裡宣告「我在紀元 e 中活躍」。當它結束時,它把自己標為不活躍。一個已退役的節點被標上它被解開連結時所在的紀元,而它只有在每個執行緒都被觀察到已越過那個紀元之後,才變得可以安全釋放。
推進規則是它的核心。一個試圖取得進展的執行緒檢查:所有活躍的執行緒現在都在目前的紀元 e 嗎(沒有人還落在 e-1)?若是,它就把全域紀元推進到 e+1。關鍵的洞見在於這保證了什麼:一旦每個人都被看到處於紀元 e,就沒有任何執行緒還能握著它在 e-2 那麼早抓到的指標,因為要還在讀它就意味著還活躍於 e-2,而我們剛剛確認沒有人如此。所以在紀元 e-2 退役的節點,如今可證明地碰不到,可以成批釋放。回收落後於當下有界的那麼幾個紀元——通常同時保有三個——而那份落後正是粗粒度的代價。
走一個具體的畫面。全域紀元是 5;執行緒 T1 與 T2 已宣告自己活躍於紀元 5,但 T3 仍活躍於紀元 4——它還沒完成它不久前開始的那個操作。此時沒有執行緒可以推進紀元,因為 T3 落在 4 意味著一個來自紀元 4 的讀取者仍然存活。稍後 T3 完成、把自己標為不活躍,並在它下一次操作時重新進入、宣告紀元 5。現在每個活躍執行緒都在 5 或之後,於是任何執行緒都可以把全域紀元推進到 6——而就在那一瞬間,每一個在紀元 4 退役的節點都可證明地無法觸及(沒有存活的讀取者能握著那麼舊的指標),於是整批一次釋放。
你買到的是一條便宜得多的讀取路徑:進入一個臨界區基本上就是把目前的紀元儲存進你的槽位一次,完全沒有逐節點的記帳——你可以在一個臨界區裡解參考一百個節點,只付一次宣告的代價。這就是為什麼紀元方案(以及它們在 Rust 的 crossbeam、或 Linux 核心的使用者空間 RCU 函式庫裡的表親)在以讀取為主的結構上很受歡迎。誠實的代價是危險指標的鏡像:保護是粗的、以時間為基準的,所以單獨一個永不離開臨界區的停滯執行緒,會把紀元永遠釘住,而退役的記憶體無上界地堆積。危險指標每個卡住的執行緒洩漏寥寥幾個節點;紀元可能洩漏一切。
如何選擇,以及它通往的那個家族
把它們並排放,選擇就變成一個關於你的讀取的問題。危險指標保護個別節點、讓你為每個受保護的指標付一次原子儲存加屏障,但它們緊緊地界定漂浮垃圾、並能優雅地容忍一個停滯的執行緒——當讀取小、記憶體寶貴、或最差情況延遲要緊時選它們。紀元保護一段時間之窗、讓讀取幾乎免費,但回收會落後、而一個卡住的執行緒可能無限期地拖住它——當讀取頻繁、碰許多節點、且你能信任執行緒會迅速離開臨界區時選它們。兩者都不是預設值;每一個都是「讀取成本」對「回收即時性」的刻意取捨。
- 讀取是否很小(只碰一兩個節點),且有界記憶體或最差情況延遲是不是硬性需求?傾向危險指標。
- 讀取是否頻繁且以讀取為主、每次操作走訪許多節點,而執行緒能可靠地很快完成?傾向基於紀元的回收。
- 是否有任何執行緒可能在操作中途阻塞、睡眠、或被長時間搶佔?那對紀元是毒藥(它會把紀元釘死);危險指標的退化要優雅得多。
- 你的工作負載是否壓倒性地以讀取為主、極少寫入,且在已知平台上?那麼下一篇那個專門化的表親——RCU——可能勝過兩者。
最後那一步直指這一級要去的地方。基於紀元的回收是一個更廣大想法的一張臉:讓讀取者暢行無阻、把每一次回收都延後到你能證明所有舊讀取者都已離開為止、並在讀取路徑上幾乎什麼都不付。把這套哲學推到極致——讀取者不拿鎖、不做原子寫入、幾乎完全不宣告自己——你就抵達了讀取-複製-更新(RCU),那個悄悄撐起 Linux 核心大片江山的技術。第五篇把它拆開,接著退後一步,問這整一級誠實的收尾問題:既然這每一個方案都依賴那麼多薄如剃刀的順序安排,為什麼把無鎖程式碼寫對,會是那麼真切而著名地困難?