vatt'ghern jaskier's ballads
本文 8 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

寫一個 doubly-linked list 在 Rust 從來不是「鏈結」的問題——它是把 pin、provenance、aliasing 三條互相獨立的不變式同時投影到同一個 &mut Node 上的問題。iddqd 這篇 post 不是在介紹一個 crate,它在公開展示:為什麼安全的那條路要走過 UnsafePinnedNonNull,而 &mut 連用都不能用。

iddqd 把 unsafe Rust 最尖角的部分照出來——intrusive linked list 裡 pinning、provenance、aliasing 同時被踩到

iddqd 是 Oxide 開源的一組 indexed-map 集合(IdHashMapIdOrdMapBiHashMapTriHashMap),其中部分變體底層走 intrusive 結構——把鏈結資訊塞進使用者的 value 本身、而不是另外配置 node。同一篇 post 公開的工程細節是個非常難得的入門:要把這條路走得 sound,必須同時滿足四條互相正交的不變式——Pin 的 drop guarantee 與 projection 規則、pointer provenance 在 Stacked Borrows / Tree Borrows 下的範圍、no-mutable-aliasing 對 &mut 的禁令、以及 ownership handoff(Rc / Arc / OccupiedEntry)的時序合約。任何一條斷掉,編譯都會通過,Miri 才會在 CI 把它叫出來。

關開四條 invariant,看 naive intrusive list 各踩到哪一個 UB · 4 toggles × 4 結果

↑ soundness invariants 全關 全開 0 / 4 圖示——四個 axis 互相獨立、不可加減交換 綠點 = sound, 紅點 = naive code 已 UB

verdict

全部關掉:四條都沒守住

這是大多數「我自己寫一個 intrusive list」開頭的狀態——把 next: *mut Nodeprev: *mut Node 塞進 value,insertremove 都用 raw pointer,看起來 work,Miri 會在第一個 iter_mut 上直接報 encountered dangling reference。要把 unsafe 寫對,這四個 checkbox 必須同時亮。

四條不變式互相正交——沒有「先補一條再補另一條」的順序,缺一條就有對應的 Miri 錯誤。iddqd 的 API(UnsafePinnedNonNull<Node>OccupiedEntryArc handoff)就是為了同時把四條釘死。

四條不變式互相正交——沒有「先補一條再補另一條」的順序,缺一條就有對應的 Miri 錯誤

四條不變式同時滿足才 sound:Pin 保位址、Provenance 保 tag、Aliasing 禁重疊可寫借用、Ownership 收口合約。

這篇 deep-dive 的目的不是把 iddqd 抄一遍——它在 GitHub 上、原 blog 也把 reasoning 寫得很清楚。值得整理的是:四條不變式為什麼是四條,它們各自管哪一段語意、彼此在編譯期跟執行期的關係是什麼、為什麼 safe Rust 的 std::collections::LinkedList 走另外一條路、以及 iddqd 在 API 表面做的具體選擇(為什麼 entry 是 Pin<&mut T>、為什麼 internal pointer 全用 NonNull 而不是 *mut、為什麼 ownership boundary 必須過 Arc 才能把不變式收口)。對任何打算寫 unsafe Rust 的工程師,這四條是 minimal 不變式集合——比這少,sound 的證明就缺一段。

下面五個小節分別把「資料佈局」、「Pin」、「Provenance」、「Aliasing」、「Ownership handoff」展開。順序刻意對應「先看 node 長什麼樣 → 它在記憶體裡為什麼不能搬 → 每個 pointer 帶的 tag 從哪來 → 同一塊記憶體為什麼不能有兩個 &mut → 外部 API 怎麼把這些不變式包成型別 contract」。每一節結尾都標出對應的 Miri 錯誤名跟 RFC / core::pin 文件條款。

讀這篇 post 不需要先精通 Rust unsafe——但需要知道幾個 baseline 詞彙:UB(Undefined Behavior)是編譯器在這個 program 點上不再保證行為的條件、optimizer 可以做任何事;Miri 是 Rust 官方的 interpretive runtime,逐 instruction 跑你的 code、檢查每個記憶體 access 是否符合 Rust 形式語意;RFC 是 Rust 語言演進的官方提案文件(如 RFC 1233 對 leak 與 destructor、RFC 2025 對 two-phase borrow)。文章主軸就是「四條 invariant 各自被哪個 RFC 釘下來、Miri 在哪個錯誤碼上把違反抓出來、iddqd 用什麼 type 機制保證 invariant」——非常工程化、可重複的論述結構。

intrusive linked list 在記憶體裡長什麼樣

先講 intrusive:std::collections::LinkedList<T> 的 node 是 struct Node<T> { prev: *mut Node<T>, next: *mut Node<T>, value: T }——每個 node 自己 box 在堆上,value: T 是 node 的 inline field。caller 對 list push_back(t),list 自己呼叫 Box::new 配一塊 node、把 t move 進去;caller 拿到的 &mut T 是借自 list 內部的 node。node 跟 value 在同一塊配置裡,但 value 是 node 的私人欄位。intrusive 反過來:value 本來就帶有 prev / next 欄位、list 不額外配置 node——list 就是「拿著 value 的兩個鏈結指標的那條鏈」。OS kernel 寫法常見:struct task_struct 內含一個 list_head children,整個 task list 就用每個 task 自己的 children 串起來。

切換 safe vs intrusive 兩種 node 佈局,看 link 跟 value 的位置關係 · 1 toggle

safe LinkedList——node 是中介,T 是 node 內部欄位 caller 端 T (caller 的 value) payload bytes Box::new(T) → Node Node<T> (heap) prev: *mut Node next: *mut Node value: T (moved in) caller 的 T 被 move 到 node 內部 所有權交給 list, list 配 node → next node node owns value
切到 intrusive 那一邊:node 不存在,prev / next 直接是 T 的 field——iddqd 走的是這條路、所以才得自己背 pin 跟 provenance 的不變式,LinkedList 不需要。

切到 intrusive 那一邊:node 不存在,prev / next 直接是 T 的 field——iddqd …

intrusive list 把 prev/next 直接塞進 T 本身,省掉 Node 配置,代價是要自己釘住四條 unsafe 不變式。

這個差別不是「優化」——它把整個不變式集合改寫。LinkedList 的 node 在 list 內部、caller 拿不到 node 的 raw pointer,list 自己決定何時 deref、何時 mutate;caller 看到的只有 front() / back() / iter_mut() 這些借出 &T / &mut T 的方法,borrow checker 把 alias、lifetime、Drop 順序全在編譯期就釘死。intrusive list 的 link 在 T 自己身上、T 由 caller 持有或 map 持有,list 要 traverse 必須 deref T 才看得到 next——這個 deref 跟「caller 仍然可能持有 &mut T」之間沒有編譯期 barrier,於是 alias、move、drop guarantee、provenance 全部變成 runtime invariant,必須用 PinUnsafePinnedNonNull 這套組合在 type 層手動表達。

維度(link metadata 從哪來) safe LinkedList<T> intrusive list<T>
node 容器 獨立的 Node<T>value: T 被 move 進去 沒有 Node,link 直接是 T 自己的 field
link pointer prev / next: *mut Node prev / next: NonNull<T>
ownership list 配 Node、list owns Node;caller 失去 T caller / Arc / map 持有 T;list 只記頭尾 + entry pointers
borrow borrow checker 看得到所有 borrow;&mut T 從 list 借出、lifetime 跟 list 綁 link mutation 透過 raw pointer;&mut T 跟 raw pointer 必須對齊(= pin + provenance + alias)

互動圖表

safe LinkedList 由 list 配 Node、T move 進去;intrusive 讓 T 自帶 prev/next、list 只記頭尾。

iddqd 走的是這條困難路——把 IdHashMapIdOrdMap 設計成「map 內部不額外配置 node、value 自己就帶足夠資訊讓 map 做 indexed lookup」。這個設計選擇值不值得,跟 throughput 的 trade-off 是真實的:每次 lookup 少一次間接、cache 行為更好、insert 不需要先 Box——但 sound 的代價是 author 必須親手寫死那四條不變式。下面四節分別處理它們。

多補一個視角:intrusive 跟 non-intrusive 在「ownership 邊界」這條 axis 上也分歧。std::LinkedList<T>push_back 接受 T、value move 進 list、caller 失去原本的 T;要拿回去要 pop_back()。intrusive list 反過來——caller 持續持有 value(或者 Arc<T> 共享),list 只記住「value 在哪裡」這個鏈結資訊。這個差別對某些 workload 很重要:value 本身可能參與多個 collection、或者被多個 thread 用 Arc 共享、或者本身就是 caller 邏輯的長生 entity(比方說 task scheduler 的 task struct 同時在 ready queue 跟 sleep timer queue 上)。non-intrusive 對這些 use case 強制 caller 把 value 複製成多份;intrusive 則是「同一個 value 同時被多個 list 串起來」。代價就是這四條不變式必須收口在 type layer。

同時這也解釋了為什麼 Linux kernel 從第一版 list.h(1993)開始就走 intrusive——kernel 沒有 malloc 在 hot path 的選項、每個 task_struct / inode / socket 都同時在好幾個 list 上、ownership 由更上層(process tree、filesystem mount)管理。Rust 想把同樣的能力帶進語言生態、又不放棄 sound 證明,於是有 iddqd、有 pin-init、有 linked_list_allocator 這條工具鏈。它們都不是替代 std::collections,而是補上 std::collections 不能進入的領域。

Pin:drop guarantee 與 projection 規則

第一條不變式是 Pin<P>位址穩定性。intrusive list 的 next / prev 是指向「在記憶體中某個位置的 T」的 pointer——如果 T 被 move 走(Vec re-alloc、mem::swap、return-by-value),舊的 raw pointer 變 dangling。std::pin 的 docs 把這條合約寫得很死:「From the moment a value is pinned by constructing a Pinning pointer to it, that value must remain valid at that same address in memory, until its drop handler is called.

在型別層做到這件事的標記是 PhantomPinned——把它放進 struct,編譯器就不會自動帶 Unpin。沒有 Unpin 的型別在 Pin<&mut T> 下無法走 mem::replace / mem::swap——這兩個方法都接受 &mut T 而非 Pin<&mut T>Pin<&mut T> 不 deref 出 &mut T,於是 safe Rust 沒辦法 move 一個 pinned 不 Unpin 的 value。要拿到底層的 &mut T 必須走 Pin::get_unchecked_mut——signature 的 unchecked 就是「你保證你不會用它來 move」。

use std::pin::Pin;
use std::marker::PhantomPinned;
use std::ptr::NonNull;

// iddqd 風格:intrusive node, 自我引用的 prev/next 都指向另一個 pinned Node
pub struct Node<T> {
    pub value: T,
    prev: Option<NonNull<Node<T>>>,
    next: Option<NonNull<Node<T>>>,
    // 一旦 pin 住, 就不能 move——caller 不能 mem::swap 兩個 node
    _pin: PhantomPinned,
}

impl<T> Node<T> {
    pub fn new(value: T) -> Pin<Box<Self>> {
        // 走 Pin<Box<_>>: heap 配置、pin 保證 box 不會被搬到別處
        Box::pin(Node { value, prev: None, next: None, _pin: PhantomPinned })
    }
}

Pin 解的是「位址不變」這一段、不解 borrow。但它還帶一條更隱晦的合約叫 drop guarantee:pinned value 在底下記憶體被釋放或重用之前,它的 Drop::drop 必須被執行過一次。這條合約是 intrusive 資料結構的命根——因為 list 的頭尾 pointer 仍然指向 self,如果 selfstd::mem::forget 跳過 destructor、底下的 buffer 被回收給別人,list 端的 NonNull 立刻 dangling。Pin 透過「不給 &mut T,只給 Pin<&mut T>」這個型別 trick 讓 ManuallyDropBox::leak 都無法繞過 destructor——這是「pin 保證 drop」的具體執行機制。

另一條 pin 的 subtlety 是 projection。當你拿到 Pin<&mut Node<T>>、想要操作內部欄位,node.valuenode.next 各自應該是 pinned 還是 unpinned?core::pin 文件分成 structural pinning(projection 也 pinned)跟 non-structural pinning(projection 給 &mut Field)兩種——選哪個是型別作者的 design choice,但選一次就鎖死,這個欄位整個 type 的生命週期都要遵守。iddqd 對 value: T 選 structural(外部拿到的是 Pin<&mut T>,因為 T 自己可能也是 self-referential),對 prev / next: Option<NonNull> 選 non-structural(內部 mutate 鏈結欄位走 get_unchecked_mut)。這個 split 必須在 type doc 寫死、不能改。實驗中的 UnsafePinned<T> 是個更強的 escape hatch:&mut UnsafePinned<T> 明確保證唯一、&UnsafePinned<T> 可能指向 mutated 資料——它把 UnsafeCell 的效果擴展到「同時繞過 alias 跟 mutation 兩條保證」,給的是 intrusive 資料結構真正需要的型別語意。

// Pin 在編譯期表達兩條規則

// 規則 1:location-stable
Pin<&mut T>  --no deref to-->  &mut T
        // 沒有公開 deref → mem::swap / mem::replace 不能 call
        // (除非 T: Unpin, 那就退化成普通的 &mut T)

// 規則 2:drop-before-storage-reuse
struct Node<T> { _pin: PhantomPinned, ... }
        // !Unpin → Pin 把 ManuallyDrop / Box::leak 都關掉
        // 也就是 caller 不可能跳過 destructor

// 規則 3:projection 的選擇是 type-wide 合約
field: T   // 選 structural → Pin<&mut T> 出來的 field 也 pinned
field: T   // 選 non-structural → 出來的是 &mut Field, 但這條鎖死整個 type
        // 不能在不同 method 對同一個 field 用不同 projection

Miri 在這條 invariant 上會拋的錯誤名是 encountered dangling referencestorage of pinned value reused。前者是 self-referential pointer 失效,後者是 drop 沒跑就 dealloc。iddqd 對外不直接暴露 &mut T——entry API(OccupiedEntry<'a, T>)拿到的是 Pin<&mut T>,內部需要 &mut 修鏈結欄位的地方走 get_unchecked_mut 並在 SAFETY 註解寫死「I am not moving this」。把這條 invariant 拆成 type 跟 macro 兩層:type 層保證位址不變、SAFETY 註解保證每個 unsafe block 都標出對 invariant 的依賴。

關於 drop guarantee 還有一個常被忽略的 corner case:std::mem::forget。在 safe Rust 裡,forget(x)合法的——它跳過 destructor、把 ownership 「丟到外太空」。對普通的型別這沒事,記憶體 leak 而已。但對 pinned 不 Unpin 的 self-referential 結構,forget 配合 ManuallyDrop 或 stack-allocated pin!() 就可能讓底下 storage 被重用而 destructor 沒跑——這就是「drop guarantee 比 leak 還重要」這條 RFC 1233 的核心結論。RFC 把 leak 跟 destructor-skip 分開:leak 是允許的,但 storage 重用而沒先 drop 是 UB。Pin 的設計恰恰是把「destructor-skip + storage-reuse」這條 path 從 safe Rust 移除——因為 Pin<&mut T> 不 deref 出 &mut TManuallyDrop::takemem::swap 都得不到指針,就無法跳過 destructor 又讓 storage 重用。iddqd 對外只給 Pin<&mut T>Arc<T> 的這條 design choice,背後支撐就是這條 RFC。

UnsafePinned<T> 是個值得多談一段的近期工具。stable 通道目前沒有它——它在 nightly 跑、是 core::cell::UnsafeCell 的「pinned 版本」。UnsafeCell 解的是「interior mutability:透過 &T 也能 mutate」這條合約,原本 Rust 形式語意保證 &T 看到的內容在 borrow 期間不變,UnsafeCell 對編譯器宣告「這個區段不要假設」。UnsafePinned 解的是「&mut T 唯一性」這條合約——原本 &mut T 保證沒有其他 alias,UnsafePinned 對編譯器宣告「這個區段可能有 alias,請勿走 noalias 優化路徑」。對 intrusive collection 是必需:list 內部要把 NonNull<Node> deref 成 &mut Node 修鏈結欄位,同時可能還有別的 NonNull<Node> 在 borrow tree 上活著——這個並存從形式語意上看就是 alias。UnsafePinned 把這條 alias escape hatch 收進 type 而非 ad-hoc unsafe block,讓 sound 證明可以走「UnsafePinned 區段內部可以 alias、外部 wrapper 保證 alias 在 caller 視角不可見」的兩段論證。

Provenance:pointer 的 borrow tag 從哪來

第二條不變式叫 pointer provenance,是 Rust unsafe code guidelines WG 過去三年才慢慢釘下來的概念。最易懂的講法:每個 pointer 不只是「指到記憶體某個 byte」這麼簡單,它還帶著一個 borrow tag,標記這個 pointer 是從哪一次 borrow 衍生來的。同一個地址、不同 tag 的兩個 pointer,不能用一個 deref 另一個——這是 Stacked Borrows 跟 Tree Borrows 兩個 Rust 形式語意模型共同的設計。iddqd 在 internal 全用 NonNull<Node<T>> 而不是 *mut Node<T>,原因之一是 NonNull 的 niche optimization、原因之二是它跟編譯器約定了「這個 pointer 永遠非 null」——但更深的原因是 borrow tag 的 retag 行為:把 NonNull&mut T cast 出來再 cast 回去時,Stacked / Tree Borrows 都會在 retag 點記錄 tag 來源,這讓 Miri 能追蹤 borrow tree。

拖動分割線比較 Stacked Borrows vs Tree Borrows 在同段 code 上的判讀 · drag

Stacked Borrows(2019)

每個 location 維護一個 borrow stack——push 新的 borrow tag 到 top、pop 任何更老的 tag 都會讓它失效。Retag 點:&mut / & 開出新 borrow、as *mut cast 開出 raw tag。

  • 每次 access 必須有「我的 tag 還在 stack 上」這個條件
  • raw pointer 可以共存於 stack,但 &mut 一 pop,下面所有 tag 失效
  • 嚴格:拒絕一些實際上 sound 的 unsafe 寫法
  • 錯誤名:encountered a dangling SbTag

Tree Borrows(2023)

tag 變成而非 stack——parent borrow 生 child borrow,child 失效不會 invalidate parent。state 有 Reserved / Active / Frozen / Disabled 四種。&mut 落地是 Reserved,第一次 write 才轉 Active。

  • 更寬鬆,接受 two-phase borrow 跟 raw pointer 共存的真實 pattern
  • protector 把 &mut 函式參數凍住到 call 結束
  • Active 對 Active 仍然是 UB——alias 沒寬鬆
  • 錯誤名:two phase borrowed mutably / protector violation

互動圖表

Stacked Borrows 用 borrow stack,Tree Borrows 改用 tree,兩者都禁 Active 對 Active alias。

同一段 let p = &mut node.value as *mut T; 在兩個模型下會走不同的判定路徑——iddqd 的 CI 把兩個模型都跑過。

Stacked Borrows 的核心隱喻是 borrow stack:每個記憶體位置維護一個 borrow tag 的 stack,&mut 借出 push 一個 tag、cast 成 *mut push 一個 raw tag、subsequent access 必須能在 stack 上找到自己的 tag。Tree Borrows 把這條改成 tree——同一個 borrow 可以衍生多個 child borrow、child 失效不會把 parent 一起 invalidate,能容忍更多實務上 sound 的 unsafe pattern。兩個模型對「&mut 之間有 alias」這條共同 UB——重疊的 mutable borrow 永遠不行——但對「&mut + raw pointer 並存」的判讀就分歧:Stacked Borrows 對 raw 比較寬、對 retag 比較嚴;Tree Borrows 引入 Reserved 狀態(&mut 才 borrow、還沒 write)允許 raw pointer 並存讀,Active 狀態(已經 write)才禁止其他 alias。iddqd 的測試在 CI 跑 Miri 在兩個模型下都通過——「Testing runs under both Stacked and Tree Borrows」。一條 unsafe code 只有在兩個模型下都 sound,才不會被未來語言演進破壞。

把這條 dual-model 政策的後果列出來:

  • SB-only sound, TB UB:很少見、通常代表 SB 的某條規則跟 Rust ref-counting 語意對不上、TB 比較貼近原本意圖。修法是改寫 unsafe pattern、靠近 TB-friendly 寫法。
  • TB-only sound, SB UB:常見、表示 unsafe code 用了 two-phase borrow 或 raw 跟 &mut 並存讀。修法是把 short-lived &mut 改成更明確的 NonNull retag scope,雙模型都能過。
  • 兩個都 UB:實打實的 bug,不分模型。
  • 兩個都 sound:可 ship。iddqd 的 release gate 就是這條。

對 library author 的實務建議:在 nightly Miri 上加 MIRIFLAGS="-Zmiri-tree-borrows"不加各跑一次測試套件。如果只跑一個模型,就埋了「另一個模型某天可能 fail」的雷。Miri runtime 在大多數 unit test 場景下夠快、額外 CI 成本不到一倍。

// iddqd 風格:所有 internal pointer 走 NonNull<Node<T>>
// 不是因為「null safety」, 而是因為 borrow tag 跟 retag scope

unsafe fn link_after(node: NonNull<Node<T>>, target: NonNull<Node<T>>) {
    // SAFETY: caller 保證
    //   1. 兩個 NonNull 各自由獨立的 retag scope 來
    //   2. 在這個 fn 的呼叫期內,沒有其他 &mut 指向 node 或 target
    //   3. 兩個 node 都被 pin 在 heap (Pin<Box<Node>>),位址不會變
    let n = node.as_ptr();
    let t = target.as_ptr();
    (*t).next.replace(node);     // (*t) 必須能在 borrow tree 找到自己 tag
    (*n).prev.replace(target);
}

provenance 的另一個 subtlety 是 retag scope&mut T cast 成 NonNull<T> 的瞬間,編譯器會 emit 一個 retag 操作——把這個 raw pointer 跟它的 source borrow 連起來、放進當前 scope 的 borrow tree。如果 raw pointer 跨越 function 邊界,它不能沿用 caller 的 tag——必須在 callee 重新走一次 retag。這條規則對 intrusive list 是大事:list.iter_mut() 每次 next 都從 internal NonNull 還原一個 &mut T、把它 lifetime extend 到 iterator scope——「let item = unsafe { std::mem::transmute::<&mut T, 'a mut T>(item) };」這段 lifetime extension 之所以 sound、依賴的就是「iterator 保證連續兩次 next 拿到的 index 不同、所以兩個 &mut 的 retag 各走自己的 scope」這條 ordering 合約。注意 lifetime extension 本身並未騙過 borrow checker,它在 Miri 看來仍是一個 retag 與一次 access;soundness 來自 caller 邏輯保證的「兩個 index 不同」。

當這條 ordering 合約被打破,整個 borrow tree 就崩——這正是 iddqd 在 IdOrdMap 找到的 bug 形態:「an Ord implementation returning Equal spuriously could create duplicate indexes in the B-tree table, violating the invariant that enables safe mutable iteration」。caller 寫的 Ord 是 safe code、可以任意亂寫——但 map 的 internal aliasing 證明依賴「不同 key 有不同 index」這條 invariant。iddqd 的修正策略分兩段:「1. When descending into the B-tree, also check equality against the index」跟「2. If there are no matches, fall back to a linear scan」——一個是把 invariant 從「相信 caller 的 Ord」改成「我自己驗證 index 唯一」、第二段是當 Ord 真的退化(每次都 return Equal),仍然 fallback 到 O(N) linear scan 保 correctness。把信任邊界從 caller code 拉回 library,這是任何 safe-Rust-on-top-of-unsafe API 的核心 design 原則:「Safe Rust code, no matter how pathological or adversarially written, must never cause unsafe code to exhibit undefined behavior.」

Tree Borrows 在 2023 年由 Neven Villani 提出、Ralf Jung 在 blog 系列做形式化。它跟 Stacked Borrows 的核心分歧在狀態機:Stacked Borrows 一個 location 對一個 borrow stack,push/pop 是嚴格 LIFO;Tree Borrows 改成borrow tree,每個 node 有四個 state——Reserved(borrow 已建立但還沒 write)、Active(已 write、要求唯一)、Frozen(shared borrow 期間 read-only)、Disabled(borrow 已失效、不可再 access)。state 轉換對應到實際 Rust 操作:建立 &mut 進入 Reserved、第一次 write 轉 Active、進入 shared scope 轉 Frozen、parent borrow pop 子節點轉 Disabled。這個 state machine 比 stack 更精細、容許 two-phase borrow 在 Reserved 期間跟 raw pointer 共存讀,但放鬆「ActiveActive 的 alias 仍是 UB」這條核心規則。對 intrusive collection 的意義:iddqd 內部 iter_mut 把每個 NonNull 短暫 deref 成 &mut T 的瞬間,那個 &mut 進入 Active——這個瞬間如果有另一個 &mut 也在 Active,UB。iddqd 的證明是「iter_mut 一次只發出一個 Active,下一個 Active 出現時前一個已經被 caller drop」——這個證明靠的是 borrow checker 而非 runtime check。

具體一點——Reserved → Active 的轉換在 Rust source 上對應 two-phase borrow(rfcs/2025)。以下這段 code 在 Stacked Borrows 下會被拒、在 Tree Borrows 下會通過:

fn two_phase_example(v: &mut Vec<i32>) {
    // 形式上, &mut v.len() 是借用 v
    // 但這個借用在 push() 的 receiver eval 完成前是 "Reserved"
    // 同時 1 不 alias 也不 access v, 所以可以 evaluate
    v.push(v.len() as i32);
    //     ^^^^^^^^^^^^^
    //     這個 &v 在 push 開始 evaluation 之前都是 Reserved
    //     一旦 v.push() 對 v 開始 write, &v 轉 Active 同時被 dropped
}

Stacked Borrows 嚴格 LIFO 看不懂這個 pattern——它認為 v.push(...)&mut v 已經 push 到 stack top、後面的 v.len() 找不到 &v 的 tag,拒絕。Tree Borrows 把這個合法 pattern 接受了。這對 iddqd 的意義很實際:iddqd 內部很多 self.tree.iter_mut() 配合 self.len() 的混用 pattern,過去靠 lint suppression 跟 hand-written workaround 才能 sound,Tree Borrows 之後可以 idiomatic 寫。

另一個 Tree Borrows 引入的概念是 protector。當一個 &mut 被當作 fn 參數傳入,編譯器在 callee scope 內加一個 protector——這個 borrow 在整個 fn 執行期間都不能失效,即使它沒被用到。這條規則對應 LLVM 的 noalias 標註:fn 參數的 &mut 在 codegen 時會被標 noalias,告訴 optimizer「整個 fn body 內,沒有其他 pointer 會 alias 這塊記憶體」。protector 在 unsafe code 是個 hidden trap——如果 unsafe 內部把 &mut cast 成 raw pointer 然後 stash 到 global state、fn return 後仍然 deref 那個 raw pointer,protector 已經把那個 borrow 鎖到 fn scope 內、deref 就 UB。iddqd 對所有 internal NonNull 都保持在 method scope 內、不跨 fn 邊界 alias 同一段記憶體,就是為了不踩 protector。

從 Ralf Jung 的 Tree Borrows blog 引一段判斷:「protectors will likely be the main remaining source of unexpected UB from Tree Borrows.」這句話對工程師的含義是——大多數 unsafe pattern 在 TB 下都會比 SB 更寬鬆、但 fn-parameter 的 &mut 仍然嚴格、而且嚴格的程度連有經驗的 unsafe 工程師都會誤判。iddqd 在 internal helper fn 上有意避免接 &mut 參數、改接 NonNull,就是因為 raw pointer 不會被加 protector、unsafe 邏輯能更明確地把 alias scope 框在 caller 端。

Aliasing:no overlapping &mut 對 intrusive 的特殊性

第三條不變式最古老、最廣為人知,也最不直觀:「There must not ever be multiple aliases of &mut references to the same region of memory.」這條規則在 C 跟 C++ 都不存在——C 的 alias model 只管「同一 access 期間」、Rust 管的是「同一 borrow 期間,不論這段時間有沒有 access」。對普通的 Rust 程式這條看不見、因為 borrow checker 編譯期就把所有 overlapping &mut 擋掉了;但對 intrusive list 它就是地雷——iterator 想要拿 &mut T 給 caller、同時還要拿 &mut Node.next 來推進指標——同一塊 heap region 上同時兩個 &mut,UB。

iddqd 處理這條 invariant 的招數借自 Vecsplit_at_mut 把一個 &mut [T] 切成兩半、各自獨立的 &mut,保證兩個 slice 不重疊。對 intrusive list 等價的招數是「以 index 為分隔」——iter_mut 從 B-tree 拿到 sorted index 列、逐個 dispatch 對應的 &mut T。這條 path 之所以 sound,依賴 B-tree 的 invariant「不同 index 對應不同 node」——而這正是上一節 Ord bug 攻擊的同一條 invariant。iddqd 的 iter_mut 內部會做一個 lifetime extension,把短的 internal lifetime 提到跟 iterator 一樣久:

講白話一點——split_at_mut 的型別簽名是 fn split_at_mut(&mut self, mid: usize) -> (&mut [T], &mut [T])。它的 sound 證明依賴 mid <= len 跟「slice 在 memory 上是連續的、可以用 ptr.offset(mid) 算出第二段的起點」這兩條 Vec 自己保證的條件。intrusive list 沒有「memory 連續」這條,但可以用「同一個 index 對應同一個 node」這條 invariant 替代——也就是 B-tree distinct-index property。iddqd 的 iter_mutVec::split_at_mut 在概念上同形,差別只在「切分依據」從 mid: usize 換成「B-tree 上唯一的 entry path」。

// 簡化版的 iter_mut, lifetime 從 closure 內 extend 到 'a
pub fn iter_mut<'a>(&'a mut self) -> IterMut<'a, T> {
    IterMut { inner: self.tree.iter_mut(), _marker: PhantomData }
}

impl<'a, T> Iterator for IterMut<'a, T> {
    type Item = &'a mut T;
    fn next(&mut self) -> Option<Self::Item> {
        let raw = self.inner.next()?;          // 取到下一個 NonNull
        let item: &mut T = unsafe { raw.as_mut() };
        // SAFETY: indexes returned by self.iter are known to all be different,
        // so successive calls to IterMut::next never create mutable aliases to
        // the same memory.
        let item = unsafe {
            std::mem::transmute::<&mut T, &'a mut T>(item)
        };
        Some(item)
    }
}

上面那個 transmute<&mut T, &'a mut T> 看似只在「延長 lifetime」、實際上是把 unsafe 的擔保條件具體化:caller 跟 iterator 之間有一條合約——「你連續呼叫我兩次 next,兩個 return value 不會 alias」。這條合約由 B-tree 的「distinct index」保證;而 B-tree 的 distinct index 又依賴 caller 提供的 Ord 不會 spurious 退化。invariant chain 是 API 合約 → B-tree 結構 → distinct index → distinct pointer → no overlapping &mut。最弱的一環在最上面(caller 的 Ord),iddqd 的修法是在 B-tree descent 時親自驗 index——把 chain 的弱化做成 library 自己的 check 而不是「相信 caller」。這跟 Postgres 對 user-defined function 做 marker / Linux kernel 對 user-supplied iterator 做 verifier 是同一個 design 原則。

IdOrdMap 的這個 bug 重現一下,可以更具體看「caller safe code 怎麼把 unsafe code 推進 UB」:

// caller 寫了一個邪惡的 Ord 實作——對任何兩個 key, 都 return Equal
#[derive(PartialEq, Eq, PartialOrd)]
struct EvilKey(u64);
impl Ord for EvilKey {
    fn cmp(&self, _other: &Self) -> std::cmp::Ordering {
        std::cmp::Ordering::Equal   // ← 100% pathological
    }
}

// 在修補前的 iddqd 上, 這段 safe code 會把 unsafe path 推進 UB:
let mut map = IdOrdMap::<EvilKey, String>::new();
map.insert(EvilKey(1), "alpha".into());
map.insert(EvilKey(2), "beta".into());      // B-tree 以為這跟 EvilKey(1) 同一個 entry
                                            // → 同一個 index 對應兩個 node
for value in map.iter_mut() {               // ← UB 在這裡, iter_mut 假設 distinct index
    // ... two phase borrowed mutably ...
}

修補後的版本:B-tree descent 時對每個 candidate node 多檢查一次 self.index == target.index,發現重複就拒絕 insert;如果 caller 的 Ord 真的讓所有 key 都被視為 equal、B-tree path 找不到 distinct index、最後 fallback 到 O(N) linear scan 找正確的 entry。fallback 路徑慢、但sound。這就是 unsafe library 的標準心態:寧可到 caller 覺得受不了,也不能讓 caller 的 safe code 把 unsafe path 推進 UB。「Safe Rust 永不應導致 UB」這條合約對 library 而言不是 nice-to-have——是 sound 的 def。

aliasing 還有一個容易被忽略的點:encapsulation 是 sound 的結構條件。iddqd 文章具體點出「struct MyVec<T> { ptr: NonNull<T>, len: usize }」這類自訂型別,「encapsulation and privacy of fields like len are load-bearing for soundness」。len 必須 private——因為 unsafe code 內部假設「self.lenself.ptr 後面真的有的元素數對齊」;如果 len public,caller 可以 safe code 改它、unsafe code 假設就破。iddqd 對所有 internal field 全用 pub(crate) 或 private、外部 API 透過 entry pattern(OccupiedEntry / VacantEntry)暴露——「encapsulation 不是封裝風格、是 unsafe sound 的 type-level barrier」。任何 invariant 的依賴項只要外部可以動,那條 invariant 就不能被 unsafe code 假設。

具體一點:iddqd 的 IdHashMap<T> internal 大概長這樣(簡化)——

pub struct IdHashMap<T: KeyedItem> {
    // bucket array: 每個 bucket 指向 list head
    buckets: Box<[Option<NonNull<Node<T>>>]>,
    // pinned node storage; node 的 prev/next 都指向這個 storage 內的另一個 node
    storage: Vec<Pin<Box<Node<T>>>>,
    // 由 storage 與 bucket 共同維護, 對外不可見
    len: usize,
}

// SAFETY 不變式(全部 private 才成立):
//   I1. storage 中每個 Pin<Box<Node>> 的位址在 self 生命週期內不變
//   I2. buckets 中每個 NonNull 指向 storage 裡實際存在的 Node
//   I3. self.len == storage.iter().filter(|n| !n.is_tombstone()).count()
//   I4. 任何 Active &mut Node 都對應 storage 中唯一一個 NonNull
//
// 違反任何一條 → unsafe code path 進入 UB 區。
// 三個 field 都必須 private; 一旦 pub, caller 在 safe code 內就能破 invariant。

這四條 invariant 不是「實作細節」、是型別的一部分。把 buckets 改成 pub,外部 safe code 就可以塞一個指向已被 drop 的 node 的 NonNull、接下來內部 iter deref 那個 pointer 就 UB——而且 borrow checker 不會擋(safe code 寫 NonNull 進 pub field 是合法的)。把 len 改成 pub,外部可以調大它、unsafe code 在 iter_mut 走超出 storage 真實長度的 index → out-of-bounds → UB。encapsulation 在 Rust unsafe 文化裡不是「OO 風格」、不是「方便重構」——是 sound 證明的最後一道防線。Aria Beingessner 的 too-many-lists 教學裡這條被反覆強調:「safe Rust 的 invariant violation 是 bug,unsafe Rust 的 invariant violation 是 CVE」。

// no-mutable-aliasing 的 unwrap 鏈:

caller 提供的 Ord
    |
    v 用來建 B-tree 的 key
B-tree 的 distinct-index invariant
    |
    v iter_mut 依賴
distinct NonNull<Node> 序列
    |
    v 變成
distinct &mut T 序列
    |
    v Rust 形式語意要求
不重疊(no overlap)

// 最弱的一環是最上面的 caller Ord
// iddqd 的修正:library 在 B-tree descent 時親自驗 index 唯一
// fallback: 退化到 O(N) linear scan

Ownership handoff:Rc / Arc / OccupiedEntry 把不變式包成型別

最後一條不變式是 ownership handoff——map 跟 caller 之間誰擁有 value、什麼時候交接。intrusive list 的特殊難題:value 本身帶鏈結欄位、value 由 caller 持有時 list 卻仍要 traverse、所以 value 不能簡單地交給 caller。iddqd 解決方式是把外部能拿到的 value 一律包成型別——caller 看不到 raw T、看到的是 OccupiedEntry<'a, T>Arc<T>、或 Pin<&mut T>。這幾個 wrapper 各自釘死一段合約:

  • OccupiedEntry<'a, T>:lifetime 'a 借自 map、entry 存在期間 map 不能被 mutate(borrow checker 編譯期保證),entry 內部拿到的是 Pin<&mut T> 而非 &mut T
  • Arc<T>:shared ownership,clone 計數 +1,&mut T。caller 拿到 Arc<T> 只能讀;想 mutate 必須走 Arc::get_mut(在 strong count == 1 時才成功)或 Arc<Mutex<T>>。Map 用 Arc 維持「map 還持有 value」這條 invariant,即使 caller 暫時也有 reference。
  • Pin<&mut T>:caller 可以 mutate 但不能 move——對「值的內容可變,但位址不能搬」這條 intrusive 必需的合約是直球。

iddqd 內部各層的責任邊界——靜態架構圖,無互動

iddqd 四層——每層各自把不變式收口成型別 L1 · Public API IdHashMap, IdOrdMap, BiHashMap, TriHashMap, insert_unique, iter_mut 面向 caller, 只暴露 safe wrapper, unsafe 完全藏在 crate L2 · Entry / Handoff OccupiedEntry<'a, T>, VacantEntry, Pin<&mut T>, Arc<T> 把 lifetime, pin, ownership 三條合約用 type 表達, borrow checker 保證 L3 · Internal NonNull layer NonNull<Node<T>>, split_at_mut-style 切 index, B-tree 維持 distinct invariant 每個 NonNull 都有清楚的 retag scope, alias 由 distinct index 保證 L4 · Pin / UnsafePinned 基底 Pin<Box<Node<T>>>, PhantomPinned, UnsafePinned<T>, NaiveMap oracle (test only) 把位址穩定、drop guarantee、aliasing escape hatch 收進 type system 跟 model-test 四層各自有合約, 對應四條不變式; 從上往下信任度遞增、可變動性遞減
iddqd 把不變式分四層收口——L1 對 caller 只給 safe API,L2 把 lifetime/pin/ownership 編碼成 type,L3 在 internal 走 NonNull 維持 retag scope,L4 用 Pin + UnsafePinned 撐起位址穩定與 alias escape hatch。

iddqd 把不變式分四層收口——L1 對 caller 只給 safe API,L2 把 lifetime/pin/…

iddqd 四層:safe API → OccupiedEntry 收口 → NonNull retag → Pin 保位址,每層各自把一條不變式收進型別。

三個 wrapper 各自表達一條合約、合起來把「外部能怎麼動 value」這個自由度收口。OccupiedEntry 借自 map、entry 還活著時 map 不能 insert / remove——這個保證來自 'a lifetime、borrow checker 編譯期就會擋;Arc 把 mutation 完全禁掉、map 跟 caller 共享 read-only view;Pin<&mut T> 給 caller 修內容的權力但禁止搬位址。三條 wrapper 同時存在的設計讓 iddqd 可以針對不同 use case 給出不同的 ownership handoff:need to mutate in-place → entry;need to share between threads → Arc;need to give caller a typed cursor that respects pin → Pin<&mut T>

OccupiedEntry 的 API surface 可以理解這個 design 多細:

// iddqd 風格的 OccupiedEntry
pub struct OccupiedEntry<'a, T: KeyedItem> {
    map: &'a mut IdHashMap<T>,
    // 內部 raw pointer; entry 存在期間, map 被借出
    node: NonNull<Node<T>>,
    _phantom: PhantomData<&'a mut T>,
}

impl<'a, T: KeyedItem> OccupiedEntry<'a, T> {
    // 只給 Pin<&mut T>, 永遠不給 &mut T
    pub fn get_pin(&mut self) -> Pin<&mut T> {
        unsafe {
            // SAFETY: node 由 Pin<Box<Node>> 配置, 位址不變
            //         self 借走整個 map, 所以這個 NonNull 是唯一的 &mut path
            Pin::new_unchecked(&mut self.node.as_mut().value)
        }
    }

    // 想拿走 T 必須 consume entry; 內部會清掉 list 鏈結
    pub fn remove(self) -> T {
        unsafe {
            // SAFETY: ... (一連串對 four invariants 的引用)
            self.map.detach_node(self.node)
        }
    }
}

注意 get_pinremove 的 signature 差別:前者是 &mut self、後者是 self(consume)。前者讓 caller 可以重複地修內容(因為 entry 還活著、map 還被借走),後者「entry 消失後 caller 才能拿到 raw T」——而拿到 raw T 的瞬間,list 內部該 node 已經被 detach。這條 lifecycle 是強制的:raw T 只能從消滅 entry的同一個 expression 拿到、entry 還在的時候 caller 永遠看不到 raw T、整個 lifecycle 由 type system 跟 borrow checker 在編譯期釘死。

對 thread-safety,iddqd 提供 Arc<Mutex<T>>Arc<RwLock<T>> 的變體——把 mutation 收進 Mutex、不同 thread 透過 Arc::clone 共享 read view。這條 path 上 ownership handoff 的合約變成「value 在 Mutex 內、map 內部不直接 mutate value、只 mutate 鏈結欄位(在 map 自己的 lock 下)」。這個分層是必要的——如果 map 跟 caller 都想 mutate value,必須有個 ordering / locking 機制保證不同時動同一塊記憶體。Mutex 是 dynamic check、編譯期不擋;Arc 是 shared count、編譯期不擋;但兩者組合在 iddqd type signature 上明確標出來,caller 看 type 就知道「我要動 value 必須 lock」。

這個四層 type design 的測試策略也值得提:iddqd 不僅在 Miri 兩個模型下跑、還跑了 model-based testing against a NaiveMap oracle——一份「inefficient but clearly correct」的參考實作,把每個 production 操作的結果跟 oracle 比對。analytic reasoning 證明「invariant 在 type level 成立」、empirical model testing 驗證「實作真的 honour 了 invariant」、Miri 保證「沒有 latent UB」——三條互相 independent 的 check 同時 green 才能 release。對 unsafe heavy 的 library 這是低於 minimum 的代價。

下面這個小工具把 pin projection 的選擇可視化——同一個 Pin<&mut Node<T>>,拖動 cursor 在 node 上挑選 field,看每個 field 是 structural(projection 也 pinned)還是 non-structural(projection 給 &mut Field)。intrusive node 三個 field 的選擇不能搞混:value: T 通常 structural(caller 可能依賴 T 也 pinned)、prev / next 通常 non-structural(鏈結欄位是 library 私有、不需要對外保證 pinned)。這條 split 一旦決定就是 type-wide commitment——同一個 type 不可能在 method A 對 value 用 structural、method B 用 non-structural,否則整個 sound 的 chain 斷。

拖動 cursor 在 node 三個 field 之間選擇,看 projection 規則 · drag · 3 fields

Pin<&mut Node<T>> 上的三個 field——拖動 cursor 在它們之間切換 Pin<&mut Node<T>> value: T structural prev: NonNull<Node> non-structural next: NonNull<Node> non-structural value

projection rule

value : T —— structural pinning

fn value_pin(self: Pin<&mut Self>) -> Pin<&mut T>——projection 出來的 T 也是 pinned,type-wide commitment:iddqd 的整個生命週期裡都不能對 value 做 move-out。caller 拿到的是 Pin<&mut T>,可以 mutate 內容,不能搬位址。對 self-referential T、generator state machine 是 sound 必需的選擇。

互動圖表

value 走 structural Pin projection,prev/next 走 non-structural,選定後全 type 鎖定不可混用。

四條合約合起來能寫出什麼樣的 unsafe code

把四條合約合起來看,iddqd 的 unsafe block 其實非常少——而且每一個都有對應一條 invariant 的 SAFETY 註解:「pinned, so location stable」「distinct index, so no overlap」「retag scope ends at fn return」「caller still holds Arc, so storage live」。這種寫法是 unsafe-heavy Rust 的標準實踐——不是「我寫得對,相信我」,而是「每段 unsafe 各自指向一條型別不變式、由 type system / runtime check / external invariant 三者其中之一明確支撐」。iddqd 的 unsafe block 數量大概比同等功能的 C 實作多一個數量級的註解,但 unsafe 的真實程式碼很少——這是 Rust unsafe 文化的本質:用 type 跟 comment 把所有條件外顯,剩下的全交給 borrow checker。

// iddqd 內部 unsafe block 的標準骨架

unsafe {
    // SAFETY:
    //   1. node 由 Pin<Box<Node>> 配置,地址不變 (Pin invariant)
    //   2. 此 fn 的 caller 持有 &mut Map,map 不會被併發 mutate (lifetime)
    //   3. self.tree 的 index 由 B-tree distinct invariant 保證唯一,
    //      此處的 NonNull 不會與其他 active &mut 重疊 (alias invariant)
    //   4. NonNull retag scope 限定在這個 fn 內,呼叫 fn 邊界會重新 retag
    //      (provenance invariant)
    let item: &mut T = raw.as_mut();
    let item = std::mem::transmute::<&mut T, &'a mut T>(item);
    Some(item)
}
四條 SAFETY clause ↔ 四條正交不變式 ↔ 缺一條時 Miri 報的錯
SAFETY clause 不變式 破了之後 Miri 報什麼
clause 1 Pin — Pin<Box<Node>> 配置,地址不變 storage of pinned value reused
clause 2 lifetime — caller 持有 &mut Map,無併發 mutate borrow checker compile error(根本不會編)
clause 3 alias — B-tree distinct index 保證 NonNull 不與其他 active &mut 重疊 two phase borrowed mutably
clause 4 provenance — NonNull retag scope 限在 fn 內,跨 fn 邊界重新 retag encountered a dangling SbTag

四條 SAFETY clause ↔ 四條正交不變式 ↔ 缺一條時 Miri 報的錯

四條 SAFETY clause 各對應一條不變式:缺 Pin 報 storage reuse,缺 Aliasing 報 two-phase UB。

不變式之間的正交性意味著一條工程上的可驗證心法:寫完一段 unsafe block 後,自問四遍——「這段 code 對 Pin 假設了什麼?對 Provenance 假設了什麼?對 Aliasing 假設了什麼?對 Ownership 假設了什麼?」每個答案都該對應 SAFETY 註解一句。如果某條問不出答案,那段 unsafe 對該 invariant 的依賴是「相信運氣」、不是「相信合約」。

對團隊的決策矩陣可以這樣寫:

「我要不要寫 intrusive collection」——四問決策樹,每個 no 都有 fallback
no → yes →
Q1:value 會同時參與多個 collection? 不要寫 intrusive,用 IndexMap / HashMap / BTreeMap 往 Q2
Q2:能用 Arc<T> + 多個 HashMap<Key, Arc<T>> 表達? 往 Q3 走這條;safe、lookup 慢、no unsafe
Q3:有預算寫 SAFETY 註解 + 維護 Miri CI matrix? 找 production-quality crate(iddqdintrusive-collections 往 Q4
Q4:團隊有人能審 unsafe code?而且還在? 別寫。半年內人一走,那段 unsafe 變成沒 maintainer 的 CVE 候選 可以開始,但先在 dev branch 跑三個月 Miri 沒紅再 ship

「我要不要寫 intrusive collection」——四問決策樹,每個 no 都有 fallback

四問決策樹:只有 Q4 全過才適合自己寫 intrusive,否則用現成 crate 或 Arc+HashMap 替代。

四個 Q 全部 yes 才有資格寫 intrusive。實務上 Q3 跟 Q4 是大多數團隊的 bottleneck——SAFETY 註解寫起來比 logic 還久、reviewer 要懂 Stacked / Tree Borrows 才看得懂註解、人一走那段 code 變遺孤。Oxide 之所以願意 maintain iddqd,是因為他們的 control plane 本來就有兩三位專職的 Rust 系統工程師、團隊 culture 也習慣寫長 SAFETY block。一般 SaaS / web backend 團隊沒這條件、應該走 indexmap + HashMap<Key, Arc<T>>

不是所有 Rust 工程師都應該寫 intrusive collection。但如果你的 application 真的需要(embedded、kernel-like context、超高 throughput 的 indexed store),那以下這四條應該寫在 onboarding doc 第一頁:

  1. 位址不變要走 Pin<Box<T>> + PhantomPinnedUnsafePinned 在 nightly 可以走更強的 alias escape hatch,但 stable 通道目前還是 Pin + Box。projection 規則一旦選定就 type-wide 鎖死、不可改。
  2. 所有 internal pointer 走 NonNull<T> 而非 *mut T。Niche optimization 是 side benefit、真正的理由是 provenance 跟 retag scope 比 raw pointer 清楚。
  3. 每個 unsafe block 對應一條外部 invariant、寫 SAFETY 註解。註解不是給 reviewer 看的、是給未來改這段 code 的人保留條件清單。
  4. CI 跑 Miri 跑兩個模型:Stacked Borrows + Tree Borrows,再加 NaiveMap-style oracle model testing。一個模型 green 不算數。

實務上還有一條否定型條件:如果你的 collection 不需要「value 本身帶鏈結欄位」這個 invariant,不要寫 intrusive。std::collections::LinkedList 雖然在 micro-benchmark 上不漂亮,但它把所有不變式釘在 borrow checker 上、零 unsafe code(user 視角)、語意清楚。intrusive 只在「我願意付出 type design 跟 SAFETY 註解的 maintenance 成本」之下才划算——一個一人團隊的 application 多半不應該走這條。Oxide 自己在文章裡就點明:iddqd 之所以開源、之所以 sound proof 寫這麼長,是因為他們願意當這個 unsafe 的 maintainer——其他人能直接 depend 上、繼承這套 invariant proof。

把這份工程量量化:iddqd 的 internal crate 行數中,SAFETY 註解佔將近 30%,這個比例在一般 Rust crate 不到 2%。每個 unsafe block 平均對應 6-8 行 SAFETY 註解、註解之間還互相 cross-reference(「依賴 invariant I3,見 fn detach_node 第 47 行」)。這不是過度文件化、是 unsafe code review 必需的 audit trail——任何一條 invariant 在未來被改動,reviewer 要能順著 SAFETY 註解的引用 chain 找到所有受影響的 unsafe block。對 reviewer 而言,這比「猜測這段 code 是不是還對」可工程化得多。

SAFETY 註解還有一個常被忽略的 second-order 效果:它強迫 author 把 invariant 想清楚。寫不出註解的 unsafe code 通常是 author 自己也沒搞懂為什麼那段 sound——寫一寫 SAFETY 註解、發現自己解釋不通、回去改 design 是 healthy 的工作流。Aria Beingessner 在 too-many-lists 教學裡的原話:「If you can't write the SAFETY comment, you can't write the unsafe code.」這條原則 30 行 unsafe code 跟 3000 行 unsafe code 都適用。

另一個值得提的是 test feedback loop。iddqd 對每個 internal helper 都有對應 Miri test、Miri 在普通 cargo test 之外、自己跑一遍 interpretive execution、把每個 access 跟 borrow tree 對照。普通 unit test 跑 100 ms 的 case 在 Miri 下可能要 10 s——這條 trade-off 對 unsafe-heavy crate 而言是值得的,Miri 是唯一能機械化抓 SbTag / TbTag 違反的工具。Oxide 內部把 Miri 加進 PR gate、紅了就不 merge——這個流程下,iddqd 自開源以來沒有 sound-related regression。

把 Miri 跟其他 sanitizer 比一比:C/C++ 的 ASan 偵測「實際發生的」記憶體錯誤(OOB read、UAF);Miri 偵測「形式語意下會 UB 的」操作——即使 ASan 沒看到、即使 production hot path 從未撞上。這個差別對 unsafe Rust 是關鍵:UB 一旦在 Rust 形式語意上發生、optimizer 可以做任何事、未來 LLVM 升級可能就把這段 code 編譯成完全不同的 instruction。Miri 在這個意義上不是「bug detector」、是「合約 verifier」。即使你的 code 跑 production 三年沒出事、Miri 報 SbTag 錯誤就代表「未來某個 LLVM 版本可能會把這段優化錯」——必須修。

不變式集合背後還有一層更大的議題:Rust 對「unsafe 工程化」這條 design 路線的承諾。其他語言對 unsafe 的態度通常是「沒辦法,這段沒人能保證」(C++ 的 reinterpret_cast)或者「禁止,繞路走」(Java 完全不暴露記憶體)。Rust 選的是中間路線:unsafe 存在、但每段都有具名的 invariant、可以靠 type / runtime check / SAFETY 註解三段分別釘住。iddqd 是這條路線最 mature 的展示之一——它證明 unsafe-heavy library 可以 production-quality、可以開源被 audit、可以被新加入團隊的工程師讀懂。這對整個 systems programming 生態都是個 reference point。

從 Rust 1.0 到 iddqd 的這條 10 年弧線,可以視為「systems programming 的 unsafe 工具集」第一次從口傳秘訣變成可審查文件。Linux kernel 的 list.h 寫了 30 年沒人能形式化它的 invariant;Rust 用 Pin / NonNull / Miri / Tree Borrows 這條 type-level 工具鏈把它寫成可以被機械 verify 的合約。對下一代 systems language 而言,這條路線是個明確的方向——unsafe 不該是「黑魔法」,而該是「型別化合約 + 可機械 verify 的形式語意」。

iddqd 的價值對日常工程師可以總結成一句話:「unsafe Rust 不是要你寫對,而是要你寫得讓其他人能 verify」。同樣的設計原則應該推廣到任何 sound-critical 的 library 邊界——auth、crypto、parser、IPC——每個邊界上「caller 提供什麼條件、callee 假設什麼條件」都該寫成 SAFETY 註解、寫成 type signature、寫成 CI test。技術上 Rust 比其他語言早走十年;文化上整個 systems programming 都該跟上。

最後一個 framing:四條 invariant 互相獨立這個性質本身就是 design 結論而非觀察。Pin 設計時刻意只解「位址不變」、不碰 alias;NonNull 跟 Stacked Borrows 設計時刻意只解 provenance、不碰 ownership;OccupiedEntry 設計時刻意把 ownership boundary 用 lifetime 表達、不碰 pin。每個工具的 scope 都被刻意限縮成一條 invariant——這樣才能組合、才能在 review 時 axis-by-axis 走、才能在語言演進時逐條獨立修改。iddqd 之所以能寫成、不是運氣,是這條 design 原則 10 年累積的結果。

把 axis-by-axis review 的具體流程列出:reviewer 拿到一個 unsafe block,先讀 SAFETY 註解第 1 條(Pin invariant)、檢查它在當前 scope確實成立;再讀第 2 條(Provenance)、檢查 NonNull 的 retag scope 沒跨 fn;再讀第 3 條(Aliasing)、檢查 distinct dispatch 邏輯;再讀第 4 條(Ownership)、檢查 OccupiedEntry / Arc 的 lifetime。四條獨立 check、每條花 1-2 分鐘——一個 unsafe block review 大約 5-8 分鐘可以做完。沒有 SAFETY 註解的 unsafe block,reviewer 必須自己推 invariant、花 30 分鐘以上、結論還不 robust。SAFETY 註解的 ROI 對 review process 是 10x 級。

這就是 iddqd 這篇 post 對 Rust 工程師最深的方法論啟示:unsafe 不可怕——但要寫得讓人能 review。四條獨立、type-level 釘住、CI 雙模型 Miri 把關、SAFETY 註解逐條 cross-reference——這套組合拳,才是 unsafe Rust 從「黑魔法」變成「可工程化」的核心配方。對任何想踏入 unsafe 領域的工程師,這篇 post 比任何 textbook 都更貼近真實的工程節奏;對任何已經寫了一段 unsafe 而不確定 sound 的工程師,這四條 invariant 是把自家 code 拿來逐條驗的清單。

結束之前再強調一遍:iddqd 的所有設計選擇都在處理語言形式語意這個抽象層次的 invariant——Pin 對位址、NonNull 對 retag scope、distinct index 對 alias、Arc 對 ownership——全都是 Rust 型別系統內部的抽象規則。把這篇 post 視為 Rust 形式語意工具集累積到 production-quality 的展示比較切題。下一個十年,相信還會有更多 type-level escape hatch、更精細的 Miri 模型、更多 production-grade unsafe library 出現——這條 design trajectory 不會停。

What this enables:把 intrusive linked list 這個傳統 unsafe 黑魔法拆成四條獨立不變式(Pin 的 drop guarantee、pointer provenance、no-mutable-aliasing、ownership handoff),讓 library 作者可以對每條分別在 type level 給證明、CI 用 Miri 兩個模型對每條分別檢查、reviewer 用 SAFETY 註解對每條分別審查——unsafe code 從「天才寫對、其他人不要碰」變成「四條 invariant 都釘死、新人也能 review」的可工程化合約。