Rust语言之Pin 与 Unpin 的内存安全保证:从原理到实战【3】
我是兰瓶Coding,一枚刚踏入鸿蒙领域的转型小白,原是移动开发中级,如下是我学习笔记《零基础学鸿蒙》,若对你所有帮助,还请不吝啬的给个大大的赞~
前言
在 Rust 的异步生态里,你几乎绕不过两个概念:Pin 与 Unpin。很多人第一次与它们相遇是在 Future 与 async/await 的上下文中:为什么 poll(self: Pin<&mut Self>, cx: &mut Context<'_>) 不是普通的 &mut self?为什么某些类型标注了 !Unpin 就必须被固定(pinned)?本文将系统性地拆解 Pin/Unpin 的设计动机、形式化保证与实现细节,并通过两个深度实战案例展示如何正确地在工程中使用它们,避免微妙的未定义行为,同时获得可验证的安全与性能收益。
1. 背景动机:为什么需要 Pin?
Rust 的“移动(move)”是语义移动:值被按位拷贝到新位置后,旧位置被视为未初始化。对大多数类型来说,移动是完全安全的;但一旦类型内部保存了指向自身内部的指针或引用(即所谓“自引用(self-referential)”结构),移动就会让这些指针悬空。在 C/C++ 中,类似问题经常以“悬垂指针”呈现,并造成未定义行为。
Pin 的核心目标就是:在你承诺“值不能再被移动”之后,编译器与库为你维持这个不变式,从而允许那些“依赖地址稳定”的结构安全地存在。与此同时,Unpin 则是类型层面的标记:若某类型 T: Unpin,表示即使被 Pin 住,它依然可以安全移动(换言之,它并不依赖地址稳定)。这两个概念共同提供了“可移动”与“不可移动”的统一抽象。
2. 形式化保证与基本用法
-
Pin<P>的含义Pin<P>包装了一个指针类型P(常见为&mut T,Box<T>,Arc<T>等)。其承诺是:如果T为!Unpin,那么一旦被Pin,T在内存中的地址就不会再改变。Pin<&mut T>:将栈上的可变借用固定到当前位置(“暂时固定”)。Pin<Box<T>>:将堆分配对象固定(“持久固定”),只要Box不被解构,T的地址就稳定。
-
Unpin的含义T: Unpin表示“就算在Pin里,移动它也安全”。大多数普通类型(不自引用)默认都是Unpin。反之,T: !Unpin(通过PhantomPinned或宏/生成器产生)意味着一旦被Pin,就不能再移动。 -
安全边界
- 你永远不能从
Pin<&mut T>/Pin<Box<T>>直接获得一个“可随意移动的&mut T”;这会破坏不变式。 - 你可以获得对字段的投影(projection),前提是保证这些字段也不会隐式移动(这需要 pin 投影技巧,见下文)。
- 你永远不能从
3. 何时需要 Pin:两个典型场景
- 自引用结构:例如异步状态机/生成器在编译期被展开为一个包含内部缓冲区与指向缓冲区切片的结构。一旦结构移动,内部引用失效。
- 依赖地址稳定的外部接口/内核交互:如驱动、异步 I/O 状态、intrusive list(入侵式链表)节点等,节点中的
next/prev指针默认以地址为标识,移动将破坏拓扑。
4. 实战一:构建一个安全的“自引用异步解析器”
我们实现一个简化版的“流式帧解析器”future。它维护一个内部缓冲 Vec<u8>,并在 poll 过程中将外部提供的字节拼入缓冲,再在同一结构内部保留一个指向该缓冲区片段的切片,用于零拷贝地交给上层处理。若不固定地址,这个切片可能因为结构移动而悬空。
4.1 错误示例(不要这么做)
use std::task::{Context, Poll};
use std::pin::Pin;
use std::future::Future;
struct BadParser {
buf: Vec<u8>,
view: Option<&'static [u8]>, // 伪装成 'static 只是为了编译通过,实际是 UB!
}
impl BadParser {
fn new() -> Self { Self { buf: Vec::new(), view: None } }
}
impl Future for BadParser {
type Output = ();
fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = &mut *self;
this.buf.extend_from_slice(b"hello world");
// 这里把借用存到结构内部是危险的 —— 一旦结构被移动,引用悬空!
let slice: &[u8] = &this.buf[0..5];
// 错误地“延长生命周期”
this.view = Some(unsafe { std::mem::transmute::<&[u8], &'static [u8]>(slice) });
Poll::Pending
}
}
上面代码通过 transmute 伪造 'static,在运行时属于未定义行为。根因是:对 self 的后续移动会让 buf 地址改变,从而让 slice 失效。
4.2 正确做法:!Unpin + Pin + 投影
我们将类型标记为 !Unpin,并通过 pin 投影安全地获得对字段的访问,仅在固定之后再创建自引用。
use std::pin::Pin;
use std::future::Future;
use std::task::{Context, Poll};
use std::marker::PhantomPinned;
// 一个自引用结构:view 指向 buf 的一段切片
struct Parser {
buf: Vec<u8>,
view: *const [u8], // 使用裸指针存储,避免生命周期绑定,读写时需小心
_pin: PhantomPinned, // 使 Parser 为 !Unpin
}
impl Parser {
fn new() -> Self {
Self { buf: Vec::new(), view: std::ptr::null(), _pin: PhantomPinned }
}
// 在 pinned 状态下建立自引用
unsafe fn init_view(self: Pin<&mut Self>) {
let this = self.get_unchecked_mut();
this.buf.extend_from_slice(b"hello world");
let slice: &[u8] = &this.buf[0..5];
this.view = slice as *const [u8];
}
fn view(&self) -> &[u8] {
assert!(!self.view.is_null());
unsafe { &*self.view }
}
}
impl Future for Parser {
type Output = Vec<u8>;
fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
// 仅第一次 poll 时初始化自引用
if self.view.is_null() {
// 安全前提:此时 self 已被 pin,后续不会移动
unsafe { self.as_mut().init_view(); }
return Poll::Pending;
}
// 后续可安全读取
let slice = self.view().to_vec();
Poll::Ready(slice)
}
}
这里的关键点:
Parser使用PhantomPinned标记为!Unpin。- 只有在
Pin<&mut Self>下(已固定地址),我们才通过get_unchecked_mut建立自引用。不变式:从此刻起,该值不可再移动。 - 对外只暴露只读视图;如需写入,需更复杂的投影(见下一节)。
工程实践建议:尽量避免手写
unsafe自引用。推荐使用pin-project或pin-project-lite宏做字段级投影,消除常见陷阱。
4.3 使用 pin-project 进行安全投影
use pin_project::pin_project;
use std::{future::Future, pin::Pin, task::{Context, Poll}};
#[pin_project]
struct Parser2 {
#[pin]
buf: Vec<u8>, // 实际上 Vec<u8> 是 Unpin,这里只是演示字段标注
view_len: usize,
_pin: std::marker::PhantomPinned,
}
impl Parser2 {
fn new() -> Self {
Self { buf: Vec::new(), view_len: 0, _pin: std::marker::PhantomPinned }
}
}
impl Future for Parser2 {
type Output = ();
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
let mut this = self.project(); // 安全获取投影
this.buf.extend_from_slice(b"hello world");
*this.view_len = 5;
Poll::Ready(())
}
}
pin_project 通过生成安全的投影代码,避免了“把 Pin<&mut T> 直接解成 &mut T”的错误,从而不会破坏 pin 不变式。
5. 实战二:一个需要地址稳定的“入侵式链表节点”
入侵式(intrusive)链表将指针直接存放在节点对象内部,节点地址就是身份。移动节点会破坏链表结构。因此,节点一旦加入链表,应视为“被固定”。
5.1 节点定义与 !Unpin 约束
use std::marker::PhantomPinned;
use std::pin::Pin;
struct Node {
next: *mut Node,
prev: *mut Node,
value: i32,
_pin: PhantomPinned, // 使 Node 为 !Unpin
}
impl Node {
fn new(value: i32) -> Self {
Self { next: std::ptr::null_mut(), prev: std::ptr::null_mut(), value, _pin: PhantomPinned }
}
}
5.2 将节点安全地插入双向链表
struct List {
head: *mut Node,
tail: *mut Node,
}
impl List {
fn new() -> Self { Self { head: std::ptr::null_mut(), tail: std::ptr::null_mut() } }
// 将一个已 Pin 的节点插入到尾部
unsafe fn push_back(self: &mut Self, mut node: Pin<&mut Node>) {
let raw: *mut Node = &mut *node; // 在 Pin 下取裸指针,不移动
(*raw).prev = self.tail;
(*raw).next = std::ptr::null_mut();
if self.tail.is_null() {
self.head = raw;
self.tail = raw;
} else {
(*self.tail).next = raw;
self.tail = raw;
}
}
}
使用方式:
let mut list = List::new();
// 将节点放在堆上并 Pin 住,保证地址稳定
let mut n1 = Box::pin(Node::new(1));
let mut n2 = Box::pin(Node::new(2));
unsafe {
list.push_back(n1.as_mut());
list.push_back(n2.as_mut());
}
要点:
- 我们用
Box::pin创建堆分配且地址稳定的节点。 - 在
push_back内部只使用裸指针链接,不移动节点本身。 - 若误用
Box::new(Node)(未 pin)并在之后移动Box内容,则可能破坏链表结构。
这个模式与大量系统编程场景相似(驱动、协议栈、zero-copy 缓冲池):对象一旦对外暴露地址,就应当 Pin。
6. Pin 与 Drop、PinnedDrop 的配合
当类型为 !Unpin 并含有自引用或指向外部资源时,销毁顺序尤为关键:你必须保证在 drop 阶段不会“悄悄移动”被 pin 的对象。此时可使用 pin_project 的 #[pinned_drop],或手写 Drop 小心处理。
use pin_project::{pin_project, pinned_drop};
use std::pin::Pin;
#[pin_project]
struct Holder {
data: Vec<u8>,
#[pin]
token: std::marker::PhantomPinned,
}
#[pinned_drop]
impl PinnedDrop for Holder {
fn drop(self: Pin<&mut Self>) {
// 安全地在 pinned 语境下清理
// 这里可以访问 self.project() 后的字段,遵守投影规则
}
}
原则:如果类型 !Unpin,请避免在 Drop 里做可能诱发隐式移动的操作;用 PinnedDrop 明确在 pinned 环境下执行清理。
7. 与 Future/async 的关系:状态机为何要求 Pin
async fn 会在编译期被展开为一个匿名 Future 状态机。为了节省拷贝、提升吞吐,这个状态机经常把本地变量“装进”自身字段,并在 await 恢复时继续使用——这天然就依赖地址稳定。因此 Future::poll(self: Pin<&mut Self>) 要求调用方保证“我现在拿到的是被固定的可变引用”。这就是在执行器里你总能看到类似逻辑:
- 把
Future存在Pin<Box<dyn Future<Output=...>>>(或Pin<&mut F>)中; - 调用
poll(Pin::as_mut(&mut future), cx); - 执行器本身绝不移动被 pin 的 future。
8. 何时选择 Pin<&mut T> vs Pin<Box<T>>?
Pin<&mut T>:临时固定。常用于一次poll调用的栈上固定;生命周期只在借用期内,离开作用域即可再次移动原值。Pin<Box<T>>:持久固定。常用于把任务/状态机提交给执行器或放入需要长期地址稳定的数据结构。
实战建议:
- 如果对象需要跨异步边界长期存在、并被其他结构持有,选
Pin<Box<T>>。 - 如果只是在一个函数里临时需要“不可移动”语义(例如一次性安全投影),选
Pin<&mut T>。
9. 手写投影的安全准则(无需宏时)
手写时必须保证:
- 只要
self被 pin,被标记为 pinned 的字段也不得移动; - 仅对未标记 pinned 的字段可安全获得
&mut; - 若要对 pinned 字段做可变访问,应以
Pin<&mut Field>的形式投影; - 绝不把
Pin<&mut T>直接“拆成”&mut T(除非T: Unpin)。
一个安全模式是结构性 pin(structural pinning):明确哪些字段需要被固定,并在投影时维持这些字段的 pin 不变式。
10. 性能与零成本
很多人担心 Pin 带来运行时开销。事实上:
Pin主要是类型层面的编译期约束,在优化后通常不会引入额外指令。- 在需要地址稳定的场景中,
Pin反而避免了防御式拷贝或堆分配,实现真正的零拷贝/零成本抽象。 - 与
Rc/Arc这类运行时引用计数不同,Pin的核心保证通过类型系统与借用规则兑现。
11. 实战收官:零拷贝帧切分器(完整示例)
最后给出一个贴近生产的示例:在一个读入缓冲区 buffer 中增量接收网络数据,按帧头长度切分出一段只读的帧视图(不做拷贝),并将它安全地暴露给上层 handler(在示例中我们只 println!)。关键点在于:视图直接引用 buffer 内部区域,因此我们必须 pin 住该结构。
use std::{pin::Pin, task::{Context, Poll}, future::Future, marker::PhantomPinned};
struct Frame<'a> {
data: &'a [u8],
}
struct ZeroCopyFramer {
buffer: Vec<u8>,
// 自引用:frame.data 将指向 buffer 内部
frame_ptr: *const [u8],
initialized: bool,
_pin: PhantomPinned,
}
impl ZeroCopyFramer {
fn new() -> Self {
Self {
buffer: Vec::with_capacity(4096),
frame_ptr: std::ptr::null(),
initialized: false,
_pin: PhantomPinned,
}
}
// 在 pinned 环境下建立对 buffer 的零拷贝视图
unsafe fn build_view(self: Pin<&mut Self>) {
let this = self.get_unchecked_mut();
// 模拟把网络数据拼入 buffer(真实工程里由 IO 驱动完成)
this.buffer.extend_from_slice(b"\x00\x05hello\x00\x07goodbye");
// 读取帧头长度(前两个字节模拟一字节高位一字节低位的简化示意)
let len = this.buffer[1] as usize; // 简化
let start = 2;
let end = start + len;
let slice: &[u8] = &this.buffer[start..end];
this.frame_ptr = slice as *const [u8];
this.initialized = true;
}
fn current_frame(&self) -> Frame<'_> {
assert!(self.initialized && !self.frame_ptr.is_null());
let data = unsafe { &*self.frame_ptr };
Frame { data }
}
}
impl Future for ZeroCopyFramer {
type Output = ();
fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
if !self.initialized {
unsafe { self.as_mut().build_view(); }
return Poll::Pending;
}
let frame = self.current_frame();
println!("frame: {:?}", std::str::from_utf8(frame.data).unwrap());
Poll::Ready(())
}
}
#[tokio::main]
async fn main() {
// 使用堆上 pin,保证地址稳定直到任务结束
let framer = Box::pin(ZeroCopyFramer::new());
framer.await;
}
在真实项目中,你会把“读入 buffer”的逻辑交给 I/O 驱动(如 Tokio),ZeroCopyFramer 的 poll 会在 Waker 唤醒后继续填充与解析。而本例强调两点:
- 自引用安全:只有在
Pin<&mut Self>下建立frame_ptr; - 零拷贝:对上层暴露
&[u8]视图,无需把帧内容再复制一份。
若没有 Pin 的约束,任何对 ZeroCopyFramer 的移动都会让 buffer 地址改变,frame_ptr 即刻失效。
12. 常见错误清单(工程落地必看)
- 在
Drop中偷偷移动 pinned 对象:请使用PinnedDrop或确保仅做就地操作。 - 从
Pin<&mut T>取出&mut T并赋给可移动变量:这将破坏不变式。请使用投影或仅在T: Unpin时这么做。 - 未区分临时 pin 与持久 pin:跨异步边界、被外部持有的状态必须
Pin<Box<T>>。 - 忘记把需要地址稳定的字段标注为 pinned:逻辑上 pinned 的字段必须在投影中保持 pinned。
- 随意使用
transmute拉长生命周期:这在自引用场景几乎必出 UB。用 pin 正道解决。
13. 总结与实践建议
- Pin 不是魔法:它只是把“不可移动”的语义搬到类型系统里,让编译器与库帮你维持不变式。
- Unpin 是能力声明:
T: Unpin代表“即便被 Pin,也可以移动”。多数类型天然Unpin,自引用或依赖地址稳定的类型通常!Unpin。 - 工程首选安全抽象:优先使用
pin-project/pin-project-lite;只有当你非常清楚不变式时,才手写unsafe。 - 与异步天然契合:
Future的状态机在本质上经常需要地址稳定,Pin让这件事显式化、可验证。 - 性能不背锅:
Pin的成本主要在类型与编译期,不会引入多余的运行时损耗;相反,它往往是实现零拷贝的前提。
当你在编写需要自引用、入侵式数据结构、或高性能零拷贝管线(如网络协议、解析器、执行器)时,请把 Pin/Unpin 作为设计的一等公民。它不仅是“能不能编过”的问题,更是“能不能在重压下稳健运行”的专业边界。愿你在下一次“奇怪生命周期/移动错误”来袭时,想到的不是 transmute,而是用 Pin 把不变式写进类型。🦀💪

…
(未完待续)
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)