Pin<P>包装指针P,承诺从值进入固定状态到析构完成,不能通过安全代码移动P指向的值;指针本身仍然可以移动。
Pin不是不可变包装器,也不会自动让自引用类型安全。若目标实现Unpin,安全API仍允许取得普通可变引用。
普通类型不必主动固定;地址敏感类型应先放入最终存储位置,再建立内部引用,并把每个unsafe操作对应到明确的固定不变量。
是什么,为什么存在
Pin 是围绕指针的类型级契约。Pin<P>不会冻结变量P,而是限制你通过这个指针移动它的目标值。移动Pin<Box<T>>只会移动拥有堆分配的指针句柄,通常不会改变堆上T的地址。
大多数Rust值从一个位置移动到另一个位置时仍然有效。String的栈上控制信息可以换地址,只要它仍然拥有同一块缓冲区;普通结构体的字段也不应保存指向自身其他字段的裸指针。这个默认模型让赋值、传参和容器重排保持简单。
有些值在生命周期的某一阶段会变成地址敏感值(address-sensitive value)。自引用结构在字段中保存自身地址,侵入式数据结构让其他节点保存其地址,编译器生成的某些future则可能在状态中保存跨挂起点的内部借用。它们一旦建立这种关系,再做按位移动就会使内部地址失效。
Unpin 是自动trait,表示类型不依赖固定保证。i32、String和绝大多数普通组合类型会自动实现它;包含PhantomPinned的类型默认不会实现。Unpin不是“当前没有移动”,而是“即使放在Pin后面,移动该值也不会破坏地址不变量”。
Pin只有在目标为!Unpin时才施加实际访问限制。对于T: Unpin,Pin<&mut T>::get_mut()可以安全地产生&mut T,调用方随后可以用mem::replace等操作移动值。对于T: !Unpin,同样的转换需要unsafe,调用者必须自行证明它不会移动地址敏感部分。
固定也不等于不可变。Pin<&mut T>可以调用只修改内容但不移动固定字段的方法,还可以通过正确的字段投影操作内部数据。反过来,一个共享的Pin<&T>不允许修改,原因来自共享借用,不是来自固定本身。
普通业务结构、只保存拥有型字段的状态机,以及不会依赖自身地址的future通常不需要手写Pin。你最常在实现Future::poll、设计地址敏感类型、编写固定字段投影,或调用要求Pin<&mut T>的异步API时遇到它。
| 形状 | 可以移动什么 | 关键条件 |
|---|---|---|
Pin<&mut T>,T: Unpin | 可通过安全API移动T | 类型声明不需要固定保证 |
Pin<&mut T>,T: !Unpin | 可移动指针值,不可通过它移动T | 借用期间维护固定不变量 |
Pin<Box<T>>,T: !Unpin | 可移动Pin<Box<T>>句柄,不可取出并移动T | 分配保持有效直到T析构完成 |
Pin<&T> | 可复制共享指针,不可通过它修改或移动T | 同时受共享借用约束 |
工作原理
固定契约从构造开始,而不是从值出生时开始。地址敏感类型可以先作为普通未固定值移动;在最终位置建立Pin后,代码才创建依赖该地址的内部关系。构造器必须保证两件事的顺序:先固定,后写入自引用。
Pin::new(pointer)是安全构造器,但要求目标实现Unpin。它适合本来就能安全移动的值,因此主要提供统一接口。Box::pin(value)会先把值放入Box的分配,再安全地产生Pin<Box<T>>,即使T: !Unpin也可使用。
Pin::new_unchecked(pointer)可以为!Unpin目标创建固定指针,因此是unsafe。调用者不仅要保证当前地址稳定,还要保证从此直到目标析构完成,任何别名、拥有指针实现和析构路径都不会移动、释放或复用该存储。局部看起来没有移动并不足以证明这个长期契约。
安全访问根据Unpin分成两条路径。as_ref()与as_mut()创建更短的固定重借用,不放弃原固定关系;get_ref()取得共享引用。get_mut()只在T: Unpin时提供普通可变引用,而get_unchecked_mut()把不移动T的责任交给unsafe调用方。
从固定结构体访问字段称为 固定投影(pin projection) 。普通共享投影通常直接产生&Field;可变投影必须先决定字段是结构性固定字段,还是允许作为普通&mut Field访问的非固定字段。这个决定属于类型的安全契约,不能根据“字段看起来是String”临时改变。
下面的流程强调状态变化和责任转移。进入地址敏感状态之后,安全API保持相同地址;如果使用unsafe绕过限制,证明责任就落到调用者身上。
固定不变量(pinning invariant) 包含析构保证:进入固定状态的值在drop被调用之前,存储不能失效或被重新用于别的值,而且析构执行期间地址仍然稳定。只禁止显式赋值还不够;提前释放分配会让依赖该地址的析构逻辑同样失效。
Pin::set说明固定并不意味着存储槽永远不能换值。该方法先在原地址完成旧值的析构,再把新值写入相同位置,所以旧值的地址敏感阶段已经正确结束。它不会把旧的内部指针自动转移给新值。
PhantomPinned是一个零大小的!Unpin标记。把它放进结构体会阻止结构体自动实现Unpin,从而使安全代码不能从固定可变引用取得普通&mut Self。它只关闭这条安全出口,不会初始化自引用、创建Pin或验证投影代码。
Future trait把接收者写成self: Pin<&mut Self>,这样具体future实现可以依赖轮询之间不被移动。实现了Unpin的简单future可以在poll中安全取得&mut Self;地址敏感future则只能使用固定访问。.await和执行器负责在轮询前安排这一条件,应用代码通常不直接调用poll。
示例
下面四个程序均用本地rustc 1.94.0编译并执行;它们只使用Rust 1.98目标版本中仍然有效的稳定API。输出块是实际结果。
Unpin类型的透明访问
String实现Unpin,所以接收Pin<&mut String>的函数仍可调用get_mut()。这个例子展示的是接口兼容性,不是String需要地址固定。
use std::pin::Pin;
fn add_status(mut message: Pin<&mut String>) {
message.as_mut().get_mut().push_str(": ready");
}
fn main() {
let mut message = String::from("job-42");
let pinned = Pin::new(&mut message);
add_status(pinned);
println!("{message}");
}job-42: readyPin::new可用,是因为String: Unpin。add_status修改字符串缓冲区的内容,但这个类型本来就不承诺依赖String控制块自身的地址。若函数只需要&mut String,直接接收可变引用会更诚实;这里只是展示Unpin如何解除固定访问限制。
固定后建立自引用
Ticket用label_ptr指向自己的label字段,并用PhantomPinned选择退出Unpin。构造器先调用Box::pin,然后才在一个很小的unsafe区域写入内部指针。
use std::marker::PhantomPinned;
use std::pin::Pin;
use std::ptr;
struct Ticket {
label: String,
label_ptr: *const String,
_pin: PhantomPinned,
}
impl Ticket {
fn new(label: String) -> Pin<Box<Self>> {
let mut ticket = Box::pin(Self {
label,
label_ptr: ptr::null(),
_pin: PhantomPinned,
});
let label_ptr = &ticket.as_ref().get_ref().label as *const String;
// SAFETY:存储指针之前,目标已经固定。
unsafe {
ticket.as_mut().get_unchecked_mut().label_ptr = label_ptr;
}
ticket
}
fn label(self: Pin<&Self>) -> &str {
// SAFETY:构造器写入了指向当前固定值内部的指针。
unsafe { &*self.get_ref().label_ptr }.as_str()
}
}
fn main() {
let ticket = Ticket::new(String::from("ticket-17"));
let moved_handle = ticket;
println!("{}", moved_handle.as_ref().label());
}ticket-17赋值给moved_handle会移动Pin<Box<Ticket>>这个句柄,不会移动其堆上目标。安全接口不能从中取出Ticket,因此label_ptr保持有效。这里使用裸指针和unsafe是为了展示机制;普通应用应优先使用索引、拥有型数据或已有的固定投影工具,避免自行维护自引用。
按Future契约轮询
Countdown没有地址敏感字段,所以自动实现Unpin。Future接口仍要求固定接收者,以便同一个trait也能安全支持需要固定的状态机。
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, Waker};
struct Countdown {
remaining: u8,
}
impl Future for Countdown {
type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.remaining == 0 {
Poll::Ready("complete")
} else {
self.remaining -= 1;
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
fn main() {
let waker = Waker::noop();
let mut context = Context::from_waker(waker);
let mut countdown = Box::pin(Countdown { remaining: 1 });
println!("{:?}", countdown.as_mut().poll(&mut context));
println!("{:?}", countdown.as_mut().poll(&mut context));
}Pending
Ready("complete")第一次轮询减少计数并通知waker,第二次返回结果。因为Countdown: Unpin,字段赋值可通过Pin的DerefMut实现;若加入结构性固定字段,就需要安全投影或经过证明的unsafe投影。真实执行器根据唤醒通知安排下一次轮询,不会像示例一样手动连续调用。
用Pin::set替换固定值
Phase因PhantomPinned而成为!Unpin,但Pin::set仍可替换整个值。输出顺序显示旧值先完成析构,新值随后占用该位置。
use std::marker::PhantomPinned;
struct Phase {
name: &'static str,
_pin: PhantomPinned,
}
impl Drop for Phase {
fn drop(&mut self) {
println!("drop {}", self.name);
}
}
fn main() {
let mut phase = Box::pin(Phase {
name: "queued",
_pin: PhantomPinned,
});
phase.as_mut().set(Phase {
name: "running",
_pin: PhantomPinned,
});
println!("current {}", phase.name);
}drop queued
current running
drop runningset保证先析构queued,所以不会在地址敏感状态仍然存在时直接覆盖字节。新Phase之后在作用域末尾正常析构。若类型需要建立内部指针,新值仍须在替换后按自己的构造协议进入地址敏感状态,不能复用旧指针。
陷阱
把固定指针当成不可移动句柄
修复方法: 每次都区分指针值与其目标值。审查赋值时写明被移动的是Pin<Box<T>>句柄还是T;真正危险的是解引用后取出、替换或复制T的字节。
认为Pin自动限制所有类型
修复方法: 先判断类型是否真的维护地址不变量。需要选择退出自动Unpin时使用PhantomPinned或依赖一个确实为!Unpin的字段;不要为解决普通借用错误而添加Pin。
在固定之前创建自引用
修复方法: 使用两阶段初始化:先把所有普通字段放到最终存储位置并固定,再通过受控的unsafe写入内部地址。构造器应返回Pin<Box<Self>>等固定句柄,不要返回还能自由移动的Self。
从unsafe投影泄漏可移动引用
修复方法: 把unsafe封装在最小的投影API中,逐字段记录哪些字段固定、哪些字段可移动,并审查Drop是否遵守同一选择。成熟宏或库可以生成投影时,应优先复用其已审查实现。
为通过约束而手写Unpin
修复方法: 把Unpin约束当作API设计信号,先改用固定调用方式或调整泛型边界。只有证明类型在移动后仍保持全部不变量时才实现Unpin,并用编译失败测试保护真正应为!Unpin的类型。
忽略析构与存储失效
修复方法: 对创建Pin的自定义指针审查Deref、DerefMut和Drop的行为,并让固定字段留在原位直到其析构结束。需要替换整个值时使用Pin::set这类保持析构顺序的安全API。
固定状态的生命周期
一个值不是因为类型为!Unpin就从创建起永远不能移动。!Unpin只让安全的Pin API在固定后施加限制;构造前仍可正常移动。地址敏感类型应明确区分尚未初始化内部关系的普通状态,以及已经依赖地址的固定状态。
这种阶段差异解释了为什么返回普通Self的构造器通常无法安全建立自引用。返回动作本身就可能移动值。安全构造器通常返回一个已经拥有最终存储的固定指针,并且不向调用者暴露能取回Self的安全路径。
Pin<Box<T>>不是唯一形式。std::pin::pin!可以把值固定在当前作用域的匿名局部存储中,并返回Pin<&mut T>,不需要堆分配。得到的固定引用不能逃出该存储的生命周期;需要长期拥有、返回或放入异构集合时,拥有型固定指针通常更方便。
直接用Pin::new_unchecked(&mut local)固定局部值更难审查。原变量仍可能通过别名访问,调用者还必须保证后续代码与析构路径都不移动它。优先使用pin!或拥有型安全构造器,让类型和借用范围替你封住这些路径。
结构性固定与字段投影
固定一个结构体不会自动规定每个字段都必须固定。类型作者可以把某些字段定义为结构性固定:只要父对象保持固定,这些字段也不允许在析构前移动。其他字段可以被视为非固定字段,并通过普通可变引用访问。
结构性固定的选择必须贯穿投影、Unpin和Drop。如果一个固定字段为!Unpin,父类型通常也不能无条件实现Unpin;析构函数也不能用mem::replace取走该字段。只在一个投影方法里返回Pin<&mut Field>,却在另一条安全路径中返回&mut Field,并没有形成有效契约。
Pin::map_unchecked_mut之所以是unsafe,是因为标准库无法从闭包知道返回引用是否指向同一对象内部、是否会在父对象之前失效,以及调用者是否还会移动父对象。正确证明至少要覆盖位置关系、生命周期和析构顺序。
非固定字段也不能随意返回超出借用期的引用。Pin不替代普通生命周期与别名规则;它只增加地址稳定这一维约束。投影API同时要满足借用检查规则和固定不变量。
Unpin的组合规则
Unpin是自动trait,因此结构体在所有相关字段都实现它时通常自动实现。泛型容器的结果会随类型参数变化,例如只保存T的容器一般在T: Unpin时实现Unpin。这让通用API可以对普通值保留简单访问,同时支持真正的地址敏感值。
PhantomPinned通过一个!Unpin字段阻止自动实现。它不代表裸指针指向哪里,也不让结构体自动变成自引用。若删除内部指针后不再需要地址稳定,也应重新评估这个标记,而不是把它当作高级类型的装饰。
为类型显式实现Unpin不需要unsafe关键字,但仍可能破坏使用unsafe建立的内部假设。这是因为unsafe代码可以依赖安全trait API的语义。代码审查必须把手写Unpin当作安全边界变化,而不是普通的编译器适配。
泛型函数要求T: Unpin时,是在声明它可能需要通过固定包装取得普通可变访问或移动T。不要自动给调用类型补实现。若函数实际上只需固定访问,应把签名改为接受Pin<&mut T>并在内部保持该约束。
指针类型的责任
Pin<P>的保证依赖P的指针行为。标准库的Box<T>、&mut T和&T有已知的解引用语义;自定义指针若让DerefMut返回会变化的地址,或在Drop中提前释放目标,就可能使Pin<P>的安全外观失真。
因此,为自定义拥有指针调用Pin::new_unchecked时,证明范围超过当前函数。你必须审查该指针所有安全API和析构行为,并保证它们无法移动目标。把一个行为不稳定的容器包装进Pin不会修复容器。
共享引用也不是随意构造固定保证的捷径。虽然&T本身不能用于移动T,但创建固定指针的人仍需确认目标不会由其他拥有路径移动或提前失效。固定契约覆盖值的全部访问路径,不只覆盖眼前这个变量。
Future与轮询边界
Future::poll使用固定接收者,是为了让具体实现可以在轮询之间保存地址敏感状态,而不是说每个future都实际自引用。通用执行器无法根据具体实现改变调用约定,所以trait在统一边界上提供较强保证。
一个future在第一次被固定和轮询之前可以移动。进入轮询协议后,实现可以开始依赖其地址,之后每次poll都必须通过固定指针。把future从一个队列取出并放入另一个队列是否安全,取决于移动的是固定指针句柄还是其目标状态机。
.await隐藏了轮询循环、Context和固定细节。手写future或组合器时,这些细节重新进入API。若实现只包含普通字段并自动实现Unpin,可以安全地使用get_mut();若包含固定子future,则需要把父固定引用正确投影到该字段。
返回Poll::Pending的future必须安排在可能取得进展时唤醒当前任务。Pin只保证地址,不保证唤醒协议、取消安全或并发正确性。把所有异步错误归因于固定会漏掉这些彼此独立的契约。
析构保证与替换
地址敏感值的析构函数可能读取内部指针或通知仍保存其地址的数据结构,因此析构期间地址也必须稳定。先释放内存、再尝试运行析构,或者把值移动到临时位置后析构,都会违反固定保证。
Pin::set的安全性来自顺序:先在固定位置析构旧值,再写入新值。此时旧值的地址敏感生命周期已经结束。mem::replace直接返回旧值,需要先把它移出,因而不能通过Pin<&mut T>对!Unpin目标安全调用。
泄漏一个固定值不会移动它,但会跳过析构并保留资源,因此通常不是解决析构困难的方法。固定保证规定哪些操作安全,不承诺程序一定回收内存,也不替代显式的资源生命周期设计。
恐慌路径同样要维护不变量。若两阶段初始化在写入部分内部链接后恐慌,析构函数必须能识别未完成状态,或者构造过程必须把可见状态安排成始终可析构。用Option表示尚未初始化的内部指针,往往比假装它始终有效更容易审查。
不安全代码的审查方法
每个unsafe固定操作都应附带局部可验证的说明。new_unchecked的说明应回答谁拥有存储、哪些路径可能移动它、保证持续多久;get_unchecked_mut应说明允许哪些修改,以及为何不会移动结构性固定字段;投影还要说明返回位置与父对象的关系。
测试可以发现构造顺序、析构次数和公开API的问题,却不能证明裸指针解引用没有未定义行为。编译通过也只证明类型检查器接受了声明的边界。审查需要从安全公开API反推,确认任何调用顺序都无法触发被unsafe假设禁止的移动。
编译失败测试适合保护!Unpin约束,例如确认调用者不能用要求Unpin的辅助函数接收该类型。运行时测试则应覆盖句柄移动、替换、提前返回和恐慌清理。两类测试分别保护类型边界和可观察行为,不能互相替代。
延伸阅读
4个问题 · 1道输出预测题 · 1道找错题