Dyn async traits

原文 | 日期:2022-09-18 13:49 -0400

在过去的几个月里,Tyler Mandry 和我一直在打磨《来自 Future 的用户指南》,其中描述了我们目前为 traits 中的异步函数提出的设计方案。

在本文中,我想深入探讨该提议的一个方面:如何处理动态分发。

我在这里的目标是探索一下这个空间,并解决一个特别棘手的话题:我们必须对分配的可能性有多明确?

这是一个棘手的话题,也是一个切中核心问题的话题:Rust 的灵魂是什么?

示例:AsyncIterator trait

在这篇博客文章中,我将专门关注这个示例 AsyncIterator trait:

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

尤其关注通过动态分发调用 next 的场景:

fn make_dyn<AI: AsyncIterator>(ai: AI) {
    use_dyn(&mut ai); // <— coercion from `&mut AI` to `&mut dyn AsyncIterator`
}

fn use_dyn(di: &mut dyn AsyncIterator) {
    di.next().await; // <— this call right here!
}

尽管我的博客文章将重点放在这段特定的代码上,但我所讨论的所有内容都适用于具有返回 impl Trait 的方法的任何 trais(异步函数本身是返回 impl Future 的函数的简写)。

我们必须面对的基本挑战是:

  • 调用方函数 use_dyn 不知道 dyn 后面的 impl 是什么,所以它需要分配一个固定大小的空间,以便适用于所有人。它还需要某种 vtable,这样它才知道要调用什么 poll 方法。
  • 被调用方 AI::next 需要能够以某种方式为其 next 函数打包 Future,以满足调用方。

本系列的第一篇博客文章1 更详细地解释了这个问题。

1

写于 2020 年 9 月,天哪!

简要介绍一些选择

这里面临的一个挑战是,有很多很多方法可以做到这一点,但没有一种方法“显然是最好的”。

我认为,以下是一个人可能处理这种情况的各种方法的详尽清单。如果有人有不符合这个清单的想法,我很乐意听听。

  1. 放入 Box。最明显的策略是让被调用方把 Future 类型放入 Box,即返回一个 Box<dyn Future>,并让调用方通过虚拟分发调用 poll 方法。这就是 async-trait 库所做的(尽管它也对静态分发进行 Box,但我们不必那样做)。

  2. 用一些定制的分配器把 Future 放入 Box。

  3. 放入 Box,并将 Box 缓存在调用方中。对于大多数应用程序来说,Box 本身并不是性能问题,除非它在一个紧密的循环中重复出现。Mathias Einwag 认为,如果你有一些代码在同一个对象上重复调用 next,你可以让调用方在调用之中缓存 Box,并让被调用方重复使用它。这样,你只需实际分配一次。

  4. 将其内联到迭代器中。将函数需要的所有状态存储在 AsyncIter 类型本身。这实际上就是现有的 Stream trait 所做的,如果你仔细想想的话:它不是返回未来,而是提供了一个 poll_next 方法,所以 Stream 的实现者实际上就是 Future,调用方不必存储任何状态。Tyler 和我设计了一种更通用的内联方法,这种方法不需要使用者参与,基本上是将 AsyncIterator 类型包装在另一种类型 W 中,该类型的字段足够大来存储 next Future。当你调用 next 时,这个包装器 W 将 Future 存储到该字段中,然后返回指向该字段的指针,因此调用方只需轮询该指针。 将对象内联到迭代器的一个问题是,它只适用于 &mut self 方法,因为在这种情况下,一次最多只能有一个活跃的 Future。使用 &self 方法,你可以拥有任意数量的活跃的 Futures。

  5. 放入 Box,并将 Box 缓存在被调用方中。不是将整个 Future 内联到 AsyncIterator 类型中,而是只内联一个指针字槽,这样就可以缓存和重用 next 返回的 Box。此策略的优点是缓存的 Box 随迭代器移动,并且有可能在调用方之间重复使用。缺点是,一旦调用方完成调用,缓存的 Box 就会一直存在,直到对象本身被销毁。

  6. 让调用方分配最大空间。调用方在栈上分配一大块空间,该空间应该足够大,供每个被调用方使用。如果你知道代码必须处理的被调用方,并且这些被调用方的 Future 在大小上足够接近,则此策略效果很好。Eric Holk 最近发布了 stackfuture 库,它可以帮助实现自动化。这种策略的一个问题是,调用方必须知道所有被调用方的大小。

  7. 让调用方分配一些空间,并为较大的被调用方回退到 Box。如果你不知道所有被调用方的大小,或者这些大小差异很大,另一种策略可能是让调用方分配一定数量的栈空间(例如 128 字节),然后在空间不足的情况下让被调用方调用 Box

  8. 调用方 alloca (在栈上分配)。你可能以为你能在 vtable 中存储要返回的 Future 的大小,然后让调用方 alloca,即动态增长栈指针。有趣的是,这不适用于 Rust 的异步模型。异步任务要求预先知道栈帧的大小。

  9. 侧栈 (side stack)。与上一条的建议类似,想象一下,让异步运行时为每个任务提供某种“动态侧栈”2。然后,我们可以在此栈上分配适当数量的空间。 这可能是最有效的选择,但它假定运行时能够提供动态栈。像 embassy 这样的运行时无法做到这一点。此外,我们目前还没有针对这类事情的任何计划。引入侧栈也开始“蚕食” Rust 异步模型的一些吸引力,该模型旨在预先分配“完美大小的栈”,并避免“每个任务分配一个大堆栈”3

3

当然,如果没有侧栈,我们只能使用 Box::new 这样的机制来处理动态分发或递归函数等情况。可悲的是,这变成了一种固定大小的分段栈 (sized segmented stack),我们为所需的每一小块额外状态进行分配。侧栈可能是一个很吸引人的中间地带,但由于像 embassy 这样的情况,它不可能是唯一的选择。

2

我很好奇地了解到这就是 Ada 所做的,并且 Ada 中像返回动态大小类型之类的功能都构建在这个模型上。我不确定 Spark 和其他针对嵌入式领域的 Ada 子集是如何做到这一点的,我想了解更多。

dyn 一起使用的异步函数可以是“正常的”吗?

对于 traits 中的异步函数,我最初的目标之一是让它们感觉“尽可能自然”。特别是,我希望你能够像使用同步函数一样将它们与动态分发一起使用。

换句话说,我希望编译这段代码,并且即使将 use_dyn 放入另一个库中(因此编译时不知道是谁在调用它),我也希望它能够工作:

fn make_dyn<AI: AsyncIterator>(ai: AI) {
    use_dyn(&mut ai);
}

fn use_dyn(di: &mut dyn AsyncIterator) {
    di.next().await;
}

我希望通过选择某种在大多数情况下都有效的默认策略来使该代码正常工作,然后为那些默认策略不太适合的代码选择其他策略。

但问题是,似乎没有哪一种默认策略在几乎所有时候都是“显而易见和正确的”…

策略缺点
使用默认分配器,把 Future 放入 Box需要分配,不是特别高效
把 Future 放入 Box,并在调用方使用缓存 Box需要分配
将 Future 内联到迭代器中增加 AI 的大小,且不适用于 &self
把 Future 放入 Box,并在被调用方缓存 Box需要分配,增加 AI 的大小,且不适用于 &self
分配最大的空间不一定能跨库使用,需要对程序与程序间的代码进行广泛分析
分配一些空间,不够时回退到 Box使用分配器,需要对程序与程序间的代码进行广泛分析或随机猜测
调用方对栈进行分配与 async Rust 不兼容
侧栈需要来自运行时和分配的协作

Rust 之魂

对于上面的表格,看起来最接近“明显正确”的策略是“把它放在 Box 里”。

它在单独编译的情况下工作得很好,非常适合 Rust 的异步模型,而且它符合今天人们在实践中所做的事情。

我与相当多在生产中使用 Async Rust 的人交谈过,几乎所有人都同意“默认情况下放入 Box,但让我控制它”在实践中会很好地工作。

然而,当我们提出将此作为默认设置的想法时,Josh Triplett 强烈反对,我也认为这是有充分理由的。

Josh 主要担心的是,这对 Rust 来说越界了。到目前为止,如果没有某种显式操作(尽管该操作可能是函数调用),就无法分配堆内存。

但是,如果我们希望将“放入 Box”设置为默认策略,那么你会编写“看起来很无辜”的 Rust 代码,但它仍然会调用 Box::New。具体地说,它将在每次调用 next 时调用 Box::new。但从 make_dynuse_dyn 来看,这一点非常不清楚。

例如,你可能正在编写一些敏感的系统代码,在这些代码中,分配始终是你非常小心地处理的事情。这并不意味着代码是 no-std,因为它可能可以访问分配器,但是你仍然想知道你将在哪里进行分配。如今,你可以人工审核代码,扫描 Box::newvec![] 等“明显”的分配点。在这一提议下,虽然仍有可能,但代码中存在分配的情况就不那么明显了。作为 vtable 构造过程的一部分,该分配被“注入”。要确定这是否会发生,你必须非常了解 Rust 的规则,并且还必须知道被调用方的签名(因为在本例中,vtable 是作为隐式强制转换的一部分而构建的)。简而言之,扫描分配从相对明显地变成了需要 Rustology 的博士学位。嗯...

另一方面,如果扫描分配是重要的,我们可以通过许多方式解决这个问题。添加一个“默认允许”的 lint 来标记构造“默认 vtable”的点,然后在项目中启用它。这样,编译器就会警告你可能分配 Future 。

事实上,即使在今天,扫描分配实际上要难得多:你可以很容易地看到你的函数是否进行了分配,但你不能轻松地看到它的被调用方做了什么。

你必须深入阅读所有依赖项,如果有函数指针或 dyn Trait,则找出可能被调用的代码。有了编译器/语言支持,我们可以使整个过程变得更一流和更好。

然而,在某种程度上,技术上的争论并不是重点。“Rust 使分配显式”被广泛视为 Rust 设计上的一个关键属性。

在做出这一改变时,我们将会把这一规则调整为“Rust 在大部分时间内明确分配”之类的。这会让用户更难理解,也会引起人们的怀疑,比如 Rust 是否真的打算成为一种可以取代 C 和 C++ 的语言4

4

具有讽刺意味的是,C++ 本身插入了隐式堆分配来帮助执行协程!

以设计原则为指导

不久前,Josh 和我为 Rust 起草了一套设计原则草案。回顾一下,看看它们对这个问题有什么看法,这是很有趣的:

  • 可依赖的:如果代码编译,它就能工作
  • 高效率的:地道的 Rust 代码高效运行
  • 提供帮助的:语言、工具和社区都在帮助你
  • 高生产力的:一点努力就能做很多工作
  • 透明的:你可以预测和控制低级别的细节
  • 通用的:你可以用 Rust 做任何事情

在我看来,默认把 Future 放入 Box 情况下,得分如下:

  • 高效率的:不一定。最干净的代码也运行得最快,这是真正的高效率。在每个动态调用上使用 Box 并不能达到这个目标,但是像“通过调用方缓存来使用 Box”或“让调用方分配空间并回退到 Box”这样的事情很有可能达到这个目标。
  • 高生产力的:是的!实际上,与我交谈过的每一位异步 Rust 的生产级用户都同意,默认使用 Box(但允许选择做其他事情来处理循环)将是 Rust 的最佳选择。
  • 透明的:不。正如我之前所写的那样,现在理解一次调用可能需要 Rustology 博士学位,所以这在透明度方面肯定是失败的。
  • 我认为,其他原则没有受到任何显著的影响。

《来自 Future 的用户指南》

这些考虑导致 Tyler 和我进行了不同的设计。在前面提到的 《User's Guide from the Future》 文档中,你将看到它并不完全接受正在运行的示例。相反,如果你编译我们到目前为止一直在使用的示例代码,你会得到一个错误:

error[E0277]: the type `AI` cannot be converted to a
              `dyn AsyncIterator` without an adapter
 --> src/lib.rs:3:23
  |
3 |     use_dyn(&mut ai);
  |                  ^^ adapter required to convert to `dyn AsyncIterator`
  |
  = help: consider introducing the `Boxing` adapter,
    which will box the futures returned by each async fn
3 |     use_dyn(&mut Boxing::new(ai));
                     ++++++++++++  +

如错误所示,为了获得 Boxing 那种方式,你必须选择 Boxing 类型5

5

非常欢迎更好的名字的建议。

fn make_dyn<AI: AsyncIterator>(ai: AI) {
    use_dyn(&mut Boxing::new(ai));
    //          ^^^^^^^^^^^
}

fn use_dyn(di: &mut dyn AsyncIterator) {
    di.next().await;
}

在这种设计下,只有当调用方可以验证 next 方法返回一个可以构造 dyn* 的类型时,才能创建 &mut dyn AsyncIterator

如果不是这样,通常也不是这样,你可以使用 Boxing::new 转换器来创建 Boxing<AI>。通过某种我们还没有完全弄明白的编译器魔术6,你可以强制让 Boxing<AI> 变成 dyn AsyncIterator

6

不要理会幕后的编译器作者。把你的眼睛移开!

Boxing 类型的细节需要做更多的工作7,但基本思想保持不变:要求用户显式地选择默认的 vtable 策略,这可能确实会执行分配。

7

例如,如果你仔细查看 User's Guide from the Future,你会发现它写的是 Boxing::new(&mut ai),而不是 &mut Boxing::new(ai)。在这个问题上,我反复思考。

Boxing 在设计原则上得分如何?

在我看来,添加 Boxing 转换器的得分如下:

  • 高性能的:不一定。这与之前大致相同。我们会回到这个话题的
  • 提供帮助的:是的!该错误消息将指导你准确地执行所需操作,并链接到一个写得很好的解释,帮助你了解为什么需要这样做
  • 高生产力的:不一定。每次创建 dyn AsyncIterator 时都要多一次调用 Boxing::New,这并不是很好,但也与其他方案相比并不差。
  • 透明的:是的!现在很容易看出 Future 中可能使用了 Box 。

这种设计现在是透明的,生产力低于以前,但我们试图提供更多帮助来弥补这一点。“Rust 并不总是那么容易,但它总是提供帮助的。”

通过更复杂的 ABI 提高性能

“默认 Box”的策略让我感到困扰的是,它不是高性能的。我喜欢 Iterator 这样的东西,因为你能写出很好的代码,得到紧凑的循环。

而编写“好的”异步代码会产生一个天真的、中等效率的东西,这让我感到不安。

也就是说,我认为 Future 还有改进的余地,并且可以向后兼容地修复它。

我们的想法是在进行虚拟调用时扩展现有的 ABI,以便调用方可以选择为被调用方提供一些“暂存空间” (scratch space)。

然后我们可以分析二进制文件,以很好地猜测需要多少栈空间(通过执行数据流或仅通过查看 AsyncIterator 的所有实现)。然后,让调用方为 Future 保留栈空间,并将一个指针传递给被调用方,如果没有足够的栈空间,被调用方仍可以选择分配,但通常它可以充分利用这些栈空间(而无需分配)。

有趣的是,我觉得,这样做会再次对 Rust 的“透明”方面造成一些压力。虽然 Rust 在很大程度上依赖优化来获得性能,但它通常将自己限制在简单的本地优化,如内联;我们不需要特别的程序间数据流 (interprocedural dataflow),尽管它当然有帮助( LLVM 就是这样做的)。

但是,很好地估计给潜在的被调用方保留多少栈空间将违反该规则(我们还需要一些简单的逃逸分析 (escape analysis),见 附录)。这些会造成些许“性能的不可预测性”。尽管如此,我不认为这是一个大问题,特别是因为回退情况下只使用 Box::new,这已经说过了,对于大多数用户来说,这是完全足够的。

另一种策略:例如内联

当然,也许你不想用 Boxing,那么可以构建其他类型的转换器,它们将以类似的方式工作。例如,内联转换器可能如下所示:

fn make_dyn<AI: AsyncIterator>(ai: AI) {
    use_dyn(&mut InlineAsyncIterator::new(ai));
    //           ^^^^^^^^^^^^^^^^^^^^^^^^
}

InlineAsyncIterator<AI> 类型将添加额外的空间来存储 Future,因此当调用 next 方法时,它将 Future 写入自己的字段中,然后将其返回给调用方。

同样,缓存的 Box 转换器也可能是 &mut CachedAsyncIterator::new(Ai),只是它会使用一个字段来缓存最终的 Box。

你可能已经注意到,内联/缓存转换器包括 trait 的名称。这是因为它们不依赖于像 Boxing 那样的编译器魔法,而是打算供最终的使用者使用,而我们还没有办法在任何 trait 定义上实现泛型。(我们编写的提案提议使用宏为你希望转换的任何 trait 生成转换器类型。)

这是我对 Future 很乐意解决的问题。你可以在 此处 阅读有关转换器 (adapter) 如何工作的更多信息。

结论

让我们把所有这些放到一个统一的设计方案中:

  • 不能将任意类型的 AI 强制为 dyn AsyncIterator。相反,你必须选择一个转换器:
    • 通常你想要 Boxing,它有不错的性能配置,并且“能工作良好”。
    • 但使用者也可以编写自己的转换器来实现其他策略,如 InlineAsyncIteratorCachingAsyncIterator
  • 从实现的角度来看:
    • 动态分发时,异步函数返回 dyn*Future,调用方可以通过虚拟分发调用 poll,并在准备好处理 Future 时调用(虚拟的) drop 函数
    • Boxing<AI> 创建的 vtable 将分配一个 Box 来存储 Future 的 AI::next(),并用它来创建 dyn*Future
    • 其他转换器的 vtable 可以使用他们想要的任何策略。例如,InlineAsyncIterator<AI> 将 Future 的 AI::next() 存储到包装器的一个字段中,获取指向该字段的原始指针,并从该原始指针创建 dyn*Future
  • 为了更好的性能,可能扩展 Future8
    • 修改异步 trait 函数(或返回位置的 impl trait 的任何 trait 函数)的 ABI,以允许调用方可以选择提供栈空间。如果栈空间可用,Boxing 转换器将在可能的情况下使用它来避免 Box。这必须与一些编译器分析相结合,以确定要预先分配的栈空间有多少。
8

我澄清一点,尽管 Tyler 和我已经讨论过这一点,但我不知道他对此有何感想。确切地说,我不会将其称为“提议的一部分”,而更像是我所感兴趣的延伸。

这让我们几乎可以表达任何模式。如果运行时提供合适的转换器,甚至可以表示侧栈(例如 TokioSideStackAdapter::new(ai)),尽管如果堆栈变得流行,我宁愿考虑一种更标准的方法来公开它们。

这项提议的主要缺点是:

  • 使用者必须写 Boxing::new,这会影响生产效率和易学性,但它避免了对透明度的重大影响。这是正确的决定吗?
    我仍然不完全确定,尽管我越来越倾向于同意了。这也是我们将来可以重新考虑的事情(例如添加默认转换器)
  • 如果我们选择修改 ABI,那么会增加一些复杂性,但换来潜在的相当大的性能优势。我希望一开始不要这样做,但一旦我们有了更多关于它有多重要的数据,就会将来将其作为扩展进行探索。

有一种模式我们无法表达:“让调用方分配最大的空间”。该模式保证不需要堆分配;我们所能做的最多是尝试避免堆分配的启发式方法,因为必须考虑库边界上的公共函数等等。
为了提供这种保证,参数类型需要从 &mut dyn AsyncIterator (它接受任何异步迭代器)更改为更窄的类型。这也将支持对离开栈帧的 Future(见 附录)。

这些细节似乎并不重要,内联 Future 或启发式方法就足够了,但如果不是这样,像 [stackfuture] 这样的库仍然是一个选择。

有什么评论吗?

请在此内部论坛帖子中留下 评论。谢谢!

附录:离开栈帧的 Futures

在所有这些讨论中,我一直假设异步调用之后紧跟着 await。但是,如果 Future 没有被等待,而是被移动到堆或其他位置,会发生什么呢?

fn foo(x: &mut dyn AsyncIterator<Item = u32>) -> impl Future<Output = Option<u32>> + '_ {
    x.next()
}

对于 Boxing,这种代码根本不会带来任何问题。但如果我们在栈上分配空间来存储 Future,这样的代码将是一个问题。

只要可以选择暂存空间,并回退到 Box,这就不成问题。对于这样的例子,我们可以进行逃逸分析,避免使用暂存空间。