的核心機(jī)制)
之前讀 async 相關(guān)源碼的時(shí)候經(jīng)??吹絇inBoxdyn Future這種類型。一開始我以為它只是一種普通的智能指針后來才意識(shí)到Pin的核心并不是“管理內(nèi)存”而是“禁止移動(dòng)”。為了徹底弄懂它我嘗試從零手寫一個(gè)簡化版Pin并對(duì)照標(biāo)準(zhǔn)庫做了一次梳理。這個(gè)過程對(duì)理解 Rust 生命周期、Unpin、PhantomPinned以及 async/await 背后的機(jī)制幫助很大。這篇文章就是這次嘗試的完整記錄適合已經(jīng)能編寫簡單 Rust 代碼、想深入理解Pin的讀者。全文會(huì)從自引用結(jié)構(gòu)體的問題出發(fā)手寫一個(gè) “More or Less” 版本的Pin再對(duì)照真實(shí) Rust API 說明差異和常見誤區(qū)。1. 從問題說起自引用結(jié)構(gòu)體為什么危險(xiǎn)1.1 什么是自引用結(jié)構(gòu)體Rust 中一個(gè)結(jié)構(gòu)體的字段可能保存著指向同一結(jié)構(gòu)體其它字段的指針這種結(jié)構(gòu)體通常被稱為自引用結(jié)構(gòu)體Self-Referential Struct。下面是一個(gè)非常典型的最小例子struct SelfReferential { value: String, ptr: *const String, }初始化時(shí)我們讓ptr指向value字段let mut sr SelfReferential { value: String::from(hello), ptr: std::ptr::null(), }; sr.ptr sr.value as *const String;這時(shí)sr.ptr保存的地址就是sr.value在當(dāng)前棧幀中的地址。如果后續(xù)操作不移動(dòng)sr那么這個(gè)指針是有效的一旦sr在內(nèi)存中被搬到了其它位置sr.value的地址會(huì)發(fā)生變化而sr.ptr仍然保留舊地址成為懸垂指針。這種結(jié)構(gòu)在實(shí)際開發(fā)中并不少見。例如手寫一個(gè)內(nèi)部包含指向自身緩沖區(qū)的解析器、實(shí)現(xiàn)零拷貝解碼器、或者設(shè)計(jì)某些緩存結(jié)構(gòu)時(shí)都可能產(chǎn)生自引用。Rust 的 async 狀態(tài)機(jī)內(nèi)部也會(huì)生成類似結(jié)構(gòu)這也是Pin被引入語言的重要原因之一。1.2 移動(dòng)帶來的懸垂指針Rust 中的賦值、函數(shù)傳參、放入容器等操作都可能發(fā)生移動(dòng)move。對(duì)普通結(jié)構(gòu)體來說移動(dòng)時(shí)整個(gè)值會(huì)被逐字節(jié)拷貝到新位置所有字段同步搬遷所以移動(dòng)本身是安全的。問題在于如果我們手動(dòng)保存了移動(dòng)前的內(nèi)部地址移動(dòng)后這個(gè)地址不會(huì)自動(dòng)更新。下面這段代碼展示了一個(gè)常見現(xiàn)象fn evaluate_move() { let mut sr SelfReferential { value: String::from(hello), ptr: std::ptr::null(), _pin: PhantomPinned, }; sr.ptr sr.value as *const String; println!(before move: value {:p}, ptr {:?}, sr.value, sr.ptr); let moved sr; println!(after move: value {:p}, ptr {:?}, moved.value, moved.ptr); }運(yùn)行后before move和after move的輸出中value的地址往往會(huì)不同而ptr仍然是移動(dòng)前的舊地址。這意味著moved.ptr已經(jīng)指向了舊棧位置繼續(xù)對(duì)它解引用就是典型的內(nèi)存安全問題。這里需要澄清一點(diǎn)編譯器并不保證每次移動(dòng)后地址一定不同也可能出現(xiàn)地址恰好復(fù)用的情況。但從語言語義上講移動(dòng)后value的地址是不可依賴的存在懸垂風(fēng)險(xiǎn)就足以判定這種設(shè)計(jì)不安全。1.3 為什么編譯器不能自動(dòng)處理有讀者可能會(huì)問既然移動(dòng)會(huì)導(dǎo)致自引用失效Rust 編譯器難道不能自動(dòng)修復(fù)ptr嗎答案是否定的。Rust 的移動(dòng)語義只是把值從一塊內(nèi)存搬到另一塊內(nèi)存編譯器不知道結(jié)構(gòu)體內(nèi)部哪些裸指針字段需要同步更新。更重要的是一旦引入了“移動(dòng)時(shí)自動(dòng)修復(fù)內(nèi)部指針”的機(jī)制結(jié)構(gòu)體的復(fù)制和移動(dòng)規(guī)則就會(huì)變得異常復(fù)雜會(huì)直接破壞 Rust 對(duì)所有權(quán)的確定性管理。因此Rust 選擇在類型系統(tǒng)層面解決這個(gè)問題如果某個(gè)類型需要自引用它應(yīng)該被標(biāo)記為!Unpin然后用Pin來保證在固定期間不發(fā)生移動(dòng)。Pin的本質(zhì)承諾是一旦某個(gè)值被“釘住”安全代碼就拿不到它的mut T從而無法將它移動(dòng)到其它位置也無法用其它值替換它。這正是自引用結(jié)構(gòu)體需要的保護(hù)。2. 理解 Unpin、!Unpin 和 PhantomPinned2.1 Unpin 是什么Unpin是 Rust 標(biāo)準(zhǔn)庫中的一個(gè)自動(dòng) traitauto trait。所謂自動(dòng) trait意味著絕大多數(shù)類型無需手動(dòng)實(shí)現(xiàn)就會(huì)自動(dòng)獲得Unpin。T: Unpin語義上表示T類型的值可以在內(nèi)存中安全地移動(dòng)。普通結(jié)構(gòu)體、枚舉、基本類型幾乎都是Unpin的。你可以把Unpin理解為“移動(dòng)沒有額外代價(jià)和額外約束”的類型標(biāo)記。舉個(gè)例子struct NormalStruct { a: u32, b: String, }NormalStruct默認(rèn)就是Unpin的因?yàn)橐苿?dòng)它不會(huì)破壞任何內(nèi)部自定義指針。即使String內(nèi)部包含堆指針也不受影響因?yàn)镾tring的堆指針會(huì)隨整個(gè)結(jié)構(gòu)體一起移動(dòng)而String內(nèi)部并不保存指向自身?xiàng)5刂返囊谩?.2 PhantomPinned 如何產(chǎn)生 !Unpin如果希望一個(gè)類型是!Unpin也就是“移動(dòng)后可能不安全”穩(wěn)定版 Rust 并不能直接寫impl !Unpin for MyType。這是未穩(wěn)定功能我們只能借助標(biāo)準(zhǔn)庫里的PhantomPinned。PhantomPinned是一個(gè)零大小類型zero-sized type它本身實(shí)現(xiàn)了!Unpin。由于 Rust 的自動(dòng) trait 會(huì)沿著結(jié)構(gòu)體字段傳播只要一個(gè)結(jié)構(gòu)體里包含PhantomPinned字段整個(gè)結(jié)構(gòu)體就會(huì)自動(dòng)變成!Unpin。看下面的例子use std::marker::PhantomPinned; struct SelfReferential { value: String, ptr: *const String, _pin: PhantomPinned, // 使整個(gè)結(jié)構(gòu)體變成 !Unpin }這個(gè)_pin字段不會(huì)占用任何內(nèi)存它的唯一作用是參與類型系統(tǒng)的約束。引入它之后SelfReferential就不再實(shí)現(xiàn)Unpin從而進(jìn)入Pin的保護(hù)范圍。2.3 “沒被 Pin 之前”仍然可以移動(dòng)一個(gè)剛剛創(chuàng)建、還沒有被Pin包裹的!Unpin類型仍然可以隨意移動(dòng)。!Unpin只表示“這類值被固定后不能移動(dòng)”并不表示“永遠(yuǎn)不能移動(dòng)”。這一點(diǎn)非常重要。很多人看到!Unpin就認(rèn)為這種類型完全不能移動(dòng)這是一個(gè)常見的誤解。實(shí)際上PhantomPinned只是讓類型具備“需要被固定”的資格真正執(zhí)行“禁止移動(dòng)”約束的是Pin及借用檢查器。只有把一個(gè)!Unpin類型交給Pin管理之后安全代碼才無法再獲得它的mut T從而無法移動(dòng)、替換、swap 它。3. 從零實(shí)現(xiàn)一個(gè)簡化版 Pin3.1 設(shè)計(jì)目標(biāo)標(biāo)準(zhǔn)庫的PinP內(nèi)部實(shí)現(xiàn)比較復(fù)雜它需要支持P是Box、mut T、Rc等各種指針類型。我們這里不打算復(fù)制整個(gè)標(biāo)準(zhǔn)庫而是實(shí)現(xiàn)一個(gè)“More or Less”版本只保留最核心的安全語義。我們的目標(biāo)非常明確只能通過“安全構(gòu)造”創(chuàng)建一個(gè)指向T: Unpin值的Pin。對(duì)于T: !Unpin的值只能通過unsafe方法創(chuàng)建Pin并且調(diào)用者需要保證固定期間不得移動(dòng)該值。任何情況下都可以從Pin獲取不可變引用T。只有在T: Unpin時(shí)才能安全獲取mut T。當(dāng)T: !Unpin時(shí)只能通過unsafe方法獲取mut T。這個(gè)設(shè)計(jì)正好對(duì)應(yīng)標(biāo)準(zhǔn)庫最核心的安全邊界。為了簡化我們讓Pin持有a mut T。3.2 結(jié)構(gòu)體定義在 Rust 中一個(gè)最簡單的最小實(shí)現(xiàn)可以寫成這樣use std::ops::{Deref, DerefMut}; pub struct Pina, T: ?Sized { inner: a mut T, }inner是被釘住值的可變借用。我們持有a mut T本身就能利用 Rust 的借用規(guī)則來阻止外部在Pin存活期間再次可變?cè)L問原變量。注意T: ?Sized是必要的因?yàn)門可以是動(dòng)態(tài)大小類型例如dyn Future或切片類型。a mut T本身可以指向 unsized 類型。3.3 安全構(gòu)造new當(dāng)T: Unpin時(shí)這個(gè)類型的值可以安全移動(dòng)因此我們?cè)试S安全代碼直接構(gòu)造Pinimpla, T: ?Sized Pina, T { pub fn new(inner: a mut T) - Self where T: Unpin, { Pin { inner } } }這里的關(guān)鍵是T: Unpin約束。編譯器看到這個(gè)約束后只有T確實(shí)實(shí)現(xiàn)了Unpin調(diào)用者才能使用Pin::new。對(duì)于!Unpin類型這條路被堵死。如果你嘗試對(duì)一個(gè)!Unpin的類型調(diào)用Pin::new會(huì)得到類似這樣的錯(cuò)誤error[E0277]: the trait bound SelfReferential: Unpin is not satisfied3.4 不安全構(gòu)造new_unchecked對(duì)于!Unpin類型我們只能提供一個(gè)unsafe構(gòu)造函數(shù)impla, T: ?Sized Pina, T { pub unsafe fn new_unchecked(inner: a mut T) - Self { Pin { inner } } }為什么這是unsafe原因在于調(diào)用者必須承諾一個(gè)關(guān)鍵不變式在Pin存活期間被釘住的值不會(huì)發(fā)生移動(dòng)。由于a mut T已經(jīng)從原變量手中接管了可變借用借用檢查器已經(jīng)能阻止很多危險(xiǎn)操作但unsafe構(gòu)造仍然要求調(diào)用者理解深層語義而不能只靠編譯器的借用規(guī)則兜底。舉個(gè)例子如果inner指向堆內(nèi)存并且之后有人釋放了這塊內(nèi)存即使沒有移動(dòng)Pin持有的指針也可能懸垂。這類問題超出了普通借用檢查的范圍需要調(diào)用者謹(jǐn)慎處理。在真實(shí)項(xiàng)目中每次使用new_unchecked時(shí)都應(yīng)該在注釋里寫明這個(gè)不變式由誰維護(hù)、在什么范圍有效。3.5 獲取引用的方法我們還需要提供三個(gè)核心方法impla, T: ?Sized Pina, T { pub fn get_ref(self) - T { self.inner } pub fn get_mut(self) - a mut T where T: Unpin, { self.inner } pub unsafe fn get_unchecked_mut(self) - a mut T { self.inner } }get_ref是安全的因?yàn)闊o論T是否Unpin共享引用都不會(huì)導(dǎo)致值被移動(dòng)。get_mut只在T: Unpin時(shí)可用因?yàn)橹挥羞@種類型才允許被可變借用后移動(dòng)或替換。get_unchecked_mut是unsafe的調(diào)用者必須保證拿回mut T后不會(huì)移動(dòng)這個(gè)值、不會(huì)用std::mem::replace等操作替換它也不會(huì)在Pin仍存活時(shí)再次固定它。這些方法雖然簡單卻完整表達(dá)了Pin對(duì)可變?cè)L問的約束邊界。3.6 實(shí)現(xiàn) Deref 與 DerefMut為了讓Pin能像普通引用一樣方便地訪問內(nèi)部字段我們需要實(shí)現(xiàn)Deref。Deref在任何情況下都應(yīng)該實(shí)現(xiàn)因?yàn)楣蚕硪貌粫?huì)破壞固定語義implT: ?Sized Deref for Pin_, T { type Target T; fn deref(self) - Self::Target { self.inner } }DerefMut則必須謹(jǐn)慎。它只應(yīng)該在T: Unpin時(shí)實(shí)現(xiàn)implT: Unpin ?Sized DerefMut for Pin_, T { fn deref_mut(mut self) - mut Self::Target { self.inner } }如果對(duì)T: !Unpin也實(shí)現(xiàn)了DerefMut那么安全代碼就可以直接寫mut *pin從而把內(nèi)部值拿出去移動(dòng)或替換整個(gè)Pin的保護(hù)就形同虛設(shè)。因此DerefMut的Unpin約束是整個(gè)設(shè)計(jì)的關(guān)鍵防線。到這里一個(gè)簡化版Pin的核心已經(jīng)完成。4. 用簡化版 Pin 實(shí)現(xiàn)自引用結(jié)構(gòu)體4.1 完整示例代碼下面的示例把上面的Pin實(shí)現(xiàn)與自引用結(jié)構(gòu)體放在同一個(gè)文件里。你可以直接復(fù)制到一個(gè) Rust 項(xiàng)目中運(yùn)行。use std::marker::PhantomPinned; use std::ops::{Deref, DerefMut}; // ---------- 簡化版 Pin ---------- pub struct Pina, T: ?Sized { inner: a mut T, } impla, T: ?Sized Pina, T { pub fn new(inner: a mut T) - Self where T: Unpin, { Pin { inner } } pub unsafe fn new_unchecked(inner: a mut T) - Self { Pin { inner } } pub fn get_ref(self) - T { self.inner } pub fn get_mut(self) - a mut T where T: Unpin, { self.inner } pub unsafe fn get_unchecked_mut(self) - a mut T { self.inner } } implT: ?Sized Deref for Pin_, T { type Target T; fn deref(self) - Self::Target { self.inner } } implT: Unpin ?Sized DerefMut for Pin_, T { fn deref_mut(mut self) - mut Self::Target { self.inner } } // ---------- 自引用結(jié)構(gòu)體 ---------- struct SelfReferential { value: String, ptr: *const String, _pin: PhantomPinned, } fn demonstrate_move() { let mut sr SelfReferential { value: String::from(hello), ptr: std::ptr::null(), _pin: PhantomPinned, }; sr.ptr sr.value as *const String; println!(before move: value {:p}, ptr {:?}, sr.value, sr.ptr); let moved sr; println!(after move: value {:p}, ptr {:?}, moved.value, moved.ptr); } fn demonstrate_pin() { let mut sr SelfReferential { value: String::from(hello), ptr: std::ptr::null(), _pin: PhantomPinned, }; sr.ptr sr.value as *const String; // 這里是 unsafe我們承諾在 pinned 存活期間不會(huì)移動(dòng) sr let pinned unsafe { Pin::new_unchecked(mut sr) }; println!(after pin: value {:p}, ptr {:?}, pinned.value, pinned.ptr); assert_eq!(pinned.ptr, pinned.value as *const String); // 下面兩行一旦放開都無法編譯 // let r mut *pinned; // sr.value String::from(world); } fn main() { demonstrate_move(); demonstrate_pin(); }編譯運(yùn)行后你會(huì)看到類似下面的輸出地址值每次運(yùn)行可能不同before move: value 0x7ffd3f5e1aa8, ptr 0x7ffd3f5e1aa8 after move: value 0x7ffd3f5e1a90, ptr 0x7ffd3f5e1aa8 after pin: value 0x7ffd3f5e1a78, ptr 0x7ffd3f5e1a78可以看到before move階段ptr和value的地址一致指針有效。移動(dòng)發(fā)生后value被搬到了新地址但ptr仍指向舊地址自引用已經(jīng)失效。使用Pin固定后我們依然可以通過pinned.value訪問值且ptr與當(dāng)前value地址保持一致自引用是有效的。4.2 安全性驗(yàn)證在demonstrate_pin中我注釋了兩行代碼。如果你試著把這兩行注釋放開會(huì)遇到不同的編譯錯(cuò)誤。第一行l(wèi)et r mut *pinned;這里會(huì)失敗因?yàn)槲覀兊暮喕鍼in只為T: Unpin實(shí)現(xiàn)了DerefMut而SelfReferential通過PhantomPinned已經(jīng)變成了!Unpin。編譯器會(huì)告訴你無法通過Pin獲取可變引用這正是Pin的保護(hù)邊界。第二行sr.value String::from(world);這里會(huì)失敗因?yàn)閟r的可變借用已經(jīng)被pinned持有。借用檢查器知道Pin還存活所以不允許再通過原變量修改sr這從另一個(gè)角度防止了值被移動(dòng)或替換。4.3 這個(gè)小版本缺了什么我們的簡化版Pin雖然體現(xiàn)了核心思路但還缺少很多工程能力它只能持有a mut T不能持有BoxT、RcT等智能指針。它沒有實(shí)現(xiàn)Drop相關(guān)的特殊處理無法描述析構(gòu)期間的額外約束。它不支持 pin 投影pin projection即無法安全地把Pinmut Struct拆成Pinmut Field的形式。這些功能并不影響我們理解Pin的本質(zhì)但到了真實(shí)項(xiàng)目里就會(huì)變得很重要。5. 真實(shí) Rust 中 Pin 與簡化版的差異5.1 標(biāo)準(zhǔn)庫 Pin 的結(jié)構(gòu)真實(shí)的標(biāo)準(zhǔn)庫Pin定義大概是這樣的思路pub struct PinP { pointer: P, }它包裝的是指針類型P而不是固定的a mut T。所以PinBoxT、Pinmut T、PinRcT本質(zhì)上都是同一種PinP在不同指針參數(shù)下的實(shí)例。這讓標(biāo)準(zhǔn)庫Pin可以同時(shí)支持堆固定和棧固定也可以與Box、Rc等所有權(quán)模型無縫配合。我們實(shí)現(xiàn)的簡化版本只是截取了最核心的“不可移動(dòng)約束”這一層語義。標(biāo)準(zhǔn)庫還規(guī)定PinP自身不保證P: Unpin但Pinmut T通常會(huì)實(shí)現(xiàn)Unpin。這些細(xì)節(jié)在簡化版中沒有展開但理解它們對(duì)深入使用 Rust 異步生態(tài)很重要。5.2 堆固定與棧固定在異步編程中最常見的兩種Pin形態(tài)是// 堆固定把值放入堆內(nèi)存返回 PinBoxT let pinned_box: PinBoxSelfReferential Box::pin(sr); // 棧固定pin! 宏在棧上固定值返回 Pinmut T let pinned_ref: Pinmut SelfReferential pin!(sr);堆固定的好處是PinBoxT自身可以自由移動(dòng)因?yàn)樗鼉?nèi)部保存的是指向堆內(nèi)存的指針堆內(nèi)存地址不會(huì)因?yàn)锽ox的移動(dòng)而改變。這意味著PinBoxT可以跨函數(shù)返回、存入結(jié)構(gòu)體或跨線程傳遞。棧固定通常更高效但需要保證Pinmut T自身不會(huì)被移動(dòng)。很多異步運(yùn)行時(shí)內(nèi)部會(huì)使用棧固定來避免不必要的堆分配。5.3 為什么 async 中到處都是 Pin如果你寫過 Rust 的異步代碼一定見過類似這樣的類型PinBoxdyn FutureOutput ()Future 本質(zhì)上是一個(gè)狀態(tài)機(jī)。當(dāng)代碼中存在跨await的局部變量引用時(shí)狀態(tài)機(jī)內(nèi)部可能生成自引用結(jié)構(gòu)。例如async fn example() { let s String::from(hello); let r s; some_future().await; println!({}, r); }在編譯器生成的 Future 狀態(tài)機(jī)中s和r有可能以自引用形式存在。由于這種狀態(tài)機(jī)可能被移動(dòng)、被傳入 executor 或跨線程調(diào)度Rust 需要Pin來保證狀態(tài)機(jī)在被 poll 期間不會(huì)被移動(dòng)。這也是為什么Future::poll的簽名要求self: Pinmut Self而不是普通的mut Self。理解了自引用結(jié)構(gòu)體和Pin的關(guān)系之后再看異步源碼就不會(huì)覺得Pin是憑空出現(xiàn)的魔法了。6. 常見理解誤區(qū)與排查思路6.1 誤區(qū)Pin 就是堆分配很多人第一次看到PinBoxT會(huì)誤以為Pin等同于堆分配。實(shí)際上Pin是一種類型層面的約束堆分配只是Box::pin的一種實(shí)現(xiàn)方式。pin!宏可以棧上固定不需要堆分配。反過來Box::new也只是堆分配如果不配合Pin并不能給自引用結(jié)構(gòu)體提供移動(dòng)保護(hù)。6.2 誤區(qū)!Unpin 類型永遠(yuǎn)不能移動(dòng)正如前文強(qiáng)調(diào)的那樣!Unpin只表示“一旦被Pin固定之后不能移動(dòng)”。類型自己并不阻止移動(dòng)。一個(gè)包含PhantomPinned的普通變量在尚未被Pin包裹時(shí)依然可以賦值給其他變量或作為參數(shù)傳入函數(shù)。換句話說!Unpin不是全局禁止移動(dòng)而是把“是否允許移動(dòng)”的決策權(quán)交給Pin和 unsafe 代碼。6.3 常見編譯錯(cuò)誤下面整理了一些實(shí)際開發(fā)中常見的錯(cuò)誤信息、原因和處理思路。不同 Rust 版本下錯(cuò)誤文案可能略有差異但排查邏輯是通用的。錯(cuò)誤現(xiàn)象常見原因解決思路error[E0277]: the trait bound T: Unpin is not satisfied對(duì)!Unpin類型使用了Pin::new或其它要求Unpin的安全 API改用unsafe Pin::new_unchecked并確保固定期間不移動(dòng)或者讓類型重新實(shí)現(xiàn)Unpinerror[E0596]: cannot borrow as mutable可變借用已交給Pin又嘗試通過原變量再次修改通過Pin的只讀接口訪問如果確認(rèn)安全可以使用unsafe get_unchecked_muterror[E0594]: cannot assign ...嘗試通過解引用Pin修改一個(gè)!Unpin內(nèi)部值檢查設(shè)計(jì)是否需要可變?cè)L問切勿盲目加unsafeerror[E0505]: cannot move out ... because it is borrowed在Pin存活期間嘗試移動(dòng)原始變量調(diào)整生命周期讓Pin先析構(gòu)再處理原變量future is not Send異步狀態(tài)機(jī)中持有跨await的自引用或非Send類型檢查狀態(tài)機(jī)包含的字段必要時(shí)加鎖或調(diào)整結(jié)構(gòu)避免持有自引用6.4 排查清單如果你在一個(gè)!Unpin相關(guān)問題上卡了很久我建議按下面的順序排查