概要
Rustでトレートメソッドにasyncが使えないが、使えない理由と、代わりにできることを解説します。
async traitsとは
Rustに非同期処理の文法が正規で導入される前からも、トレートの定義に非同期メソッドが書けるようにしてもらいたいという要望が長くあります。
トレートに非同期のメソッド定義は以下のような構文になります。
trait MyAsyncTrait {
async fn answer(&self) -> usize;
}
struct Life(usize);
impl MyAsyncTrait for Life {
async fn answer(&self) -> usize {
self.0
}
}
しかし、投稿時のステーブルRustでは、上記のコードはコンパイルされず、以下のようなエラーが出力されます。
functions in traits cannot be declared `async`
`async` trait functions are not currently supported
consider using the `async-trait`
aysync-traitについては後々触れますが、なぜこのようなごく簡単に見えることができないのだろうか?async {}の非同期ブロックとasync fn foo() {}なら問題なくRustのコンパイルが受け付けてくれるのに、なぜトレートになると、急に弱腰になるのだろうか?
実は深い訳があるのです。
Rustにおけるトレイトの動的ディスパッチ
いったん非同期は傍に置いて、動的ディスパッチについて話しましょう。
Rustには、ジェネリック型の延長線で「このトレートを実装していれば、どんな型でもいいよ」という記法があります。
trait Greetable {
fn greet(&self);
}
fn call_greet(o: &dyn Greetable) {
o.greet();
}
struct Foo {}
impl Greetable for Foo {
fn greet(&self) {
println!("hello world, from Foo.");
}
}
fn main() {
let foo = Foo {};
call_greet(&foo); // hello world, from Foo.
}
この記法を動的ディスパッチと言われており、どのstructでもGreetableを実装していれば引数としての条件を満たすことができます。
複数のGreetableを実装しているオブジェクトを引数にすることもできます。
fn greet_all(objects: &[&dyn Greetable]) {
for obj in objects {
obj.greet();
}
}
さらにBarというstructを定義しても同様に使えます。
struct Bar {}
impl Greetable for Bar {
fn greet(&self) {
println!("hello world, from Bar.");
}
}
fn main() {
let foo = Foo {};
let bar = Bar {};
call_greet(&foo); // hello world, from Foo.
call_greet(&bar);
greet_all(&vec![&foo, &bar]);
}
動的ディスパッチとvtableの関係
上記のロジックを見て、読者はあるいは「なぜcall_greetは違うstructでもどこにそのメソッドがあるのかを知れるんだろう」と首を傾げるのかもしれません。あるいはコンパイラーの魔法を信じることにするかもしれません。
前者のお相手をしたいのですが、動的ディスパッチはまさにそのことであり、実行時に同トレートを実装しているFooとBarのどこをどうすればcall_greetが呼べるのかは、vtableというものを参照して::greetと::greet`のメモリ上におけるありかを調べられるからです。
dyn Greetableのような動的ジェネリックを使った時に、実はコンパイラーが裏で配線図になるvtableを生成してくれているのです。例えば上記のFooとBarだと、以下のような vtableが生成されます:
struct Foo_Greetable_123123<'a> {
greet: &'a fn(&'a Foo),
}
struct Bar_Greetable_038484<'a> {
greet: &'a fn(&'a Bar),
}
上記のvtableは、実行時には動的に使っているdyn FooBarのポインターに裏でくっ付けられて、「ファットポインター」というものになります。つまり、call_greet(&foo);における&fooはただ単にFooへのポインタだけでなく、Fooにも、Foo_Greetable_123123にも参照を持っているのです。
上記のようなファットポインタがあるから、たとえstructが違えど、どこを見れば呼びたい関数(greet)があるのか、実行時にわかります。
動的ディスパッチの大きな制約
少しずつ話をまとめて非同期トレートに戻したいのですが、動的ディスパッチの仕組みを少し理解した今、その仕組みの制約について触れたいのです。
動的ディスパッチには、ジェネリックの引数が使えないのです。
例えば、以下のようなメソッドをGreetableに追加したらコンパイラーがご立腹に。
trait Greetable {
fn greet(&self);
fn greet_with_name<T>(&self, name: &T)
where
T: std::fmt::Display;
}
fn call_greet(o: &dyn Greetable) {
o.greet_with_name("Austin");
}
コンパイラーのエラーは以下の通りです。
the trait `Greetable` cannot be made into an object
for a trait to be "object safe" it needs to allow building a vtable to allow the call to be resolvable dynamically
「object safe」というのがキーワードですが、引数にジェネリックを追加したことで、急にGreetableがオブジェクトセーフじゃなくなったのですね。
これはRustのmonomorphizationという機能が原因であってジェネリック関数をコンパイル時に実際に使われている型に具現化する仕組みのことです。
例えば、以下のように::great_with_nameのTをStringとusizeに具現化したら以下のようなメソッドがコンパイラによって生成されます:
impl Greetable {
fn greet_with_name_string(&self, name: &String) {...}
fn greet_with_name_usize(&self, name: &usize) {...}
}
さらに、この拡張されたGreetableの実装からvtableを作ろうとすると、複雑になってきます。
struct Foo_Greetable_038484<'a> {
greet: &'a fn(&Foo),
greet_with_name_string: &'a fn(&'a Foo, name: &'a String),
greet_with_name_usize: &'a fn(&'a Foo, name: &'a usize),
}
実際、動的ディスパッチでどのような組み合わせが必要なのか、コンパイラーには計り知れないので、今現在のRustではこのようなvtable生成が不可能なのです。したがって、このようなジェネリックを使った関数をトレートに追加すると、オブジェクトセーフじゃないと言われます。
動的ディスパッチと非同期トレートの関係
上記のvtable生成の制約を知った上で、それが非同期トレートの実現と何の関係があるのか考えていきましょう。
まず、以下のfutの型は何になるのでしょうか?
let fut = async { 42 };
実は、asyncブロックは、impl Future
let fut: impl Future<Output = i32> = async { 42 };
impl Future
trait Greetable {
fn greet(&self);
fn greet_async(&self) -> impl Future<Output = i32>;
}
エラーの内容は以下の通りです。
`impl Trait` only allowed in function and inherent method return types, not in trait method return types
これは先ほどのvtableとも、Monomorphizationとも関係があります;async fnとasyncブロックが返しているのは、言えばジェネリックタイプなのです。上記のgreet_asyncを以下のように考えれば明確です。
trait Greetable {
...
fn greet_async<T>(&self) -> T where T: Future<Output = i32>;
}
これがMonomorphizationとvtableが相性悪くぶつかってしまう制約なのです。つまり、Future
じゃあ、そもそもdynを使わないで、ずっとimpl Futureを使えばいいのでは?と思うかもしれませんが、実はコンパイラーが生成してくれるFutureトレートの実装には深い、深い訳があって、async fnもしくはasyncブロックは必ず動的オブジェクトに変換されないといけないのです。
なぜasync fnとasyncブロックはPin<Box>に変換される
上記の深い深い訳ですが、実はすべてのasyncが付くブロックと関数はPin<Box>に裏で変換されているのです。これまで出てこなかったPinという新しい型も登場します…
これは、非同期処理の性質と関係があります。以下の例で考えましょう。
async fn count_from_file_and_save(path: &str, c: char) -> usize {
let contents: String = async_read_to_string(path).await;
// ここで段階の区切りが入る
let count: usize = contents.chars().filter(|char| char == c).count();
save_count_to_server(count).await;
// ここで段階の区切り
count
}
これのFutureの実装が以下のようになり得ます:
enum CountFromFileAndSave {
Read,
CountAndSave(String),
Return(usize)
}
impl Future for CountFromFileAndSave {
type Output = usize;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
loop {
match self {
Read => {
// ここで実際にファイルを読む処理を始めて、それが終わるまでここを通る。
// 読む処理が終わっていなければ、Wakerを渡して、「起こされる」まで進まない
},
CountAndSave(contents) => {
// コンテンツを読んで、データベースか何かにそれを保存する
},
Return(count) => {
// 処理が終わったので、数を返す
}
}
}
}
}
実際にもっと深掘りしてFutureの実装を模範してみると、生成されるstructには、前の段階のステートを参照する部分があって、オブジェクトが自身の内部に対してポインターを持っているコードがあることに気づきます。
通常だと、Rustでstructの内部ポインターを作ることは不可能です。なぜなら、以下の例で考えればわかるように、その内部ポインターが不正にならないか保証できないからです。
struct InternalPointer<'a> {
v: Vec<usize>,
v_ref: &'a Vec<usize>,
}
let v = vec![0,1,2],
let mut int_pointer = InternalPointer {
v_ref: &v, // 不可能だけど例のためにOKにしよう
v,
}
int_pointer.v.push(3); // これでVecがメモリ上で再アロケートされた
int_pointer.v_ref // これはもう、int_pointer.vに繋がっておらず、どこか不正にメモリを参照している
しかし、詳細に入らずに説明すれば、Futureトレートを実際に実装するためには、上記のような内部参照が必要不可欠になりますので、困ります。
ここでPinが登場するのですが、Pinは「このBoxに入っているオブジェクトは、メモリ上で動かないと約束する」という保証をしてくれるものです。
Futureの実装をPin<Box<**>>に入れれば、内部参照があってもコンパイルできますし、安全に実行できます。なので、async fnとasyncブロックがコンパイラーによって秘密裏で実装に展開された時に、Pin<Box>になっているのです。
さらに、Sendも付いてくるのは、非同期のランタイムにおいて、Readの時にFuture::pollを呼んで処理をしたスレッドと、CountAndSaveの処理をするスレッドが必ずしも同じスレッドだとは限らないからです。Tokioなどは、パフォーマンス向上のために複数のスレッドで処理をしていますし、Futureの実装は必ずスレッドセーフじゃないと行けないようになっています。
最後に’staticのライフタイムも付きますが、これはFutureが永遠にメモリに居座るという意味ではなく、いつ終わるかわからないから、Futureが完了するまでメモリに残れることを保証する意味合いの’staticです。
完全に詳細まで踏み込んではいないのですが、結論として、動的ディスパッチができないと実行できないのです。
ステーブルRustでどのようにしてasync traitの代わりになるものを定義できるのか
ともかく、今のRustでは思うようにトレートに非同期メソッドがかけないことが訳あってできないことを理解しましたが、代わりに何か打つ手はないのか?
あります。
まず近道から紹介すれば、Rustコンパイラーが推奨しているようにこのクレートを使えばいいです。
このクレートのasync_trait!マクロを使えばできます。しかし、記事としては面白くないので、Pinを理解した今、実際にasync_trait`がやっていることを書いてみましょう。
Pin<Box>>の戻り値を返すトレードメソッド
async fnが書けなえいなら、async fnの戻り値を返せばいいのです!
pub trait Insertable: Into<mysql_async::Params> {
fn insert(self) -> Pin<Box<dyn Future<Output = Result<u64, mysql_async::Error>>>>;
fn batch<T>(values: T) -> Pin<Box<dyn Future<Output = Result<(), mysql_async::Error>>>>
where
T: IntoIterator<Item = Self> + Send + 'static,
T::IntoIter: Send,
Self: Sized;
}
実際に上記のようなトレートを実装するとなれば、以下のようになります。
impl Insertable for WpPost {
fn insert(
self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<u64, mysql_async::Error>>>> {
let fut = async {
let mut conn = get_conn().await?;
let stmt = Self::get_stmt(&mut conn).await?;
conn.exec_drop(stmt, self).await?;
let post_id: u64 = conn
.exec_first("SELECT LAST_INSERT_ID();", ())
.await?
.unwrap();
Ok(post_id)
};
Box::pin(fut)
}
...
}
さらにmacro_rules!を使ってasync_traitsのクレートがやるように自動的にasync fnをfn foo() { Box::pin(async {…}) }に書き換えるようにもできます。
まとめ
長い記事になってしまいましたが、非同期のトレートメソッドがRust標準ライブラリに入らない理由と、代わりにどうやって非同期メソッドをトレート内で書けるかについて提案しました。
本記事ではRustのコンパイラーが裏で展開してくれるFutureの実装については大幅に省略したのが多少悔しいのですが、興味をお持ちの方は、ぜひrustのasync state machineについて調べていただければと思います。英語でもOKな方は、ぜひJon GjengsetさんのRustの非同期処理についての講義を聞いたらいいと思います。Pin、State Machineなど、内部的な話をより細かく深掘りしてくれます。
それでは非同期処理を筆者と同様に楽しく書けていることをお祈りします!