Rust で async な関数を再帰的に呼び出したい……という気持ちになったことはありませんか?

そんなときに便利な async-recursion クレートを見つけたので簡単に紹介します。

dcchut/async-recursion - GitHub

経緯

ISUCON9 の移植をしようとした

先日、こんなツイートがタイムラインに流れてきました。

ちょうど「Rust でWebアプリを書く知見を貯めたいな〜」と考えているところだったので、すぐに応募しました。さすがに ISUCON に一度も参加したことがなく、さらに Rust で Web アプリを作った経験もほとんどなかったので、練習のために去年開催された ISUCON9 の予選問題の参考実装 (Go) を Rust に移植しようと思い立ち、毎日ちょこちょこと作業をしています。

magurotuna/isucon9-rust - GitHub

移植元に再帰関数があった

移植元の実装で、このようなコードがありました:

func getCategoryByID(q sqlx.Queryer, categoryID int) (category Category, err error) {
	err = sqlx.Get(q, &category, "SELECT * FROM `categories` WHERE `id` = ?", categoryID)
	if category.ParentID != 0 {
		parentCategory, err := getCategoryByID(q, category.ParentID)
		if err != nil {
			return category, err
		}
		category.ParentCategoryName = parentCategory.CategoryName
	}
	return category, err
}

getCategoryByID 関数の中で getCategoryByID を呼んでいます。再帰です。

Rust に素直に移植しようとしたら、コンパイルエラー

これを Rust にそのまま移植しようとすると、こんな感じの雰囲気になると思います(launchbadge/sqlx を使っています)。

async fn get_category_by_id(conn: &mut PoolConnection<MySql>, category_id: u32) -> Result<Category>
{
    let mut category: Category = sqlx::query_as(
        "SELECT * FROM `categories` WHERE `id` = ?",
    )
    .bind(category_id)
    .fetch_one(&mut *conn)
    .await?;
    if category.parent_id != 0 {
        match get_category_by_id(&mut *conn, category.parent_id).await {
            Err(_) => return Ok(category),
            Ok(parent) => category.parent_category_name = Some(parent.category_name),
        };
    }
    Ok(category)
}

(2箇所ある &mut *conn のところを conn と書くことはできません。*1)

これがコンパイルできれば何の問題もないのですが、コンパイラに怒られてしまいます。

error[E0733]: recursion in an `async fn` requires boxing
  --> src/handlers.rs:87:6
   |
87 | ) -> Result<Category> {
   |      ^^^^^^^^^^^^^^^^ recursive `async fn`
   |
   = note: a recursive `async fn` must be rewritten to return a boxed `dyn Future`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0733`.
error: could not compile `isucon9-rust`.

Rust で再帰 async 関数を実現する (自力編)

エラーメッセージに従って直してみる

dyn Future にしろ!と言っているので、これに従って直してみます。 それと、元のコードだとノイズが多いので、問題の本質に集中できるよう、簡略化したコードを取り扱っていきます。

async fn recursion(some_ref: &str) -> Result<String> {
    let result = recursion(some_ref).await?;
    Ok(result.to_string())
}

このコードも先ほどと同様のコンパイルエラーが出ます。どう考えても無限ループに陥りますが、そこはスルーしてください。

エラーメッセージに従って、async を取っ払ったり、返り値を Box<dyn Future<.>> で包んだり、といったことをやってみると、また別のエラーが出ますが、ごちゃごちゃとやってみると、とりあえずこんな感じになります。

fn recursion(some_ref: &str) -> Pin<Box<dyn Future<Output = Result<String>>>> {
    Box::pin(async move {
        let result = recursion(some_ref).await?;
        Ok(result.to_string())
    })
}

この時点でなかなか禍々しい見た目をしていますが、これでもまだコンパイルは通りません。

error[E0621]: explicit lifetime required in the type of `some_ref`
   --> src/handlers.rs:222:5
    |
221 |   fn recursion(some_ref: &str) -> Pin<Box<dyn Future<Output = Result<String>>>> {
    |                          ---- help: add explicit lifetime `'static` to the type of `some_ref`: `&'static str`
222 | /     Box::pin(async move {
223 | |         let result = recursion(some_ref).await?;
224 | |         Ok(result.to_string())
225 | |     })
    | |______^ lifetime `'static` required

ライフタイムを明示しろ(そして 'static が必須である)、と言っています。'static なライフタイムの参照で事足りるならこれで良いですが、元々のケースのようにDBへのコネクションを参照で持ち回っていると、'static なライフタイムというわけにはいきません。

答え合わせ

ではどうすればコンパイルが通るかというと、こうします。

fn recursion<'a, 'b>(some_ref: &'a str) -> Pin<Box<dyn Future<Output = Result<String>> + 'b + Send>>
where
    'a: 'b,
{
    Box::pin(async move {
        let result = recursion(some_ref).await?;
        Ok(result.to_string())
    })
}

'a: 'b は、'a'b より長生きであるということです。つまり、この関数のライフタイムの要請を日本語で説明すると、「再帰中に持ち回る参照のライフタイムは、Future よりも長生きでなければならない」という感じになると思います。Future が終わるのを待っている間に some_ref のライフタイムが切れてしまったら良くないので、冷静に考えてみればとても自然な要請です。

(ライフタイムに関してこのエントリがわかりやすく、おすすめです: Rustの2種類の 'static | 俺とお前とlaysakura

でも、このシグネチャはとても noisy ですし、型に振り回されている感がどうしてもしてしまいます(少なくとも自分は)。ライフタイムの関係性などを理解しておいて損はないと思いますが、できればもっとスマートに書きたいなと感じます。

Rust で再帰 async 関数を実現する (async-recursion 編)

散々引っ張ってしまいましたが、ここで async-recursion を登場させます。

dcchut/async-recursion - GitHub

async-recursion を使うと、再帰 async 関数がこのように書けます。

#[async_recursion]
async fn recursion(some_ref: &str) -> Result<String> {
    let result = recursion(some_ref).await?;
    Ok(result.to_string())
}

#[async_recursion] を関数の前につけただけで、ほかは通常の async 関数と変わりありません。素晴らしい!

元々の目的であった、ISUCON9 の Go のコードの移植も、以下のように #[async_recursion] をつけるだけで問題なくコンパイルが通るようになります。

#[async_recursion]
async fn get_category_by_id(conn: &mut PoolConnection<MySql>, category_id: u32) -> Result<Category>
{
    let mut category: Category = sqlx::query_as(
        "SELECT * FROM `categories` WHERE `id` = ?",
    )
    .bind(category_id)
    .fetch_one(&mut *conn)
    .await?;
    if category.parent_id != 0 {
        match get_category_by_id(&mut *conn, category.parent_id).await {
            Err(_) => return Ok(category),
            Ok(parent) => category.parent_category_name = Some(parent.category_name),
        };
    }
    Ok(category)
}

ちなみに、簡略版に関してマクロの展開後の様子を見てみると、このようになりました。自力で書いたのと似たようなものになっていますね!

自力で書く前に展開後のものを見てカンニングしたのはここだけの秘密です。

fn recursion_macro<'life0, 'async_recursion>(
    some_ref: &'life0 str,
) -> core::pin::Pin<
    Box<
        dyn core::future::Future<Output = Result<String>>
            + 'async_recursion
            + ::core::marker::Send,
    >,
>
where
    'life0: 'async_recursion,
{
    Box::pin(async move {
        let result = recursion_macro(some_ref).await?;
        Ok(result.to_string())
    })
}

というわけで、とても便利な async-recursion の紹介でした。

注釈