Rust Pin深度解析:从自引用陷阱到async执行器的底层设计 📅 发布时间:2026/8/31 11:59:40 👁 浏览次数: Rust 里劝退率最高、讨论热度也最高的类型Pin如果不排第一至少也在前三。它不像Option、Result那样天天用但一旦你碰 async 执行器、自引用数据结构、底层 Future 实现第一个撞上的就是它。更麻烦的是网上讲Pin的文章大多停留在怎么用告诉你Box::pin包一层、PhantomPinned放一个、Unpin记一下但很少有人讲清楚它为什么长这样为什么标准库非要设计成这么绕。这篇我们换一个更硬核的角度Implementing Rusts Pin from Scratch, More or Less。也就是不完全依赖标准库的魔法从我到底需要一个什么类型出发手写一个近似Pin的东西再回头看标准库的Pin到底多了什么。整个过程会覆盖自引用结构体、Unpinauto trait、Deref/DerefMut条件实现、Box::pin/pin!两种固定方式以及 async 执行器里Future::poll为什么必须是Pinmut Self。先说结论Pin的本质不是指针魔法而是用类型系统把mut T的可访问性卡住配合 unsafe 约定来阻止自引用值被移动。理解这一层后面所有细节都能顺下来。如果你最近在学 Rust 语言、准备深入 async/await或者想自己写一个执行器、状态机框架这篇文章建议收藏备用。1. 核心概念速览先给一张速览表把整个Pin体系涉及的核心概念和它们各自的作用列清楚后面展开讲的时候可以随时回来对照。概念作用难点PinP包装一个指针类型当P::Target: !Unpin时阻止目标值被移动需要理解它与Deref的联动Unpinauto trait标记类型能否安全移动所有普通类型默认实现auto trait 的自动推导规则PhantomPinned零大小标记类型用来让结构体变为!Unpin它本身只是一个标记没有任何运行时行为Box::pin在堆上分配值并返回PinBoxT把值固定在堆地址与Box::new的区别pin!在栈上固定值的宏返回Pinmut T生命周期和作用域约束DerefMut条件实现T: Unpin时可以实现DerefMut否则不能拿到mut T这是Pin限制可变访问的关键机制这六个概念是一套组合不是孤立的知识点。Unpin决定一个值能不能移动PhantomPinned让一个值不能移动PinP负责在类型层面标记这个值已经被固定Box::pin和pin!是两种创建固定值的方式DerefMut的条件实现则是实际拦截可变访问的关卡。2. Pin 到底解决什么问题自引用结构体的移动陷阱先看一个所有 Rust 初学者都容易被坑的问题自引用结构体。struct SelfReferential { data: String, self_ref: *const String, }这个结构体里的self_ref指向自己的data字段。初始化的时候可以这样写let mut value SelfReferential { data: String::from(hello), self_ref: std::ptr::null(), }; value.self_ref value.data;现在value.self_ref保存的是value.data的地址。如果后续代码把value移动了let moved value;问题就出现了value的data字段被搬到了新的内存地址但self_ref里存的还是旧地址。旧地址上的内存已经不再属于这个值再通过self_ref解引用就是悬垂指针属于未定义行为。普通结构体不会有这个问题因为它不会保存指向自己内部的指针。但异步编程里自引用结构体几乎不可避免。一个 async 块的局部变量在跨await点需要保持状态编译器生成的匿名 Future 结构体里某些字段可能保存着指向其他字段的引用。一旦这个 Future 被移动引用就悬垂了。Pin的解法非常直接不让你移动那个值。它通过类型系统把一个值标记为已固定然后所有需要mut T或移动操作的路径都被拦截。你想拿到mut T可以但必须是T: Unpin也就是编译器确认这个类型移动是安全的。否则你只能通过 unsafe 接口在保证固定期间不移动的前提下操作。这里要强调一个关键点Pin限制的是移动不是访问。只读访问一直都可以可变访问被限制移动操作被禁止。这个设计思路贯穿整个Pin的 API。3. 从零手写一个最小 Pin标题既然叫 Implementing Rusts Pin from ScratchMore or Less那这一节就真正动笔写一个简化版。先想清楚一个最小可用 Pin需要满足什么条件包装一个值对外提供只读访问。当值的类型是Unpin时允许获取mut T。当值的类型是!Unpin时从 API 层面禁止获取mut T。运行时不引入额外开销最好是一个零大小包装。基于这个设计可以先写一个只包装BoxT的版本因为BoxT是最常用的托管方式写成Box::pin也最接近实际场景。use std::ops::{Deref, DerefMut}; /// 手写版 Pin它的职责就是在 T: !Unpin 时 /// 让调用方无法通过安全 API 拿到 mut T。 pub struct PinnedBoxT { inner: BoxT, } implT PinnedBoxT { pub fn new(value: T) - Self { Self { inner: Box::new(value), } } } implT Deref for PinnedBoxT { type Target T; fn deref(self) - Self::Target { self.inner } } implT: Unpin DerefMut for PinnedBoxT { fn deref_mut(mut self) - mut Self::Target { mut self.inner } }这个版本核心的思路就一句话DerefMut的实现带上T: Unpin约束。T默认都是Unpin所以普通类型照常使用一旦T通过某种方式比如包含PhantomPinned变成了!UnpinPinnedBoxT就不再实现DerefMut调用方试图写mut *pinned_box会直接编译失败。可以写一个小测试验证这个约束// 这个类型包含 PhantomPinned整体是 !Unpin use std::marker::PhantomPinned; struct NotUnpin { _marker: PhantomPinned, } fn main() { let mut pinned PinnedBox::new(NotUnpin { _marker: PhantomPinned }); // 只读访问没问题 let _r: NotUnpin pinned; // 可变访问编译失败 // error[E0277]: NotUnpin cannot be unpinned // let _m: mut NotUnpin mut *pinned; }但这里有一个细节PinnedBoxT自己有没有实现Unpin实际是实现了的因为它内部只有BoxTBoxT无论T是不是Unpin自己都是Unpin。所以PinnedBoxT这个包装本身可以被移动移动的只是指向堆内存的指针堆上的T数据不会动。这个层级关系很重要包装器可以移动被固定的目标值不能移动。标准库PinP也是这样它有implP Unpin for PinP {}的专门实现。不过这个最小的 Pin离标准库还很远主要有三个缺口第一它只能包装BoxT标准库的PinP可以接受任意指针类型包括mut T、BoxT、RcT、ArcT等只要实现了Deref。第二它没有提供如何在!Unpin类型上安全建立可变引用的 unsafe 通道。真实场景里自引用结构体必须在初始化阶段拿到mut Self来设置内部指针这个操作不可能完全避免所以标准库提供了get_unchecked_mut把违背移动约束的责任交给调用方。第三它没有表达固定的生命周期语义。标准库Pin的 unsafe 约定是只要Pin活着被固定的值就不能移动。这个约定需要靠 unsafe 代码来维护类型系统只负责在安全代码里阻止移动。所以More or Less的意义就在这我们复刻了Pin的外层约束模型但Pin真正的安全边界必须由编译器辅助的Unpinauto trait 和标准库的 unsafe 契约共同完成纯用户态只能做到近似。4. 标准库 Pin 的完整使用模型再看标准库的Pin它并不是一个复杂魔法定义只有一行字段pub struct PinP { pointer: P, }P是一个指针类型比如mut T、BoxT、RcT。PinP提供两个核心构造方式// 安全构造只有 P::Target: Unpin 时才能用 implP PinP { pub fn new(pointer: P) - PinP where P: Deref, P::Target: Unpin, { ... } } // unsafe 构造调用方必须保证被固定值在 Pin 存活期间不被移动 implP PinP { pub const unsafe fn new_unchecked(pointer: P) - PinP { ... } }Pin::new是安全函数但约束了P::Target: Unpin。也就是说如果你拿Pin::new去包一个!Unpin类型的引用编译器会拒绝。Pin::new_unchecked是 unsafe 函数它不对Unpin做任何要求。这给了!Unpin类型一个入口通过 unsafe 代码把mut T或BoxT包装成Pin但调用方必须自行承诺在固定期间不移动目标值。取回引用的方法也很讲究implP: Deref PinP { pub fn as_ref(self) - PinP::Target { unsafe { Pin::new_unchecked(*self.pointer) } } } implP: DerefMut PinP { pub fn as_mut(mut self) - Pinmut P::Target { unsafe { Pin::new_unchecked(mut *self.pointer) } } }注意as_ref是安全函数任何情况下都可以拿只读引用as_mut返回的是Pinmut P::Target不是普通的mut T。这意味着你拿到手的是一个仍然受 Pin 保护的可变引用想通过它移动值依然不行。如果确实需要直接拿裸的mut T标准库提供了get_unchecked_mutimplP: DerefMut PinP { pub unsafe fn get_unchecked_mut(self) - a mut P::Target { ... } }这个函数是 unsafe 的因为它破坏了 Pin 的约束调用方必须自行保证后续不会移动目标值。自引用结构体初始化时通常就用它。还有一个值得注意的 API 是set方法implP: DerefMut PinP { pub fn set(mut self, value: P::Target) where P::Target: Sized, { ... } }它的作用是把固定的值替换成新值但保证旧值不会被移动而是先 drop 再放置。这在某些需要重置内部状态但不想破坏固定位置的场景里挺有用。5. Unpin 与 PhantomPinned谁决定能不能移动Pin只是一个包装真正决定能不能移动的是Unpin这个 auto trait。Unpin是一个标记 trait它的定义大致是这样的pub auto trait Unpin {}这里需要理解 Rust auto trait 的推导规则如果一个类型的字段都实现了Unpin那么这个类型自动实现Unpin只要有一个字段是!Unpin整个类型就是!Unpin。所有基本类型、String、VecT、BoxT这些默认都是Unpin因为它们内部没有自引用。所以对一个普通类型来说它既可以被移动也可以用Pin包装两种方式都安全。那怎么让一个类型变成!Unpin标准库提供了一个专门的零大小类型pub struct PhantomPinned;这个类型没有实现Unpin所以你的结构体只要包含它整个类型自动推导为!Unpinuse std::marker::PhantomPinned; struct SelfReferential { data: String, self_ref: *const String, _pin: PhantomPinned, }注意PhantomPinned是零大小类型不占内存也没有任何运行时行为。它纯粹是一个编译期标记告诉编译器这个类型的值如果被移动内部自引用会悬垂必须通过Pin保护。所以判断一个类型能不能移动可以写一个很实用的编译期断言fn assert_unpinT: Unpin() {} // 普通类型编译通过 assert_unpin::String(); // 包含 PhantomPinned 的类型编译失败 // assert_unpin::SelfReferential();这个断言在调试复杂泛型时很好用能快速确认一个类型到底是不是Unpin。很多 Rust 项目里也能看到类似的assert_impl_all!宏在测试里做类型约束检查。6. 自引用结构体实战从定义到初始化现在把前面所有概念串起来写一个完整的自引用结构体。use std::marker::PhantomPinned; use std::pin::Pin; struct SelfReferential { data: String, self_ref: *const String, _pin: PhantomPinned, } impl SelfReferential { fn new(data: String) - Self { Self { data, self_ref: std::ptr::null(), _pin: PhantomPinned, } } /// 初始化自引用。此时必须通过 Pin 传入 mut Self。 fn init(self: Pinmut Self) { let this unsafe { self.get_unchecked_mut() }; this.self_ref this.data; } /// 读取自引用内容。 fn get_ref(self: PinSelf) - String { unsafe { *self.self_ref } } }这里的self: Pinmut Self是 Rust 2021 里支持的接收者写法直接把方法限制为只能由Pinmut Self调用。init方法里用了self.get_unchecked_mut()这是 unsafe 操作。为什么必须 unsafe因为调用方要承诺这个值在init之后不会再被移动。get_unchecked_mut把这个承诺的责任从类型系统转移给了调用方所以需要 unsafe 块包裹。get_ref里*self.self_ref也是 unsafe因为self_ref是裸指针裸指针解引用永远需要 unsafe。主函数这样调用fn main() { let mut value Box::pin(SelfReferential::new(String::from(hello))); value.as_mut().init(); println!({}, value.as_ref().get_ref()); }运行结果是输出hello。这里的执行顺序很关键Box::pin在堆上分配了SelfReferential返回PinBoxSelfReferential。value.as_mut()把PinBoxT变成Pinmut T然后调用init建立自引用。value.as_ref()变成PinT调用get_ref读取self_ref指向的内容。因为整个值一直在堆上地址没有变所以self_ref一直有效。如果不用Box::pin而是想在栈上固定可以这样做fn main() { // 需要用 unsafe因为 SelfReferential 是 !Unpin let mut value SelfReferential::new(String::from(hello)); let mut pinned unsafe { Pin::new_unchecked(mut value) }; pinned.as_mut().init(); println!({}, pinned.as_ref().get_ref()); }这种写法的问题是value仍然是一个普通栈变量编译器不会阻止你在Pin结束后再次移动它。只要你在pinned析构之后把valuemove 到别的位置self_ref就悬垂了。这也是为什么Pin::new_unchecked要求你保证固定期间不移动。所以实际工程中自引用结构体首选Box::pin或pin!宏。Box::pin把数据放在堆上Box指针本身随便移动堆上数据不变pin!则通过宏的绑定结构让栈上固定的值无法被重新绑定。7. 堆上 Box::pin 与栈上 pin!两种固定方式怎么选先看pin!宏的用法fn main() { let value pin!(SelfReferential::new(String::from(hello))); // 注意这里 value 本身就是 Pinmut SelfReferential value.init(); println!({}, value.get_ref()); }pin!宏展开后会创建一个匿名的临时绑定来持有数据然后返回一个指向该数据的Pinmut T。它要求这个Pin不能逃逸出当前作用域否则编译器会报错。所以pin!天然适合局部变量的场景。Box::pin则是把所有数据搬到堆上返回PinBoxT可以随意返回给调用方只要整个PinBoxT不被销毁堆上的数据就始终固定在同一个地址。两者的选择建议使用场景推荐方式原因函数内部临时固定一个值pin!零堆分配作用域安全需要把固定的值返回给调用方Box::pin堆上数据不受栈帧影响生命周期灵活自引用结构体作为对象长期存在Box::pin规避栈上被意外移动的风险泛型函数中创建一个固定的临时值pin!语法简便不要求额外分配Box::pin和Box::new的内存开销一致都是一次堆分配。PinBoxT与BoxT的内存布局相同没有额外字段所以性能上不存在额外损耗。还有一种从PinBoxT转换成Pinmut T的常见方式let mut boxed Box::pin(SelfReferential::new(String::from(hello))); let mut_pinned: Pinmut SelfReferential boxed.as_mut();反过来要得到一个PinBoxT除了Box::pin也可以先构造BoxT再包裹let boxed Box::new(SelfReferential::new(String::from(hello))); let pinned unsafe { Pin::new_unchecked(boxed) };但Box::pin已经把这件事封装好了实际项目里直接用Box::pin就行。8. Pin 在 async 执行器中的角色理解了上面这些就可以看懂 async 执行器里最关键的那段设计了。Futuretrait 的定义长这样pub trait Future { type Output; fn poll(self: Pinmut Self, cx: mut Context_) - PollSelf::Output; }注意poll的第一个参数是self: Pinmut Self不是普通的mut Self。为什么必须是Pinmut Self因为一个 async 块编译后生成的匿名结构体本质上就是一个自引用结构体。举个例子async fn example() { let x String::from(hello); let y x; some_await().await; println!({}, y); }编译器生成的 Future 结构体里y可能保存着指向x的引用。x和y都是这个 Future 的字段因此y指向的是自己结构体内部的另一个字段。跨过await点之后这个 Future 如果在内存里被移动y就悬垂了。所以 async 执行器在驱动 Future 时必须保证 Future 在内存中的位置不变。执行器内部通常会这样处理fn spawnF: Future(future: F) { let mut future Box::pin(future); let waker ...; let mut cx Context::from_waker(waker); loop { match future.as_mut().poll(mut cx) { Poll::Ready(value) break value, Poll::Pending continue, } } }Box::pin把 async 块生成的 Future 固定在堆上future.as_mut()得到Pinmut F才能安全调用poll。如果没有Pin执行器每次移动 Future 都可能导致内部自引用悬垂整个 async 模型就无从谈起。理解了这一点你再看网上讨论的Pin 是 async 的基础这种说法就会有具体的感知它不是因为语言设计者喜欢复杂而是因为 async 状态机确实需要自引用字段。9. 常见误区与编译错误排查Pin相关的问题里有几类错误几乎是每个初学者都会踩的。整理成一张排查表。问题现象可能原因排查方式解决方案对PinBoxT调用mut *value编译失败T是!Unpin标准库没有给Pin实现DerefMut检查T是否包含PhantomPinned改用value.as_mut()拿Pinmut T确认可以移动再用pin!或重新设计Pin::new报T: Unpin不满足new函数要求P::Target: Unpin检查目标类型是否!Unpin使用unsafe { Pin::new_unchecked(...) }并仔细确认不移动约束poll方法报错提示无法移动selfpoll 接收者类型不匹配检查poll签名是不是self: Pinmut Self按Futuretrait 签名实现外层用Box::pin或pin!包裹自引用结构体初始化后self_ref悬垂结构体被移动了确认是否用Box::pin或pin!固定固定后不要再 move 堆上的值也不要通过安全 API 取mut Tassert_unpin::T()编译失败类型包含PhantomPinned或!Unpin字段查看类型定义中的字段确认这是否是业务需求去掉PhantomPinned则恢复Unpin使用pin!宏时生命周期报错Pinmut T逃出了作用域检查是否把pin!返回值传给外界改用Box::pin堆分配手写PinnedBoxT无法访问mut TDerefMut条件实现约束了T: Unpin检查解引用操作如果需要可变访问确认类型应该实现Unpin否则改用 unsafe 通道其中最常见的组合错误是自己定义了一个自引用结构体想用PinBoxT把它包住然后直接写mut *boxed拿可变引用结果编译失败。PinBoxT并没有可变解引用拿到的只能是Pinmut T需要再次通过as_mut或其他方式处理。另一个值得警惕的坑是PinBoxT本身是Unpin的但不要把Pin 指针可以移动误当成目标值可以移动。PinBoxT这个盒子在栈上随便移但盒子指向的堆内存地址不变目标值T并没有移动。真正不能移动的是T本身。这个层级区分清楚了很多困惑会自动消失。10. 性能开销与工程建议先说性能结论Pin是典型的零成本抽象PinP只有一个字段pointer与P本身内存布局完全一致没有额外字段、没有对齐开销。Pinmut T与mut T的表示相同PinBoxT与BoxT的表示也相同。Box::pin与Box::new同样都是一次堆分配不存在额外分配。pin!宏在栈上固定零堆分配连运行时检查都没有。真正的成本来自 unsafe 约定的人为维护一旦写错就是悬垂指针和未定义行为。所以工程上的核心原则是能用安全 API 就不用 unsafe能少写自引用结构体就少写。几个具体的建议第一如果只是在 async 函数内部使用直接让编译器处理即可通常不需要手动碰Pin。只有当你实现自定义Future、写执行器、或者做状态机框架时才需要直接操作Pin。第二需要自引用结构体时优先考虑Box::pin不要逞强在栈上用Pin::new_unchecked包一个普通变量。栈上固定容易在作用域结束后被意外移动堆上固定则安全很多。第三Pin与RcRefCellT经常搭配使用。Rc指针本身不要求目标必须Unpin你只要保证在Pin存活期间不通过其他途径移动目标值。这种组合在 GUI 框架和异步状态管理里很常见。第四如果要在自己写的库中暴露自引用类型建议直接设计成返回PinBoxT的 API把固定操作封装在构造函数内部而不是让调用方自己处理 unsafe 细节。这样接口的安全边界更清晰调用方也不容易踩坑。11. 最佳实践什么时候必须用 Pin什么时候别用不是所有场景都需要Pin。反过来说有些场景不用Pin根本写不出来。必须使用Pin的场景包括实现自定义Futuretrait 的poll方法。自引用数据结构尤其是内部字段保存着指向自己其他字段的指针。异步执行器、状态机、任务调度器的底层实现。需要长期固定某个值在内存地址不被移动的底层库代码。不需要使用Pin的场景包括普通的数据结构设计没有自引用、跨await状态保存等需求。一般的BoxT、RcT值传递。用 async/await 写业务代码但不自己实现Future。大部分 Web 服务业务逻辑。判断规则很简单你的类型如果实现了Unpin移动它就绝对安全Pin是可选的如果类型是!Unpin移动它有未定义行为风险就必须用Pin保护。对绝大多数业务代码来说类型都是Unpin所以Pin才会显得那么特殊。如果你真的想彻底掌握这段内容最有效的办法不是再刷十遍文档而是自己动手写一个带Pin的自引用结构体然后跑一遍初始化、读取、移动失败这三个流程。把Box::pin、pin!、Pin::new_unchecked、get_unchecked_mut这几个 API 都实际调用一遍比背任何概念都有用。这个方向再往下可以继续研究Future状态机的内部结构、手写一个极简 async executor或者看看futurescrate 里pin_mut!、pin_project这类宏是怎样包装 unsafe 逻辑的。把Pin从好像懂了变成真的能写你就已经超过大部分踩坑者了。