Rust Async: Pin概念解析
Pin
这个零抽象概念的引入重塑了rust生命周期借用检查的规则,是rust异步生态中极为关键的一环。然而其本身过于抽象,api过于生硬,即便是当时的不少rust官方人员在review 这部分api时也是一头雾水。要尽可能把这个概念讲清楚,本文先讲讲 Pin
出现的历史背景和所需要解决的问题,具体的api后续再作解析。
async/await的实现机制¶
我们都知道rust在实现闭包时,编译器通过隐式地创建一个匿名的struct保存捕获到的变量,并对struct实现call方法来实现函数调用。 实现async/await函数时,由于需要记录当前所处的状态(每次await的时候都会导致一个状态),所以编译器往往生成的是一个匿名的enum,每个enum变体保存从外部或者之前的await点捕获的变量。 以如下代码为例:
fn main() {
async fn func1() -> i32 { 12 }
let func2 = async || -> i32 {
let t = 1;
let v = t + 1;
let b = func1().await;
let rv = &v;
*rv + b
};
let fut = func2();
println!("future size: {}", std::mem::size_of_val(&fut));
}
从代码形式上看,好像 t
, v
是局部变量,运行时存储在stack中。然而由于await的存在,整个函数不再是一气呵成从头执行到尾,而是分成了两段。在执行第二段的时候, 前半段执行的局部变量已经从stack中清理掉了,而第二段捕获了第一段的局部变量 v
, 因此 v
只能保存在编译器生成的匿名enum中。这个enum充当了函数执行时的虚拟栈(virtual stack)。 如果将 letb=func1().await;
和 letrv=&v;
调换位置呢?从打印结果来看,生成的enum大小变大了,因为捕获的是 rv
这个引用变量,而被引用的变量v也得一起保存在enum中, 也就是说借用一旦跨了await,就会导致编译器需要构造一个自引用的结构!
自引用结构¶
支持自引用结构是rust社区期待已久的特性,然而完美地支持却极具挑战,短时间内很难稳定。自引用结构类似下面的:
Foo
作为一个整体,并没有借用外部的变量,因此具有static生命周期,然而内部ptr却借用了另一个field的元素。如果将一个 Foo
的实例变量进行移动(memcpy整个结构),则移动后的ptr依然指向之前的地址,导致悬空指针。防止自引用变量被意外地移动是自引用需要解决的问题之一。
那是不是在支持async/await前得先稳定自引用特性呢?答案是不需要,因为async/await生成是匿名的自引用结构,用户无法直接读写结构内部的字段,因此只需要处理好意外移动的问题就可以。 防止意外移动的方案之前有人提出增加一个 Move
marker trait,对于没有实现 Move
的类型,编译器禁止类型的实例移动,这种方案涉及到编译器比较大的改动, 也增加了语言的复杂度。那能不能不动编译器,而只是在标准库里增加几个api的方式实现呢?事实上如果不想让一个 T
类型的实例移动,只需要把它分配在堆上,用智能指针(如 Box<T>
)访问就行了, 因为移动 Box<T>
只是memcpy了指针,原对象并没有被移动。不过由于 Box
提供的api中可以获取到 &mut T
,进而可以通过 mem::swap
间接将T移出。 所以只需要提供一个弱化版的智能指针api,防止泄露 &mut T
就能够达到防止对象被移动。这就是实现 Pin
api的主要思路。 Pin
就像是一个铁笼子, 将自引用的猛兽关进去后,依然可以正常观察它,或者给它投点食物修改它,也可以把铁笼子移来移去,但不能把它放出来自由活动。
为啥Pin是零开销抽象?¶
既然为了防止对象移动,需要将其分配到堆上,需要额外的内存分配开销,怎么能称之为零开销的呢? 首先说明一个重要的特性:很多人会误认为调用带引用的async函数会生成自引用的对象,因此不能移动,这是不对的。async函数生成的匿名enum类似下面:
编译器生成的 AsyncFuture
初始时是处于 InitState
变体状态, State0
只捕获了外部的变量,不存在自引用,因此可以自由移动和调用各种 future
的组合子, 而只有将其提交到 executor
中执行的时候, executor
才会将状态推进到 Await1State
等变体状态, State1
及后续状态才会存在自引用的情况。 因此,使用 async
构建异步逻辑时并不需要每处都进行内存分配,而是将异步逻辑构建成一整个task放进 executor
的最后一步才需要内存分配。这是理解 Pin
的关键。
其次,由于 executor
本身通常需要执行各种不同的 Future
,所以也意味着其处理的通常是 Box<dynFuture>
,也需要将 Future
分配在堆上。因此 Pin
的方式没有产生额外的开销。
Future组合子的生命周期限制¶
由于在safe rust中,使用Future组合子写代码没法构造自引用结构,所以接触过Futures 0.1版本的就应该清楚,要在组合子之间 传递数据非常麻烦,要么组合子通过传递 self
, 然后又从 Future::Output
传出来,要么把数据包装在 Arc
中,使用引用计数共享,否则就会报生命周期错误,代码写起来很费劲, 不美观,同时也不够高效。 Pin
这个概念的引入,使得rust代码在不使用 unsafe
的前提下,支持编译器生成的自引用结构, async
函数中可以从虚拟栈中借用数据,拓展了safe rust本身的表达能力。 没有Pin前写Future的api画风:
impl Socket {
fn read(self, buf: Vec<u8>) ->
impl Future<Item = (Self, Vec<u8>, usize), Error = (Self, Vec<u8>, io::Error)>;
}
有Pin的概念后:
对应async函数的写法:
总结¶
总结下Pin提出的主要思路:
- 在safe rust代码中写Future会因生命周期的限制,导致api复杂难用,等价的问题出现在async函数中引用变量不能跨越await;
- 分析发现其本质原因是因为这样会导致生成自引用结构;
- 自引用的rfc现在不完善,要在rust中完美支持自引用结构会是一个漫长的过程;
- 进一步分析发现编译器生成的enum是一个特例(结构是匿名的,内部字段不可直接访问,同时初始状态不包含自引用,可以自由移动);
- 不需要完美支持自引用,只需要保证自引用结构不可移动就能解决问题;
- Pin概念提出并进入标准库,问题解决。