在 Rust 1.39 中稳定的 async/await,以及 1.33 版本中引入的 Pin 类型,使得安全的自引用结构成为可能,这对于异步状态机至关重要。这些结构通常包含指向由结构本身拥有的数据的内部指针,例如缓冲区和对这些缓冲区的活动视图。在实现手动 futures 或复杂的侵入式数据结构时,开发者必须通过 Pin<&mut Self> 访问单个字段,这需要安全的投影机制来保持内存位置的保证。
当一个结构通过 Pin 被固定时,编译器保证其内存地址在整个固定生命周期内保持不变,前提是该类型不实现 Unpin。如果结构持有自引用指针,例如指向内部向量的原始指针,移动结构会使这些指针失效,从而产生悬空引用。一个天真的投影方法,简单地将 Pin<&mut Self> 解引用为 &mut Self,使字段暴露给安全的 Rust 代码,而这些代码可能合法地调用 mem::swap 或 mem::replace 在这些字段上,从而将它们从固定内存位置移动,违反了基本的固定合同。
安全的投影需要一种不安全的转换,以保持固定不变:如果父结构是 !Unpin,则字段投影必须返回 Pin<&mut Field> 而不是 &mut Field 以防止移动。实现必须保证字段是结构上固定的,这意味着其固定状态与父结构的固定状态相 tied 通常通过指针算术或 Pin::map_unchecked_mut 来实现。对于实现了 Unpin 的字段,投影可以安全地返回 &mut Field,因为这些类型即使嵌套在固定数据中也被允许移动,尽管必须注意这样的移动不会使其他自引用字段失效。
use std::pin::Pin; use std::marker::PhantomPinned; struct Buffer { data: [u8; 1024], cursor: *const u8, _pin: PhantomPinned, } impl Buffer { // 安全地投影到数据字段(Unpin) fn data_mut(self: Pin<&mut Self>) -> &mut [u8; 1024] { unsafe { &mut self.get_unchecked_mut().data } } // 投影到光标字段 fn cursor(self: Pin<&mut Self>) -> *const u8 { unsafe { self.get_unchecked_mut().cursor } } }
背景
我们正在构建一个高性能的零拷贝解析器,用于金融协议,其中消息可以引用可重用内部缓冲区的子范围。解析器的状态需要在异步 I/O 操作之间保持不变,这意味着结构必须被固定,以允许自引用指针指向缓冲区。
问题描述
Parser 结构持有一个 Vec<u8> 缓冲区和一个指向该缓冲区的 &[u8] 切片,表示当前消息。在为该解析器实现 Stream 时,poll_next 方法接收 Pin<&mut Self>。我们需要修改缓冲区(以读取更多数据),同时保持切片引用的有效性,这需要小心的字段投影。
考虑的解决方案
解决方案 A:基于索引的寻址
除了存储切片 &[u8] 外,我们存储了对向量的 (usize, usize) 索引。优点:完全安全,没有 Pin 复杂性,易于实现。缺点:运行时边界检查开销,较弱的 API 需要在每次访问时手动切片,潜在的索引不同步错误。
解决方案 B:使用原始指针的不安全固定投影
我们将消息存储为原始指针 *const u8 和长度,使用 Pin::map_unchecked_mut 实现手动投影方法以访问缓冲区,同时保持指针字段固定。优点:零成本抽象,保持自引用性,允许直接的指针算术。缺点:需要 unsafe 代码块,如果违反了 Pin 不变性(例如错误地实现 Unpin)则存在未定义行为的风险。
解决方案 C:使用 pin-project crate
利用程序宏自动生成安全的投影代码。优点:符合人体工程学,经过良好测试的安全不变性,减少样板代码。缺点:额外依赖,宏生成的代码可能更难调试,轻微的编译时开销。
选择的解决方案及结果
我们选择了 解决方案 B,以避免在我们的嵌入式系统上下文中使用外部依赖,并保持对内存布局的明确控制。我们仔细确保结构不实现 Unpin,通过添加 PhantomPinned 并编写详尽的 Miri 测试来验证固定不变性。结果是一个实现零拷贝语义的解析器,每个消息没有分配,以 10Gbps 的吞吐量运行,没有 CPU 饱和。
为什么为包含自引用指针的结构实现 Unpin 是不安全的?
Unpin 特别表示一个类型在被包装在 Pin 中时是安全的移动,允许安全代码通过 Pin::into_inner 等方法从 Pin<&mut T> 获取 &mut T。对于自引用结构,移动结构会改变其内容的内存地址,从而使任何引用这些内容的内部指针失效。实现 Unpin 将允许安全代码在固定状态下移动结构,违反了 Pin 为异步运行时提供的安全保证,导致使用后的内存释放漏洞。因此,此类结构必须使用 PhantomPinned 明确选择退出 Unpin,以防止意外自动实现。
枚举变体的投影与结构字段的投影有什么不同?
许多候选人认为枚举和结构的投影机制是相同的,但枚举提出了独特的挑战,因为判别式确定哪个变体处于活动状态。将 Pin<&mut Enum> 投影到特定变体需要确保变体保持固定,同时也防止判别式变化,因为切换变体会移动基础数据。 Rust 缺乏对变体投影的稳定内置支持,因为判别式和变体数据共享内存布局考虑;安全投影需要不安全代码来断言活动变体并确保在枚举保持固定时不发生变体交换。
PhantomPinned 在防止自动特征实现中的作用是什么?
初学者常常忽视 Rust 自动为大多数类型实现 Unpin,除非它们显式包含 !Unpin 字段,这将使包含类型默认 !Unpin。PhantomPinned 是一种零大小的标记类型,显式定义为 !Unpin,在包含于结构中时作为负实现约束。如果没有这个标记,即使开发者编写了无安全的投影代码,假设结构是不可移动的,编译器仍然可能自动实现 Unpin,允许安全代码通过 Pin::into_inner_unchecked 提取并移动结构,从而破坏不安全的不变性并引发未定义行为。