trait 中的异步函数 MVP 进入 nightly 版本

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

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

#![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 { ... }
}

这种“脱糖”后的签名是您可以自己编写的,它对于检查底层发生了什么很有用。这里的 impl Future 语法表示一些实现了 Future不透明类型

future 是一个状态机,负责知道下次唤醒时如何继续取得进展。当您在 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>;
}

请注意,此关联类型是泛型的。该语言尚未支持泛型关联类型...直到现在!不幸的是,即使有了 GAT,您仍然无法编写使用 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,我们需要在 impl 中指定一个具体的类型。现在有几种方法可以实现这一点。

运行时类型擦除

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

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

这要冗长得多,但它实现了将异步与 trait 结合起来的目标。更重要的是,async-trait proc 宏 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 依赖项的体验是一种阻碍,这使得 Rust 的异步使用起来很困难。

手动 poll 实现

需要在零开销或 no_std 上下文中工作的 trait 还有另一个选择:它们可以从Future trait中获取轮询的概念,并将其直接构建到它们的接口中。如果 future 完成,则 Future::poll 方法返回 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 这样的泛型函数中生成,并且(像大多数异步用户一样)您使用需要生成的任务是 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() 返回的 futureSend。但是正如我们在本文开头看到的那样,异步函数返回匿名类型。无法表达这些类型的约束。

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

假设:这种情况并不常见

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

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

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

一种假设是,虽然人们会遇到这个问题,但他们遇到它的频率相对较低,因为大多数时候 spawn 不会在泛型于具有异步函数的 trait 的代码中被调用。

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

当它发生时

最终您可能想从泛型于您调用的异步 trait 的上下文中生成。那该怎么办呢!?

现在可以使用另一个新的仅限 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 脱糖为返回 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您可以通过关注跟踪问题来关注进度。

首先要回答两个大问题

  • 我们是否需要先解决“从泛型生成”(Send 约束)问题?请在此问题上留下反馈。
  • 还存在哪些其他错误和质量问题? 请为此提交新问题。您可以在此处查看已知问题。

如果您是异步 Rust 爱好者并且愿意尝试实验性的新功能,我们非常欢迎您试用!

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

结论

这项工作得以实现,归功于许多人的努力,包括

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

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

要了解有关此功能及其背后的挑战的更多信息,请查看trait 中的静态 async fn RFC以及为什么 trait 中的 async fn 很难。此外,请继续关注后续文章,我们将探讨使表达 Send bounds 而无需特殊 trait 的语言扩展。

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

  1. 此功能称为 “type alias impl trait”

  2. 编译器生成的实际错误消息比这更嘈杂一些,但这将会得到改进。

  3. SendSync 等自动 trait 在这方面是特殊的。编译器知道 print_all 的返回类型只有在其参数的类型为 Send 时才为 Send,并且与常规 trait 不同,它允许在类型检查程序时使用此知识。

  4. 请参阅倡议网站上的 dyn trait 中的 Async fn,以及本系列中的第 8 篇和第 9 篇文章。

  5. 什么时候?可能在未来六个月左右的某个时间。但不要以此为准 :)