Dyn async traits
原文 | 日期:2022-01-07 19:37 -0500
大家好!欢迎来到 2022 年!去年年底,Tyler Mandry 和我围绕支持 dyn async trait 做了大量迭代工作
—— 也就是让使用 async fn
的 trait 变成 dyn 安全 —— 我们开始对我们的设计感觉很好。
本文是讨论我们现有成果的几篇博客文章的第一篇。
在这篇文章中,我将重申我们的目标,并给出设计的高级大纲。接下来的几篇帖子将更深入地探讨细节和下一步。
目标:让 traits 具有“正常”工作方式的 async fn
距离上一篇关于 dyn trait 的帖子已经有一段时间了,所以让我们从回顾总体目标开始:我们的愿景是允许 async fn
像 fn
一样在 traits 中使用。
例如,我们希望拥有一个异步版本的 Iterator
trait1,大致如下所示:
习惯上被称作 Stream
。
trait AsyncIterator {
type Item;
async fn next(&mut self) -> Self::Item;
}
你应该能够像使用任何其他特征一样使用这个 AsyncIterator
trait。当然,静态分发和 impl Trait
应该都可以工作:
async fn sum_static(mut v: impl AsyncIterator<Item = u32>) -> u32 {
let mut result = 0;
while let Some(i) = v.next().await {
result += i;
}
result
}
但动态分发也应该起作用:
async fn sum_dyn(v: &mut dyn AsyncIterator<Item = u32>) -> u32 {
// ^^^
let mut result = 0;
while let Some(i) = v.next().await {
result += i;
}
result
}
另一个目标:让 dyn 比现有的更干净
虽然我们一开始的目标是改进 async fn
,但让 dyn Trait
在整体上更易用也是我们的兴趣。
原因有几个。首先,async fn
本身就是一个返回 impl Trait
的函数的语法糖,所以让 trait 中的 async fn
工作相当于让 [RPITIT]2 工作。
即 return position impl trait in traits —— traits 中函数的返回位置上支持 impl Trait。
但是,现有的 dyn Trait
设计包含了一些令人沮丧的限制,所以我们希望一个能尽可能多的改进这些限制的设计。
目前,我们的方案取消了以下限制,使使用这些功能的 trats 仍然与 dyn
兼容:
- 只要
Trait
是 dyn safe,返回位置就支持impl Trait
- 例如
fn get_widget(&self) -> impl Iterator<Item = Widget>
- 如上所述,这意味着
async fn
能工作,因为它解糖成impl Future<Outut = ...>
- 例如
- 只要
Trait
是 dyn safe,参数位置就支持impl Trait
- 例如
fn process_widgets(&mut self,items: impl Iterator<Item = Widget>)
- 例如
- 按值传入 self 的方法
- 例如
fn process(self)
和d: Box<dyn Trait>
都能够调用d.process()
- 最终这将扩展到其他像 Box 一样的智能指针
- 例如
如果你把这三个放在一起,它代表 dyn safe 在 Rust 中的一个相当大的扩展。
下面的例子是 dyn safe,它以一种自然的方式将所有这些东西结合在一起:
trait Widget {
async fn augment(&mut self, component: impl Into<WidgetComponent>);
fn components(&self) -> impl Iterator<Item = WidgetComponent>;
async fn transmit(self, factory: impl Factory);
}
最终目标:在没有分配器的情况下也能工作
支持 RPITIT 最直接的方式是分配一个 Box
来存储返回值。在大多数情况下,这是很好的。但在一些用例中,它不是一个好的选择:
- 在内核中,你希望使用自定义分配器
- 在紧循环 (tight loop) 中,分配的性能成本太高
- 在极端的嵌入式情况下,你根本没有分配器
因此,我们希望确保可以使用支持异步函数或 RPITIT 的特征,而不需要分配器,尽管这样做需要更多的工作。以下是想要支持这种情况的一些替代策略:
- 预先分配栈空间:当你创建
dyn Trait
时,你在栈上预留了一些空间来存储它可能返回的任何 Future 或impl Trait
- 缓存:重用相同的
Box
以减少对性能的影响(一个好的分配器可以为你做这件事,但并不是所有的系统都带有高效的分配器) - 封装的 trait:你只为你需要的类型派生一个包装器枚举体
不过,最终我们可以管理动态分发的方法的数量没有限制,因此我们目标不是有一套“内置”的策略,而是允许人们使用过程宏开发自己的策略集。
然后,我们可以在工具库甚至标准库中提供最常见的策略,同时如果他们有非常特殊的需求,还允许人们开发自己的策略。
设计的流程图
我画了一个流程图来说明我们的设计是如何在高层次上工作的(需使用明亮主题才能看清图下方的文字):
- 调用方 (caller) 有权访问某种
dyn Trait
,如w: &mut Widget
,并希望调用一个方法,如w.argument()
- 调用方在 vtable 中查找函数并调用它
- 但 argument 接收一个
impl Into<WidgetComponent>
,这意味着它是一个泛型函数。通常,每个Into
类型都有一个单独的函数副本!但我们必须让 vtable 只有一个副本!该怎么做呢? - 答案是 vtable 给一个副本进行编码,该副本期望“某种指针指向
dyn Into<WidgetComponent>
”。这可以是一个Box
,但也可以是其他类型的指针:稍后我会详细介绍。 - 因此,调用者的任务是创建一个“指向
dyn Into<WidgetComponent>
的指针”。它之所以能做到这一点,是因为它知道所提供的值的类型;在本例中,它将通过在栈上分配一些内存空间来做到这一点。
- 但 argument 接收一个
- 同时,vtable 包含指向要调用的正确函数的指针。但它不是从 impl 直接指向函数的指针:它是包装该函数的轻量级填充程序。该填充程序负责将 vtable 的 ABI 转换为用于静态分发的标准 ABI。
- 同时,当函数返回时,它还带回了某种 Future。被调用方 (callee) 知道该类型,但调用方不知道。因此,被调用方的任务是将其转换为“某种指向
dyn Future
的指针”,并将该指针返回给调用方。- 默认情况下,将其放入 Box,但被调用方可以自定义该类型以使用其他策略。
- 调用方取回其“指向
dyn Future
的指针”,并且等待该类型,即使它不确切地知道等待什么样的 Future。
即将发布的帖子
在接下来的博客文章中,我将详述我在上述提到的几件事:
- 指向
dyn Trait
的指针:- 我们到底如何编码“某种类型的指针”?这是什么意思?
- 这真的很关键,因为我们需要能够支持它
impl Trait
参数的转化器 (adaptation):- 对于泛型类型参数,如何转化成 vtable,或者从 vtable 中转化回来?
- 提示:它涉及为参数创建一个
dyn Trait
impl Trait
返回值参数的转化器:- 对于泛型类型参数,如何转化成 vtable,或者从 vtable 中转化回来?
- 提示:它涉及返回一个
dyn Trait
,可能但不一定以Box
方式
- 按值 self 的转化器:
- 对于按值的 self,如何转化成 vtable,或者从 vtable 中转化回来?什么时候可调用这些函数?
- Boxing 及其替代方法:
- 当你通过动态分发调用异步函数或者返回
impl Trait
的函数时,默认将分配一个Box
,但我们已经看到这并不适用于所有人。 - 如何方便地选择另一种策略,如栈预分配,以及人们如何创建自己的策略?
- 当你通过动态分发调用异步函数或者返回
我们还将更新异步基础计划 (async fundamentals initiative) 页面,以提供更详细的设计文档。
附录:我仍希望看到的东西
我对我们在这一轮工作中的进展感到非常兴奋,但它并没有达到我最终想要的水平。
我的最终目标是,人们能够像使用 impl Trait
那样方便地使用动态分发,但我不完全确定如何实现这一目标。
这意味着能够编写不会涉及 Box
与 &
或其他细节的函数签名,当涉及 impl Trait
时,你不必处理这些细节。这也意味着不必太担心 Send/Sync
和生命周期。
如果我们能弄清楚如何做到的话,以下是我希望看到的一些改进:
- 支持
Clone
:- 给定
Widget: Clone
和w: Box<dyn Widget>
,能够调用w.clone()
- 这几乎可以工作,但
trait Clone: Sized
这一事实让它变得困难
- 给定
- 支持“部分 dyn safe” traits:
- 现在,dyn safe 要么全是,要么不是。这有一个很好的含义,
dyn Foo: Foo
适用于所有类型。然而,它也有局限性,许多人告诉我,他们觉得这很令人困惑。 - 此外,
dyn Foo
不是Sized
的,因此,虽然在概念上dyn Foo
实现了Foo
很酷,但实际上不能像使用大多数其他类型一样使用dyn Foo
- 现在,dyn safe 要么全是,要么不是。这有一个很好的含义,
- 改进
Send
与返回值的交互方式(例如,RPIT、traits 中的异步函数等):- 如果你写
dyn Foo + Send
的话
- 如果你写
- 避免过多地谈论指针
- 当你使用
impl Trait
时,如今你获得了真正符合人体工程学的体验:fn apply_map(map_fn: impl FnMut(u32) -> u32)
fn items(&self) -> impl Iterator<Item = Item> + '_
- 相比之下,当你使用
dyn Trait
时,你最终不得不在很多细节上非常明确,你的调用者也必须改变:fn apply_map(map_fn: &mut dyn FnMut(u32) -> u32)
fn items(&self) -> Box<dyn Iterator<Item = Item> + '_>
- 当你使用
- 使
dyn Trait
感觉更参数化:struct Foo<T: Trait> { t: Box<T> }
有一个很好的性质,它公开了T
。这意味着我们知道如果T: Send
,那么Foo<T>: Send
(假设Foo
没有任何不是Send
的字段);如果T: 'static
,那么Foo<T>: 'static
,以此类推。这很酷。- 相反,
struct Foo { t: Box<dyn Trait> }
隐藏了很多细节 —— 它不允许T
包含任何引用,也不允许Foo
为Send
。
- 让它健全 (sound):
- 关于
dyn Trait
有一些开放的健全性错误 (soundness bugs),例如 #57893,我想关闭它们。这与列表中的其他内容相关。
- 关于