2023 年在 trait 中稳定化 async fn

2023 年 5 月 3 日 · Niko Matsakis 和 Tyler Mandry 代表 Rust 异步工作组

异步工作组 2023 年的首要目标是稳定化特性中异步函数(async functions in traits)的“最小可行产品”(MVP)版本。我们目前的目标是在 Rust 1.74 版本中完成稳定化。本文阐述了我们计划发布的特性以及各项特性的当前状态。

去年 11 月,我们发文介绍了 trait 中 async fn 的 nightly 支持,并确定了一些关键的后续步骤,其中最重要的是支持 Send 约束,以便在泛型函数中创建任务。自那时以来,我们进行了大量的设计探索,并收集了一系列案例研究,评估当前代码在实践中的表现如何。

截至目前,本文中描述的所有功能都已在 nightly 编译器中可用。其中一些工作是使用实验性功能门控(experimental feature gates)实现的,以便我们能够进行案例研究并验证其可行性;我们目前正在为这些功能撰写 RFC(详细信息见下文)。

MVP 第一部分:“特性中异步函数”的核心支持

解释我们即将稳定化的内容的最简单方法是使用代码示例。首先,我们将允许在 trait 定义中使用 async fn...

trait HealthCheck {
    async fn check(&mut self) -> bool;
}

...然后你可以在相应的 impl 中使用 async fn

impl HealthCheck for MyHealthChecker {
    async fn check(&mut self) -> bool {
        do_async_op().await
    }
}

带有异步函数的 trait 随后就可以像往常一样使用

async fn do_health_check(hc: impl HealthCheck) {
    if !hc.check().await {
        log_health_check_failure().await;
    }
}

状态: 此功能已在 RFC 3185 中描述,于 2021 年 12 月 7 日合并,并在 nightly 中可用。它在我们之前的博文中已详细介绍。

Playground

MVP 第二部分:Send 约束和关联返回类型

在使用 trait 中的异步函数时会出现一个同步函数不会遇到的复杂情况。许多异步运行时(特别是包括 Tokioasync-std 的默认配置)使用了工作窃取线程调度器。这意味着 Future 可能会在工作线程之间动态移动以实现负载均衡。因此,Future 只能捕获 Send 数据。

然而,如果你编写一个在这些运行时之一上创建任务的泛型异步函数,你就会开始遇到编译错误(Playground

async fn do_health_check_par(hc: impl HealthCheck) {
    tokio::task::spawn(async move {
        if !hc.check().await {
            log_health_check_failure().await;
        }
    });
}

问题在于 hc.check() 返回的 future 不能保证是 Send 的。它可能访问非 Send 的数据。解决方案是添加一个 Send 约束,但考虑到这是一个异步函数,如何做到这一点并不直观。我们如何讨论调用 hc.check() 返回的 future?关联返回类型(Associated return types)提供了答案。我们可以将上述函数转换为使用显式的类型参数 HC(而不是 impl HealthCheck),然后添加一个新的约束:HC::check(): Send。这意味着“HC::check 返回的值必须是 Send 类型”

async fn do_health_check_par<HC>(hc: HC)
where
    HC: HealthCheck + Send + 'static,
    HC::check(): Send, // <-- associated return type
{
    tokio::task::spawn(async move {
        if !hc.check().await {
            log_health_check_failure().await;
        }
    });
}

当然,为了使用这种写法,我们不得不将接受 impl HealthCheck 重写为显式的 HC 类型参数,这有点不幸。RFC 2289,“关联类型约束”(associated type bounds),引入了一种紧凑的写法来解决这个问题。该 RFC 不是本次 MVP 的一部分,但如果它稳定化了,那么就可以简单地写成

async fn do_health_check_par(hc: impl HealthCheck<check(): Send> + Send + 'static) {
    //                                            -------------
    tokio::task::spawn(async move {
        if !hc.check().await {
            log_health_check_failure().await;
        }
    });
}

在我们之前的博文中,我们推测这个问题在实践中可能不会经常出现。然而,我们的案例研究发现它出现的频率相当高,因此我们认为需要一个解决方案。我们探索了多种解决方案,并得出结论:关联返回类型(ARTs)是一个灵活且相当符合人体工程学的构建模块,这使得它们非常适合作为 MVP 的一部分。

状态: 关联返回类型(Associated return types)有一个实验性实现,我们目前正在起草 RFC。存在几个需要修复的未解决 bug。我们还发现,在包含许多方法的 trait 中,ART 会变得冗长,将来可能会考虑更简洁的语法(见下文)。

Playground

MVP 第三部分:“特性中的 impl trait”(返回位置)

在 Rust 中,异步函数是返回 impl Future 的函数的“语法糖”,trait 中的异步函数也不例外。作为 MVP 的一部分,我们计划稳定化在 trait 定义和 trait impl 中使用 -> impl Trait 的语法。

特性中的 Impl trait 有各种用途,但对于异步编程来说,一个常见的用途是通过执行一些同步工作来避免捕获函数的所有参数,然后返回一个 future 来处理其余部分。例如,这个 LaunchService trait 声明了一个不捕获 selflaunch 函数(类似于现有的 Tower Service trait)

trait LaunchService {
    fn launch(
        &mut self, 
        request: Request,
    ) -> impl Future<Output = u32>;
    //   -------------------------
    //   Does not capture `self` as it does
    //   not include a `+ '_`.
}

由于 async fn 是返回 impl Future 的常规函数的语法糖,这两种语法形式可以互换使用。

trait HealthCheck {
    async fn check(&mut self) -> bool;
}

impl HealthCheck for MyType {
    fn check(&mut self) -> impl Future<Output = bool> + '_ { ... }
}

尽管在异步编程中经常出现“特性中的 impl trait”的需求,但它们是一项通用功能,在许多与异步无关的场景中也很有用(例如,从 trait 方法返回迭代器)。

状态: 返回位置的特性中 impl trait 有一个实验性实现,并在目前开放的 RFC 3425 中有所描述。此功能本身是独立的,但它是特性中 async fn 整体图景的重要组成部分。

Playground

评估 MVP

为了评估此 MVP 的实用性,工作组收集了五个案例研究,涵盖了AWS SDK 中使用的 builder-provider 模式;以及 async function 在 tower 中的潜在使用、在 embassy 中的实际使用、Fuchsia 网络栈以及微软的一个内部工具。这些研究验证了上述功能足以在 trait 中使用 async function 来实现各种用途,尽管某些情况下需要变通方法(因此才有了“MVP”的名称)。

MVP 不支持或支持不好的方面

案例研究揭示了 MVP 支持得不太好的两种情况,但这两种情况都有可用的变通方法。这些变通方法是机械性的,一旦 MVP 在 stable 版本中可用,就可以通过自定义 derive 或 crates.io 上的其他 crate 来自动化实现它们。

模拟动态分发

在 MVP 中,使用异步函数的 trait 不是“dyn safe”的,这意味着它们不支持动态分发。因此,例如,对于我们之前看到的 HealthCheck trait,你不能写成 Box<dyn HealthCheck>

起初,这似乎是一个关键的限制,因为许多用例都需要动态分发!但事实证明,存在一种变通方法。你可以在 crate 内部定义一个“擦除”的 trait,从而实现动态分发。这个过程由 erased serde 等 crate 首创,并在builder-provider 案例研究中详细解释。

为了在短期内使这种变通方法更容易实现,我们计划提供一个过程宏来自动化它。将来,async fn 应该能够直接与 dyn Trait 一起工作。

Send 约束冗长,特别是对于包含许多方法的 trait

关联返回类型提案对于只有一个方法的 trait 非常有效,但对于包含许多方法的 trait 可能会很麻烦。一个便捷的解决方案是使用“trait 别名模式”:1

trait SendHealthCheck
where
    Self: HealthCheck + Send,
    Self::check(): Send,
{}

impl<T> SendHealthCheck for T
where
    T: HealthCheck + Send,
    T::check(): Send,
{}

使用这样的模式意味着你可以写成 T: SendHealthCheck。我们计划提供一个过程宏来为你生成这些 trait 别名,这样你就可以写出类似下面这样的代码

#[make_alias(Send = "SendHealthCheck")]
trait HealthCheck {
    async fn check(&mut self) -> bool;
}

将来,trait transformer 之类的东西可能会在没有过程宏的情况下提供更简洁的语法。但是,由于存在需要关联返回类型提供的细粒度控制的用例,我们选择先稳定化关联返回类型,并在积累经验后再考虑更简洁的语法。

时间表和路线图

我们的目标是在 Rust 1.74 版本中稳定化 MVP,该版本将于 2023 年 11 月 16 日发布。此特性的分支窗口(branch window)于 7 月 14 日开放,于 8 月 24 日关闭。为了真正实现在 1.74 版本中稳定化,我们希望在发布分支创建之前预留修复可能出现的 bug 的时间。此目标的关键里程碑如下:

  • MVP 实现
  • 案例研究评估完成
  • 接受返回位置 impl trait 的 RFC(目标:2023-05-31)
  • 接受关联返回类型的 RFC(目标:2023-06-15)
  • 评估期和 bug 修复(目标:2023-06-30)
  • 稳定化报告撰写完成(目标:2023-07-01)
  • 1.74.0 版本稳定化完成(目标:2023-07-21)

你可以在我们的 GitHub 项目中找到完整的时间表。

下一步是什么?

那么,一旦这个 MVP 完成,下一步是什么?我们接下来的即时目标是在 2024 年推出动态分发异步闭包支持。这些加在一起将为解决未来的异步问题(例如 async drop 支持、简单的异步迭代器或跨运行时可移植性)奠定坚实的基础。