Rust Async: Pin概念解析

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)訪問就行了, 因爲移動 Box只是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,也需要將 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提出的主要思路:

在safe rust代碼中寫Future會因生命週期的限制,導致api複雜難用,等價的問題出現在async函數中引用變量不能跨越await;
分析發現其本質原因是因爲這樣會導致生成自引用結構;
自引用的rfc現在不完善,要在rust中完美支持自引用結構會是一個漫長的過程;
進一步分析發現編譯器生成的enum是一個特例(結構是匿名的,內部字段不可直接訪問,同時初始狀態不包含自引用,可以自由移動);
不需要完美支持自引用,只需要保證自引用結構不可移動就能解決問題;
Pin概念提出並進入標準庫,問題解決。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章