【译】Async/Await(四)—— Pinning
原文标题:Async/Await
原文链接:https://os.phil-opp.com/async-await/#multitasking
公众号: Rust 碎碎念
翻译 by: Praying
Pinning
在本文中我们已经与pinning
偶遇多次。现在终于可以来讨论pinning
是什么以及为什么需要它?
自引用结构体(Self-Referential Structs)
正如上面所解释的,状态机的变换把每个暂停点的局部变量存储在一个结构体中。对于像example
函数这样的小例子,这会很直观且不会导致什么问题。但是,当变量开始互相引用时,事情就变得困难了。例如,考虑下面的函数:
async fn pin_example() -> i32 {
let array = [1, 2, 3];
let element = &array[2];
async_write_file("foo.txt", element.to_string()).await;
*element
}
这个函数创建了一个array
,其中包含有1
,2
,3
。它接着创建了一个对 array
最后一个元素的引用然后把它存入element
变量。接下来,它把这个已经转换为字符串的数字异步地写入到文件foo.txt
中。最后,它返回了被element
引用的数字。
因为这个函数使用了一个的await
操作,所以得到的状态机有三种状态:启动(start)、结束(end)和等待写入(waiting on write)。这个函数没有传入参数,所以开始状态的结构体是空的。和之前一样,结束状态也是空的因为函数在这个位置已经结束了。等待写入状态的结构体就比较有意思了:
struct WaitingOnWriteState {
array: [1, 2, 3],
element: 0x1001c, // address of the last array element
}
我们需要把array
和element
变量都存储起来,因为element
在返回值的时候需要,而array
被element
所引用。element
是一个引用,它存储了一个指向被引用元素的指针(也就是一个内存地址)。这里我们假设地址是0x1001c
,在实际中,它需要是array
的最后一个元素的地址,因此,它取决于结构体在内存中所处的位置。带有这样的内部指针的结构体被称为自引用(self-referential)结构体
,因为它们通过自己的一个字段引用了它们自身。
自引用结构体的问题
我们的自引用结构体的内部指针导致了一个基本问题,当我们看到它的内存布局后,这个问题就会变得明显:
array
字段的起始地址为0x10014
,element
字段在地址0x10020
。它指向了地址0x1001c
,因为 array
的最后一个元素的位置就在这里。此时,一切都没有问题。但是,当我们试图把这个结构体移动到一个不同的内存地址时,问题就出现了:
我们把结构体往后移动了一下,因此现在它的起始地址为0x10024
。当我们把结构体作为函数参数传递时或者把它赋值给另一个栈上的变量,就会发生这种情况。问题在于,element
字段仍然指向地址0x1001c
,而array
的最后一个元素的地址已经变成0x1002c
。因此,这个指针是悬垂(dangling)的,并会导致下一次调用poll
时发生未定义行为。
可能的解决方案
解决这个悬垂指针问题有三种基本方式:
-
在移动时更新指针:思路是无论什么时候,只要结构体在内存中被移动,就更新内部的指针,因此这个指针在移动后仍然是有效的。不幸的是,这种方式将会需要 Rust 作出很大的改变并且有可能导致巨大的性能开销。原因是,运行时需要追踪所有结构体字段的类型并且在每次移动操作时都要检查是否需要更新指针。
-
存储一个偏移量来取代自引用:为了避免更新指针的需要,编译器可以把自引用存储为个结构体开始位置的偏移量。例如,上面的
WaitingOnWriteState
结构体中的element
字段可以存储为值为 8 的element_offset
字段。因为,引用指向的 array 里的元素起始于结构体开头的 8 字节。因为偏移位置在结构体移动时是不变的,所以不需要进行字段更新。这种方式的问题在于它需要编译器去探查所有的自引用。这在编译时是不可能实现的,因为一个引用的值可能取决于用户输入,因此,我们可能再次需要一个运行时系统来分析引用并正确地创建状态结构体。这不会导致运行时开销,但是也阻碍了特定的编译器优化,因此,它可能会再度引起巨大的性能开销。
-
禁止移动结构体:正如我们上面所见,悬垂指针仅发生于我们在内存中移动结构体时,通过完全禁止在自引用结构体上的移动操作,可以避免这个问题。这种方式的一个显著优势在于,它可以在类型系统层面上被实现而不需要额外的运行时开销。缺点在于,它把处理可能是自引用结构的移动操作的负担交给了程序员。
因为要保证提供零成本抽象(zero cost abstraction)的原则,这意味着抽象不应该引入额外的运行时开销,所以 Rust 选择了第三种方案。也因此,pinningAPI 在RFC2349中被提出。接下来,我们将会对这个 API 进行简要介绍,并解释它是如何与 async/await 以及 future 一同工作的。
堆上的值(Heap Values)
第一个发现是,在大多数情况下,堆分配(heap allocated)的值已经在内存中有了一个固定地址。它们通过调用allocate
来创建,然后被一个指针类型引用,比如Box<T>
。尽管指针类型有可能被移动,但是指针指向的堆上的值仍然保持在相同的内存地址,除非它被一个deallocate
调用来释放。
使用堆分配,我们可以尝试去创建一个自引用结构体:
fn main() {
let mut heap_value = Box::new(SelfReferential {
self_ptr: 0 as *const _,
});
let ptr = &*heap_value as *const SelfReferential;
heap_value.self_ptr = ptr;
println!("heap value at: {:p}", heap_value);
println!("internal reference: {:p}", heap_value.self_ptr);
}
struct SelfReferential {
self_ptr: *const Self,
}
我们创建了一个名为SelfReferential
的简单结构体,该结构体仅包含一个单独的指针字段。首先,我们使用一个空指针来初始化这个结构体,然后使用Box::new
在堆上分配它。接着,我们计算出这个分配在堆上的结构体的内存地址并将其存储到一个ptr
变量中。最后,我们通过把ptr
变量赋值给self_ptr
字段使得结构体成为自引用的。
当我们在 playground 上执行这段代码时,我们看到这个堆上的值的地址和它的内部指针的地址是相等的,这意味着,self_ptr
字段是一个有效的自引用。因为heap_value
只是一个指针,移动它(比如,把它作为参数传入函数)不会改变结构体自身的值,所以self_ptr
在指针移动后依然是有效的。
但是,仍然有一种方式来破坏这个示例:我们可以摆脱Box<T>
或者替换它的内容:
let stack_value = mem::replace(&mut *heap_value, SelfReferential {
self_ptr: 0 as *const _,
});
println!("value at: {:p}", &stack_value);
println!("internal reference: {:p}", stack_value.self_ptr);
这里,我们使用mem::replace
函数使用一个新的结构体实例来替换堆分配的值。这使得我们把原始的heap_value
移动到栈上,而结构体的self_ptr
字段现在是一个仍然指向旧的堆地址的悬垂指针。当你尝试在 playground 上运行这个示例时,你会看到打印出的"value at:"
和"internal reference:"
这一行确实是输出的不同的指针。因此,在堆上分配一个值并不能保证自引用的安全。
出现上面的破绽的基本问题是,Box<T>
允许我们获得堆分配值的&mut T
引用。这个&mut
引用让使用类似mem::replace
或者mem::swap
的方法使得堆上值失效成为可能。为了解决这个问题,我们必须阻止创建对自引用结构体的&mut
引用。
Pin<Box>和 Unpin
pinning API 以Pin
包装类型和Unpin
标记 trait 的形式提供了一个针对&mut T
问题的解决方案。这些类型背后的思想是对Pin
的所有能被用来获得对 Unpin trait 上包装的值的&mut
引用的方法(如get_mut
或者deref_mut
)进行管控。Unpin
trait 是一个auto trait,它会为所有的类型自动实现,除了显式选择退出(opt-out)的类型。通过让自引用结构体选择退出Unpin
,就没有(安全的)办法从一个Pin<Box<T>>
类型获取一个&mut T
。因此,它们的内部的自引用就能保证仍是有效的。
举个例子,让我们修改上面的SelfReferential
类型来选择退出Unpin
:
use core::marker::PhantomPinned;
struct SelfReferential {
self_ptr: *const Self,
_pin: PhantomPinned,
}
我们通过添加一个类型为PhantomPinned的_pin
字段来选择退出。这个类型是一个零大小标记类型,它唯一目的就是不去实现Unpin
trait。因为 auto trait 的工作方式,有一个字段不满足Unpin
,那么整个结构体都会选择退出Unpin
。
第二步是把例子中的Box<SelfReferential>
改为Pin<Box<SelfReferential>>
类型。实现这个的最简单的方式是使用Box::pin
函数,而不是使用Box::new
创建堆分配的值。
let mut heap_value = Box::pin(SelfReferential {
self_ptr: 0 as *const _,
_pin: PhantomPinned,
});
除了把Box::new
改为Box::pin
之外,我们还需要在结构体初始化添加新的_pin
字段。因为PhantomPinned
是一个零大小类型,我们只需要它的类型名来初始化它。
当我们尝试运行调整后的示例时,我们看到它无法编译:
error[E0594]: cannot assign to data in a dereference of `std::pin::Pin<std::boxed::Box<SelfReferential>>`
--> src/main.rs:10:5
|
10 | heap_value.self_ptr = ptr;
| ^^^^^^^^^^^^^^^^^^^^^^^^^ cannot assign
|
= help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `std::pin::Pin<std::boxed::Box<SelfReferential>>`
error[E0596]: cannot borrow data in a dereference of `std::pin::Pin<std::boxed::Box<SelfReferential>>` as mutable
--> src/main.rs:16:36
|
16 | let stack_value = mem::replace(&mut *heap_value, SelfReferential {
| ^^^^^^^^^^^^^^^^ cannot borrow as mutable
|
= help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `std::pin::Pin<std::boxed::Box<SelfReferential>>`
两个错误发生都是因为Pin<Box<SelfReferential>>
类型没有实现DerefMut
trait。这也正是我们想要的,因为DerefMut
trait 将会返回一个&mut
引用,这是我们想要避免的。发生这种情况是因为我们选择退出了Unpin
并把Box::new
改为了Box::pin
。
现在的问题在于,编译器不仅阻止了第 16 行的移动类型,还禁止了第 10 行的self_ptr
的初始化。这会发生时因为编译器无法区分&mut
引用的有效使用和无效使用。为了能够正常初始化,我们不得不使用不安全的get_unchecked_mut方法:
// safe because modifying a field doesn't move the whole struct
unsafe {
let mut_ref = Pin::as_mut(&mut heap_value);
Pin::get_unchecked_mut(mut_ref).self_ptr = ptr;
}
get_unchecked_mut函数作用于Pin<&mut T>
而不是Pin<Box<T>>
,所以我们不得不使用Pin::as_mut
来对之前的值进行转换。接着,我们可以使用get_unchecked_mut
返回的&mut
引用来设置self_ptr
字段。
现在,生下来的唯一的错误是mem::replace
上的期望错误。记住,这个操作试图把一个堆分配的值移动到栈上,这将会破坏存储在self_ptr
字段上的自引用。通过选择退出Unpin
和使用Pin<Box<T>>
,我们可以在编译期阻止这个操作,从而安全地使用自引用结构体。正如我们所见,编译器无法证明自引用的创建是安全的,因此我们需要使用一个不安全的块(block)并且确认其自身的正确性。
栈 Pinning 和 Pin<&mut T>
在先前的部分,我们学习了如何使用Pin<Box<T>>
来安全地创建一个堆分配的自引用的值。尽管这种方式能够很好地工作并且相对安全(除了不安全的构造),但是需要的堆分配也会带来性能损耗。因为 Rust 一直想要尽可能地提供零成本抽象, 所以 pinning API 也允许去创建Pin<&mut T>
实例指向栈分配的值。
不像Pin<Box<T>>
实例那样能够拥有被包装的值的所有权,Pin<&mut T>
实例只是暂时地借用被包装的值。这使得事情变得更加复杂,因为它要求程序员自己确认额外的保证。最重要的是,一个Pin<&mut T>
必须在被引用的T
的整个生命周期被保持 pinned,这对于栈上的变量很难确认。为了帮助处理这类问题,就有了像pin-utils这样的 crate。但是我仍然不会推荐 pinning 到栈上除非你真的知道自己在做什么。
想要更加深入地了解,请查阅pin 模块和Pin::new_unchecked方法的文档。
Pinning 和 Futures
正如我们在本文中已经看到的,Future::poll方法以Pin<&mut Self>
参数的形式来使用 pinning:
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>
这个方法接收self: Pin<&mut Self>
而不是普通的&mut self
,其原因在于,从 async/await 创建的 future 实例常常是自引用的。通过把Self
包装进Pin
并让编译器为由 async/await 生的自引用的 futures 选择退出Unpin
,可以保证这些 futures 在poll
调用之间在内存中不被移动。这就保证了所有的内部引用都是仍然有效的。
值得注意的是,在第一次poll
调用之前移动 future 是没问题的。因为事实上 future 是懒惰的(lazy)并且直到它们被第一次轮询之前什么事情也不会做。生成的状态机中的start
状态因此只包含函数参数,而没有内部引用。为了调用poll
,调用者必须首先把 future 包装进Pin
,这就保证了 future 在内存中不会再被移动。因为栈上的 pinning 难以正确操作,所以我推荐一直使用Box::pin
组合Pin::as_mut
。
如果你想了解如何安全地使用栈 pinning 实现一个 future 组合字函数,可以去看一下map 组合子方法的源码,以及 pin 文档中的 projections and structural pinning部分