Rust 的 Unpin Trait 也不可怕,今天继续讲一讲

153 阅读7分钟

大家好,今天我们来深入探讨 Rust 中另一个非常重要但又有些微妙的概念:Unpin Trait。要理解 Unpin,我们首先需要了解它的“另一半”——Pin(钉住/固定)。

1.前置概念 - 为什么要“钉住” (Pin)?

昨天已经写了一篇 Rust的Pin并不可怕,今天就来学一学

在 Rust 中,值默认是可以被移动(move)的。当我们把一个值从一个变量赋给另一个变量,或者把它传入一个函数时,它的内存地址可能会改变。对于大多数类型(如 i32StringVec)来说,这完全没问题。

但是,存在一类特殊的结构,它们被称为自引用结构 (Self-Referential Structs) 。这种结构内部包含一个指向其自身字段的指针。

思考一下:  如果我们移动了一个自引用结构,会发生什么?

答案是:结构体被移动到新的内存地址,但它内部的指针还指向旧的、无效的地址。当我们试图使用这个指针时,就会导致未定义行为(Undefined Behavior),通常是程序崩溃。

为了解决这个问题,Rust 引入了 PinPin<T> 是一种智能指针,它向编译器做出一个承诺:被 Pin 包裹起来的值,其内存地址将不会被改变。这使得我们可以安全地创建和使用自引用结构,这在异步编程的 Future 中尤为关键。

2.Unpin Trait 是什么?

现在我们知道了 Pin 是用来“钉住”那些不能被移动的值的。那么 Unpin 是什么呢?

Unpin 是一个标记(Marker Trait),它告诉编译器: “这个类型就算被 Pin 包裹了,移动它也是完全安全的。”

换句话说,如果一个类型实现了 Unpin,它就表示自己“不介意被固定”,或者说它没有自引用这类必须保持地址不变的内部结构。当 Pin 遇到一个 Unpin 的类型时,Pin 的严格限制就会被“放开”,允许我们像操作普通类型一样操作它。

核心:

  1. Unpin 是一个 auto trait。这意味着编译器会为你自动实现它。
  2. 一个自定义类型默认是 Unpin 的,前提是它的所有字段也都是 Unpin 的。
  3. Rust 中绝大多数标准库类型,如 StringVec<T>i32bool 等,都实现了 Unpin

3.Unpin 的实际影响 - 代码示例

Unpin 最直接的影响体现在当你试图从 Pin<&mut T> 获取一个 &mut T 时。

  • 如果 T: Unpin,你可以安全地从 Pin<&mut T> 得到 &mut T
  • 如果 T: !Unpin(即 T 没有实现 Unpin),你不能安全地从 Pin<&mut T> 得到 &mut T。这是 Pin 的核心安全保障。

让我们来看官方文档中的例子:

use std::mem;
use std::pin::Pin;

fn main() {
    // String 类型是 Unpin 的
    let mut string = "this".to_string();
    let mut pinned_string: Pin<&mutString> = Pin::new(&mut string);

    // 因为 String 是 Unpin 的,我们可以从 Pin<&mut String> 获得 &mut String
    // Pin::deref_mut() 会隐式调用,允许我们获得可变引用
    // 然后我们就可以安全地替换它的内容
    mem::replace(&mut *pinned_string, "other".to_string());

    println!("String is now: {}", pinned_string);
}
  1. 我们创建了一个 String,它是一个 Unpin 类型。
  2. 我们用 Pin::new 将它的可变引用“钉住”。
  3. 因为 String 是 Unpin 的,Pin 允许我们通过 DerefMut trait 拿到 &mut String
  4. mem::replace 需要一个 &mut T 参数,我们成功地提供了它,并替换了字符串的内容。这证明了对于 Unpin 类型,Pin 的限制被放宽了。

4.如何让类型 !Unpin (Not Unpin)?

既然大多数类型默认都是 Unpin,那我们如何创建一个必须被“钉住”的自引用结构呢?我们需要一种方法来告诉编译器:“我的这个类型是不能随便移动的,请不要为我自动实现 Unpin。”

方法就是使用 std::marker::PhantomPinned

PhantomPinned 是一个零大小的标记类型,它本身没有实现 Unpin。根据 auto trait 的规则,任何包含一个 !Unpin 字段的结构体,其本身也会变成 !Unpin

示例:创建一个自引用(因此 !Unpin)的结构体

use std::marker::PhantomPinned;
use std::pin::Pin;
use std::ptr::NonNull;
// 这是一个自引用结构体。它包含数据和一个指向该数据的指针。
// 为了保证安全,它必须是 !Unpin。
struct Unmovable {
    data: String,
    // slice 是一个指向 data 第一个字节的裸指针
    slice: NonNull<u8>,
    // 这个字段让整个结构体变为 !Unpin
    _pin: PhantomPinned,
}
impl Unmovable {
    // 我们不再使用一个简单的 `new` 函数直接返回 Self,因为它无法安全地创建自引用。
    // 相反,我们先创建一个 "未初始化" 的实例。
    fn new(data: String) -> Self {
        Unmovable {
            data,
            // 先用一个临时的、无效的指针。
            // `NonNull::dangling()` 提供一个非空但无效的指针,用于初始化。
            slice: NonNull::dangling(),
            _pin: PhantomPinned,
        }
    }
    // 然后,我们提供一个 `init` 方法来建立自引用。
    // 这个方法要求实例已经被 Pin 住,从而保证它不会再被移动。
    fn init(self: Pin<&mut Self>) {
        // `self.data` 的地址现在是固定的了。
        // 我们可以安全地获取指向它的指针。
        let slice_ptr = self.data.as_ptr();

        // `unsafe` 块是必需的,因为我们要绕过 Pin 的保护来修改字段。
        // 我们知道这是安全的,因为我们只是在初始化一个指针,
        // 并且 Pin 保证了 `self` 的地址是稳定的。
        let this = unsafe { self.get_unchecked_mut() };
        this.slice = unsafe { NonNull::new_unchecked(slice_ptr as *mut u8) };
    }
    // get_slice 方法现在也需要一个 Pin 住的引用,确保安全。
    fn get_slice(self: Pin<&Self>) -> &str {
        // 因为我们知道 self 被 Pin 住,所以可以安全地解引用内部指针
        let slice_bytes = unsafe {
            std::slice::from_raw_parts(self.slice.as_ptr(), self.data.len())
        };
        std::str::from_utf8(slice_bytes).unwrap()
    }
}
fn main() {
    // 1. 创建一个实例。此时它的 `slice` 指针是无效的。
    let data = Unmovable::new("hello".to_string());

    // 2. 将它放到堆上并 Pin 住。`Box::pin` 可以做到这一点。
    //    从这里开始,`pinned_data` 的内存地址就不会再改变了。
    let mut pinned_data = Box::pin(data);
    // 3. 调用 `init` 来安全地建立自引用。
    //    `as_mut()` 可以从 `Pin<Box<T>>` 获得 `Pin<&mut T>`。
    pinned_data.as_mut().init();
    // 4. 现在可以安全地使用它了。
    //    `as_ref()` 可以从 `Pin<Box<T>>` 获得 `Pin<&T>`。
    let slice = pinned_data.as_ref().get_slice();

    println!("Slice: {:?}", slice); // 输出: Slice: "hello"
    // 下面的代码仍然无法通过编译,这证明了 Pin 的作用:
    // `mem::replace` 需要 `&mut Unmovable`,但我们无法从 `Pin<Box<Unmovable>>`
    // (当 Unmovable 是 !Unpin 时) 安全地获得它。
    // std::mem::replace(&mut *pinned_data, Unmovable::new("world".to_string()));
}

1.Unmovable::new 现在只创建一个“部分完成”的实例,其内部指针 slice 是一个临时的无效值 (NonNull::dangling)

2.在 main 函数中,我们立即使用 Box::pin 将这个实例固定在堆上。这是关键步骤,此后 pinned_data 的内存地址不会再改变。

3.我们定义了一个新的方法 init,它的 self 参数是 Pin<&mut Self>。这保证了只有被固定的实例才能调用它。

4.在 init 内部,因为我们知道实例地址是稳定的,所以可以安全地获取 data 的地址并赋值给 slice 字段。这里需要 unsafe 代码,因为我们在 Pin 的保护下修改数据,但我们很清楚这个操作的上下文是安全的。

5.完成初始化后,get_slice 方法也要求 Pin<&Self>,确保在读取数据时,实例也不会被移动。

5.Unpin 在异步编程中的角色

Unpin 在 async/await 中扮演着至关重要的角色。一个 async 函数会生成一个 Future

  • 如果 Future 是自引用的(例如,它在 await 点之间保存了对局部变量的引用),那么这个 Future 就是 !Unpin 的。在调用它的 .poll() 方法之前,必须先将它 Pin 住。
  • 如果 Future 不是自引用的,那么它就是 Unpin 的。这样的 Future 可以被更自由地处理,无需强制固定。

Unpin trait 使得 Future 的 API 设计可以兼顾两全:既能通过 Pin 保证自引用 Future 的内存安全,又能让非自引用的 Future 免于 Pin 的使用复杂性,更加符合人体工程学。

最后总结一下

特性T: Unpin (例如 String)T: !Unpin (例如 Unmovable)
含义移动此类型是安全的,即使它被 Pin 包裹。移动此类型是危险的,Pin 必须保证其地址不变。
来源默认。只要所有字段都是 Unpin,类型就是 Unpin。通过添加 PhantomPinned 字段来显式选择退出 Unpin。
Pin 的行为Pin<&mut T> 几乎等同于 &mut T,限制被放宽。Pin<&mut T> 严格受限,不能安全地获得 &mut T。
使用场景大多数普通数据类型。自引用结构,异步 Future。

希望这份教程能帮助大家彻底理解 Unpin 的概念!这是一个深入 Rust 内存安全模型和异步编程核心的绝佳切入点。

看完了,欢迎大家关注我们公众号 猩猩程序员