很多 Rust 开发者第一次被Pin卡住,不是在看文档的时候,而是在写异步代码的时候。一个看起来完全合理的async fn,编译时突然抛出一大屏错误,里面反复出现future is not Send、cannot be sent between threads safely。网上一搜,有人说“用Box::pin包一下”,你照做了,确实能编过,但完全不清楚Pin到底做了什么。
这篇文章想换一种讲法:不直接背Pin的 API,而是从第一性原理出发,自己动手实现一个“近似版 Pin”。我会带着你从“自引用结构体为什么危险”这个问题开始,一步步推导出Pin的核心设计。你会发现,Pin并不是 Rust 为了async临时打上的补丁,而是“移动语义 + 自引用安全”这对矛盾下自然生长出来的基础设施。
先给一个明确判断:Pin真正难的地方不是unsafe,而是大多数学习者缺少一条完整的 “为什么需要它” 的问题链。等你自己实现完一个MiniPin,再回头看标准库的Pin,很多曾经觉得玄妙的细节会自然串起来。
1. 为什么 Rust 需要一个叫 Pin 的东西
1.1 移动语义:Rust 里最便宜也最危险的操作
Rust 的赋值、传参、函数返回,默认都是移动语义。移动的本质是一次位拷贝,同时原位置“逻辑上失效”。对绝大多数类型来说,移动是安全且高效的,编译器可以随便把String、Vec从一个栈帧搬到另一个栈帧,所有堆内存的指针都会跟着搬走,因为指针本身就是被移动结构体的一部分。
问题出在自引用结构体。如果一个结构体内部有一个指针或引用,指向的是它自己的某个字段,那么移动整个结构体时,这个内部指针不会被更新,它仍然指向旧的内存地址。旧地址在语义上已经不属于这个对象,继续通过它访问数据就是未定义行为。
1.2 用一段安全代码观察“移动导致地址失效”
这里先用一个安全的示例说明地址变化,不涉及任何未定义行为:
// 文件路径:examples/self_addr_demo.rs // 演示:移动一个结构体时,内部记录的旧地址并不会被更新。 #[derive(Debug)] struct SelfReferential { data: [u8; 32], // 记录 data[0] 的地址,模拟一个“指向自身字段的指针” data_ptr_addr: usize, } impl SelfReferential { fn new() -> Self { SelfReferential { data: [0u8; 32], data_ptr_addr: 0, } } fn init_ptr_addr(&mut self) { self.data_ptr_addr = &self.data[0] as *const u8 as usize; } } fn main() { let mut a = SelfReferential::new(); a.init_ptr_addr(); println!("a.data[0] 实际地址: {:#x}", &a.data[0] as *const u8 as usize); println!("a.data_ptr_addr 记录: {:#x}", a.data_ptr_addr); // 移动 a 到 b let b = a; println!("--- 移动之后 ---"); println!("b.data[0] 实际地址: {:#x}", &b.data[0] as *const u8 as usize); println!("b.data_ptr_addr 记录: {:#x}", b.data_ptr_addr); }这段代码里没有使用任何unsafe。移动前后,b.data[0]的实际地址和结构体内部记录的地址不再一致。如果内部真的有一个指针依赖这个地址来访问数据,移动之后就会读到错误的位置。
真实项目中更常见的情况是:结构体持有*const T、usize偏移量、或者一个生命周期关联的&self引用。这些东西都依赖“对象地址不再变化”这一前提。
1.3 小结:Pin 要解决的问题
Pin的使命不是提高性能,也不是让对象“不可变”,而是解决一个非常具体的问题:当一个值必须保持地址稳定时,阻止代码通过安全方式移动它。
2. Pin 与 async Future:自引用在现实中最常见的入口
理解Pin的经典场景是异步编程。为什么async fn编译出来的 future 需要Pin?因为 future 本质上是一个状态机,编译器会把每个await点之间用到的局部变量保存到这个状态机里。
看这段代码:
async fn example() ->