.
Pin 与自引用结构:为什么状态机不能随便移动
. Pin 与自引用结构:为什么状态机不能随便移动
从跨挂起点的局部引用出发,图解自引用状态机在按位移动后的悬垂指针问题,并阐明 Pin、Unpin 与结构化投影的安全边界.
在
async/await 脱糖
中,我们暂时避开了一种常见写法: 如果在
.await
.await
之前获取了一个局部变量的引用(
&T
&T
或
&mut T
&mut T
),并在
.await
.await
之后继续使用这个引用,编译器生成的状态机会长什么样? 这个问题直接引出了标准库中的
Pin<P>
Pin<P>
.
1 跨挂起点的借用如何产生自引用结构体
看下面这段异步函数:它在局部变量
buf
buf
上创建了一个引用
ptr
ptr
,跨越一次
.await
.await
后解引用读取该字节:
async fn self_ref_example() -> u8 {
let buf = [10u8, 20, 30, 40];
let ptr: &u8 = &buf[1];
yield_once().await;
*ptr
}
async fn self_ref_example() -> u8 {
let buf = [10u8, 20, 30, 40];
let ptr: &u8 = &buf[1];
yield_once().await;
*ptr
}
按照上一篇总结的存活变量规则: 数组
buf
buf
在
.await
.await
之后还要通过
ptr
ptr
被读取,因此
buf
buf
必须存进
Waiting
Waiting
状态变体; 引用
ptr
ptr
在
.await
.await
之前创建、在
.await
.await
之后解引用,因此
ptr
ptr
也必须存进同一个
Waiting
Waiting
状态变体.
于是,
Waiting
Waiting
状态的内存布局变成了:同一个结构体内部的字段
ptr
ptr
,保存着指向旁边的字段
buf[1]
buf[1]
的内存地址. 这就是自引用结构.
2 为什么按位移动会摧毁自引用结构
在 Rust 的基本内存模型中,所有类型默认都是可按位移动的: 无论是把一个值按值传给函数、从函数返回,还是在拿到
&mut T
&mut T
时调用
std::mem::swap(&mut x, &mut y)
std::mem::swap(&mut x, &mut y)
或
std::mem::replace
std::mem::replace
, 编译器都只是调用
memcpy
memcpy
把旧地址上的字节原封不动地复制到新地址,并不会通知或修正结构体内部的任何指针字段.
当
self_ref_example()
self_ref_example()
处于未启动的
Start
Start
状态时,
buf
buf
和
ptr
ptr
都还没有构造,此时无论怎么把状态机搬来搬去都毫无问题. 但一旦第一次调用
poll
poll
进入了
Waiting
Waiting
状态,字段
ptr
ptr
里就已经写死了当时
buf[1]
buf[1]
所在的绝对内存地址(比如
0x1001
0x1001
). 如果此时外部代码通过
&mut SelfRefFuture
&mut SelfRefFuture
调用
std::mem::swap
std::mem::swap
,把这个处于挂起状态的状态机搬到了新地址
0x2000
0x2000
:
如上图所示,按位复制后,新结构体里的
buf[1]
buf[1]
已经搬到了
0x2001
0x2001
,但新结构体里的
ptr
ptr
存的值仍然是
0x1001
0x1001
! 等到下一次调用
poll
poll
恢复执行
*ptr
*ptr
时,程序就会去读取已经被释放或覆写的旧地址
0x1001
0x1001
,造成悬垂指针解引用(use-after-free).
3
Pin<P>
Pin<P>
的设计:限制拿到
&mut T
&mut T
怎么在编译器不介入运行时重写指针的前提下阻止这场灾难? 关键观察在于:在安全 Rust 中,要想把一个已经在内存里落脚的值移动走,调用者要么拥有该值的按值所有权
T
T
,要么拥有指向它的可变引用
&mut T
&mut T
(从而调用
mem::swap
mem::swap
或
mem::replace
mem::replace
).
反之,如果一个值已经被放进了某块内存,且此后再也不允许安全代码直接拿到它的
&mut T
&mut T
(除非能证明移动它不会破坏任何不变量),那么安全代码就永远无法调用
mem::swap
mem::swap
把它从当前内存地址搬走!
这就是
Pin<P>
Pin<P>
的全部作用.
Pin<P>
Pin<P>
是一个编译期的零大小包装器,它包装的不是目标值
T
T
本身,而是指向
T
T
的指针类型
P
P
(例如
Pin<&mut T>
Pin<&mut T>
或
Pin<Box<T>>
Pin<Box<T>>
):
#[repr(transparent)]
pub struct Pin<P> {
pointer: P,
}
#[repr(transparent)]
pub struct Pin<P> {
pointer: P,
}
一旦你把指针
P
P
(假设指向目标类型
T
T
)放进了
Pin<P>
Pin<P>
,就相当于对编译器立下了一条地址固定契约:
从被固定的这一刻起,直到目标值
T
T
在原地被调用
Drop::drop
Drop::drop
销毁为止,目标值
T
T
所在的内存字节绝不会被搬移到其他地址,该块内存也绝不会被提前挪作他用.
为了守护这条契约,当
T
T
可能包含自引用时,
Pin<&mut T>
Pin<&mut T>
的安全接口故意不提供获取
&mut T
&mut T
的方法: 想从
Pin<&mut T>
Pin<&mut T>
中取出裸的
&mut T
&mut T
(即
Pin::get_unchecked_mut
Pin::get_unchecked_mut
),必须使用
unsafe
unsafe
,并由编写者人工保证不会利用这个
&mut T
&mut T
把值移走.
这也是为什么
Future::poll
Future::poll
的签名必须写成:
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
因为只有第一次调用
poll
poll
时,状态机才会开始执行函数体并构造内部自引用指针; 将
poll
poll
的接收者规定为
Pin<&mut Self>
Pin<&mut Self>
,就保证了:任何想要开始驱动(
poll
poll
)状态机的人,都必须先把状态机固定在某块内存上,此后再也无法在两次
poll
poll
之间用
mem::swap
mem::swap
移动它.
下面将上述直觉整理为精确的定义:
为什么 Rust 没有像 C++ 那样引入移动构造函数、或者把自引用编译成相对偏移量、或者直接加一个
?Move
?Move
特型?为什么许多开发者觉得
Pin
Pin
比普通引用难用得多?详见下面的附注:
4
Unpin
Unpin
:绝大多数类型的通行证
如果所有类型一旦放进
Pin<&mut T>
Pin<&mut T>
就再也不能安全地拿到
&mut T
&mut T
,那么我们在
Future 与 Waker
中手写
Countdown
Countdown
时,为什么可以直接在
poll(mut self: Pin<&mut Self>, ...)
poll(mut self: Pin<&mut Self>, ...)
里写
self.remaining -= 1
self.remaining -= 1
?
因为
Countdown
Countdown
里只有一个整数
u32
u32
,根本没有任何自引用指针! 把
Countdown
Countdown
在内存里随便
mem::swap
mem::swap
到哪里,它的整数值都完好无损.
为了不限制这些“随便移动也安全”的普通类型,标准库定义了一个自动特型
Unpin
Unpin
:
- 绝大多数普通类型自动实现
UnpinUnpin:u32u32、StringString、Vec<T>Vec<T>、&mut T&mut T、Box<T>Box<T>,以及完全由UnpinUnpin字段组成的结构体和枚举,全都默认实现UnpinUnpin. - 对实现了
T: UnpinT: Unpin的类型,Pin<&mut T>Pin<&mut T>等同于普通的&mut T&mut T:你可以用安全的Pin::new(&mut x)Pin::new(&mut x)随意构造Pin<&mut T>Pin<&mut T>,也可以通过DerefMutDerefMut或Pin::into_innerPin::into_inner随时安全地变回&mut T&mut T. - 编译器为
asyncasync生成的状态机默认实现!Unpin!Unpin(利用内部标记类型PhantomPinnedPhantomPinned):因此由async fnasync fn或async { ... }async { ... }产生的FutureFuture是真正的受保护对象,不能用Pin::newPin::new直接从普通引用构造.
可以用下面的决策树来理清
Pin<P>
Pin<P>
与
T: Unpin
T: Unpin
的关系:
5
怎样在栈上或堆上固定一个
!Unpin
!Unpin
的
Future
Future
既然
async
async
状态机不满足
Unpin
Unpin
,当我们要在自己的代码里手动调用它的
poll
poll
,或者把它传给要求
F: Unpin
F: Unpin
的组合子(例如前面我们写的
Double<F>
Double<F>
)时,怎样把它变成
Pin
Pin
? 标准库提供了两种零
unsafe
unsafe
的标准方式:
- 堆固定
Box::pin(fut)Box::pin(fut): 把状态机移动到堆上新分配的BoxBox里,并返回Pin<Box<F>>Pin<Box<F>>. 因为值一旦放进堆上的BoxBox并被PinPin封存,只要不把值从BoxBox中拆出来,即使Pin<Box<F>>Pin<Box<F>>这个胖指针本身在栈上到处移动,堆上那块存放状态机的物理地址也始终固定不变. 更进一步,对于任意FF(哪怕F: !UnpinF: !Unpin),指针类型Pin<Box<F>>Pin<Box<F>>自身始终实现UnpinUnpin且实现FutureFuture! - 栈固定
std::pin::pin!(fut)std::pin::pin!(fut)(Rust 1.68 引入): 通过局部宏生成的匿名生命周期与同名变量遮蔽,把值藏在当前函数栈帧的一个不可直接访问的临时槽位上,只向后续代码暴露一个Pin<&mut F>Pin<&mut F>. 由于原来的值变量名已被遮蔽,后续代码在栈帧退栈析构前绝对拿不到原值的移动权,从而在不分配任何堆内存的前提下完成了固定.
use std::pin::{pin, Pin};
use std::future::Future;
async fn example_task() -> u32 { 42 }
fn drive_examples() {
// 方式 1:堆上固定,得到 Pin<Box<impl Future>>(可跨函数返回或移动)
let mut heap_fut: Pin<Box<_>> = Box::pin(example_task());
let _p1: Pin<&mut _> = heap_fut.as_mut();
// 方式 2:栈上固定,零堆分配,直接得到 Pin<&mut impl Future>
let mut stack_fut = pin!(example_task());
let _p2: Pin<&mut _> = stack_fut.as_mut();
}
use std::pin::{pin, Pin};
use std::future::Future;
async fn example_task() -> u32 { 42 }
fn drive_examples() {
// 方式 1:堆上固定,得到 Pin<Box<impl Future>>(可跨函数返回或移动)
let mut heap_fut: Pin<Box<_>> = Box::pin(example_task());
let _p1: Pin<&mut _> = heap_fut.as_mut();
// 方式 2:栈上固定,零堆分配,直接得到 Pin<&mut impl Future>
let mut stack_fut = pin!(example_task());
let _p2: Pin<&mut _> = stack_fut.as_mut();
}
6 结构化固定投影
最后还有一个编写自定义组合子时经常遇到的问题: 在
Future 与 Waker
的
Double<F>
Double<F>
中,我们为了能写
Pin::new(&mut self.inner).poll(cx)
Pin::new(&mut self.inner).poll(cx)
,加上了
F: Unpin
F: Unpin
的约束. 如果想要让
Double<F>
Double<F>
也能直接内联包装一个
!Unpin
!Unpin
的
async
async
状态机
F
F
,我们就需要完成一步转换: 已知外层结构体是固定的
self: Pin<&mut Double<F>>
self: Pin<&mut Double<F>>
,怎样得到内层字段的固定借用
Pin<&mut F>
Pin<&mut F>
?
从
Pin<&mut Struct>
Pin<&mut Struct>
取得字段的
Pin<&mut Field>
Pin<&mut Field>
称为结构化固定投影. 这一步在标准库里不能自动发生,因为如果外层结构体
Double<F>
Double<F>
自己额外手动实现了
Unpin
Unpin
(脱离了字段
F
F
的约束),或者在自己的
Drop::drop(&mut self)
Drop::drop(&mut self)
实现里偷偷调用
mem::swap
mem::swap
把
self.inner
self.inner
移走了,那么内层字段
F
F
的固定承诺就会被外层打破!
在工程实践中,除非为了教学研究,不要手写
unsafe
unsafe
的
map_unchecked_mut
map_unchecked_mut
来做字段投影,而是直接使用社区经过严格形式化审查的
pin-project-lite
pin-project-lite
(或
pin-project
pin-project
)宏,由宏自动生成安全的投影结构并静态禁止冲突的
Drop
Drop
与
Unpin
Unpin
实现.
7
练习:理解
Pin
Pin
的边界
Exercises (2)
Exercise 4.1. 移动
Pin<Box<F>>
Pin<Box<F>>
为什么是安全的
fut
fut
是由包含跨
.await
.await
自引用的
async
async
块生成的
!Unpin
!Unpin
状态机. 将它放进
let mut b1 = Box::pin(fut1); let mut b2 = Box::pin(fut2);
let mut b1 = Box::pin(fut1); let mut b2 = Box::pin(fut2);
后,为什么安全代码依然可以合法地调用
std::mem::swap(&mut b1, &mut b2)
std::mem::swap(&mut b1, &mut b2)
?这会破坏
fut1
fut1
内部的自引用指针吗?Hint
区分“指针本身所在的栈地址”和“指针所指向的堆上状态机地址”.Solution
Pin<P>
Pin<P>
承诺地址不变的对象是指针
P
P
所指向的目标值
F
F
,而不是指针
P
P
本身. 在
Pin<Box<F>>
Pin<Box<F>>
中,状态机
F
F
存放在堆内存上(假设堆地址为
0x9000
0x9000
),它内部的自引用指针指向的也是
0x9000
0x9000
附近的堆地址; 而
Pin<Box<F>>
Pin<Box<F>>
本身只是一个存放着
0x9000
0x9000
这个地址值的 8 字节栈变量. 对两个
Pin<Box<F>>
Pin<Box<F>>
变量调用
mem::swap
mem::swap
,仅仅交换了栈上的两个 8 字节地址值,堆上
0x9000
0x9000
处的状态机字节连同它内部的自引用关系连一比特都没有移动. 因此,无论
F
F
是否满足
Unpin
Unpin
,
Pin<Box<F>>
Pin<Box<F>>
自身总是满足
Unpin
Unpin
.Exercise 4.2. 什么时候状态机还没产生自引用
既然self_ref_example()
self_ref_example()
返回的状态机类型是
!Unpin
!Unpin
的自引用结构体,为什么在固定(pin)它之前,我们仍然可以把
let f = self_ref_example();
let f = self_ref_example();
按值传给
Box::pin(f)
Box::pin(f)
或
tokio::spawn(f)
tokio::spawn(f)
(这明明发生了一次按值移动)?Hint
回想async fn
async fn
被调用但尚未第一次调用
poll
poll
时,它处于哪个枚举变体.Solution
因为调用let f = self_ref_example();
let f = self_ref_example();
时,函数体内的代码一行都还没有执行! 此时
f
f
处于初始变体
Start
Start
,局部变量
buf
buf
和指向它的引用
ptr
ptr
根本还没有被构造出来. 在这个阶段,无论把
f
f
按值传给
tokio::spawn(f)
tokio::spawn(f)
还是装进
Box::pin(f)
Box::pin(f)
,移动的都只是尚未建立任何内部指针的初始参数. 直到
f
f
被固定在最终归宿(如执行器的堆任务块)并第一次通过
Pin<&mut Self>
Pin<&mut Self>
调用
poll
poll
时,它才会进入
Waiting
Waiting
状态并真正写下自引用地址——而从那一刻起,它就再也不会被移动了.