Rust自引用结构与Pin/Unpin:内存安全的盲区与解决之道

发布时间:2026/9/30 9:19:42
Rust自引用结构与Pin/Unpin:内存安全的盲区与解决之道 记得刚接触 Rust 那会儿我最自信的一件事就是只要代码能通过编译内存安全就稳了。直到我第一次在结构体里存了一个指向自己字段的裸指针然后看着程序在 release 模式下莫名崩溃才意识到——Rust 里有一个连借用检查器都管不到的角落那就是自引用结构。而解决它的钥匙正是Pin与Unpin这套设计。这篇东西我会把自己踩过的坑、翻过的文档以及最终落地可用的代码模式完整摊开希望能帮同样被自引用结构折磨的人少走弯路。1. 自引用结构为什么是Rust内存安全的“盲区”1.1 move的本质地址被悄悄换掉先别看Pin先理解问题本身。Rust 里的 move 不是一个复杂操作它本质上就是把一个结构体按字节从旧位置拷贝到新位置。对普通数据这完全没影响但对内部持有“指向自己某个字段”指针的结构体来说这就是灾难。举个例子你在栈上创建了一个结构体它的某个字段存了指向另一个字段的裸指针。这个指针的值就是“旧位置”的栈地址。一旦这个结构体被 move——比如从函数里返回、放进Option然后又take出来、或者作为枚举成员被重新赋值——结构体整体会搬到新地址。可那个内部指针还倔强地指向旧地址而旧地址上的数据已经“逻辑死亡”了。这就像搬家之后你名片上印的还是老门牌号。地址变了名片上的信息却不会自动跟着变。C 语言里这种情况靠约定和自觉没人管你Rust 声称自己内存安全但如果你用裸指针在结构体内部画了一条“隐形引用”借用检查器也看不见。用生活化的类比再加深一下印象你把一张纸条塞进一个盒子里纸条写着“我的内容在桌上这个位置”。然后你顺手把盒子从桌子搬到了架子上。纸条还是写着“桌上这个位置”但你真正要找的内容其实已经在架子上了。这就是自引用 move 的全部问题。1.2 一段“必崩”的最小复现代码下面这段代码建议你亲自跑一遍分别用 debug 和 release 模式编译看看结果差异。#[derive(Debug)] struct SelfReferential { data: String, ptr: *const String, } impl SelfReferential { fn new(value: String) - Self { let mut s Self { data: value, ptr: std::ptr::null(), }; s.ptr s.data; s } fn get(self) - str { unsafe { *self.ptr } } } fn main() { let s SelfReferential::new(hello.to_string()); println!({}, s.get()); }注意看new里面的执行顺序先在栈上构造s然后s.ptr s.data这个裸指针记录的是s当前在栈上的地址。接着s作为返回值被 move 到main的栈帧ptr指着的旧地址大概率已经被其他数据覆盖。有趣的是这段代码有时候能跑出正确结果。原因是编译器做了返回值优化NRVO直接在你预期接收结果的地址上构造旧地址和新地址碰巧是同一个。但这完全是未定义行为换个优化等级、加一层包装就可能炸。我在实际项目里第一次遇到时是结构体被塞进Vec的扩容逻辑里扩容搬移后立刻出现悬垂访问。那种“时好时坏一开优化就抽风”的感觉比单纯崩溃难查一百倍。1.3 借用检查器为什么管不到裸指针很多人会问Rust 不是有借用检查器吗为什么这种问题编译器不拦原因在于借用检查器追踪的是“借用关系”也就是编译器能够看见的引用。裸指针*const T、*mut T在类型系统里是另一个世界的东西——它不携带生命周期参数编译器也不知道它指向哪里。你的结构体里存一个裸指针在编译器看来就是存了一个usize仅此而已。更麻烦的是即使你想让编译器检查自引用本身就有鸡生蛋的问题你没法在结构体初始化完成之前拿到对自身字段的引用也没法用安全代码表达“这个字段指向另一个字段”这种循环依赖。借用规则本质上是一棵有向无环图而自引用是一个环。也就是说自引用结构天然无法用安全 Rust 直接表达。要么用裸指针要么用库包装好安全接口。而Pin正是那个让“裸指针自引用”从根部变得安全的机制——不是替你去检查裸指针而是从源头保证“对象不会移动”。2. Pin的设计哲学把“不许移动”写进类型系统2.1 Pin长什么样核心方法速览Pin翻译成中文就是“钉住”。从语义上理解它的职责是一旦某个值被Pin包裹在安全代码中你就再也无法拿走mut T从而把它 move 走了。看一下核心定义pub struct PinP { pointer: P, }它并不持有值本身而是包裹一个“可以解引用到T的指针类型P”比如BoxT、mut T、RcT。真正重要的不是结构体长什么样而是它提供的方法签名带有强烈的“限制”意味implP: Deref PinP { pub fn as_ref(self) - PinP::Target { ... } } implP: DerefMut PinP where P::Target: Unpin, { pub fn as_mut(mut self) - Pinmut P::Target { ... } } implP: Deref PinP where P::Target: Unpin, { pub fn get_mut(self) - a mut P::Target { ... } pub fn into_inner(self) - P { ... } }注意几个关键点as_ref不需要T: Unpin因为返回的是PinT引用本身就带着“被钉住”的身份你拿到手也搬不走T。as_mut反而要求T: Unpin。为什么因为Pinmut T已经可以让你拿到mut T了如果T不是Unpin那你就能通过mem::replace把它 swap 走这直接破坏承诺。get_mut同样要求T: Unpin它直接把内部的mut T交到你手上。用大白话讲Pin不是靠拒绝让你访问内部数据来保证安全而是靠“即使你拿到了引用也不能移走它”这种类型层面的封锁。2.2 固定方式的三种来源实际使用中Pin主要有三种来源分别适配不同的场景。第一种堆固定Box::pin。let pinned Box::pin(String::from(hello));这会分配一块堆内存返回PinBoxT。堆上对象的地址不会因为栈帧变化而改变因此PinBoxT是最省心的固定方式适合长期存活、需要跨作用域的值。异步执行器里会大量用到。第二种栈固定pin!宏。Rust 1.68 之后标准库内置了std::pin::pin!let mut pinned std::pin::pin!(String::from(hello)); pinned.as_mut().push_str( world);它的内部原理大致是在栈上创建局部变量然后用Pin::new_unchecked(mut local)把引用包起来再通过闭包防止变量被移动。好处是零堆分配坏处是这个变量不能逃逸出当前作用域否则编译器直接报错。第三种不安全构造Pin::new_unchecked。let mut value String::from(hello); let pin unsafe { Pin::new_unchecked(mut value) };这个函数存在的意义是应对某些编译器无法静态证明安全的场景比如在自引用结构初始化过程中。使用者必须自己保证在Pin存活期间value绝不能被移动。写这个函数必须非常克制我在第四部分会专门展开安全论证。2.3 安全代码为什么拿不到mut我们来看一个直观的例子理解为什么Pin能在安全代码层面把路堵死。use std::marker::PhantomPinned; struct CannotMove { _marker: PhantomPinned, } fn main() { let mut value CannotMove { _marker: PhantomPinned }; let pinned Pin::new(mut value); // 下面这行代码无法编译 // let mut_ref: mut CannotMove pinned.get_mut(); }编译错误会告诉你CannotMove没有实现Unpin所以get_mut不可用。这意味着安全代码中没有任何合法途径从这个Pinmut CannotMove里掏出mut CannotMove。没有mut T就无法调用std::mem::replace、std::mem::swap或者通过解引用赋值把它移走。这就实现了“把不许移动写进类型系统”的目的。以后并不是靠人肉约定“不要移动”而是编译器直接拒绝你的移动操作。3. Unpin自动trait如何划分安全与“准危险”3.1 自动trait机制与Unpin的真实含义Unpin是一个标记 trait而且是一个自动 trait——编译器会根据类型字段自动推导。它的定义在标准库里几乎是个空壳pub auto trait Unpin {}真正的语义藏在文档注释里如果一个类型实现了Unpin那么当它被Pin包裹时仍然可以安全地移动它。换句话说Unpin类型即使被钉住也不存在“移动后内部指针悬垂”的问题因为这类类型根本不会在内部保存指向自己字段的裸指针。这里容易产生一个反直觉的误解名字叫Unpin好像意思是“不能被 pin”。但真实含义恰恰相反——Unpin的意思是“即使被 pin也可以 unpin移走”。那些不自引用的普通类型几乎都是Unpin整数、浮点数、String、VecT、BoxT只要字段都是Unpin整个类型自动就是Unpin。这背后的直觉很朴素我又没有内部指针想移动多少遍就移动多少遍你 pin 我等于没 pin。而!Unpin的类型恰恰是需要被真正“钉住”的类型。标准做法是在结构体里放一个PhantomPinned字段。这个零大小类型专门用来关掉自动Unpin推导标记“本类型内部可能藏有自引用指针”。3.2 哪些类型是Unpin、哪些是!Unpin一张表看明白常见的分类类型是否 Unpin原因u32、f64、bool是标量没有内部指针String是数据在堆上移动String只是移动栈上的指针和长度VecT是元素在堆上移动Vec不移动堆缓冲区BoxT是指针本身指向堆移动Box不移动TT、mut T是借用本身就是复制地址移动引用不影响被引用者自定义类型无裸指针字段是编译器自动推导自定义自引用类型否内部裸指针指向自身字段移动即悬垂PhantomPinned否专门用于关掉 Unpinasync 块生成的状态机否可能保存跨 await 的局部变量自引用generator/GenFuture否自带自引用状态需要注意一个细节VecT是Unpin不代表Vec里的元素不重要。VecT被 move 时堆上的缓冲区没动所以自引用关系没有破坏。但如果T本身是!Unpin的你把它swap出Vec仍然会出问题——这就是为什么VecT的swap_remove等方法对!Unpin元素需要额外小心。3.3 同一个Pin在Unpin与!Unpin上的行为差异同一个Pin作用在不同类型上安全性完全不一样。我用一段代码对比use std::marker::PhantomPinned; // Unpin 类型 let mut x 42u32; let pinned_x Pin::new(mut x); // 因为 u32: Unpin所以可以安全拿到 mut 并修改 let ref_mut pinned_x.get_mut(); *ref_mut 100; println!(修改后的值为{}, x); // !Unpin 类型 struct KeepStill { val: usize, _marker: PhantomPinned, } let mut y KeepStill { val: 1, _marker: PhantomPinned }; let pinned_y Pin::new(mut y); // 编译错误KeepStill 未实现 Unpin // let ref_mut_y pinned_y.get_mut();这个对比很直白Unpin类型被 pin 住只是一个形式该干嘛还能干嘛!Unpin类型被 pin 住就真的动弹不得了。这种区分是刻意的——绝大多数类型不需要移动保护却也不应该为“可能未防护”付出代价。只有极少数类型需要真正钉住。4. 实战完整构造一个受Pin保护的自引用结构4.1 经典模式PhantomPinned init自定下面这段代码是我自己项目中常用的一个模板它构造了一个“字段指向自身另一个字段”的自引用结构而且在安全代码中不会悬垂。use std::marker::PhantomPinned; use std::pin::Pin; #[derive(Debug)] struct SelfReferential { name: String, name_ptr: *const String, // 占据 0 字节只为了让编译器认为该类型 !Unpin _pin: PhantomPinned, } impl SelfReferential { fn new(name: String) - Self { Self { name, name_ptr: std::ptr::null(), _pin: PhantomPinned, } } // 初始化必须接收 Pinmut Self // 因为在拿到 Pin 之前对象地址尚未稳定不能写入自引用指针 fn init(self: Pinmut Self) { let this unsafe { self.get_unchecked_mut() }; this.name_ptr this.name; } // 提供安全的只读访问 fn name_ref(self: PinSelf) - str { self.name } } fn main() { // 方式一堆固定长期存活 let mut sr Box::pin(SelfReferential::new(hello.to_string())); sr.as_mut().init(); // 方式二栈固定作用域内使用 // let mut sr std::pin::pin!(SelfReferential::new(hello.to_string())); // sr.as_mut().init(); println!(name {}, sr.as_ref().name_ref()); // 读取自引用指针指向的内容 // 这里使用 unsafe 是因为我们持有裸指针但对象已被 Pin 固定所以安全 unsafe { println!(通过内部指针读取 {}, *sr.name_ptr); } }核心逻辑拆解结构体SelfReferential包含一个裸指针name_ptr和PhantomPinned占位字段。PhantomPinned让这个类型变成!Unpin从此安全代码无法从Pin中取出mut Self来移动它。init方法接收Pinmut Self。为什么要这样设计因为在对象还“没被钉住”的时候裸指针一旦指向旧地址后续 move 就崩了。init拿到的Pin意味着对象已经固定此刻写入自引用指针才安全。get_unchecked_mut是不安全方法但这里只用于设置指针不移动对象因此不破坏Pin的契约。之后只要sr一直存活name_ptr就永远指向有效的name字段。这种方式比“直接裸指针自引用”安全得多编译器虽然看不到裸指针但通过!Unpin和Pin的配合把移动的可能性锁死在了类型层面。4.2 unsafe契约清单与安全论证get_unchecked_mut是unsafe的很多人看到 unsafe 就头皮发麻。其实如果遵守下面这份清单完全可以把风险控制住对象必须保持固定在同一个地址从init成功返回之后这个SelfReferential实例不能被 move、不能被mem::swap、不能被mem::replace、不能被塞进会触发挪位置的容器操作里。裸指针的写入必须在 Pin 建立之后如果先写指针再Box::pin那么在Box::pin移动对象的瞬间裸指针就悬垂了。所以初始化必须放在 pin 之后通过as_mut().init()完成。Pin 存活期间mut 的生命周期不能溢出固定生命周期get_unchecked_mut返回的mut引用不能在其作用域中把对象移走。没有move操作就不会破坏承诺。析构顺序要预演一遍Rust 结构体的字段按声明顺序 drop。我的name_ptr指向name如果字段声明顺序是name在前、name_ptr在后那么name_ptr被销毁前name仍然活着读取它没问题。把name_ptr声明在name之前则相反析构时name可能已经没了。这一点容易忽略但影响实际安全性。在这个模板里我自己习惯的声明顺序是struct SelfReferential { name: String, name_ptr: *const String, _pin: PhantomPinned, }name先销毁name_ptr后销毁即使指针本身在 drop 时没什么特殊行为这个顺序也保证了更多的安全余量。4.3 释放顺序、对齐与其他隐蔽坑除了上面已经提到的字段 drop 顺序问题实际写自引用结构时还会遇到其他几个隐蔽坑。对齐问题。如果你的结构体包含不同对齐要求的字段Rust 会在字段之间插入填充字节甚至在结构体末尾调整整体对齐。但你写入裸指针时用的是self.name这样由编译器计算好的地址所以对齐通常不是问题。真正需要小心的是你手动算偏移的场景——比如从某个基地址加上偏移量来定位自引用字段一旦结构体布局变了指针就错了。Rust 没有稳定 ABI除非你加了#[repr(C)]否则千万别自己算偏移。把 Pin 塞进集合的注意事项。VecPinBoxT是安全的因为Vec移动的是PinBoxT这些盒子盒子里T的地址不变VecPinmut T则需要小心因为Pinmut T本身可以被 move但 move 的是引用被引用的T没动这仍然安全。唯一要避免的是让T本身在集合扩容时被搬走所以优先用VecPinBoxT。不要重复 init。如果init被调用两次第二个name_ptr会覆盖前一个这本身不会造成内存安全问题但如果你的结构体有其他自引用字段重复 init 可能导致指针交叉、旧指针覆盖等逻辑混乱。我一般会在 init 里加一个检查如果name_ptr不是空指针就直接panic!防止误调用。async 生命周期。如果这个自引用结构需要在 async 函数中使用建议把init放在Box::pin之后立刻调用并且这个SelfReferential实例尽可能不要跨过await被 move。async 块的捕获有时会把对象包装进一个更大的自引用状态机这会形成两层自引用复杂度成倍上升。遇到这种情况我更倾向于用ouroboros这类库替我生成安全封装而不是完全手写。5. Pin的硬核应用async/await底层到底发生了什么5.1 poll为什么收的是Pinmut Self如果你看过Futuretrait 的定义会发现poll的方法签名是fn poll(self: Pinmut Self, cx: mut Context_) - PollSelf::Output;接收的是Pinmut Self不是普通的mut Self。这不是为了炫技而是因为 async 块编译出来的状态机很可能是!Unpin的。考虑一个最简单的 async 函数async fn demo() { let x 42; let y x; do_something(y).await; println!({}, y); }编译器会把demo的内部状态编译成一个匿名结构体。这个结构体需要保存y而y借用的是x。于是在await挂起期间状态机内部存着指向x的指针。如果执行器在await恢复前把这个状态机 move 了x的地址就变了y立刻悬垂。解决方案就是Pin执行器把Future钉在某个地址上poll每次接收Pinmut Self状态机内部的自引用指针因此始终有效。这就是为什么所有异步执行器在spawn之后都会把Future放进Box::pin或者pin!宏里。5.2 一个典型的跨await自引用场景我们来模拟一个更具体的场景。假设你在 async 函数里分配了一个缓冲区然后让一个异步操作借用这个缓冲区async fn process() - Vecu8 { let mut buf vec![0u8; 1024]; read_into_buf(mut buf).await; buf }read_into_buf持有mut buf在它await挂起时编译器需要把buf的地址记录在状态机里。如果这个状态机被移动buf也跟着动那么read_into_buf手里那个mut buf就会指向旧地址——内存安全直接归零。Rust 编译器为 async 块生成的状态机里会有类似这样的自引用字段struct AsyncProcessFuture { buf: Vecu8, buf_ptr: *mut Vecu8, // 编译器生成的内部自引用 state: u8, }这正是为什么AsyncProcessFuture必须实现!Unpin执行器必须保证它被固定。你平时用 tokio 感受不到这一切是因为spawn底层已经替你做了Box::pin。5.3 手写Future时的实战教训如果你因为某些原因需要手写Future比如要做一个自定义执行器下面几条经验是我拿真实 bug 换来的。第一不要在内部保存裸指针指向自己的局部字段后又试图跨 poll 移动状态机。我犯过的错误是在poll里用了std::mem::replace(self, ...)来切换状态结果把自引用指针搞悬垂了。后来改成只借用字段、不整体移动状态机才解决问题。第二在首次poll之前所有自引用指针必须已经初始化。如果一个Future是先构造后 pin再通过某个init方法写入内部指针那么init和第一次poll的调用之间绝对不能 move。最稳妥的办法是使用Box::pin后立刻调用as_mut().init()让指针初始化和固定连续完成。第三如果状态机里没有任何跨 await 的借用编译器可能不会标记它 !Unpin。这时手写代码反而容易疏忽。我会习惯性地在自写状态机里放一个PhantomPinned强行让它变成!Unpin。这样即使暂时没有自引用未来加字段时也不容易踩雷。第四poll必须遵守一条铁律不能把self里的值移走。拿到Pinmut Self并不代表内部的每个字段都固定了。可以用get_unchecked_mut获取mut但用完不能让对象产生移动。如果确实需要把某个字段拿出来放进其他容器先用Option::take把它挪走再把空壳留在状态机里避免整个状态机被搬动。写在最后这两年写 RustPin是少有的让我觉得“文档很短、用起来却要反复推演”的概念。我自己的体会是不要试图用安全代码模拟自引用结构那是死路但也不要一碰到unsafe就绕道Pin的正确用法就是把 unsafe 限制在一个极小的初始化函数里然后用类型系统守住边界。如果只是作为 async 生态的使用者你几乎不需要直接接触Pin一切都被Box::pin和poll签名封装好了可一旦想写执行器、手撸Future或者搞带缓存的库这套机制就是你绕不过去的基础课。我现在每写一个自引用结构都会先在草稿纸上画出地址关系谁指向谁、什么时候固定、什么时候可能 move、析构顺序是什么。想清楚这些再写代码比事后 debug 快太多了。Pin与Unpin这套设计本质上不是限制而是把以前 C 语言里靠“自觉”维持的约定升级成了编译器强制执行的契约——这才是真正的内存安全。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询