那個什麼都不付的讀者
第四篇留給你兩種方式來回答那個回收問題——什麼時候釋放一個可能還有人在讀的節點才安全?危險指標讓每個讀者用一次原子儲存宣告它正在碰哪個節點,於是刪除者在釋放前可以掃過那些宣告。基於紀元的回收更便宜:讀者只在進入時碰一下紀元計數器,而一個節點只有在每個讀者都越過了它被解除連結的那個紀元之後才被釋放。RCU——讀-複製-更新——把紀元的想法推到極限,問了一個大膽的問題:讀的那一側能不能在快速路徑上字面意義地不花任何代價?沒有原子、沒有 CAS、連一次儲存都沒有?
在經典的核心設計中,那個驚人的答案是:能。要進入一段 RCU 讀者端臨界區,你呼叫 rcu_read_lock();要離開它,你呼叫 rcu_read_unlock()。儘管名字裡有 lock,在最初的不可搶佔核心建置中,這兩者展開成的是完全不碰共享記憶體的東西——它們只在這顆 CPU 上停用搶佔,那是一個便宜的每-CPU 旗標,不是鎖、也不是原子操作。在這段區間裡,一個讀者跟著指標走、讀取資料,沒有爭用、沒有快取行來回彈跳、不必等任何人。讀者不阻塞寫者,寫者也不阻塞讀者。整個不對稱是刻意的:RCU 是為「讀得極頻繁、寫得極稀少」的資料而生——路由表、組態、核心自己的內部串列。
讀、複製、更新——以及寬限期
如果讀者從不上鎖,寫者要怎麼改任何東西、而不把結構從讀者腳下抽掉?答案就是名字裡那三個字。寫者不在原地修改節點。它讀取目前的節點、做一份私有複本、在複本上編輯,然後透過原子地把單一一個指標從舊節點甩向新節點,來更新結構。因為這一甩是一次對齊的指標儲存,每個讀者看到的要嘛是整個舊節點、要嘛是整個新節點——絕不會是編輯到一半的混合物。在甩動之後抵達的新讀者跟著新指標走;在一瞬之前抓到舊指標的讀者則繼續走那個舊節點,它在記憶體裡仍然完好無缺。
到目前為止,這只是一次小心的指標交換——第二篇的教訓「一次對齊的指標儲存是原子的」,現在被刻意拿來用。深的部分在於回收那個舊節點。寫者已經把它解除連結,但還不能 free() 它:某個在甩動之前就開始的讀者可能仍在走它。RCU 的核心想法是寬限期(grace period)。寫者等到甩動那一刻所有正在活動的讀者都結束了它們的讀者端臨界區為止。一旦它們全都至少呼叫過一次 rcu_read_unlock(),就不可能還有任何執行緒握著指向舊節點的指標——現在還在讀的任何人,一定是在甩動之後才抵達、走在新節點上。只有到這時,free() 才安全。
寫者在讀者完全不發任何信號的情況下,怎麼知道寬限期已經過去?這正是經典設計安靜的天才之處。因為一個不可搶佔的讀者在它的臨界區裡無法被脈絡切換,寫者要等一個寬限期,只需單純地等到每顆 CPU 都通過了一個靜止狀態(quiescent state)——一個可證明它不在任何讀者端區間裡的時刻,例如一次脈絡切換、一段閒置迴圈、或一次返回使用者空間。觀察到每顆 CPU 各排程一次,你就知道之前所有的讀者都已排空。核心呼叫 synchronize_rcu() 會把寫者阻塞到這件事發生為止;而 call_rcu() 則改為登記一個回呼,稍後再釋放節點,於是寫者根本完全不阻塞。
WRITER READER (lock-free, no atomics) ------------------------------------ -------------------------------- new = copy_of(old); rcu_read_lock(); // just disables preempt edit(new); p = rcu_dereference(shared); rcu_assign_pointer(shared, new); use(*p); // walks old OR new node // ^ one atomic pointer swing rcu_read_unlock(); synchronize_rcu(); // wait grace free(old); // now safe -- no reader can still hold 'old'
藏在解參考裡的屏障
那個讀者端有一個微妙之處,曾讓真實的核心開發者付出真實的臭蟲代價,而它直接連回你上一級學的記憶體順序。寫者在甩動指標之前填好新節點的欄位。但讀者載入指標、然後解參考它去讀那些欄位。如果編譯器或 CPU 任何一個讓讀者在新節點的欄位變得可見之前就先看見新指標,讀者就會走進一個初始化到一半的節點——一堆垃圾。單獨一次樸素的指標儲存本身,並不保證寫者較早的那些欄位寫入會先變得可見。
這就是為什麼這個 API 有兩個成對的巨集,看起來像樸素的指標存取,其實不是。寫者端的 rcu_assign_pointer() 是一次釋放(release)儲存:它強制寫者所有的欄位初始化在指標本身之前變得可見。讀者端的 rcu_dereference() 是對應的消費(consume)/取得(acquire)載入:它保證一旦讀者看見了新指標,它也看見寫者在甩動之前發布的一切。兩者一起建起那個讓發布安全的釋放-取得關係——正是記憶體模型那一級的「先行發生」機制,只是換上了 RCU 的外衣。省略它們,你的程式碼會在硬體排序很強的 x86 上通過每一項測試,然後在它跑上 ARM 的那天毀壞記憶體。
為什麼把這件事做對如此之難
現在退後一步、俯瞰整這一級。你有了一個有條件的原子寫入、一個重試迴圈、ABA 陷阱、三四種回收方案、以及 RCU 的寬限期。每一塊單獨拿來看都可以理解。那為什麼這仍是一個連世界級工程師都會把微妙臭蟲送進正式系統、而新的無鎖結構至今仍能當成可發表研究的領域?誠實的答案是:困難不在任何單獨一塊——它是組合性的。正確性必須在每個執行緒、在每一個指令邊界上、每一種可能的交錯之間都成立,而那個空間大得像天文數字。
具體而言,好幾層的非確定性疊在彼此之上。排程器可以在任兩道指令之間搶佔任何執行緒。CPU 可以在它的記憶體模型容許的範圍內重排載入與儲存。編譯器可以在「彷彿(as-if)規則」下重排、熔合、或刪除操作,而且被允許假設未定義行為永不發生——資料競爭就是未定義行為,所以最佳化器可以合法地做出讓一個有競爭的程式表現得毫無道理的事。一個臭蟲可能需要一個精確的三方時序巧合,十億次執行才出現一次,這意味著它順利通過你的測試套件、在正式環境裡存活數個月,然後在某個流量尖峰的凌晨三點毀掉一個資料庫。在這裡,你無法像對循序程式碼那樣靠測試測到放心。
再回想第一篇那兩條軸,因為困難住在它們的乘積裡,不是它們的和裡。線性化要求你釘下一個單一瞬間——線性化點——讓每個操作彷彿在那一刻生效,並證明那個瞬間在所有交錯下都一致;而回收工作要求你同時證明:絕不會有任何節點在仍可達時被釋放,一樣是在所有交錯下。一個修好了進展故事的改動,可能悄悄弄壞線性化的論證、或開啟一個 ABA 視窗、或把某個 free() 挪早了一道指令。這些片段並不獨立,所以你不能一次只推理一塊——這恰恰就是為什麼整體比它的各部分難上太多。
專家實際上是怎麼應對的
面對一個大到測不完的空間,靠這個吃飯的人倚靠的是工具與紀律,而不是聰明。他們對所有東西都跑執行緒淨化器(ThreadSanitizer),把隱形的資料競爭變成大聲的失敗。他們用模型檢查器與壓力工具——CDSChecker、Rust 裡的 Loom、核心自己的 RCU torture 測試——系統性地探索極大量的交錯,包括那些殘酷到模糊測試要花上好幾年才偶然撞上的。對於真正要緊的結構,他們動用形式化證明與機器化驗證,因為對這些演算法來說,一份紙上證明是最低門檻,不是奢侈品。這一切都不是可有可無的潤飾;它是入場的代價。
不過,最重要的紀律,是知道什麼時候根本不該做這件事。無鎖程式設計只替你買到一樣東西——第一篇那條進展保證——而它讓你付出巨大的代價:設計時間、審查時間,以及一個潛伏競爭一輩子的風險。對絕大多數程式而言,一把擺對位置的互斥鎖正確、夠快、而且推理起來容易得多;那不是雄心的失敗,那是好的工程。正確的做法通常是減少共享、縮小臨界區、或動用一個專家已經驗證過的、久經沙場的函式庫結構——而不是趕著截止日手刻你自己的 Michael-Scott 佇列。
這就是這一級誠實的收尾。你現在理解的是真正的機械——進展保證、CAS 與它的 ABA 陷阱、無鎖堆疊與佇列與環形緩衝區、危險指標與紀元、RCU 與它的寬限期——而不只是名字。正是這份理解讓你能讀懂這些程式碼、能對一份臭蟲報告進行推理、能認出同事那個聰明的無鎖點子其實悄悄壞了。也正是它讓你能在完全清楚取捨的情況下,做出最明智的一步:絕大多數時候,伸手去拿那把互斥鎖,把無鎖的機械留給那個罕見的、它那唯一一條保證真正值得它極高代價的地方。