TiKV Rust Client 遷移記 – Futures 0.1 至 0.3

  • 2019 年 10 月 8 日
  • 筆記

作者介紹:Nick Cameron,PingCAP 研發工程師,Rust core team 成員,專註於分佈式系統、數據庫領域和 Rust 語言的進展。

最近我將一個中小型的 crate 從 futures 庫的 0.1 遷移至了 0.3 版本。過程本身不是特別麻煩,但還是有些地方或是微妙棘手,或是沒有很好的文檔說明。這篇文章里,我會把遷移經驗總結分享給大家。

我所遷移的 crate 是 TiKV 的 Rust Client。該 crate 的規模約為 5500 行左右代碼,通過 gRPC 與 TiKV 交互,採用異步接口實現。因此,對於 futures 庫的使用頗為重度。

異步編程是 Rust 語言中影響廣泛的一塊領域,已有幾年發展時間,其核心部分就是 futures 庫。作為一個標準 Rust 庫,futures 庫為使用 futures 編程提供所需數據類型以及功能。雖然它是異步編程的關鍵,但並非你所需要的一切 – 你仍然需要可以推進事件循環 (event loop) 以及與操作系統交互的其他庫。

futures 庫在這幾年中變化很大。最新的版本為 0.3(crates.io 發佈的 futures 預覽版)。然而,有許多早期代碼是 futures 0.1 系列版本,且一直沒有更新。這樣的分裂事出有因 – 0.1 和 0.3 版本之間變化太大。0.1 版本相對穩定,而 0.3 版本一直處於快速變化中。長遠來看,0.3 版本最終會演進為 1.0。有一部分代碼會進入 Rust 標準庫,其中的第一部分已在最近發佈了穩定版,也就是 Future trait。

為了讓 Rust Client 跑在穩定的編譯器上,我們將核心庫限制為僅使用穩定或即將穩定的特性。我們在文檔和示例中確實使用了 async/await,因為 async/await 更符合工程學要求,而且將來也一定會成為使用 Rust 進行異步編程的推薦方法。除了在核心庫中避免使用 async/await,我們對使用 futures 0.1 的 crate 也有依賴,這也意味着我們需要經常用到兼容層。從這個角度說,我們這次遷移其實並不夠典型。

我不是異步編程領域的專家,或許有其他方法能讓我們這次遷移(以及所涉及的代碼)更符合大家的使用習慣。如果您有好的建議,可以在 Twitter 上聯繫我。如果您想要貢獻 PR 就更贊了,我們期待越來越多的力量加入到 TiKV Client 項目里。

機械性變化

此類變化是指那些 「查詢替換類」 ,或其他無需複雜思考的變化。

這一類別中最大的變化莫過於 0.1 版本的 Future 簽名中包含了一個 Error 關聯類型,而且 poll 總是會返回一個 Result。0.3 版本里該錯誤類型已被移除,對於錯誤需要顯式處理。為了保持行為上的一致性,我們需要將代碼里所有 Future<Item=Foo, Error=Bar> 替換為 Future<Output=Result<Foo, Bar>>(留意 ItemOutput 的名稱變化)。替換後, poll 就可以返回和以前一樣的類型,這樣在使用 futures 的時候無需任何變化。

如果你定義了自己的 futures,那就需要根據是否需要處理錯誤的需求更新 futures 的定義。

futures 0.3 中支持 TryFuture 類型,基本上可以看作 Future<Output=Result<...>> 的替代。使用這個類型,意味着你需要在 FutureTryFuture 之間轉換,因此最好還是盡量避免吧。TryFuture 類型包含了一個 blanket implementation,這使它可以通過 TryFutureEx trait 輕鬆將某些函數應用於此類 futures。

futures 0.3 中,Future::poll 方法會接受一個新的上下文參數。這基本上只需要調用 poll 方法即可完成傳遞(偶爾也會忽略)。

我們的依賴包依然使用了 futures 0.1,所以我們必須在兩個版本的庫之間轉換。0.3 版本包含了一些兼容層以及其他實用工具(例如 Compat01As03)。我們在調用依賴關係時會用到這些。

wait 方法已被從 Future trait 中移除。這是讓人拍手稱快的變化,因為該方法確實夠反人性,而且本身可以用 .awaitexecutor::block_on 代替(需要注意的是後者可能會阻斷整個進程,而並不只是當前執行的 future)。

Pin

futures 0.3 中, Pin 是一個頻繁使用的類型, Future::poll 方法簽名的 self 類型對其尤為青睞。除了對這些簽名進行一些機械性的處理之外,我還得藉助於 Pin::get_unchecked_mutPin::new_unchecked 這兩種方法(均為不安全方法)對 futures 的項目字段做一些變更。

指針定位(pinning)是一個微妙又複雜的概念,我至今也不敢說自己已經掌握了多少。我能提供的最好的參考是 std::pin docs。下面是我整理的一些要點(有一些重要的細節此處不會涉及,這裡本意也並非提供一個關於指針定位的教程)。

  • Pin 作為一個類型構造,只有用於指針類型(如 Pin<Box<_>>)時才會生效。
  • Pin 本身是一種「標識/封裝」類型(有一點像 NonNull),並不是指針類型。
  • 如果一個指針類型被「定位」了,意味着指針指向的值不可移動(當一個非拷貝對象通過數值傳入,或者調用 mem::swap 時會發生移動)。需要注意的移動只能發生在指針被定位之前,而非之後。
  • 如果某個類型使用了 Unpin trait,這意味着無論此類型移動與否都不會有任何影響。換句話說,即使指向該類型的指針沒有被定位,我們也可以放心把它當作被定位的。
  • PinUnpin 並沒有置入 Rust 語言,雖然某些特性會對指針定位有間接依賴。指針定位由編譯器強制執行,但編譯器本身卻不自知(這點非常酷,也體現了 Rust 特性系統對此類處理的強大之處)。它是這樣工作的:Pin<P<T>> 只允許對於 P 的安全訪問,禁止移動 P 指向的任何數值,除非 T 應用了 Unpin(代碼編寫者已宣稱 T 並不在意是否被移動)。任何允許刪除沒有執行 Unpin 數值的操作(可變訪問)都是 unsafe 的,且應該由程序編寫者決定是否要移動任何數值,並保證之後的安全代碼中不可刪除任何數值。

讓我們回到 futures 遷移的話題上。如果你對 Pin 使用了不安全的方法,你就需要考慮上面的要點,以保證指針定位的穩定。std::pin docs 提供了更多的解釋。我在許多地方通過字段投射的方式為另外一個 future 調用 poll 方法(有時是間接的),為了達到這個目的,你需要一個已定位的指針,這也意味着能你需要結構性指針定位。如,你可以將 Pin<&mut T> 字段投射至 Pin<&mut FieldType>

函數

遷移中比較讓人不爽的一點是 futures 庫里有許多函數(與類型)的名稱改變了。有的名稱和標準庫里的通用名重複,這讓用自動化的手段處理變更的難度變大。比如,Async 變成了 PollOk 變成了 readyfor_each 變成 thenthen 變成 mapEither::A 變成 Either::Left

有時名稱沒有變化,但其代表的功能語義變了(或者兩方面都變了)。一個較為普遍的變化就是 closure 函數現在會返回可以使用 T 類型生成數值的 future,而不會直接返回數值本身。

有許多組合子函數從 Future trait 移至擴展 crate 里。這個問題本身不難修復,只是有時候不容易從錯誤信息中判定。

LoopFn

0.1 版本的 futures 庫包含了 LoopFn 這個 future 構造,用於處理多次執行某動作的 futures。LoopFn 在 0.3 版本中被移除,這樣做的原因個人認為可能是 for 循環本身是 async 的函數,或者 streams 才是長遠看來的更佳解決方案。為了讓我們的遷移過程簡單化,我為 futures 0.3 寫了我們自己版本的 LoopFn future,其實大部分也都是複製粘貼的工作,加上一些調整(如處理指針定位投射):code。後來我將幾處 LoopFn 用法轉換為 streams,對代碼似乎有一定改進。

Sink::send_all

我們在項目中幾個地方使用了 sink。我發現對於它們對遷移和 futures 相比要有難度不少,其中最麻煩的問題就是 Sink::send_all 結構變了。0.1 版本里,Sink::send_all 會獲取 stream 的所有權,並在確定所有 future 都完成後返回 sink 以及 stream。0.3 版本里, Sink::send_all 會接受一個對 stream 的可變引用,不返回任何值。我自己寫了一個 兼容層 在 futures 0.3 里模擬 0.1 版本的 sink。這不是很難,但也許有更好的方式來做這件事。

大家可以在 這個 PR 里看到整個遷移的細節。本文最初發表在 www.ncameron.org