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社区期待已久的特性,然而完美地支持却极具挑战,短时间内很难稳定。自引用结构类似下面的:

struct Foo {
    array: [Bar; 10],
    ptr : &'array Bar,
}

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类似下面:

enum AsyncFuture {
 InitState(State0),
 Await1State(State1),
 Await2State(State2),
 //...
} 

编译器生成的 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的概念后:

fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> impl Future<Item = usize, Error = io::Error> + 'a;

对应async函数的写法:

async fn read(&mut self, buf: &mut [u8]) -> Result<usize, io::Error>;

总结

总结下Pin提出的主要思路:

  1. 在safe rust代码中写Future会因生命周期的限制,导致api复杂难用,等价的问题出现在async函数中引用变量不能跨越await;
  2. 分析发现其本质原因是因为这样会导致生成自引用结构;
  3. 自引用的rfc现在不完善,要在rust中完美支持自引用结构会是一个漫长的过程;
  4. 进一步分析发现编译器生成的enum是一个特例(结构是匿名的,内部字段不可直接访问,同时初始状态不包含自引用,可以自由移动);
  5. 不需要完美支持自引用,只需要保证自引用结构不可移动就能解决问题;
  6. Pin概念提出并进入标准库,问题解决。
Last modification:May 11th, 2021 at 04:29 pm
安安,亲觉得有用, 请随意打赏嗷