Rust Pin/Unpin 深度实战:自引用结构、异步状态机与 C++ 及 Go 的工程对比
一、问题的根源:自引用结构为什么不能 Move
在 Rust 中,move 语义是默认行为——当一个结构体被赋值或传参时,只是按字节复制它的内存,然后让原变量失效。这对绝大多数类型都完美适用,唯独对 自引用结构(self-referential struct) 是个灾难。
看一个最简示例:
struct SelfRef {
data: String,
// 指向 self.data 的裸指针
pointer_to_data: *const str,
}
impl SelfRef {
fn new(s: String) -> Self {
let data = s;
let pointer_to_data: *const str = &data[..];
Self { data, pointer_to_data }
}
}
初看没有问题。但当你写出这行代码时:
let a = SelfRef::new("hello".to_string());
let b = a; // move!a 的内存被按字节复制到 b,原指针现在指向已被释放的内存
此时 b.pointer_to_data 仍指向 a 的旧内存地址。这种 use-after-move 不是编译器能轻松捕获的——它发生在运行时,且只在 move 之后访问指针才触发。
自引用在异步编程中无处不在。当我们写 async fn 时,编译器生成的状态机 struct 天生就是自引用的:
async fn example(x: String) -> usize {
let y = x.len(); // 状态 0:y 借用 x
some_async_op().await; // await 点:状态机在这里暂停
y // 状态 1:返回 y
}
编译后的状态机 struct(伪代码):
struct ExampleFuture<'a> {
x: String,
y: &'a usize, // 自引用!指向 self.x.len() 的结果
state: u8,
}
整个异步编程大厦就建立在这个棘手的问题之上——如何安全地让自引用结构在内存中固定不动。
二、Pin 的解法:不是禁止移动,而是禁止访问
Pin<P> 的核心设计哲学可以用一句话概括:
Pin 不保证底层数据不动,而是保证你不能获得 &mut 引用来移动它。
这是关键的语义差异。Pin<Box<T>> 表示:"这个 T 被堆分配,且我承诺永远不会把它从 Box 里移出来"。但 Pin<&mut T> 并不要求 T 不动——它的意思是我持有一个 &mut T,但我通过 Pin 发誓不会用这个 &mut 去替换或 swap 它。
Pin 的内部实现
// 标准库中的 Pin 简化定义
pub struct Pin<P> {
pointer: P, // 通常是被 Deref/DerefMut 的指针类型
}
impl<P: Deref> Pin<P> {
/// 安全构造函数:P 已经是"堆固定"的(如 Box、Arc)
pub unsafe fn new_unchecked(pointer: P) -> Self {
Self { pointer }
}
}
impl<P: DerefMut> Pin<P> {
/// 获取 &mut T — 仅当 T: Unpin 时才安全
pub unsafe fn get_unchecked_mut(self: &mut Pin<P>) -> &mut P::Target {
&mut self.pointer
}
}
Unpin:大多数类型的"通行证"
Unpin 是一个自动 trait(auto trait),几乎所有类型都自动实现了它。Unpin 的含义是:"即使我被 Pin 住,我仍然可以安全地 move"。
// 标准库中:以下类型全部自动实现 Unpin
// i32, String, Vec<T>, Box<T>, Arc<T>, HashMap<K, V>...
// 以及任何所有字段都 Unpin 的组合类型
只有你手动标记了 !Unpin 的类型才具有"不可移动"语义。Rust 的 async fn 生成的 Future 就自动是 !Unpin 的(如果它包含自引用字段)。
三、Pin 的分类与指针语义
| Pin 类型 | 堆固定 | 可降级为 &T |
可降级为 &mut T(需 T: Unpin) |
典型用途 |
|---|---|---|---|---|
Pin<Box<T>> |
是 | 是 | 是(T: Unpin 时) | 重量级 Future,异步运行时内部 |
Pin<Arc<T>> |
是 | 是 | 否 | 跨线程共享的固定数据 |
Pin<&mut T> |
否 | 需通过 get_ref |
否 | 在已有内存上临时固定 |
Pin<Unique<T>> |
是 | 是 | 否 | 自定义分配器 |
四、工程实践一:异步状态机的 Pin 保证
4.1 async fn 生成的 Future 为什么自动 !Unpin
编译器将 async fn 转换为 Future trait 的实现,如果函数体内有跨 .await 的引用借用,生成的状态机必然是自引用 → 编译器自动让其 !Unpin:
async fn cross_await_reference(input: &[u8]) -> usize {
let processed = compute(input);
tokio::task::yield_now().await; // yield 点
processed.len() // 对 processed 的引用跨越了 .await
}
// 编译器生成的 Future:processed 存活于栈上,但 yield 后整个栈可能消失
// 解决方案:编译器将局部变量存储在状态机 struct 内部
这等价于手动实现:
struct CrossAwaitReference<'a> {
input: &'a [u8],
processed: Vec<u8>, // 与 self 共存亡
_pin: PhantomPinned, // 标记 !Unpin
}
impl<'_a> Future for CrossAwaitReference<'_a> {
type Output = usize;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = &mut *self;
let processed = compute(this.input);
this.processed = processed;
Poll::Ready(this.processed.len())
}
}
4.2 为什么 tokio::select! 需要 Unpin
tokio::select! 的展开代码大致是:
loop {
if let Poll::Ready(val) = Pin::new(&mut future1).poll(cx) {
break val;
}
if let Poll::Ready(val) = Pin::new(&mut future2).poll(cx) {
break val;
}
// 如果没有 ready,退出让出
}
所以 tokio::select! 要求 Future 是 Unpin 的。对于跨 await 借用导致 !Unpin 的 Future,需要用 Box::pin 将其堆固定:
// 错误:!Unpin 的 future 不能用于 select!
// tokio::select! { ... }
// 正确:通过 Box::pin 堆固定
let pinned: Pin<Box<dyn Future<Output = ()>>> = Box::pin(my_async_fn());
tokio::select! {
_ = pinned => {},
_ = other => {},
}
4.3 Pin 与 Stream 的集成
Stream trait 的 poll_next 方法本身就接收 Pin<&mut Self>:
pub trait Stream {
type Item;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>>;
fn size_hint(&self) -> (usize, Option<usize>) { ... }
}
这意味着任何实现了 Stream 的类型都要遵循 Pin 规则。生产中的 Stream 案例:TCP 接收流、消息队列消费流、实时推理日志流。
五、工程实践二——自定义自引用结构的安全封装
5.1 ouroboros 宏:自动化自引用结构
手写自引用结构繁琐且极易误用 unsafe。ouroboros 宏自动生成安全的自引用结构体:
use ouroboros::self_referencing;
#[self_referencing]
struct DataWithRef {
data: String,
#[borrows(data)]
#[covariant]
reference: &'this str,
}
impl DataWithRef {
fn get_ref(self) -> &str {
self.with_ref(|r| *r)
}
}
let dwr = DataWithRefBuilder {
data: "hello world".to_string(),
reference_builder: |data: &String| &data[0..5],
}.build();
assert_eq!(dwr.get_ref(), "hello");
5.2 pin-project:为 struct 生成安全的 Pin 投影
当你的 struct 同时有 !Unpin 字段和普通字段,需要安全地"投影"——即从 Pin<&mut Struct> 分别获取各字段的 Pin 状态:
use pin_project::pin_project;
#[pin_project]
struct TaskWithIO {
#[pin]
async_io: AsyncReader, // !Unpin,需要 Pin<&mut T> 来 poll
buf: Vec<u8>, // Unpin,直接 &mut 即可
state: TaskState, // Unpin
}
impl TaskWithIO {
// pin_project 生成的 as_mut() 返回 Projected 类型
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
let this = self.project();
// this.async_io: Pin<&mut AsyncReader>
// this.buf: &mut Vec<u8>
// this.state: &mut TaskState
match this.async_io.poll_read(cx, this.buf) {
Poll::Ready(n) => { *this.state = TaskState::Done(n); Poll::Ready(()) }
Poll::Pending => Poll::Pending,
}
}
}
没有 pin_project,你要自己写 unsafe 投影,错误概率极高。
5.3 手写安全自引用:完整示例
模拟一个缓冲区持有其切片指针的真实场景:
use std::pin::Pin;
use std::marker::PhantomPinned;
use std::ptr::NonNull;
struct PinnedBuffer {
data: Vec<u8>,
// 指向 self.data 内部的切片
slice_ptr: NonNull<[u8]>,
_pin: PhantomPinned,
}
impl PinnedBuffer {
/// 安全堆构造(必须 Box::pin,因为内部持自引用)
fn new(capacity: usize) -> Pin<Box<Self>> {
let mut data = vec![0u8; capacity];
let slice_ptr = NonNull::from(&data[..]);
let mut boxed = Box::new(Self {
data,
slice_ptr,
_pin: PhantomPinned,
});
// 重新建立指针 — 可能发生 realloc
boxed.slice_ptr = NonNull::from(&boxed.data[..]);
// 固定后永不移动
Pin::new(boxed)
}
/// 安全接口:通过 Pin<&mut Self> 修改缓冲区
fn write(self: Pin<&mut Self>, offset: usize, bytes: &[u8]) {
let this = unsafe { self.get_unchecked_mut() }; // 我们承诺 fix
this.data[offset..offset+bytes.len()].copy_from_slice(bytes);
}
// 注意:不能有 fn move_data(self) -> Self 这类会转移所有权的方法
}
六、工程实践三——Trait Object 与动态分发
Pin<Box<dyn Future>>:异步运行时的基石
运行时无法知道每个 Future 的具体类型(用户注册的 async handler 是异构的),所以必须通过 Trait Object 统一:
type Task = Pin<Box<dyn Future<Output = ()> + Send>>;
fn spawn_task(task: Task) {
// 推入运行队列,等待 poll
}
但 dyn Future 默认既不是 !Unpin 也不是 Unpin——它的实现类型可能是任一状态。这就造成了一个问题:
// 无法编译!dyn Future 没有实现 Future 的 Pin 版本
fn bad() -> Pin<Box<dyn Future<Output = ()>>> {
Box::pin(async {}) // async{} 生成 !Unpin Future,但 Box<dyn> 擦除了 !Unpin 信息
}
Rust 通过一个特殊规则解决:Box::pin 生成的 Pin<Box<F>> where F: Future,且 Box 是堆固定的,所以即使 F 是 !Unpin,Pin<Box<F>> 仍然实现了 Future(因为移动 Box 不会移动内部数据)。
Pin<Box<dyn Stream>> 是同样的模式
struct StreamConsumer {
inner: Pin<Box<dyn Stream<Item = Vec<u8>> + Send>>,
}
impl StreamConsumer {
fn poll_next(&mut self, cx: &mut Context<'_>) -> Poll<Option<Vec<u8>>> {
self.inner.as_mut().poll_next(cx)
}
}
七、与 C++ 工程实践的对比:协程与移动语义
C++20 coroutines 为什么不需要 Pin
C++20 协程也面临自引用问题,但他们的处理方式截然不同:
// C++20 coroutine
task<int> async_compute() {
std::string data = co_await fetch_data(); // 协程帧分配到堆
co_return data.size();
}
C++ 协程的 coroutine frame 一定在堆上(通过 operator new)。共享指针 coroutine_handle 管理它的生命周期。但 C++ 没有 move 后的 use-after-move 保护——悬垂引用是 UB,不被编译器阻止。
这是关键权衡:
| 特性 | Rust Pin |
C++ 协程 |
|---|---|---|
| 内存固定 | 类型系统保证 | 依赖开发者不移动 |
| UB 保护 | move 后访问直接编译错误 | 运行时 UB(segfault 或静默错误) |
| 性能 | 零成本:Pin 是 phantom 类型 | 零成本:堆分配协程帧 |
| 调试难度 | 编译失败时很清晰 | 运行时崩,可能数小时定位 |
| 对跨 async 借用 | 通过 !Unpin 自动阻止 | 依赖开发者认知 |
C++ 生产中被 Pin 坑惨的真实案例
某数据库团队在 C++20 协程化改造中出现一段诡异的崩溃:
// 异步游标
struct AsyncCursor {
std::string buffer;
std::string_view current_row; // 引用 buffer 内的数据
// 使用 emplace/swap 操作后,current_row 悬垂
};
这个 bug 在线上跑了四周才被抓到——因为 std::string_view 的悬垂不会在 move 时立即报错。如果是 Rust,SelfRef 结构要么编译失败,要么必须 Pin 在堆上。
八、与 Go 工程实践的对比:goroutine 为什么不在乎 Pin
Go 的 goroutine 完全绕开了这个问题:
func asyncTask(ch chan int) {
data := <-ch
result := process(data)
ch <- result
// data 的引用被 ch 内部持有?没问题——Go 是 GC 语言
}
Go 通过两个避开了 Pin:
- 垃圾回收:移动 GC 会自动修复指针
- 逃逸分析:如果共享引用,变量自动分配到堆上
但这是有代价的:
- STW 暂停:即使小到 10μs 的 GC pause,对 HFT 或游戏服务器也是不可接受的
- 没有确定性析构:
Drop语义缺失导致 RAII 资源管理困难 - Pin 的缺失使得自引用结构需要额外 unsafe
对比总结:
| 维度 | Rust Pin | Go GC | C++ 协程 |
|---|---|---|---|
| 内存安全保证 | 编译时 | 运行时 | 无 |
| 运行时开销 | 零 | GC 暂停 | 零 |
| 对自引用 | 显式 Pin / !Unpin 标记 | GC 自动跟踪 | 开发者自管 |
| 资源释放确定性 | Drop + RAII | GC 非确定 | RAII |
| 适用领域 | 嵌入式/实时/Web 后端 | 微服务/云原生 | 游戏引擎/高频交易 |
九、常见陷阱与最佳实践
陷阱一:Pin::new vs Pin::new_unchecked
Pin::new 要求目标已堆固定(Box / Arc / Vec)。对于 &mut T 临时固定,使用 Pin::new 是安全的——但如果你的代码同时满足以下两个条件,必须用 unsafe:
1. 在栈上持有自引用结构
2. 想从 Pin<&mut Self> 获取字段的 Pin<&mut Field>
这是 pin-project 存在的理由——宏展开生成 checked 的 unsafe。
陷阱二:move 了 Pin 包裹的值
Pin<Box<T>> 的 Deref 返回 &T,但 没有 DerefMut 提供 &mut T(除非 T: Unpin)。所以 mem::replace / mem::take 直接失效——你换出来就打破了"不移动"的契约。
fn broken<T>(mut pinned: Pin<Box<T>>, fallback: T) {
// 编译错误!不能 mem::replace Pin 内部的值
// let old = std::mem::replace(&mut *pinned, fallback);
}
fn correct<T: Default>(mut pinned: Pin<Box<T>>) {
// 如果 T: Default 且 Unpin,可以:
*pinned = T::default(); // OK:通过 Pin<Box<T>>::set(仅 Unpin 类型可用)
}
陷阱三:Unpin 违反引发 UB
如果你的类型标记了 !Unpin,但拿到了它的 &mut 然后 move 出去,这是 即时 UB——不是逻辑错误,而是 Rust 语义层面的 UB。LLVM 会基于"Pin=不会移动"的假设做优化;违反它可能产生不正确的编译器优化。
//危险!通过 get_unchecked_mut 拿到 &mut 后绝对不要 move
let mut pinned: Pin<Box<SelfRef>> = Box::pin(SelfRef::new("x".to_string()));
let r = unsafe { pinned.as_mut().get_unchecked_mut() };
let moved = SelfRef::new("y".to_string());
*r = moved; // 表面是赋值,实际是 move+drop — 破坏了 Pin 契约
最佳实践清单
- 优先用
Box::pin而不是手动unsafe创建栈自引用 - 自定义 async 状态机用
pin-project宏而不是手写投影 - Stream/Future trait object 用
Pin<Box<dyn ...>>统一抽象 - 有
PhantomPinned的类型不提供into_inner/take等转移所有权方法 unsafe impl !Unpin只在真正需要时标注,不要滥用
十、展望:Pin 在未来系统编程中的演进
addr_of! 宏与更安全的裸指针
Rust 1.51 引入 addr_of! / addr_of_mut! 宏,可以直接获得字段的指针而无需先构造引用(触发引用别名检查)。这让自引用结构的构造流程在 safe Rust 中更可行:
let mut data = vec![0u8; 1024];
let ptr = std::ptr::addr_of!(data); // 不创建 & 引用,不触发 borrow checker
异步闭包与 async blocks 的演进
社区提案包括 async trait(不再需要 async-trait 宏的 Box 分配)、generator(更接近状态机的表达能力)等。Pin 模型始终保持向下兼容——核心契约不变。
十一、小结
Pin 的设计是 Rust 对"内存安全 vs 零成本抽象"这道题最优雅的回答之一:
- 它不是禁止 move 本身,而是精确切断从 Pin 到 move 的唯一通道(&mut 引用)
!Unpin标记是编译器强制执行的承诺,而非单纯的约定- 与 C++ coroutines 对比时,Pin 让异步代码的安全性从"文档中的警告"提升为"违反即编译失败"
- 与 Go 对比时,Pin 用零成本换来了确定性析构和实时性保障
下次写异步 Rust 时遇到 Pin::new 编译错误,不要绕开它——停下来思考这个结构里隐藏的自引用关系,才是正道。
延伸阅读:
- The Pin API — official Rust docs
- Pin, Unpin, and why Rust needs them — fasterthanlime
-pin_projectionsRFC — 提案起源
-tokio::pin!宏 — 栈固定工具
-C++20 coroutines— Andrew Sutton, Gor Nishanov
文章写于 2026年10月,基于 Rust 1.8x 与 tokio 1.x 生产实践总结。

发表评论 取消回复