深入 Rust

Stacked Borrows 與 Miri

/ Miri -> "MEER-ee" /

安全 Rust 的別名與可變互斥規則由借用檢查器檢查,但帶原始指標的 unsafe 程式碼跨出了那道檢查——然而規則仍然存在。若你造出一個 &mut T,最佳化器就被允許「假設」在這個參考存活時沒有別的東西碰那份資料,於是它能把值快取在暫存器、重排讀寫。若你的 unsafe 程式碼透過一個原始指標偷偷對那個 &mut 製造別名,你就對最佳化器說了謊,結果就是未定義行為,即使沒有明顯違反任何安全規則。問題在於:在原始指標的層級,究竟什麼算「對一個 &mut 製造別名」需要一個精確的定義。Stacked Borrows 是一個提出的形式模型,給出那個定義,而 Miri 是一個直譯器,拿你的程式去對照它檢查。

Stacked Borrows 大致略知如下運作。記憶體的每個位元組都帶著一個想像的「標籤」堆疊,每個目前被允許存取它的參考或指標各一個標籤。當你從一個既有參考造出一個新參考時,它的標籤被推上堆疊;當你使用一個參考時,模型檢查它的標籤仍在堆疊上某個有效之處;而在一個較新者被推上之後再使用一個「舊」參考,實際上會把它上面的一切彈掉,使那些較新的借用失效。由此落下的那一條規則正是你已知道的:當一個 &mut 是作用中(頂端)標籤時,透過任何其他指標存取同一記憶體都是非法的,並會彈掉那個 &mut,使它之後的使用成為 UB。它把「一個 &mut 意味著獨占存取」形式化到原始指標的層級,使最佳化器的假設永遠被遵守。(Tree Borrows 是同一想法較新、較寬鬆的精修;你不需要細節,只要知道有個別名模型存在。)Miri 是一個直譯器,在一台虛擬機上跑你的 Rust,盯著每個記憶體操作與每次借用,並在你的 unsafe 程式碼違反模型、解參考懸置指標、讀取未初始化記憶體、或撞上資料競爭的那一刻回報——那是一次普通執行可能藏起來的未定義行為。

為何重要:在 C 裡,別名違反造成的未定義行為以隱形著稱——程式今天在這個編譯器上可能沒事,明天卻無聲地損毀。Stacked Borrows 加上 Miri 給了 Rust 的 unsafe 程式碼一樣 C 所欠缺的東西:一個「什麼別名被允許」的可檢查定義,以及一個在測試中決定性地抓出違反的工具。誠實的提醒:Miri 是直譯器,所以遠比原生慢,且只檢查你的測試實際執行到的程式碼路徑——它找出它看見的 UB,而非你從未跑過的 UB。而別名模型仍在定案中,所以 Stacked Borrows 是運作中的定義,而非凍結的語言保證。儘管如此,若你寫任何 unsafe 程式碼,在 Miri 下跑你的測試(cargo +nightly miri test)是抓出編譯器抓不到之 UB 的單一最高價值習慣。

// 這「看起來」沒問題,在普通建構下可能印出 1, 2…… let mut n = 0i32; let r = &mut n; // &mut:r 有獨占存取 let p = r as *mut i32; // 一個對同一資料製造別名的原始指標 *r = 1; let bad = unsafe { *p }; // 在 r 作用中時使用 p:別名違反 *r = 2; println!("{bad} {n}"); // ……但 `cargo +nightly miri run` 會把它回報為未定義行為。

普通建構可能藏起一個別名違反;Miri 對照 Stacked Borrows 模型檢查,會決定性地把它標出。

Miri 只在你的測試實際跑到的路徑上抓 UB,且比原生慢很多。Stacked Borrows 是運作中的別名模型,尚非凍結的保證(Tree Borrows 在精修它)。即便如此,在 Miri 下跑 unsafe 程式碼,是抓出編譯器抓不到之 UB 的最佳方式。

又称
Stacked BorrowsTree BorrowsMirithe aliasing modelRust aliasing model別名模型