本文基于 Cloudflare 工程师 Adam Chalmers 的技术博客,从一个实际编程场景出发,由浅入深地讲清楚 Rust 中 Pin、Unpin 与自引用类型的来龙去脉。
原文链接:blog.cloudflare.com/pin-and-unp...
一个看起来很简单的需求
假设你想写一个工具类型,能把任意异步函数包一层,额外记录它的执行耗时:
rust
let async_fn = reqwest::get("http://example.com");
let timed = TimedWrapper::new(async_fn);
let (resp, duration) = timed.await;
println!("耗时 {}ms,状态码 {}", duration.as_millis(), resp.unwrap().status());
接口设计挺优雅的。下面来实现它:
rust
pub struct TimedWrapper<Fut: Future> {
start: Option<Instant>,
future: Fut,
}
实现 Future trait,在 poll 方法里调用内层 Future:
rust
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
let start = self.start.get_or_insert_with(Instant::now);
let inner_poll = self.future.poll(cx); // 编译报错!
// ...
}
编译器直接报错:Fut 类型没有 poll 方法,只有 Pin<&mut Fut> 才有。
这个错误对初学者来说相当令人困惑------明明 Fut 实现了 Future,为什么不能直接调用 poll?Pin 是什么?为什么它要出现在这里?
搞清楚这两个问题,是理解 Rust 异步编程的一道必经之坎。
先从 Future 说起
在 Rust 里,async fn 本质上是一个返回 Future 的普通函数。Future trait 只有一个方法:poll。
调用 poll,它会返回两种结果:Poll::Pending(还没好,待会再来)或者 Poll::Ready(value)(完成了,结果在这里)。异步运行时(比如 Tokio)就是在不断地轮询各个 Future,谁 Ready 了就把结果给出去。
一个最简单的 Future 实现:
rust
struct RandFuture;
impl Future for RandFuture {
type Output = u16;
fn poll(self: Pin<&mut Self>, _cx: &mut Context) -> Poll<Self::Output> {
Poll::Ready(rand::random())
}
}
注意 poll 方法的接收者不是 &mut self,而是 Pin<&mut Self>。这个细节正是问题的核心所在。
自引用类型:一个危险的结构
要理解 Pin,必须先理解它要解决的问题:自引用类型(self-referential types)。
自引用类型,就是结构体内部的某个字段指向自身另一个字段的地址。设想有这样一个结构体:
rust
struct MyStruct {
val: i32, // 存储在内存地址 A
pointer: *const i32, // 指向地址 A
}
初始状态下一切正常,pointer 指向 val 所在的内存地址,读取它能得到合法的值。
但是,如果这个结构体被移动了,会发生什么?
在 Rust 中,"移动"意味着把数据从一个内存位置搬到另一个位置。常见触发场景:把结构体传入函数、放进 Box、或者 Vec 扩容重新分配内存......
移动之后,val 搬到了新地址 B,但 pointer 字段里存的值还是老地址 A,而 A 处的内存已经不再属于这个结构体,随时可能被别的数据覆盖。
结果:悬垂指针。轻则程序崩溃,重则产生可被利用的安全漏洞。
偏偏 Future 经常是自引用的
为什么 Future 和自引用有关?
当你写下一个 async 函数,Rust 编译器会把它编译成一个状态机。这个状态机需要在各个 await 点之间保存局部变量。如果某个变量在 await 前被借用,借用的引用也需要被保存在状态机里------而这个状态机本身就包含了这个借用,于是形成了自引用。
这就是为什么 Future::poll 的接收者不能是普通的 &mut self,而必须是 Pin<&mut Self>------这是在要求调用方保证:调用 poll 之前,这个 Future 已经被"钉住",不会再被移动。
Unpin:大多数类型的默认状态
在深入讲 Pin 之前,先讲 Unpin。
Rust 把所有类型分成两类:
第一类:可以安全移动的类型。 绝大多数类型都属于这一类,比如数字、字符串、布尔值,以及由这些类型组成的结构体和枚举。它们没有自引用,移动之后所有字段的值依然有效。这些类型自动实现 Unpin trait(它是一个自动 trait,类似 Send 和 Sync,不需要手动实现)。
第二类:不能安全移动的类型。 也就是自引用类型,它们在 trait 系统里被标记为 !Unpin(! 表示"不实现")。数量很少,但一旦被错误移动就会产生未定义行为。
Unpin 这个名字乍看有点反直觉------它不是说"这个类型可以被 unpin",而是说"这个类型根本不需要被 pin,随便移动都安全"。
Pin:给不能移动的类型上一把锁
Pin<P> 是一个包装类型,它包裹一个指针 P,并做出如下保证:如果 P 指向的类型是 !Unpin,那么这个值在 Pin 存活期间不会被移动。
如果类型是 Unpin,Pin 就没什么限制效果,你随时可以取出值来移动。如果类型是 !Unpin,Pin 就是一把锁,取值只能通过 unsafe 代码,编译器用这种方式迫使你明确表态:"我知道这里有风险,我已经仔细审查过。"
简单来说:Pin 不是锁住指针本身,而是锁住指针所指向的值,让它不能被移动。
r
Pin<&mut T>:给我一个对 T 的可变引用,但我保证在此期间不会移动 T。
这就是为什么 Future::poll 要求 Pin<&mut Self>:执行器(executor)通过 Pin 向 Future 承诺,在调用 poll 的过程中,不会把这个 Future 移动到别的地方去。
回到原来的问题:怎么调用内层 Future 的 poll?
现在回头看 TimedWrapper 的问题。我们有一个 Pin<&mut TimedWrapper<Fut>>,想要调用 self.future.poll(cx),也就是需要一个 Pin<&mut Fut>。
从一个被 Pin 住的结构体中,访问其各个字段的过程,叫做 projection(投影)。规则是这样的:
- 如果字段类型是
Unpin,可以直接拿到&mut T(普通引用),随便用; - 如果字段类型是
!Unpin(比如内嵌的 Future),要拿到Pin<&mut T>,否则会破坏 Pin 的保证。
手动实现 projection 需要 unsafe 代码,且容易出错。好在有一个 crate 帮你做这件事。
pin-project:让 projection 变得安全又简洁
pin-project 这个 crate 通过过程宏自动生成安全的 projection 代码。用法很直观:
rust
#[pin_project::pin_project]
pub struct TimedWrapper<Fut: Future> {
start: Option<Instant>,
#[pin] // 标记这个字段需要 Pin projection
future: Fut,
}
加上 #[pin_project] 之后,它会自动生成一个 project() 方法。对标记了 #[pin] 的字段,project() 返回 Pin<&mut Fut>;对其余字段,返回普通的 &mut T。
现在可以正确实现 poll 了:
rust
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
let mut this = self.project(); // 调用自动生成的 projection
let start = this.start.get_or_insert_with(Instant::now);
let inner_poll = this.future.as_mut().poll(cx); // 正确!
let elapsed = start.elapsed();
match inner_poll {
Poll::Pending => Poll::Pending,
Poll::Ready(output) => Poll::Ready((output, elapsed)),
}
}
全程没有 unsafe,编译通过,功能正确。
把整个脉络串起来
梳理一下这篇文章讲的内容:
为什么需要自引用类型? 因为 Rust 的 async/await 编译出来的状态机,天然需要在不同 await 点之间保存引用,由此产生自引用结构。
自引用类型为什么不能移动? 因为移动只搬数据,不更新内部的指针,移动之后指针就悬空了,产生未定义行为。
Unpin 是什么? 一个标记 trait,实现了它的类型表示"随便移动,安全无虞"。绝大多数类型自动实现了 Unpin。
Pin 是什么? 一个包装类型,用于包裹指针,承诺所指向的值在 Pin 存活期间不会被移动。对 !Unpin 类型(比如 async 状态机),这个承诺由类型系统强制执行。
为什么 Future::poll 要求 Pin<&mut Self>? 因为很多 Future 内部是自引用的,在 poll 时不能被移动,所以调用方必须通过 Pin 做出这个承诺。
怎么在实践中使用? 用 pin-project crate,加几个属性宏,让编译器帮你生成安全的 projection 代码,不用手写 unsafe。
写在最后
Pin 和 Unpin 是 Rust 类型系统中为数不多的"晦涩角落"之一,但它们的存在是有充分理由的。没有它们,Rust 的 async/await 就无法在不引入 GC 的前提下保证内存安全。
作为普通的 async Rust 用户,日常写代码几乎不会直接碰到 Pin------运行时和库都帮你处理了。但一旦你开始写自己的异步基础设施、封装 Future、或者实现某些底层 trait,这套机制就变成了绕不过去的知识。
理解了"为什么","怎么做"就容易多了。