Rust 学 Cpp Bug -- Future 自引用 与 Box::pin

Rust 中 Future 的语义

就是异步的函数对象

基本概念

在 Rust 里:

use std::future::Future;

async fn foo() -> i32 {
    42
}
  • foo() 返回的是一个 实现了 Future trait 的匿名类型
  • Future 的语义:表示一个值可能在未来某个时间才准备好
  • Future 本身是 惰性 的,不会立即执行,只有 .await 或 executor 调用它时才运行

Future Trait

核心 trait 定义:

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

关键点:

  1. poll 方法:每次调用推进 Future 的状态机

  2. Pin<&mut Self>:必须固定在内存里,不能移动

  3. Context:提供 Waker,让 Future 在准备好时被再次调度

  4. 返回值 Poll<Self::Output>

    • Poll::Pending → 任务还没完成
    • Poll::Ready(val) → 任务完成,返回结果

内部语义:状态机

Rust 编译器把 async fn 转换成 状态机

async fn foo() {
    let x = async_op().await;
    let y = other_op().await;
    return x + y;
}

转换后(简化):

enum FooFuture {
    Start,
    WaitingX,
    WaitingY,
    Done(i32),
}
  • 每次 .poll() 调用 → 状态机推进到下一状态
  • 局部变量(x, y)被存储在 Future 内部
  • Future 内部 可能存在自引用(例如 x 的引用被保存在 Future 内部状态)

这就是为什么 Future !Unpin:它的内存地址一旦移动,内部引用可能悬空 → UB。

自引用问题 - 内存

image

移动在内存上意味着什么

在 Rust 中:

let x = 42;
let y = x; // move
  • 对于简单类型(i32、usize),move 只是拷贝(或者直接复用寄存器/栈空间),无副作用。
  • 对于 复杂类型(struct 或 Future),move 会把内存块整块复制到新的地址

汇编层面

假设有一个 struct:

struct MyStruct {
    a: i32,
    b: i32,
}

Rust 编译成 x86 汇编时,move s1s2 可能就是:

mov eax, [rbp-8]   ; 读取 s1.a
mov [rbp-16], eax  ; 写入 s2.a
mov eax, [rbp-4]   ; 读取 s1.b
mov [rbp-12], eax  ; 写入 s2.b

这是安全的,因为内部数据只是值,没有指向自己内部的指针。

自引用结构的危险

假设我们有一个自引用 struct:

struct SelfRef {
    data: String,
    ptr: *const String,
}

impl SelfRef {
    fn new(txt: &str) -> Self {
        let s = String::from(txt);
        Self { data: s.clone(), ptr: &s }
    }
}
  • ptr 指向 data 内部。
  • 如果 Rust 允许 移动整个 struct 到堆上或新地址:
old address: 0x1000
data -> 0x1008
ptr -> 0x1008

移动后:

new address: 0x2000
data -> 0x2008
ptr -> 0x1008  <-- 仍指向老地址!

ptr 指向了“旧地址”,已经悬空,产生 未定义行为

Pin 如何解决这个问题

Pin<T> 的本质就是:

  • 在堆上分配内存(Box)
  • 禁止移动内部数据的地址
  • 编译器通过类型系统阻止调用可能导致 move 的操作

汇编上:

Box::pin(my_self_ref):
heap_addr = malloc(sizeof(SelfRef))
copy data to heap_addr
return Pin<heap_addr>

之后 Rust 不允许再生成对 my_self_ref 的 move 汇编代码,否则编译报错。

  • 实际上,move 就是汇编的 memcpy 或寄存器重定向
  • Pin 通过类型系统阻止 memcpy,确保指针 ptr 永远指向固定地址

Future 的例子(async/await)

Future 也是 自引用结构,状态机里存储了自己的局部变量的指针。
汇编上:

; 假设 Future 在栈上
mov rax, [rbp-32]   ; 读取状态机内部引用
mov [rbp-64], rax   ; 试图 move 状态机 -> 错误
  • 如果允许这条汇编执行,内部指针就悬空了
  • Pin 禁止生成这种 move 汇编
  1. 移动 = 内存复制/地址变更
  2. 普通类型:move 不会影响内部数据指针 → 安全
  3. 自引用类型或 !Unpin 类型:move 会破坏内部指针 → UB
  4. Pin:在堆上固定地址,并在类型层面禁止移动 → 汇编无法执行不安全的 move

所有权原来就支持对这种情况进行校验吗,还是后添加的检查

Rust 的所有权系统本身并没有直接解决自引用类型的移动问题,而是通过 Pin API 在语言层面“补充”了检查。

所有权系统的能力

Rust 的所有权/借用系统天生可以保证:

  1. 唯一性:每个可变借用(&mut T)在任意时刻只有一个。
  2. 不可悬空:借用的生命周期内,引用总是有效。
  3. 防止悬垂指针:当值离开作用域,编译器不允许引用继续使用它。

但是,普通所有权系统无法知道“结构内部某个字段指向自己”

struct SelfRef {
    data: String,
    ptr: *const String,
}
  • ptr 是裸指针(*const T
  • 所有权系统只知道 SelfRef 的整体所有权
  • 不知道内部 ptr 指向自己 data

所以,如果你直接 move 这个 struct:

let s2 = s1; // move
  • 编译器会允许,因为从所有权角度来看,这只是整体值的移动
  • 但内部裸指针已经悬空 → 所有权系统无法直接阻止

Pin 是后添加的安全检查

Pin<T> 出现的背景:

  • async/await 和 Future 需要 状态机自引用
  • 早期 Rust 中,如果 Future 被移动,可能 UB
  • Rust 团队添加了 Pin API 来:
  1. 固定值在堆上(Box::pin)
  2. 通过类型系统禁止移动(编译器检查)

也就是说:

  • Pin 是一种语言级别的额外约束,在所有权系统之上
  • 它利用类型系统和标记 (!Unpin) 来禁止潜在危险的移动操作

Cpp 也有,但是很难通过编译器少量标记去自动检查,需要自行处理

什么时候需要 Pin

async fn 的基本情况

在 Rust 中:

async fn foo() -> i32 {
    42
}
  • foo() 返回类型是 impl Future<Output=i32>

  • 这个 Future 默认是 !Unpin 的类型(内部状态机可能自引用局部变量)

  • 能否直接移动 Future

    • 如果你只是立即 .await,通常不需要 Box::pin
    • 因为 Future 还在栈上,Rust 编译器可以保证生命周期和借用安全

例如:

let result = foo().await; // ✅ 不需要 Pin

什么时候需要 Box::pin

你必须用 Box::pin 的场景:

  1. Future 会被存储(长期保留)

    • 例如放在 struct 里,或者交给 executor/调度器
    • 移动 Future 会破坏自引用 → UB
  2. 需要类型擦除(trait 对象)

    • 例如返回 Pin<Box<dyn Future<Output=()>>>
    • async fn 返回的是匿名类型 impl Future,有些接口需要统一类型
  3. 可能在多线程环境传递

    • 堆分配 + Pin 可以保证地址固定、安全

典型例子:

let job = Job::new_async("0 0 * * * *", move |_, _| {
    let db_client = db_client.clone();
    Box::pin(async move {
        db_client.delete_expired_files().await;
    })
});
  • JobScheduler 需要 可存储和重复 poll 的 Future
  • 必须 Pin + Box

什么时候不用 Box::pin

  • 你直接调用并 .await 的 async fn:
async fn foo() -> i32 { 42 }

let val = foo().await; // ✅ 栈上 Future,直接 await 即可
  • 临时创建的 Future,只在作用域内使用,不会移动,不需要 Pin
  • 简单 async/await 使用几乎都不用手动 Box::pin

编译器主动被动

  • unpin 可以移动,可以move
  • !unpin 不可安全移动,可以move
  • pin() 固定不可移动的,限制move

既然要主动锁住发挥效果,那编译器还有啥用,我一懒或忘了不就废了。有的。

日常开发:基本不会忘(编译器会逼你用 Pin)
底层/unsafe:可能会忘(需要你自己保证)

看一个最关键的设计:

fn poll(self: Pin<&mut Self>, ...)

像 Future 这样的核心 API:

  • 必须要 Pin<&mut Self> 才能调用
  • 你如果没 Pin,连 .poll() 都调不了

举个例子

let fut = async { 1 };

// ❌ 你拿不到 poll 所需的类型
// fut.poll(...) // 编译错误

接口或者库设计者会提前使用类型约束调用者。强制其 Pin。

posted @ 2026-03-17 14:25  丘狸尾  阅读(24)  评论(0)    收藏  举报