Rust 中的 Pin
Why we need it?
在Pull 与 Push 模式中,我们说到了 Rust 是基于 Pull 模型的,也就是说,在Rust中,一个异步任务(Future)是不会自己运行的,必须由执行器去推(Pull)一下,它才会去执行。
比如下面这个简化版的Future Trait :
trait Future {
type Output;
// 注意这里:self 被 Pin 包裹着
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>;
}
当我们写一个 async fn 时,Rust 编译器会在背后悄悄把它编译成一个 枚举(Enum) 或者 结构体(Struct),也就是我们常说的“状态机”。它必须把函数里的所有局部变量都打包存起来,以便下次 poll 恢复执行时还能找到它们。
假设我们写了这样一段代码:
async fn my_task() {
let mut buffer = [0u8; 1024];
let reader = MyAsyncReader::new();
reader.read(&mut buffer).await;
}
编译器就会把他转换成下面这种类似的结构体:
struct MyTaskState {
buffer: [0u8; 1024],
reader: MyAsyncReader,
// 阶段 1:正在等待 read 完成
}
我们可以注意到,reader.read 接受的是 buffer 的引用 (&mut buffer),所以在这个状态下,reader 内部持有一个指向 buffer 的指针。
而这就是自引用(Self-Reference):结构体里的一个字段,指向了结构体里的另一个字段。
那么让我们现在回到 Poll 模型。Rust 的特点是默认栈分配且随意移动(Move)。
如果不加限制,执行器在两次 poll 之间,完全有权利把这个 MyTaskState 从内存地址 A 搬运到 内存地址 B(比如为了重新排列任务队列,或者从栈上通过 Box::new 移到堆上)。
那么如果发生了移动,会产生什么后果呢?(我们假设原来结构体的内存地址在0x1000)
- 整个结构体被拷贝到了新地址
0x2000。 buffer现在位于0x2000。reader也被拷贝到了0x2000。- 但是
reader里面的那个指针,原本存储的是0x1000(旧 buffer 的地址)。简单的memcpy不会自动更新这个指针。 - 结果:
reader现在指向的是0x1000,而那个地方可能已经被回收、覆盖或者是垃圾数据。 - 后果: 下次你再
poll它时,reader往0x1000写入数据,导致程序崩溃或严重的数据损坏。
正是这种冲突, Rust 引入了 Pin 。
What is Pin ?
正如它的名字一样, Pin 其实是一个锁:对于那些也是自引用的类型(实现了 !Unpin 的类型),一旦被 Pin 包裹,就永远不能通过 Safe Rust 拿到它的 &mut T。
为什么不能拿 &mut T? 因为只有拿到了 &mut T,你才能使用 std::mem::swap 或者 std::mem::replace 把里面的值移走。
Pin 如何工作?
Pin<P> 是一个包裹指针 P(如 &mut T, Box<T>)的包装器。它的作用是限制对被包裹值 T 的访问权限:
- 对于绝大多数类型 (
Unpin): 像i32,String这种普通类型,移动它们是安全的。它们实现了Unpintrait。对于这些类型,Pin没有任何限制,你可以随意拿到&mut T并移动它。 - 对于特殊类型 (
!Unpin): 像async生成的Future或者是自引用结构体,它们没有实现Unpin。 当这种类型被Pin包裹时(Pin<&mut T>),Rust 禁止你拿到原始的&mut T。- 为什么禁止?因为有了
&mut T,你就可以用std::mem::swap或std::mem::replace把里面的值移走(Move out)。 Pin就像给数据加了一个笼子,你可以透过笼子操作它(调用poll),但不能把它拿出来换个位置。
- 为什么禁止?因为有了
Pin 的逻辑是:
- 当你第一次
poll一个 Future 时,必须先把它Pin住(比如Box::pin(future))。 - 这相当于告诉编译器:“这个 Future 已经在内存里安家了(无论是栈上还是堆上)。”
- 从此以后,你只能通过
Pin<&mut T>来操作它。 - 因为你拿不到原始的
&mut T,你就物理上无法把它移动到别的地方。 - 既然它不会动,那么它内部的自引用指针(
reader指向buffer)就永远是安全的、有效的。
Example
让我们依旧演示来对比 No Pin 与 Pin 的区别,使用的代码你依旧可以在这里找到。
No Pin
运行结果:

With Pin
运行结果:

About C++
什么? 还有C++ 的事?
是的,虽然并没有像rust一样在标准库中存在,而是以一种概念的形式出现。而这恰好又与我们在C++中的右值引用与移动语义提到的移动语义相关:Cpp 中的 Pin 主要通过禁用移动语义来实现。
实现方式
在 C++ 中,如果你想让一个对象 “Pinned”(地址不可变),通常有以下做法:
- 删除移动构造函数和移动赋值操作符: 这是最明确的方式。如果一个类不能被移动,编译器会强制它只能呆在原地(或者被拷贝,如果你允许的话)。
- C++20 Coroutines (协程): C++20 的协程与 Rust 的 Future 类似,都需要保存状态。C++ 编译器通常会在堆上为协程帧(Coroutine Frame)分配内存。因为是在堆上分配的,只要你不手动 delete,它的地址就是固定的,天然就是 “Pinned” 的。
- 库级别的实现 (如 Folly): Facebook 的 Folly 库等有一些辅助类可能叫
Pinned,或者利用std::unique_ptr等智能指针的特性(指向堆内存,对象本身不移动,只是指针的所有权在移动)来实现地址固定。
Example:
class PinnedClass {
public:
PinnedClass() = default;
// 禁止移动
PinnedClass(PinnedClass&&) = delete;
PinnedClass& operator=(PinnedClass&&) = delete;
// 通常也要小心拷贝,因为拷贝后的自引用指针可能需要重定向
PinnedClass(const PinnedClass&) = delete;
PinnedClass& operator=(const PinnedClass&) = delete;
};