公司动态
手写简化版Pin:深入理解Rust中禁止移动的核心机制
之前读 async 相关源码的时候经常看到PinBoxdyn Future这种类型。一开始我以为它只是一种普通的智能指针后来才意识到Pin的核心并不是“管理内存”而是“禁止移动”。为了彻底弄懂它我尝试从零手写一个简化版Pin并对照标准库做了一次梳理。这个过程对理解 Rust 生命周期、Unpin、PhantomPinned以及 async/await 背后的机制帮助很大。这篇文章就是这次尝试的完整记录适合已经能编写简单 Rust 代码、想深入理解Pin的读者。全文会从自引用结构体的问题出发手写一个 “More or Less” 版本的Pin再对照真实 Rust API 说明差异和常见误区。1. 从问题说起自引用结构体为什么危险1.1 什么是自引用结构体Rust 中一个结构体的字段可能保存着指向同一结构体其它字段的指针这种结构体通常被称为自引用结构体Self-Referential Struct。下面是一个非常典型的最小例子struct SelfReferential { value: String, ptr: *const String, }初始化时我们让ptr指向value字段let mut sr SelfReferential { value: String::from(hello), ptr: std::ptr::null(), }; sr.ptr sr.value as *const String;这时sr.ptr保存的地址就是sr.value在当前栈帧中的地址。如果后续操作不移动sr那么这个指针是有效的一旦sr在内存中被搬到了其它位置sr.value的地址会发生变化而sr.ptr仍然保留旧地址成为悬垂指针。这种结构在实际开发中并不少见。例如手写一个内部包含指向自身缓冲区的解析器、实现零拷贝解码器、或者设计某些缓存结构时都可能产生自引用。Rust 的 async 状态机内部也会生成类似结构这也是Pin被引入语言的重要原因之一。1.2 移动带来的悬垂指针Rust 中的赋值、函数传参、放入容器等操作都可能发生移动move。对普通结构体来说移动时整个值会被逐字节拷贝到新位置所有字段同步搬迁所以移动本身是安全的。问题在于如果我们手动保存了移动前的内部地址移动后这个地址不会自动更新。下面这段代码展示了一个常见现象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); }运行后before move和after move的输出中value的地址往往会不同而ptr仍然是移动前的旧地址。这意味着moved.ptr已经指向了旧栈位置继续对它解引用就是典型的内存安全问题。这里需要澄清一点编译器并不保证每次移动后地址一定不同也可能出现地址恰好复用的情况。但从语言语义上讲移动后value的地址是不可依赖的存在悬垂风险就足以判定这种设计不安全。1.3 为什么编译器不能自动处理有读者可能会问既然移动会导致自引用失效Rust 编译器难道不能自动修复ptr吗答案是否定的。Rust 的移动语义只是把值从一块内存搬到另一块内存编译器不知道结构体内部哪些裸指针字段需要同步更新。更重要的是一旦引入了“移动时自动修复内部指针”的机制结构体的复制和移动规则就会变得异常复杂会直接破坏 Rust 对所有权的确定性管理。因此Rust 选择在类型系统层面解决这个问题如果某个类型需要自引用它应该被标记为!Unpin然后用Pin来保证在固定期间不发生移动。Pin的本质承诺是一旦某个值被“钉住”安全代码就拿不到它的mut T从而无法将它移动到其它位置也无法用其它值替换它。这正是自引用结构体需要的保护。2. 理解 Unpin、!Unpin 和 PhantomPinned2.1 Unpin 是什么Unpin是 Rust 标准库中的一个自动 traitauto trait。所谓自动 trait意味着绝大多数类型无需手动实现就会自动获得Unpin。T: Unpin语义上表示T类型的值可以在内存中安全地移动。普通结构体、枚举、基本类型几乎都是Unpin的。你可以把Unpin理解为“移动没有额外代价和额外约束”的类型标记。举个例子struct NormalStruct { a: u32, b: String, }NormalStruct默认就是Unpin的因为移动它不会破坏任何内部自定义指针。即使String内部包含堆指针也不受影响因为String的堆指针会随整个结构体一起移动而String内部并不保存指向自身栈地址的引用。2.2 PhantomPinned 如何产生 !Unpin如果希望一个类型是!Unpin也就是“移动后可能不安全”稳定版 Rust 并不能直接写impl !Unpin for MyType。这是未稳定功能我们只能借助标准库里的PhantomPinned。PhantomPinned是一个零大小类型zero-sized type它本身实现了!Unpin。由于 Rust 的自动 trait 会沿着结构体字段传播只要一个结构体里包含PhantomPinned字段整个结构体就会自动变成!Unpin。看下面的例子use std::marker::PhantomPinned; struct SelfReferential { value: String, ptr: *const String, _pin: PhantomPinned, // 使整个结构体变成 !Unpin }这个_pin字段不会占用任何内存它的唯一作用是参与类型系统的约束。引入它之后SelfReferential就不再实现Unpin从而进入Pin的保护范围。2.3 “没被 Pin 之前”仍然可以移动一个刚刚创建、还没有被Pin包裹的!Unpin类型仍然可以随意移动。!Unpin只表示“这类值被固定后不能移动”并不表示“永远不能移动”。这一点非常重要。很多人看到!Unpin就认为这种类型完全不能移动这是一个常见的误解。实际上PhantomPinned只是让类型具备“需要被固定”的资格真正执行“禁止移动”约束的是Pin及借用检查器。只有把一个!Unpin类型交给Pin管理之后安全代码才无法再获得它的mut T从而无法移动、替换、swap 它。3. 从零实现一个简化版 Pin3.1 设计目标标准库的PinP内部实现比较复杂它需要支持P是Box、mut T、Rc等各种指针类型。我们这里不打算复制整个标准库而是实现一个“More or Less”版本只保留最核心的安全语义。我们的目标非常明确只能通过“安全构造”创建一个指向T: Unpin值的Pin。对于T: !Unpin的值只能通过unsafe方法创建Pin并且调用者需要保证固定期间不得移动该值。任何情况下都可以从Pin获取不可变引用T。只有在T: Unpin时才能安全获取mut T。当T: !Unpin时只能通过unsafe方法获取mut T。这个设计正好对应标准库最核心的安全边界。为了简化我们让Pin持有a mut T。3.2 结构体定义在 Rust 中一个最简单的最小实现可以写成这样use std::ops::{Deref, DerefMut}; pub struct Pina, T: ?Sized { inner: a mut T, }inner是被钉住值的可变借用。我们持有a mut T本身就能利用 Rust 的借用规则来阻止外部在Pin存活期间再次可变访问原变量。注意T: ?Sized是必要的因为T可以是动态大小类型例如dyn Future或切片类型。a mut T本身可以指向 unsized 类型。3.3 安全构造new当T: Unpin时这个类型的值可以安全移动因此我们允许安全代码直接构造Pinimpla, T: ?Sized Pina, T { pub fn new(inner: a mut T) - Self where T: Unpin, { Pin { inner } } }这里的关键是T: Unpin约束。编译器看到这个约束后只有T确实实现了Unpin调用者才能使用Pin::new。对于!Unpin类型这条路被堵死。如果你尝试对一个!Unpin的类型调用Pin::new会得到类似这样的错误error[E0277]: the trait bound SelfReferential: Unpin is not satisfied3.4 不安全构造new_unchecked对于!Unpin类型我们只能提供一个unsafe构造函数impla, T: ?Sized Pina, T { pub unsafe fn new_unchecked(inner: a mut T) - Self { Pin { inner } } }为什么这是unsafe原因在于调用者必须承诺一个关键不变式在Pin存活期间被钉住的值不会发生移动。由于a mut T已经从原变量手中接管了可变借用借用检查器已经能阻止很多危险操作但unsafe构造仍然要求调用者理解深层语义而不能只靠编译器的借用规则兜底。举个例子如果inner指向堆内存并且之后有人释放了这块内存即使没有移动Pin持有的指针也可能悬垂。这类问题超出了普通借用检查的范围需要调用者谨慎处理。在真实项目中每次使用new_unchecked时都应该在注释里写明这个不变式由谁维护、在什么范围有效。3.5 获取引用的方法我们还需要提供三个核心方法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是安全的因为无论T是否Unpin共享引用都不会导致值被移动。get_mut只在T: Unpin时可用因为只有这种类型才允许被可变借用后移动或替换。get_unchecked_mut是unsafe的调用者必须保证拿回mut T后不会移动这个值、不会用std::mem::replace等操作替换它也不会在Pin仍存活时再次固定它。这些方法虽然简单却完整表达了Pin对可变访问的约束边界。3.6 实现 Deref 与 DerefMut为了让Pin能像普通引用一样方便地访问内部字段我们需要实现Deref。Deref在任何情况下都应该实现因为共享引用不会破坏固定语义implT: ?Sized Deref for Pin_, T { type Target T; fn deref(self) - Self::Target { self.inner } }DerefMut则必须谨慎。它只应该在T: Unpin时实现implT: Unpin ?Sized DerefMut for Pin_, T { fn deref_mut(mut self) - mut Self::Target { self.inner } }如果对T: !Unpin也实现了DerefMut那么安全代码就可以直接写mut *pin从而把内部值拿出去移动或替换整个Pin的保护就形同虚设。因此DerefMut的Unpin约束是整个设计的关键防线。到这里一个简化版Pin的核心已经完成。4. 用简化版 Pin 实现自引用结构体4.1 完整示例代码下面的示例把上面的Pin实现与自引用结构体放在同一个文件里。你可以直接复制到一个 Rust 项目中运行。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 } } // ---------- 自引用结构体 ---------- 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 存活期间不会移动 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(); }编译运行后你会看到类似下面的输出地址值每次运行可能不同before move: value 0x7ffd3f5e1aa8, ptr 0x7ffd3f5e1aa8 after move: value 0x7ffd3f5e1a90, ptr 0x7ffd3f5e1aa8 after pin: value 0x7ffd3f5e1a78, ptr 0x7ffd3f5e1a78可以看到before move阶段ptr和value的地址一致指针有效。移动发生后value被搬到了新地址但ptr仍指向旧地址自引用已经失效。使用Pin固定后我们依然可以通过pinned.value访问值且ptr与当前value地址保持一致自引用是有效的。4.2 安全性验证在demonstrate_pin中我注释了两行代码。如果你试着把这两行注释放开会遇到不同的编译错误。第一行let r mut *pinned;这里会失败因为我们的简化版Pin只为T: Unpin实现了DerefMut而SelfReferential通过PhantomPinned已经变成了!Unpin。编译器会告诉你无法通过Pin获取可变引用这正是Pin的保护边界。第二行sr.value String::from(world);这里会失败因为sr的可变借用已经被pinned持有。借用检查器知道Pin还存活所以不允许再通过原变量修改sr这从另一个角度防止了值被移动或替换。4.3 这个小版本缺了什么我们的简化版Pin虽然体现了核心思路但还缺少很多工程能力它只能持有a mut T不能持有BoxT、RcT等智能指针。它没有实现Drop相关的特殊处理无法描述析构期间的额外约束。它不支持 pin 投影pin projection即无法安全地把Pinmut Struct拆成Pinmut Field的形式。这些功能并不影响我们理解Pin的本质但到了真实项目里就会变得很重要。5. 真实 Rust 中 Pin 与简化版的差异5.1 标准库 Pin 的结构真实的标准库Pin定义大概是这样的思路pub struct PinP { pointer: P, }它包装的是指针类型P而不是固定的a mut T。所以PinBoxT、Pinmut T、PinRcT本质上都是同一种PinP在不同指针参数下的实例。这让标准库Pin可以同时支持堆固定和栈固定也可以与Box、Rc等所有权模型无缝配合。我们实现的简化版本只是截取了最核心的“不可移动约束”这一层语义。标准库还规定PinP自身不保证P: Unpin但Pinmut T通常会实现Unpin。这些细节在简化版中没有展开但理解它们对深入使用 Rust 异步生态很重要。5.2 堆固定与栈固定在异步编程中最常见的两种Pin形态是// 堆固定把值放入堆内存返回 PinBoxT let pinned_box: PinBoxSelfReferential Box::pin(sr); // 栈固定pin! 宏在栈上固定值返回 Pinmut T let pinned_ref: Pinmut SelfReferential pin!(sr);堆固定的好处是PinBoxT自身可以自由移动因为它内部保存的是指向堆内存的指针堆内存地址不会因为Box的移动而改变。这意味着PinBoxT可以跨函数返回、存入结构体或跨线程传递。栈固定通常更高效但需要保证Pinmut T自身不会被移动。很多异步运行时内部会使用栈固定来避免不必要的堆分配。5.3 为什么 async 中到处都是 Pin如果你写过 Rust 的异步代码一定见过类似这样的类型PinBoxdyn FutureOutput ()Future 本质上是一个状态机。当代码中存在跨await的局部变量引用时状态机内部可能生成自引用结构。例如async fn example() { let s String::from(hello); let r s; some_future().await; println!({}, r); }在编译器生成的 Future 状态机中s和r有可能以自引用形式存在。由于这种状态机可能被移动、被传入 executor 或跨线程调度Rust 需要Pin来保证状态机在被 poll 期间不会被移动。这也是为什么Future::poll的签名要求self: Pinmut Self而不是普通的mut Self。理解了自引用结构体和Pin的关系之后再看异步源码就不会觉得Pin是凭空出现的魔法了。6. 常见理解误区与排查思路6.1 误区Pin 就是堆分配很多人第一次看到PinBoxT会误以为Pin等同于堆分配。实际上Pin是一种类型层面的约束堆分配只是Box::pin的一种实现方式。pin!宏可以栈上固定不需要堆分配。反过来Box::new也只是堆分配如果不配合Pin并不能给自引用结构体提供移动保护。6.2 误区!Unpin 类型永远不能移动正如前文强调的那样!Unpin只表示“一旦被Pin固定之后不能移动”。类型自己并不阻止移动。一个包含PhantomPinned的普通变量在尚未被Pin包裹时依然可以赋值给其他变量或作为参数传入函数。换句话说!Unpin不是全局禁止移动而是把“是否允许移动”的决策权交给Pin和 unsafe 代码。6.3 常见编译错误下面整理了一些实际开发中常见的错误信息、原因和处理思路。不同 Rust 版本下错误文案可能略有差异但排查逻辑是通用的。错误现象常见原因解决思路error[E0277]: the trait bound T: Unpin is not satisfied对!Unpin类型使用了Pin::new或其它要求Unpin的安全 API改用unsafe Pin::new_unchecked并确保固定期间不移动或者让类型重新实现Unpinerror[E0596]: cannot borrow as mutable可变借用已交给Pin又尝试通过原变量再次修改通过Pin的只读接口访问如果确认安全可以使用unsafe get_unchecked_muterror[E0594]: cannot assign ...尝试通过解引用Pin修改一个!Unpin内部值检查设计是否需要可变访问切勿盲目加unsafeerror[E0505]: cannot move out ... because it is borrowed在Pin存活期间尝试移动原始变量调整生命周期让Pin先析构再处理原变量future is not Send异步状态机中持有跨await的自引用或非Send类型检查状态机包含的字段必要时加锁或调整结构避免持有自引用6.4 排查清单如果你在一个!Unpin相关问题上卡了很久我建议按下面的顺序排查