trait 中 async fn 的 MVP 版本已登陆 nightly 版本

2022 年 11 月 17 日 · Tyler Mandry 代表 Rust 异步工作组

异步工作组很高兴宣布,async fn 现在已可在 nightly 编译器中的 trait 里使用。您现在可以编写如下代码:

#![feature(async_fn_in_trait)]

trait Database {
    async fn fetch_data(&self) -> String;
}

impl Database for MyDb {
    async fn fetch_data(&self) -> String { ... }
}

一个完整的可运行示例可在 playground 中找到。我们将介绍一些限制以及一些待解决的已知 bug,但我们认为它已经准备好供一些用户尝试了。继续阅读以了解具体细节。

回顾:async/await 在 Rust 中如何工作

async.await 极大地改进了在 Rust 中编写异步代码的人机工程学。在 Rust 中,async fn 返回一个 Future,它代表一个正在进行的异步计算对象。

future 的类型实际上不出现在 async fn 的签名中。当您编写如下异步函数时:

async fn fetch_data(db: &MyDb) -> String { ... }

编译器将其重写为如下形式:

fn fetch_data<'a>(db: &'a MyDb) -> impl Future<Output = String> + 'a {
    async move { ... }
}

这个“脱糖”(desugared)后的签名是可以自己编写的,并且对于检查底层发生的事情很有用。这里的 impl Future 语法表示某个实现了 Future 的*不透明类型*。

future 是一个状态机,负责知道下次被唤醒时如何继续推进。当您在 async 块中编写代码时,编译器会为您生成一个特定于该 async 块的 future 类型。这种 future 类型没有名称,因此我们必须在函数签名中使用不透明类型。

trait 中 async fn 的历史性问题

Trait 是 Rust 中抽象的基本机制。那么,如果您想在 trait 中放入一个异步方法会怎样?每个 async 块或函数都会创建一个独特的类型,因此您需要表达每个实现可以为返回类型拥有不同的 Future。幸运的是,我们为此提供了关联类型:

trait Database {
    type FetchData<'a>: Future<Output = String> + 'a where Self: 'a;
    fn fetch_data<'a>(&'a self) -> FetchData<'a>;
}

请注意,这种关联类型是泛型的。泛型关联类型(GATs)在此之前并未得到语言支持... 直到现在!不幸的是,即使有了 GATs,您仍然无法编写使用 async 的 trait *实现*:

impl Database for MyDb {
    type FetchData<'a> = /* what type goes here??? */;
    fn fetch_data<'a>(&'a self) -> FetchData<'a> { async move { ... } }
}

由于您无法命名由 async 块构建的类型,唯一的选择是使用不透明类型(我们之前看到的 impl Future)。但这在关联类型中不受支持!1

稳定版编译器中的可用变通方法

因此,要在 trait 中编写 async fn,我们需要在实现中指定一个具体类型。目前有两种方法可以实现这一点。

运行时类型擦除

首先,我们可以通过使用 dyn 擦除 future 类型来避免编写它。以上面的示例为例,您可以像这样编写您的 trait:

trait Database {
    fn fetch_data(&self)
    -> Pin<Box<dyn Future<Output = String> + Send + '_>>;
}

这显著地更加冗长,但它实现了将 async 与 trait 结合的目标。更重要的是,async-trait proc macro crate 会为您重写代码,让您可以简单地编写:

#[async_trait]
trait Database {
    async fn fetch_data(&self) -> String;
}

#[async_trait]
impl Database for MyDb {
    async fn fetch_data(&self) -> String { ... }
}

对于能够使用它的人来说,这是一个极好的解决方案!

不幸的是,并非所有人都能使用。您不能在 no_std 环境中使用 Box。动态分派和分配带来的开销对于对性能高度敏感的代码来说可能是巨大的。最后,它将许多假设硬编码到 trait 本身中:使用 Box 进行分配、动态分派以及 future 的 Send 属性。这使得它不适用于许多库。

此外,用户期望能够在 trait 中编写 async fn,而添加外部 crate 依赖的体验是一种微小的痛点(papercut),这使得异步 Rust 获得了难以使用的名声。

手动 poll 实现

需要在零开销或 no_std 环境中工作的 trait 有另一种选择:它们可以从 Future trait 中提取轮询(polling)的概念,并将其直接构建到它们的接口中。Future::poll 方法在 future 完成时返回 Poll::Ready(Output),在 future 等待某个其他事件时返回 Poll::Pending

您可以在例如不稳定版 AsyncIterator trait 的当前版本中看到这种模式。AsyncIterator::poll_next 的签名是 Future::pollIterator::next 的结合体。

pub trait AsyncIterator {
    type Item;

    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>
    ) -> Poll<Option<Self::Item>>;
}

在 async/await 出现之前,编写手动 poll 实现非常普遍。不幸的是,事实证明正确编写它们具有挑战性。在去年我们进行的愿景文档流程中,我们收到了许多关于这对于 Rust 用户来说极其困难并且是bug 的来源的报告。

事实上,编写手动 poll 实现的困难是首先将 async/await 添加到核心语言的主要原因。

nightly 版本中支持什么

我们一直在努力直接在 trait 中支持 async fn,并且一个实现最近登陆到了 nightly 版本!这个功能仍然有一些粗糙的地方,但让我们看看你可以用它做什么。

首先,正如您可能期望的那样,您可以像上面那样编写和实现 trait。

#![feature(async_fn_in_trait)]

trait Database {
    async fn fetch_data(&self) -> String;
}

impl Database for MyDb {
    async fn fetch_data(&self) -> String { ... }
}

这项工作将使我们能够标准化一些我们一直在等待此功能的新的 trait。例如,上面提到的 AsyncIterator trait 比其对应物 Iterator 复杂得多。有了新的支持,我们可以简单地这样写:

#![feature(async_fn_in_trait)]

trait AsyncIterator {
    type Item;
    async fn next(&mut self) -> Option<Self::Item>;
}

标准库中的 trait 很有可能最终会是这样!目前,您也可以使用 async_iterator crate 中的 trait,并像往常一样使用它编写泛型代码。

async fn print_all<I: AsyncIterator>(mut count: I)
where
    I::Item: Display,
{
    while let Some(x) = count.next().await {
        println!("{x}");
    }
}

限制:从泛型中生成任务

有一个注意事项!如果您尝试从像 print_all 这样的泛型函数中*生成任务*(spawn),并且(像大多数 async 用户一样)您使用了要求生成的任务必须是 Send 的工作窃取执行器,那么您将遇到一个不容易解决的错误。2

fn spawn_print_all<I: AsyncIterator + Send + 'static>(mut count: I)
where
    I::Item: Display,
{
    tokio::spawn(async move {
        //       ^^^^^^^^^^^^
        // ERROR: future cannot be sent between threads safely
        while let Some(x) = count.next().await {
            //              ^^^^^^^^^^^^
            // note: future is not `Send` as it awaits another future which is not `Send`
            println!("{x}");
        }
    });
}

您可以看到我们在函数签名中添加了 I: Send 约束,但这还不够。为了满足这个错误,我们需要声明*next() 返回的 future* 是 Send。但是正如我们在文章开头看到的那样,异步函数返回匿名类型。无法对这些类型表达约束。

对于这个问题有一些潜在的解决方案,我们将在后续文章中探讨。但就目前而言,您可以做几件事来摆脱这种情况。

假设:这种情况不常见

首先,您*可能*会惊讶地发现这种情况在实践中并不经常发生。例如,我们可以毫无问题地生成一个调用上面 print_all 函数的任务:

async fn do_something() {
    let iter = Countdown::new(10);
    executor::spawn(print_all(iter));
}

这可以在没有任何 Send 约束的情况下工作!这是因为 do_something 知道我们迭代器的具体类型 Countdown。编译器知道这种类型是 Send,因此 print_all(iter) 生成的 future 也是 Send 的。3

一个假设是,虽然人们会遇到这个问题,但他们遇到的频率相对较低,因为大多数情况下 spawn 不会在对带有异步函数的 trait 进行泛型处理的代码中调用。

我们希望开始收集人们对此的实际经验数据。如果您有相关的经验可以分享,请在此 issue 上评论

当它确实发生时

最终您可能*确实*会想从对您调用的异步 trait 进行泛型处理的环境中生成任务(spawn)。那该怎么办?!

目前可以使用另一个新的仅 nightly 版本的功能 return_position_impl_trait_in_trait,直接在您的 trait 中表达所需的约束:

#![feature(return_position_impl_trait_in_trait)]

trait SpawnAsyncIterator: Send + 'static {
    type Item;
    fn next(&mut self) -> impl Future<Output = Option<Self::Item>> + Send + '_;
}

在这里,我们将 async fn *脱糖*(desugared)为返回 impl Future + '_ 的常规函数,这与普通 async fn 的工作方式相同。它更冗长,但给了我们一个放置 + Send 约束的地方!更重要的是,您可以在此 trait 的 impl 中继续使用 async fn

这种方法的缺点是 trait 变得不够泛型,使其不太适合库代码。如果您想维护 trait 的两个不同版本,可以这样做,并且(也许)提供宏来使实现两者更容易。

此解决方案旨在作为临时措施。我们希望在语言本身中实现一个更好的解决方案,但由于这是一个仅限 nightly 版本的功能,我们更倾向于尽快让更多人尝试它。

限制:动态分派

还有一个最后的限制:您不能使用 dyn Trait 调用 async fn。支持此功能的现有设计4仍处于早期阶段。如果您需要从 trait 进行动态分派,目前最好使用 async_trait 宏。

稳定化路径

异步工作组希望将一些有用的东西交到 Rust 用户手中,即使它不能做他们可能想要的*所有*事情。这就是为什么尽管存在一些限制,目前 trait 中 async fn 的版本可能离稳定化不远了。5 您可以通过关注跟踪 issue 来跟踪进展。

首先需要回答两个大问题:

如果您是 Rust 异步编程的爱好者,并且愿意尝试实验性的新功能,我们将非常感激您的尝试!

如果您使用 #[async_trait],可以尝试从一些不使用动态分派的 trait(及其 impl)中移除它。或者如果您正在编写新的 async 代码,请尝试在那里使用它。无论哪种方式,您都可以在上面的链接中告诉我们您的经验。

结论

这项工作得益于许多人的努力,其中包括:

  • Michael Goulet
  • Santiago Pastorino
  • Oli Scherer
  • Eric Holk
  • Dan Johnson
  • Bryan Garza
  • Niko Matsakis
  • Tyler Mandry

此外,这项工作建立在多年编译器工作的基础之上,这些工作使我们能够发布 GATs 以及其他基础类型系统改进。我们非常感谢所有做出贡献的人;没有你们,这项工作是不可能的。谢谢你们!

要了解有关此功能及其背后挑战的更多信息,请查看 Static async fn in traits RFC为什么 trait 中的 async fn 很难。另外,请继续关注后续文章,我们将在其中探讨语言扩展,这些扩展使得无需特殊 trait 即可表达 Send 约束。

感谢 Yoshua Wuyts, Nick Cameron, Dan Johnson, Santiago Pastorino, Eric Holk 和 Niko Matsakis 对本文草稿的审阅。