刚把 poll 手写出来的时候,我在想一个问题:为什么这里非得是 Pin<&mut Self>,不能是普通的 &mut Self?
答案很简单:Future 里存着指向自己的指针。
Rust 所有权模型不允许结构体字段互相引用,但异步状态机必须要这么干。编译器负责生成状态机代码,它需要一种方式让这种"看起来违反规则"的代码安全地存在——Pin<Box<T>> 就是那层和编译器之间的契约。
这篇东西我会从一段实际编译错误的 async 代码开始,讲清楚三件事:自引用结构为什么会冒出来,Pin 到底靠什么机制约束了移动,以及 Pin<Box<T>> 这个组合为什么在异步状态机里几乎不可替代。适合已经写过一段时间 Rust、但还没系统搞懂 Pin 的读者,也适合从 C++ 转过来、对"移动语义"有直觉但对应到 Rust 还得适应一下的人。
1. 从一段编译不过的 async 代码说起:自引用是怎么冒出来的
很多人第一次撞上 Pin 不是在看文档,而是在写 async 代码时被编译器甩了一脸错误。所以先不急着讲理论,我复现一个特别典型的场景。
1.1 一个看起来无害的代码片段
假设我要写一个函数,它先分配一个数组,然后对数组做迭代处理,最后返回处理结果。同步版本很好写:
rust复制fn process() -> i32 {
let buf = [1, 2, 3, 4, 5];
let iter = buf.iter();
let mut acc = 0;
for v in iter {
acc += v;
}
acc
}
buf 是一个栈上数组,iter 借用它,这个借用关系在函数体内完全合法。现在把它改成异步版本:
rust复制async fn process_async() -> i32 {
let buf = [1, 2, 3, 4, 5];
let iter = buf.iter();
let mut acc = 0;
for v in iter {
acc += v;
}
acc
}
这段代码在 Rust 2021 里常常能编译通过(因为编译器能优化掉一些借用链问题),但如果加一个跨 .await 的借用,事情就不一样了:
rust复制async fn process_async_long() -> i32 {
let buf = vec![1, 2, 3, 4, 5];
let iter = buf.iter();
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let mut acc = 0;
for v in iter {
acc += v;
}
acc
}
这时代码基本不可能编译过。核心原因是:sleep.await 挂起了当前 Future,而 iter 还持有 buf 的借用。编译器生成 Future 状态机时,需要把 buf 和 iter 都存进状态机结构体里,但 iter 是指向 buf 内部元素的借用,这在结构体层面是"自引用"——一个字段指向另一个字段。
1.2 报错背后的信息量
编译器给的错误可能是 borrow may still be in use,也可能是 lifetime 相关的复杂提示。不管表面措辞怎么变,本质都是同一个问题:状态机结构体不能包含指向自己的引用。
这里需要理解编译器在后台做了什么。一个 async fn 会被展开成类似这样的结构:
rust复制struct ProcessAsyncLongStateMachine {
// 编译器生成的状态字段
buf: Vec<i32>,
iter: Option<std::slice::Iter<'?, i32>>,
acc: i32,
state: u8,
}
问题在于 iter 字段的类型里那个 '?,它实际上是一个"生命周期参数",而这个参数最终会被实例化为指向 buf 字段的生命周期。听起来像个死循环:'? 必须是一个能覆盖 buf 字段存在期间的生命周期,但 buf 自身也被装进了同一个结构体。
这在 C 语言里很好办,直接存指针就行:
c复制struct state {
int *buf;
int *iter;
};
buf 和 iter 都只是指针,它们在内存里连续存放,iter 指向 buf 指向的堆内存,不会因为 state 本身被移动而失效。但 Rust 的借用检查器没法在一个结构体字段上表达"这个借用是对另一个字段的借用"这种关系,因为结构体一旦作为整体被移动,所有字段的地址都会变,而借用检查器无法静态地验证"字段间借用在移动后依然有效"。
所以编译器选择让这种代码直接报错。为了让异步代码能工作,Pin 被引入进来:它把"结构体不能自引用"这条规则打破,但在打破的同时施加一个新约束——被固定的对象不能移动。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么普通 Rust 写不出“自我指涉”:所有权模型的边界
写到这里我得把问题再往深处挖一层,因为很多读者在理解了"async 会生成状态机"之后,还是不太明白为什么状态机里的自引用会让 Rust 迫不得已引入 Pin。
2.1 自引用结构的定义与用途
自引用结构,通俗点说就是结构体里有一个字段,指向另一个字段的内存地址。这是 C/C++ 里极其常见的模式:
c复制struct SelfReferential {
int value;
int *value_ptr;
};
在 C++ 里我可以这样初始化:
c复制SelfReferential s{42, &s.value};
这里 s.value_ptr 保存了 s.value 的地址。只要 s 不被移动(比如不按值传递、不放进会触发拷贝/移动的容器),这个指针就一直有效。移动后它就变成悬垂指针,解引用是未定义行为。
Rust 里想在安全代码里实现同样的事,会立刻被借用检查器拦下来。看这个试图用裸指针绕开的例子:
rust复制struct SelfReferential<'a> {
value: i32,
value_ptr: Option<&'a i32>,
}
fn main() {
let mut s = SelfReferential {
value: 42,
value_ptr: None,
};
s.value_ptr = Some(&s.value); // 编译错误!
}
错误信息会告诉你"cannot borrow s as immutable because it is also borrowed as mutable"或者经典的"self-referential struct"提示。这里的原因可以从内存地址的角度理解:&s.value 是 s 内部字段的地址,如果后面 s 被 moves 到另一个内存位置,&s.value 就成了指向旧地址的悬垂引用。Rust 默认所有值都可以移动(赋值、传参、放进集合都会触发移动),所以编译器无法保证这个引用的安全性,干脆拒绝。
2.2 移动语义导致地址失效
核心矛盾就在于 Rust 默认的移动语义。"移动"在 Rust 里不只是逻辑上的所有权转移,它在很多实际情况里就是一个 memcpy——源位置的字节被复制到目标位置,然后逻辑上源位置失效。
看这个例子:
rust复制let a = String::from("hello");
let b = a; // a 被移动,b 接管堆内存所有权
这个例子里 String 本身是 24 字节(指针 + 长度 + 容量),移动时复制的是这 24 个字节,而不是堆上的内容。b 里的指针仍然指向同一个堆地址,所以可用的。但如果你把 String 放在结构体里,结构体整体移动时,String 的 24 字节换了个地址存放,不过堆数据本身没动,所以 String 内部指针依然有效。
问题出在哪呢?如果结构体里的另一个字段存的是"指向这个 String 字段的地址",那这个地址在结构体移动后就失效了,因为 String 字段本身的存放位置变了,哪怕它持有的堆数据没变。
Rust 的借用检查器不是不知道这个区别,它只是不愿意在语言层面区分"引用指向栈上位置"和"引用指向堆上数据"。因为即使是 &str 指向 String 的堆缓冲,一旦 String 被移动,堆缓冲不会变(所有权转移,数据原地不动),但实际上 &str 里存的是指向堆缓冲的指针,这个指针的值不变,所以它依然有效。问题在于 Rust 的类型系统无法表达这种"引用指向我内部某个字段持有的堆数据"这种关系——借用检查器只认地址是否静态可判定为有效。
Pin 的巧妙之处就在于:它不试图去区分这些情况,而是直接禁止移动。Pin<P> 约束"被指向的对象"不能被移动,只要这个前提成立,内部的任何自引用指针就不会因为外部移动而失效。
2.3 经典错误尝试:用裸指针自引用
有些初学者会想到用裸指针加上 unsafe 绕开借用检查器。比如这样:
rust复制struct SelfReferential {
value: i32,
value_ptr: *const i32,
}
impl SelfReferential {
fn new(value: i32) -> Self {
let mut s = SelfReferential {
value,
value_ptr: std::ptr::null(),
};
s.value_ptr = &s.value;
s
}
}
这里 &s.value 隐式转换成了裸指针。编译能过,因为裸指针不参与借用检查。但这段代码是定时炸弹:SelfReferential::new 返回后,如果返回值被移动到另一个内存位置,value_ptr 里保存的还是旧地址,解引用就是未定义行为。
一个真实的崩溃场景:
rust复制fn main() {
let s = SelfReferential::new(42);
let s2 = s; // 移动!
unsafe {
// 现在读取的是旧地址处的值,这里实际是未定义行为
println!("{}", *s2.value_ptr);
}
}
这段代码在 Release 编译下可能碰巧能跑,因为编译器可能做 NRVO 优化、不会真正拷贝,但在 Debug 编译或者更复杂的调用链里,崩溃概率极大。这就是为什么 Pin 是必要的——它让"我保证这个对象不被移动"变成类型系统里可检查的约束,而不是靠程序员小心。
3. Pin 的真正含义:把“不移动”从语义承诺变成编译期约束
Pin 这个名字经常让人误解,好像它是一种把对象"钉住"的某种运行时机制。其实不是。Pin 纯粹是一个编译期类型层面的约束,它本身不改变内存布局,不触发任何复制,也不负责阻止你做任何事。它只是在类型系统里声明:"这个对象被移动是禁止的"。
3.1 Pin
的定义与内部结构
来看标准库里的定义:
rust复制pub struct Pin<P> {
pointer: P,
}
关键点在于 Pin 没有实现 Unpin 依赖,这有点绕。真正的约束来自它提供的 API:
rust复制impl<P: Deref> Pin<P> {
pub fn new(pointer: P) -> Pin<P>
where
P::Target: Unpin,
{ ... }
}
只有当你 P::Target 实现了 Unpin,Pin::new 才是安全的。Unpin 是一个 auto trait,绝大多数类型都自动实现它。自动实现 Unpin 的含义是:这个类型移动是安全的,不需要固定。
反过来理解:!Unpin 的类型(目前主要通过 PhantomPinned 来标记)声称"我不允许被移动"。对这类类型,你没办法用 Pin::new 去安全地创建 Pin,只能通过 unsafe 的方式,比如 Pin::new_unchecked,或者通过安全的高级 API(如 Box::pin)。
3.2 从 Unpin 到 !Unpin:谁来承担固定义务
用一个生活中的类比来理解"固定义务"这个概念。想象你有一块带插脚的主板,插脚方向必须固定。主板就是那个要被固定的对象。你把主板固定在机箱里,机箱就是那个持有它的容器。
绝大多数对象是一块"没有插脚"的普通主板,你想怎么动都行——这些是自动实现 Unpin 的类型。而少数对象带了敏感插脚(自引用指针),它必须待在固定位置——这种类型通过 PhantomPinned 主动选择成为 !Unpin。
这里的核心设计是:固定义务是由被固定对象自己声明的,而不是由容器决定的。一个类型如果没有实现 Unpin,那任何人想把它放入 Pin 就必须经过 unsafe;反过来,一个类型如果实现了 Unpin,你也可以强行把它固定,但意义不大,因为你不需要担心它移动后会出事。
写一个实际展示 Unpin 和 !Unpin 差异的例子:
rust复制use std::marker::PhantomPinned;
use std::pin::Pin;
struct Unpinnable {
data: i32,
}
struct NotUnpinnable {
data: i32,
_pin: PhantomPinned,
}
fn main() {
let unpinned = Unpinnable { data: 42 };
let p1 = Pin::new(&unpinned); // 编译通过,因为 Unpinnable: Unpin
let not_unpinned = NotUnpinnable { data: 42, _pin: PhantomPinned };
// 这一行编译失败:
// let p2 = Pin::new(¬_unpinned);
// 因为 NotUnpinnable: !Unpin
// 必须这样创建:
let boxed = Box::pin(not_unpinned);
println!("{}", boxed.data);
}
注意这里 _pin: PhantomPinned 字段本身就是不占空间的,它的作用纯粹是类型标记——告诉编译器"这个类型不实现 Unpin"。PhantomPinned 就是那个"插脚",一旦有它就再也不能随便移动了。
3.3 手动构造固定对象时的那段 unsafe 到底在承诺什么
当你要安全地固定一个 !Unpin 类型时,正确的做法是使用 Box::pin:
rust复制let pinned_box = Box::pin(not_unpinned);
这行代码内部做了什么?它先分配堆内存、把 not_unpinned 移动进去,然后调用 Pin::new_unchecked 把 &mut Box<T> 转成 Pin<Box<T>>。这个转换是 unsafe 的,原因是:在 Box::pin 这个函数内部,编译器需要保证在 Pin 创建之后,not_unpinned 不会再被移动。
为什么 Box::pin 能保证这个条件?因为 not_unpinned 已经从栈上移到了堆上,而 Pin<Box<T>> 持有的是堆内存的所有权。从此刻起,如果要移动"被固定在堆上的数据",必须先取出一个 &mut T——但 Pin<Box<T>> 的 API 设计使得 !Unpin 类型无法直接拿到 &mut T,从而阻止了移动。
这里就显示出 Pin 设计的精妙之处:它把所有"不安全"都限制在创建 Pin 的那一刻。只要创建 Pin<Box<T>> 的代码遵守"将来不会通过 Pin 提供的接口(除非使用 unsafe)移动 T"这个承诺,之后的安全代码就可以放心地持有自引用指针。
看看 Pin<P> 实际提供的 API 有哪些:最重要的是 as_ref() 和 as_mut():
rust复制impl<P: Deref> Pin<P> {
pub fn as_ref(&self) -> Pin<&P::Target> {
unsafe { Pin::new_unchecked(&**self.pointer) }
}
}
impl<P: DerefMut> Pin<P> {
pub fn as_mut(&mut self) -> Pin<&mut P::Target> {
unsafe { Pin::new_unchecked(&mut *self.pointer) }
}
}
可以看到 Pin<P> 的所有接口都强制返回 Pin<&T> 或 Pin<&mut T>,永远不会给你一个裸的 &mut T。这样安全代码就无法把被固定的对象"借出来"然后移动它(因为移动需要 &mut T 上的按值操作,比如 std::mem::replace,而你没有 &mut T)。
4. 为什么是 Pin<Box>:堆分配在异步状态机里的不可替代性
现在到了关键问题:为什么异步任务的标准实践是 Pin<Box<dyn Future>>,而不是用栈上固定 Pin<&mut dyn Future> 或 Pin<&mut T>?实际上这两种方式都合理,但适用场景完全不同。
4.1 栈上固定与堆上固定的本质区别
直接做一个对比表格来理清差距:
| 维度 | Pin<Box<T>> |
Pin<&mut T> |
|---|---|---|
| 分配位置 | 堆上分配 | 引用指向栈上或堆上任意位置 |
| 创建方式 | Box::pin(t) |
Pin::new(&mut t)(要求 T: Unpin)或从 Pin<Box<T>> 降级而来 |
| 所有权 | 拥有 T |
只借用 T |
| 所有权的移动 | 可以移动 Pin<Box<T>> 本身(即移动 Box 指针),但被固定的 T 不动 |
借用关系存在期间,原对象不能移走(借用检查器负责) |
| 适用场景 | 异步任务的生命周期跨越多个作用域,需要把 Future 交给运行时 | 同步代码中临时固定一个对象,作用域简单清晰 |
为什么要用堆分配?一个关键原因是栈上对象无法满足我所在的作用域之外的生命周期要求。看个实际场景:
rust复制async fn make_future() -> impl Future<Output = ()> {
// 这个 Future 需要被 move 到别的任务里,可能在另一个线程执行
// 如果它内部有自引用,就不能用栈固定
let value = Box::new(42);
async move {
let ptr = &*value;
tokio::time::sleep(...).await;
println!("{}", ptr);
}
}
make_future 返回的 impl Future 必须能自由地被传递给 tokio::spawn——而 tokio::spawn 的要求是 Send + 'static。这个 Future 内部有 &*value 的自引用,它需要被固定,但固定处需要和将来 poll 它的地方一致。栈上固定意味着你要把 Future 留在当前栈帧,然后手动调用 poll,这在异步运行时里根本不现实——运行时拿到的是一个堆上的 Box<dyn Future>。
还有一个极端重要的点:Pin<Box<T>> 本身允许移动。因为移动的是 Box 这个堆指针,堆上的 T 原地不动。这给了运行时极大的灵活性——它可以高效地把任务在 cpu 池、线程池之间调度,而不必担心被固定对象会跟着变地址。
4.2 栈固定的分叉陷阱
栈上固定 Pin<&mut T> 并非不能用,但有一个很隐蔽的坑:一旦你把一个 &mut T 变成了 Pin<&mut T>,这个 Pin 借用关系消失后,你拿回 &mut T 时,编译器不会帮你确认对象是否真的没被移动过。
看这个例子:
rust复制fn process(pinned: Pin<&mut MyType>) {
// 在 pinned 内部做一些操作
let inner = pinned.get_mut(); // 只有 MyType: Unpin 时才可用
// 如果 MyType: !Unpin,get_mut 不可用,必须用 unsafe
}
对于 !Unpin 类型,安全代码只能拿到 Pin<&mut MyType>,在函数内部如果不再需要 Pin 包装、想拿回裸的 &mut MyType,就只能用 unsafe——因为你得保证之后不会移动 MyType。但在栈场景里,这个保证往往很难维持,因为一旦离开 Pin 的约束范围,rustc 的借用检查器就只盯着 &mut,它可不管你是否曾经固定过。
实际上,更常见的坑是"同一个 T 被固定了两次"。如果你有两个 Pin<&mut T> 指向同一个 T,并且第一个 Pin 的持有者已经假设"对象不会移动",那么第二个 Pin 的创建者如果在持有两个 Pin 期间通过某种手段移动了 T,就会违反第一个 Pin 的假设。Rust 借用检查器会阻止这种双重可变借用的存在,但 unsafe 代码可以让它发生——这也是 Pin 安全的微妙之处。
4.3 异步运行时选择 Box 的现实原因
Box<dyn Future> 里的 dyn 说明还有一层动态分派在里面。为什么不用 Pin<Box<impl Future>>?因为 impl Trait 不能出现在 struct 或 Vec 里面充当统一类型。异步运行时管理的不是一两个任务,而是成千上万个任务,它们必须在同一个集合里轮询,所以只能使用 Box<dyn Future>。
dyn Future 本身默认是 !Unpin 的吗?不,dyn Future 的 Unpin 取决于它内部动态类型。但 Box<dyn Future + Send> 作为 trait object,在使用 Box 的情况下,指针本身移动不会影响内部数据,所以它允许 Pin<Box<dyn Future>> 作为统一任务类型。这是非常实际的设计:运行时只需要持有一个 Pin<Box<dyn Future + Send>>,就能安全地调度、轮询、销毁。
具体到 tokio,任务存储在共享的堆分配器里。这个堆分配不仅仅是为了固定,也让任务的内存管理跟调用栈解耦。一个异步任务可能跨多个 await 点,可能被调度到不同的线程上执行,所以任务对象必须能脱离创建它的栈帧存在——堆分配是唯一合理的选择。
5. 异步状态机的底层视角:编译器用 Pin 做了什么
理解了 Pin 之后,再去看异步状态机的生成逻辑,一切就清晰了。这里我尝试还原编译器做的一部分"脏活",帮大家把抽象层剥开。
5.1 Future::poll 的签名含义
先看标准库中的定义:
rust复制pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
注意到 self: Pin<&mut Self> 不是 &mut self。这意味着 poll 函数接收的是一个被固定视角的 &mut Self。调用 poll 相当于在告诉编译器:"这个 Future 现在处于固定状态,我在轮询它,但我不会移动它。"
这个签名是决定性差异。如果是普通的 &mut self,实现方可以在 poll 里自由地调用 std::mem::swap 之类的方法移动自身数据。但 Pin<&mut Self> 从类型层面禁止了这种事——编译器在检查 poll 内部实现时,会要求你遵循 !Unpin 的固定规则。这让 Future 的实现者(包括编译器生成的状态机代码)可以安全地在 poll 过程中持有自引用指针。
实际工程上,poll 的调用链是这样:运行时拿到的任务对象是 Pin<Box<dyn Future>>,当它要轮询时,会调用 pin.as_mut().poll(cx)。as_mut() 把 Pin<Box<T>> 转换成 Pin<&mut T>,然后传给 poll。整个过程里,任务对象始终待在堆上,不会移动。
5.2 状态机生成逻辑的编码方式
async 块展开后的状态机是什么样的?我写一个极简、不依赖语法糖的示例。假设有这样的异步代码:
rust复制async fn example() -> i32 {
let x = 10;
let y = 20;
let r = x + y;
r
}
编译器生成的简化版状态机大概长这样(实际上依赖 generator 内部机制,但语义等价):
rust复制enum ExampleStateMachine {
Start,
Ready,
}
impl Future for ExampleStateMachine {
type Output = i32;
fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<i32> {
match std::mem::replace(&mut *self, ExampleStateMachine::Ready) {
ExampleStateMachine::Start => {
let x = 10;
let y = 20;
let r = x + y;
Poll::Ready(r)
}
ExampleStateMachine::Ready => {
unreachable!("polled after completion");
}
}
}
}
编译器把每个 .await 点切分成状态,在暂停时保存局部变量。如果中间存在跨 .await 的借用,那个借用关系就会变成自引用字段。
比如这段:
rust复制async fn example2() -> i32 {
let buf = Box::new([1, 2, 3]);
let first = &buf[0];
tokio::time::sleep(...).await;
*first
}
编译器生成的理想化状态机会是这样(为了演示我把它写出来,这类代码在安全的 Rust 里无法手写,因为字段引用字段):
rust复制enum Example2StateMachine {
Start,
Pending {
buf: Box<[i32; 3]>,
first: *const i32, // 指向 buf 内部
},
Ready,
}
first 指向 buf 的堆缓冲内部,而 buf 自己也被存放在 Pending 变体里。问题是 Pending 这个结构可能在 poll 之间被移动吗?如果不使用 Pin,答案是可以——enum 匹配操作可能会移动字段,比如 mem::replace 会把整个 Pending 变体从状态机里搬出去。Pin<&mut Self> 正是阻止这个移动发生的机制。对 !Unpin 类型,mem::replace 在 Pin 的约束下根本无法安全使用,除非通过 unsafe 方式绕开。
所以 Pin 的实际作用是:在异步状态机上放置一个"永不移动"的标签。编译器生成的状态机代码,利用了这个"永不移动"承诺,才能在内部安全地使用自引用字段。
5.3 状态机实现里 Pin 与 match 的相互作用
poll 的 match 过程往往需要移动字段值,比如从 Start 状态切到 Pending 状态时,把局部变量塞进字段里。如果没有 Pin,这些移动没问题;但一旦 Pin 在场,match 里的 std::mem::replace 就需要小心处理。
Future 的 poll 要满足"被调用多次"的语义。第一次调用时从 Start 推进到 Pending,后续调用停留在 Pending 等待唤醒。为了实现这种状态迁移,编译器内部需要访问 self 的各个字段。在 Pin<&mut Self> 下访问字段是安全的——因为 Pin 不阻止字段访问,只阻止整个 Self 的移动。但如果你试图把 self 整体按值取出(比如 let mut s = *self; 或 mem::replace(self, ...)),编译器会报错,要求你使用 as_mut() 先拿到 &mut Self。这也是为什么在 poll 实现里,经常看到 let this = self.as_mut().get_mut(); 这种先解 Pin 再操作的方式。
实际工程中手写 Future 实现(比如实现自定义 Stream 或者自定义 AsyncRead trait)时,一个常见做法是内部使用 State 枚举存储数据,然后:
rust复制impl Future for MyFuture {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
unsafe {
let this = self.as_mut().get_unchecked_mut();
// 这里拿到了裸 &mut MyFuture,可以自由操作字段
// 只要你不去移动 this 本身,是安全的
}
}
}
get_unchecked_mut 是 Pin 提供的 unsafe 接口,它让我们在 poll 内部拿到裸 &mut 做字段操作。为什么标准库不直接给安全接口?因为一旦拿到裸 &mut,除了访问字段之外,也可以执行 std::mem::replace(this, ...) 整体替换——这会违反固定约束。所以 poll 的作者必须自己保证"只操作字段,不移动整个对象"。
这其实就是标准库文档里说的 "structural pinning" 问题:如果你要安全地暴露 Pin<&mut Field>(允许用户固定字段但整体仍然固定),你必须保证字段的地址在移动时不会变,这在 enum 的变体切换时往往不成立。这也是为什么很多异步数据结构选择不用 enum 直接承载自引用数据,而是用 Option<T> 或者 Box<T> 来站桩。
6. 工程实战中的经验与避坑清单
理论讲完,落到工程上,这里整理我在实际上手 Pin<Box<T>> 和异步状态机时积累的一手经验。这些坑教科书上很少写,但写异步代码时几乎必然碰到。
6.1 在 async 块的借用与 move 之间做正确的选择
最经典的坑:在 async 块里借用局部变量,编译报借用错误。
rust复制async fn bad_example(v: Vec<i32>) -> i32 {
let iter = v.iter();
tokio::time::sleep(...).await;
iter.sum()
}
这个代码几乎肯定编译不过,原因前面分析过——iter 是对 v 的借用,而 v 被存放进状态机时 iter 就可能变成自引用。解决办法是"拥有"迭代器,而不是借用它。比如 v.into_iter() 会消耗 v 的所有权,将迭代器本身作为状态机的一个字段。
rust复制async fn good_example(v: Vec<i32>) -> i32 {
let iter = v.into_iter();
tokio::time::sleep(...).await;
iter.sum()
}
into_iter() 返回的是一个拥有 Vec 数据的迭代器(std::vec::IntoIter),它内部把 Vec 的堆指针保存下来。这个迭代器在状态机里移动时,只是移动了指针和游标,堆数据不动,所以没有自引用问题。
但 into_iter() 不能解决所有问题。如果你需要的是"对 v 的多个元素的并行引用"(比如 v[0] 和 v[1] 同时用),那就不能直接拥有迭代器,因为 IntoIter 一次只能吃一个元素。这时候的常见方案是提前把索引范围存下来,避免借用:
rust复制async fn indexed_example(v: &[i32]) -> i32 {
let indices = 0..v.len();
tokio::time::sleep(...).await;
indices.map(|i| v[i]).sum()
}
但 v: &[i32] 本身是一个借用。如果 v 的生命周期是跨 .await 的,那 v 的生命周期必须是 'static 或者 Future 带有 lifetime 参数。在 tokio 里,spawn 要求 'static,所以你通常得把数据放进 Arc 或者 Box。
实际工程中的经验法则是:跨 await 的状态尽量做成"拥有"(owned)而不是"借用"(borrowed)。需要共享引用就 Arc<T>,需要可变引用就 Arc<Mutex<T>>,需要迭代就消耗所有权。这样状态机里的字段全部是 'static 的,自引用问题直接绕开。
6.2 unsafe 代码的固定不变量:哪些动作真会"移动"对象
unsafe 代码即使在 Pin 的约束下也可能违反固定承诺。最常见的危险动作就是 std::mem::replace 和 std::mem::swap。
比如 poll 函数里想重置状态:
rust复制// 危险写法,编译器会阻止,但 unsafe 可以绕开
unsafe {
let this = self.as_mut().get_unchecked_mut();
let old = std::mem::replace(this, MyFuture::default());
// old 被移动了!如果 MyFuture 实现了 !Unpin 并且有自引用,这就是 UB
}
这种写法的问题在于,MyFuture::default() 被放到了 this 的位置,而原来的 MyFuture 被搬走。如果原 MyFuture 内部有指向自身其他字段的指针,搬走之后那些指针的地址就失效了。
安全的替代方案是:如果 MyFuture 的状态确实需要重置,可以直接修改字段,而不是替换整个结构体:
rust复制unsafe {
let this = self.as_mut().get_unchecked_mut();
this.state = State::Waiting;
this.data.clear();
}
另一个容易忽略的隐蔽移动操作是 Vec::push。当你 vec.push(x) 且 vec 容量不够时,Vec 会重新分配堆内存并把原有元素移动过去——但这里的移动对于 Vec 内的元素来说,是堆地址的搬迁。如果 vec 中某个元素内部有指向另一个元素的自引用,这同样会导致悬垂指针。所以 !Unpin 类型绝对不能放在标准库容器里,除非你通过某种方式保证容器不会触发元素搬迁。
这也是为什么标准库对 Box<T> 和 Vec<T> 的处理方式不同:Box<T> 的 T 在堆上独立分配,移动 Box 不影响 T 的地址;Vec<T> 的元素是连续存储的,扩容会整体搬迁,因此 Vec<Box<T>> 里的 Box 元素不会触发 T 移动,但 Vec<T> 本身存放会移动的 !Unpin 类型就是问题。
6.3 固定类型测试时的 Pinned 与 Unpin 的互操作写法
实际写代码时,你可能会遇到"我要把一个 !Unpin 类型传进函数,但函数接收的是 &mut T"。这时你必须写出一个接受 Pin<&mut T> 的函数,然后在不安全代码里解出 &mut T。这个过程很容易出错,推荐的做法是使用 pin_project 宏来辅助。
pin_project 可以给结构体加上"投影"能力:让你安全地访问 Pin<&mut Struct> 的某些字段,同时保持固定约束。比如:
rust复制use pin_project::pin_project;
#[pin_project]
struct MyPinnedStruct {
#[pin]
field_that_must_stay: SomeFutureType,
normal_field: i32,
}
impl MyPinnedStruct {
fn poll_me(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
let this = self.project();
// this.field_that_must_stay 是 Pin<&mut SomeFutureType>
// this.normal_field 是 &mut i32,因为它不需要固定
this.field_that_must_stay.poll(cx)
}
}
这个宏的价值在于它正确区分了"哪些字段是结构化的(pinned)"和"哪些字段非结构化(unpinned)"。你自己手写 unsafe 投影往往容易出错,而 pin_project 已经帮你处理好了 Drop 检查和 Unpin 条件推导。
pin-project-lite 是它的轻量版本,不依赖 proc_macro,适合嵌入式或 no_std 项目。如果你写异步运行时或者底层异步驱动,建议直接上 pin-project 或者 pin-project-lite,不要手搓 unsafe。
6.4 实测下来的性能认知
我在项目里测过 Box::pin 和直接 poll 一个栈上 Pin<&mut T> 的性能差距。结论是:Box::pin 只有一次堆分配的开销(大约几十纳秒),之后每次 poll 都是纯粹的指针解引用,没有额外开销。如果你在一个 loop 里反复 pin! 同一个变量,不会有重复分配。
tokio 里每个任务本身就是一次 Box::pin 分配。实际上任务的全部状态都封装在 Box<Future> 里,一次分配,反复轮询,所以对绝大多数应用,async 任务的堆分配开销完全可以忽略不计。
真正影响性能的不是 Pin,而是 .await 点之间的状态切换代码(enum 变体匹配、waker 的 clone 与注册)。Pin 本身是零成本的类型抽象,Box::pin 唯一真实代价是一次堆分配,但这是执行异步任务必须付出的——不像栈上操作 Pin<&mut T>,那倒是完全没有分配,但它能做的也极其有限,通常只在单线程同步循环中临时固定自引用数据结构时用到。
这里也顺便提一个实测结论:在 Future 状态机的 poll 循环里,每次 poll 都会创建 Context,其中 Waker 至少有一次或多次 clone(通常还会导致堆分配)。Pin 相关的操作反而不出现在任何 profile 热点里。所以如果你看到有人为了"减少 Pin 开销"写复杂的 unsafe 代码,基本是在优化一个不存在的问题。
7. 写在项目里的最终取舍:我一般怎么选
项目里每次遇到"要不要用 Pin<Box<T>>"这个问题,我的选择逻辑基本是固定的,分享出来供参考。
第一,能用普通借用就不自引用。异步代码里只要状态能设计成 'static 数据加索引的组合,就完全不用碰 Pin。这是第一优先级的方案,因为它最符合 Rust 的心智模型,类型系统能给你最强的保障。我在实际项目里写异步 I/O 驱动时,大量状态直接存 VecDeque<u8>、Option<FutureState> 之类的普通类型,没有任何一个字段去借用其他字段。
第二,的确有自引用需求时,优先用 Box::pin 加 pin_project。比如实现自定义 Stream 时,内部持有一个 Box<dyn Future + Unpin> 或者 Box<dyn Future>,对于前一种情况,因为 Future + Unpin 不需要固定,可以直接在 poll_next 里用 self.future.as_mut().poll(cx);如果是 Box<dyn Future>(即 !Unpin),就要用 self.future.as_mut() 拿到 Pin<&mut dyn Future> 再 poll——这就是 Pin<Box<T>> 在工程里最常见的用法。
第三,栈上 pin! 只适合同步循环里的一次性轮询,跨函数边界或者跨线程调度就别碰。tokio::pin! 宏创建的 Pin<&mut T> 生命周期严格受限于当前作用域,一旦你想把固定对象交给其他任务,就必须用 Box::pin 把所有权转移出去。
最后写一个我在异步运行时 mini 实现里反复用到的模式,算是收尾的完整实战代码。目标是实现一个最简单的 Timer Future,它的自引用需求是:持有 tokio::time::Sleep 这个 !Unpin 类型,并把它轮询到 Ready。这里我用 Box::pin 来调度它:
rust复制use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use tokio::time::Sleep;
struct Timer {
sleep: Pin<Box<Sleep>>,
}
impl Timer {
fn new(duration: Duration) -> Self {
Timer {
// Sleep 是 !Unpin,所以必须用 Box::pin 固定
sleep: Box::pin(tokio::time::sleep(duration)),
}
}
}
impl Future for Timer {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
// 因为 Sleep 字段被 Box 固定在堆上,Box 自身移动不影响 Sleep
// 所以这里可以安全地打印,甚至调用 std::mem::replace 也无所谓
let sleep_mut = self.sleep.as_mut();
match sleep_mut.poll(cx) {
Poll::Ready(()) => Poll::Ready(()),
Poll::Pending => Poll::Pending,
}
}
}
#[tokio::main]
async fn main() {
let mut timer = Timer::new(Duration::from_millis(100));
tokio::time::timeout(Duration::from_secs(1), &mut timer)
.await
.unwrap();
println!("timer done");
}
注意我在 poll 里使用了 self.sleep.as_mut()。因为 self.sleep 的类型是 Pin<Box<Sleep>>,它本身就是被固定的,所以 as_mut() 返回 Pin<&mut Sleep>,这个可以直接传给 Sleep::poll。这里 Pin 的层级关系是"外层的 Pin<&mut Timer>"和"内层的 Pin<Box<Sleep>>"互相独立,不会纠缠,这是 Box::pin 最舒服的地方。
如果我把 sleep 字段类型直接写成 Sleep,那 poll 就需要先手动给 Sleep 字段做投影(pin project),并且要处理 Timer 本身的 Unpin 问题。相比之下,Box::pin 让整个生命周期的管理简单了一个数量级。
在真实项目里,标准做法大多也是这种:内部持有 Box<dyn Future> 或者 Pin<Box<Future>>,外层结构体不需要自己实现 !Unpin 特性,整个类型自动 Unpin,使用者完全感知不到 Pin 的存在。这也是为什么 Pin<Box<T>> 在异步生态里能成为默认选择——它把"固定"这件事通过堆分配隔离在内部,对外暴露的是干净的、可 Send 可 Sync 的任务对象。
踩过几次 unsafe 的坑之后,我个人现在写异步状态的默认姿势:能 Box::pin 就别手写 Pin<&mut T> 的 unsafe 投影,能用 pin_project 就别自己制造 !Unpin 的结构体。Pin 提供的是类型层面的安全网,但你要是用 unsafe 把它剪开,就只能是自己在 poll 的每一行里小心了。
