异步 Rust 的四年计划

  • 资料来源:

    https://without.boats/blog/a-four-year-plan/

  • 更新

    1
    2024.03.07 初始

导语

gpts 代劳, 校正中很多概念并不是特别清晰,故此篇质量不佳.

正文

四年前的今天,Rust async/await 特性在 1.39.0 版本中发布.发布公告 说:” 这项工作的开发已经进行了很长时间——例如,zero-cost futures 的关键思想最早是由 Aaron Turon 和 Alex Crichton 在 2016 年提出的 “.从 future 的第一个设计工作到 async/await 语法的发布,现在已经比 async/await 发布后的时间还要长了.尽管如此,尽管 async/await 语法被明确作为 “ 最小可行产品 “ 发布,但在 MVP 发布后的四年里,Rust 项目几乎没有为 async/await 发布任何扩展.

这一事实已经引起注意,我认为这是 async Rust 声誉不佳的主要原因 (其他原因,如其 本质复杂性, 不在项目的控制范围内).看到 Niko Matsakis 等项目领导者也 认识 到这个问题,这很令人鼓舞.我想概述一下我认为 async Rust 需要继续改进用户体验的特性.我将这些特性组织成我认为项目可以在短期内 (比如说,在未来 18 个月内) 发布的特性,到需要更长时间 (长达三年) 的特性,最后是关于可能对语言进行的潜在更改的部分,我认为需要数年的时间来计划和准备.

近期 Features

我相信这些 features 都是 Rust 项目能够在未来一两年内发布的特性.它们都只需要对编译器进行相对较小的更改,因为它们依赖于已经实现的抽象能力,并且涉及对表面语法的相对较小的更改,主要是现有语法已经隐含的新语法.我认为这些是项目应该关注的事情,因为它们应该更容易发布,更容易达成共识.

AsyncIterator 和 Async Generator

我在过去曾 一再强调generator 对 Rust 的重要性,所以我在这里不会花太多精力.我也在之前强调过,iterator 的最初计划 包括发布 generator 语法.简而言之,我的观点是,没有 generator 使 Rust 处于一种混乱的状态,异步性和迭代之间的关系不清楚 (我在链接的博文中有更多阐述).我想特别关注 async iterator 和 async generator,以及完成这些所需的特性.

async generator 是从 generator 的自然转换: 就像函数一样,generator 可以标记为 async,现在你可以在其中使用 await 操作符.使用我喜欢的语法,它看起来像这样:

1
2
3
4
5
6
7
async gen fn sum_pairs(rx: Receiver<i32>) yields i32 {
loop {
let left = rx.next().await;
let right = rx.next().await;
yield left + right;
}
}

这些特性的组合从这些语法中自然地产生.与 generator 不同,async generator 编译为 AsyncIterator.

还需要一个语法: 用于 await 循环.它们可以从任何 async 上下文中调用,并从 AsyncIterator 中消费条目,当 AsyncIterator yield pending 时让出控制:

1
2
3
for await item in async_iter {
println!("{}", item);
}

当我在开发 async Rust 时,这个语法被两个不同的设计问题所阻碍.一方面,Taylor Cramer 认为这个特性是一个糟糕的选择,因为用户应该使用 for_each_concurrent 来获得一些并发性.我不同意这一点: 用户并不总是想要使用 for_each_concurrent,在你的 async 函数中添加更多内部并发性是一个需要仔细考虑的决定,当你不想这样做时,应该有一个明显的语法,这就是 for await.另一方面,有人猜测制作 “await 模式 “,将 future 解构,然后以某种方式使其在此处工作; 我认为这是不明智的,将 await 作为表达式,将 for await 作为处理 AsyncIterator 的特殊表达式保留,是最明智的选择.

回顾我之前博文中的表格,你可以为异步迭代添加这一列:

ASYNCHRONOUS ITERATION
CONTEXTasync gen { }
EFFECT (iteration)yield
FORWARD (asynchrony)await
COMPLETE (iteration)for await

阻碍这一点的最大问题是库方面的问题:AsyncIterator 接口应该如何表示.我已经 写过 我更倾向于按原样稳定 AsyncIterator,使用 poll_next 方法.这仍然存在一些争议,所以我会回到这个问题上,但不是在这篇文章中.

现在我只想说,我认为过去 4 年未能稳定 AsyncIterator(这绝对不是我们计划 async MVP 时的意图) 对 async Rust 是有害的,因为基于异步迭代的 API 已经被归入不稳定的特性和第三方库,当用户需要处理重复的异步事件 (一种非常常见的模式) 时,他们感到困惑和支持不足.Rust 项目能为用户做的最好的事情就是稳定 AsyncIterator,这样生态系统就可以在此基础上构建,它可以明天就做到这一点.

好消息是,已经有工作在为下一个版本 保留 gen 关键字,以便可以实现 generator.这个特性使用与 async 函数相同的状态机转换,类比来看,应该可以在不对编译器进行大的更改的情况下实现.generator(不适用于 async generator,如果 AsyncIterator 按原样稳定) 的唯一大的未解决问题是如何使它们自引用.我将在本文后面回到这个问题.

协程方法

与引入这些额外类型的协程正交的是它们与 trait 系统的集成.现在,你不能在稳定的 Rust 中定义 async trait 方法.好消息是这种情况正在改变,在即将发布的 Rust 版本中,可以编写 async trait 方法.对于其他协程,generator 和 async generator 不应该需要任何特殊支持就可以在 trait 中使用,这在 async 函数中已经实现了.因此,当 generator 和 async generator 实现并稳定后,它们应该可以开箱即用地作为方法支持.

协程方法仍需要实现的唯一一件事是 “ 返回类型表示法 “(RTN) 的概念.问题是,向 trait 添加协程方法会向该 trait 添加一个匿名关联类型,即该方法的返回类型.有时 (最重要的是: 在 work-stealing executor 上的任务中生成该方法或以其他方式将其移动到另一个线程时),用户需要向该匿名关联类型添加附加的约束.所以 Rust 需要一些语法来声明这一点.这就是 RTN.例如:

1
2
3
4
5
6
7
trait Foo {
async fn foo(&self);
}

// later:
where F: Foo + Send,
F::foo(): Send

我认为,发布 RTN 很重要,因为我称之为 “ 你能修复它吗?” 原则.如果你的上游依赖有一个 async 方法,而你需要为返回类型添加 Send 约束,你能修复它吗,还是需要 fork 这个库?如果没有在 where 子句中添加 RTN 约束的能力,你就无法表达所需的约束而无需更改上游代码,即使你的代码都是完全有效的 (即使你想调用的 async 方法是 Send 的).对于用户来说,遇到这样一个问题是非常令人沮丧的,他们的代码应该可以正常编译,但满足编译器的唯一方法是 fork 一个依赖项.

幸运的是,项目已经在关注这个特性,我预计它将在明年发布.关于这个特性的确切语法,似乎有一些讨论: 我鼓励贡献者不要因语法差异而过于固执,这些差异不会实质性地改变特性.

协程闭包

Rust 当前语言设计中协程支持不佳的另一个方面是闭包.Niko Matsakis 在最近的两篇博文中探讨了这个问题,只关注 async 闭包,而不是 generative 或 asynchronously generative 闭包.在第一篇文章中,他提出将 async 闭包视为一个新的函数 trait 层次结构 (即添加 AsyncFnAsyncFnMutAsyncFnOnce).在 第二篇文章 中,他探索了将 async 闭包建模为返回 impl Future 的闭包的想法 (例如 F: Fn() -> impl Future).

我更喜欢第二种方法,因为它不会导致更多 trait 的激增.当你考虑 generative 闭包和 asynchronously generative 闭包时,这一点尤其明显: 如果每个函数 trait 都是不同的,Rust 将拥有 12 个函数 trait,而不是 3 个.相比之下,通过将协程闭包建模为返回 impl Trait 的闭包,不需要新的 trait.它还有一个额外的好处,即它以与 Rust 已经对普通 async 函数进行 desugar 的完全相同的方式对它们进行建模.

正如 Niko 在他的博文中强调的那样,这需要调整 Fn trait,允许它们的返回类型捕获输入生命周期.Niko 在他的帖子中指出了一些需要改变 Rust 语法的地方,可能跨越版本边界:

  • Fn trait 的 Output 参数添加生命周期
  • -> impl Trait 解糖为关联类型投影的约束,而不是新变量

因为这些可能需要版本更改,项目应该立即解决这些更改的细节.但它们似乎不是非常棘手的问题.

我要在这一节中补充一点: 一旦你有了 Fn() -> impl Future 等,自然地扩展语法以拥有一种 “async sugar”(和 “gen sugar”),就像函数一样,这将是很自然的.也就是说,应该为 Fn trait 添加特殊的语法糖,使得可以像这样编写闭包约束:

1
2
3
4
5
6
7
8
9
10
11
where F: async FnOnce() -> T
// equivalent to:
where F: FnOnce() -> impl Future<Output = T>

where F: gen FnOnce() yields T
// equivalent to:
where F: FnOnce() -> impl Iterator<Item = T>

where F: async gen FnOnce() yields T
// equivalent to:
where F: FnOnce() -> impl AsyncIterator<Item = T>

这里很好的一点是,它不是一些新的通用抽象概念,如 “trait transformer” 或 “effect generic”: 它只是一点点糖,是将已经存在的糖从一个地方 (函数声明) 自然扩展到另一个地方 (函数 trait 约束).这些函数 trait 已经有了特殊的语法,因为它们对参数和返回类型使用了括号和箭头.这不需要大量的实现工作或就有争议的新特性达成共识.

中期特性

上一节中的特性都是我认为无需大量实现工作就可以发布的特性,它们在设计中没有很多棘手的未解决问题.而本节中的特性则更加困难.现在有人已经在研究它们,这很好,但它们似乎离发布还很遥远,我不指望在未来一两年内看到它们.

对象安全的协程方法

尽管 async trait 方法很快就会成为稳定特性,但它们最初不会是对象安全的.我认为这是正确的决定,但如果有朝一日它们能够实现对象安全,那就理想了.对象安全的问题是这样的: 每个协程方法都隐含了一个匿名关联类型,即该方法的返回类型.在每个实现中,该类型的大小和布局都会不同.为了抹去 trait 对象的静态类型,你还需要抹去该方法的匿名返回类型: 换句话说,它也需要以某种方式成为 trait 对象.

对于我们的示例,我们将考虑这个 trait:

1
2
3
trait Foo {
async fn foo(&self);
}

如果我想创建 Foo 的 trait 对象,我需要指定 Foo::foo 的返回类型.值得庆幸的是,RTN 开始解开这个问题,允许我们使用这种语法:Box<dyn Foo<foo() = Something>>Something 是什么?它不能是一个特定的类型,否则就会将 trait 对象限制为返回该类型的实现: 在实践中,这意味着将其限制为单一特定类型,现在它甚至不是一个有意义的 trait 对象.这就是为什么它需要本身就是一个 trait 对象.

例如,它可能是 Box<dyn Foo<foo() = Pin<Box<dyn Future<Output = ()>>>>>.当然,这非常冗长.基本上有两个问题在影响设计空间:

  • 需要有某种转换器,获取你的 Foo 实现,并包含将 future 分配到堆上的粘合代码.
  • 项目领导中的一些成员非常坚持认为,堆分配应该是 “ 显式的 “,其中显式意味着需要更多语法来实现它.

因此,项目考虑了一个新的包装器类型,它将是必需的,它将 “ 显式 “ 表示 (因为它是一个不同的类型)future 类型将被堆分配.据我了解,类似于我上面写的内容将是 Box<Boxed<dyn Foo>>,或者可能只是 Boxed<dyn Foo>(从我手头的材料来看,我不太清楚).

我自己的观点是不同的.我认为,将堆分配 trait 对象 (即 Box<dyn Foo>Rc<dyn Foo>Arc<dyn Foo>) 的默认行为设置为使用与该类型相同的分配器分配状态机是合理的.对于非所有权的 trait 对象,如 &mut dyn Foo,我也可以接受将它们的默认行为设置为使用全局分配器进行分配,尽管在这里我更能理解这一点 (特别是因为这在 no_std 上下文中是不可能的).

无论如何,我同意允许用户使用某种替代的粘合机制来覆盖此行为很重要.这需要一个用于编写自己的粘合代码的接口,该接口可能会做一些其他事情 (例如使用 alloca 在栈上分配动态大小的类型).我只是认为应该有一个合理的默认行为,对于堆分配的 trait 对象来说,可能是堆分配该状态.在我看来,这并不比要求所有用户使用适配器更 “ 隐式 “,它只是涉及设置合理的默认值.不过,让所有人都满意地解决这一争议将是该特性的障碍,以及开发粘合代码的接口.

我想在这一节中再补充一点: 以前关于这个问题的讨论将不稳定的 dyn* 特性视为对象安全协程方法的先决条件.我不认为是这样.dyn* 所做的是创建一个存在类型,所有不同的 trait 对象指针类型都将实现它,方法是虚拟化它们的析构函数代码; 如果你可以接受使用不同分配策略的 trait 对象的虚拟协程方法是不同类型,那么根本不依赖于 dyn*.我个人认为 dyn* 特性是 Rust 项目追求的一个值得商榷的方向.

异步析构函数

另一个非常棘手的问题是异步析构函数的问题.有时,析构函数可能需要执行某种 IO 操作或以其他方式阻塞当前线程; 支持非阻塞析构函数是可取的,它们会让出控制,以便其他任务可以同时运行.不幸的是,这有几个问题.

第一个问题是,运行异步析构函数是尽最大努力的,甚至比运行任何析构函数都更加如此.这是因为如果你在非异步上下文中 drop 具有异步析构函数的类型,就无法运行析构函数,因为这不在异步上下文中.有几个不同的想法来解决这个问题,例如使用 let async 绑定来指示不能移动到非异步上下文的变量,或者只是接受它并将异步析构函数视为非异步析构函数的优化.

第二个问题实际上与 trait 对象非常相似: 如果异步析构函数需要使用某种状态,你将它存储在哪里?一个选择是不允许异步析构函数具有状态,使用 poll 方法.这很简单,但对于像数据结构这样的东西来说是有问题的: 例如,Vec 无法存储它已经 poll 过的条目,必须在循环中不断 poll 它们的析构函数.这可能是非常不可接受的.但是,处理状态会引发与 trait 对象相同的问题.

异步析构函数的第三个问题是如何处理它们与 unwinding 的交互.特别是,如果你正在通过一个返回 Pending 的异步析构函数进行 unwinding,会发生什么?需要有某种异步版本的 catch_unwind,以便 pending 调用可以跳转到它,这样其他任务就可以运行.我认为这个问题比其他两个更容易解决,但它需要制定规范.

我一直在犹豫是否认为异步析构函数的困难是 async Rust 最糟糕的事情之一,以及是否认为异步析构函数无论如何都不是那么有用.无论你的立场如何,要使该特性可发布,还需要大量的设计工作,我认为它不会很快到来.

长期特性

与近期和中期特性相比,我认为应该仔细考虑 Rust 设计中的某些更大的问题,以便在未来几年内无法解决它们.不过,考虑它们的工作必须在某个时候开始,以便最终能够完成.我说的是 “ 改变 “ Rust 的规则.

截至目前,Rust 无法真正支持几种有价值的类型:

  • 不可移动类型: 一旦目击了它们的地址,就不能移动的类型.
  • 不可遗忘类型: 在不运行其析构函数或解构它们的情况下,不能超出作用域的类型.
  • 不可丢弃类型: 不能 drop 或遗忘,但必须解构的类型.

(人们谈论这些时,后两个通常被归为 “ 线性类型 “,但有非常重要的区别.)

我认为有证据表明,至少对前两类有强烈的动机.

为了支持自引用协程和侵入式数据结构,Rust 需要某种支持,以确保类型在移动后不会再次移动.因为 Rust 不支持不可移动类型,我们使用 Pin API 添加了这个功能.但 Pin API 有一些大缺陷: 一是 API 笨拙,难以使用.更重要的是,它需要一个接口来明确选择支持不可移动类型; 在 Pin 之前存在的 trait 无法获得处理不可移动类型的能力.

对于两个特定的 trait,这是一个大问题:

  • Iterator: 因为 iterator 不支持不可移动类型,项目在如何支持不可移动 generator 上陷入僵局.
  • Drop: 因为 drop 不支持不可移动类型,一个神秘的含义是你需要像 pin-project 这样的 crate 来访问 pinned 类型的字段.这一切都非常巴洛克式和令人困惑,如果 Drop 支持不可移动类型,就没有必要了.

另一方面,如果 Rust 有 Move trait,这些问题就会消失.自引用 generator 就不会实现 Move,自然就可以工作.Pin 类型可以完全弃用,对不实现 Move 的类型的引用将具有与对不实现 Unpin 的类型的 pinned 引用相同的语义.当然,这需要跨版本的重大更改.

scoped task 三难困境 为不能被遗忘的类型提供了有力的论据.无栈协程不能使用基于析构函数的并发借用技巧: 使其工作的唯一方法是使用基于闭包传递的 “ 内部 “ 样式,这是 Rust 在选择无栈协程时所反对的.Rust 设计的这两个理想方面之间的不兼容性有力地表明,不支持不可遗忘类型的决定是错误的.

我之所以将这篇文章命名为 “ 四年计划 “,是有原因的: 如果 Rust 要采用这些根本性的改变,它必须跨版本边界完成,我非常怀疑它能否作为 2024 版的一部分完成.这使得四年后的 2027 版成为这种变化的目标.但是项目应该在不久的将来,在未来两年内,就这一变化做出决定,这应该包括一个临时的 generator 解决方案,例如要求在将它们用作 iterator 之前对它们进行 pin.

我一直在我的博客上探索进行这种改变所需的内容,因为我认为这是 Rust 项目应该认真考虑改变的事情.我打算明年继续关注这个问题,因为我认为需要充分理解所有不同选项的影响.我正在努力寻找让这个过程具有协作性的方法,但我的选择是有限的.我的目标并不是真的要提出一个特定的建议 (尽管我肯定会有意见),而只是要了解解决这些问题的所有选择的全部空间.

解决自引用 generator 问题的不同选项之间的确切权衡是什么?支持 “ 不可遗忘 “ 类型与 “ 不可丢弃 “ 类型有什么不同的要求?如果要添加 Move,如何跨版本边界移除 Pin?这些是我想回答的问题.

然而,我意识到,为 Rust 添加对这些类型的支持将是自 2015 年 Rust 稳定以来最大的变化,进行这一变化将给项目和社区带来巨大的成本.我也认识到,有充分的理由说明支持这些类型并不值得 (比如与 trait 对象的痛苦交互).出于这些原因,Rust 项目在考虑这个想法时应该考虑到,最终什么都不做可能是正确的结果.

总的来说,在 Rust 的设计过程的这个阶段,我的直觉是怀疑对 Rust 进行大的改变.我认为 Rust 需要做的是完成已经承诺的特性的集成——像外部 iterator、无栈协程、单态泛型和无大小 trait 对象类型这样的特性.我特别觉得改变有关可移动性和线性类型的规则是合理的,因为这些现有特性的集成有影响.

结束语

这篇文章再次变得非常长.我决定将这篇文章的重点放在语言的变化上; 在接下来的另一篇文章中,我将把注意力集中在标准库和异步库生态系统上,并专门写一篇关于 AsyncIterator 接口的文章.我想再提一点,我试图在这篇文章和前一篇文章中找到一个位置,但找不到.它涉及 2019 年围绕 await 运算符的最终语法而展开的争议.

对于那些不知道的人,当时有一场关于 Rust 中的 await 运算符应该是前缀运算符 (像其他语言一样) 还是后缀运算符 (最终结果) 的大辩论.这引起了极大的关注——超过 1000 条评论.事情的经过是,几乎语言团队的所有人都达成了共识,认为运算符应该是后缀的,但我是唯一的反对者.在这一点上,很明显不会出现新的论点,也没有人会改变主意.我让这种状况持续了几个月.我为这个决定感到遗憾.很明显,除了我让步于多数人之外,没有其他方法可以发布,但我没有这样做.在这样做的过程中,我允许情况随着越来越多的 “ 社区反馈 “ 重申已经提出的观点而升级,让所有人筋疲力尽,尤其是我自己.

我从这次经历中学到的教训是要区分真正关键的因素和无关紧要的因素.如果你要在某个问题上固执己见,你最好能阐明一个深层次的理由,说明为什么它很重要,而且它必须是比语法选项之间的细微差别更紧迫的事情.从那以后,我一直努力在处理技术问题时牢记这一点.

我担心 Rust 项目从这次经历中吸取了错误的教训.正如 Graydon 在 这里 提到的,该项目继续其规范,即通过足够的构思和头脑风暴,最终可以发现每一个争议的双赢解决方案.该项目没有接受有时必须做出艰难决定的事实,而是将这些争议无限期地悬而未决,导致精疲力竭,解决方案是向内转.设计决策现在主要记录在非索引格式中,如 Zulip 线程和 HackMD 文档.在公开表达设计的程度上,它是属于不同贡献者的六个不同博客之一.作为局外人,几乎不可能理解项目认为什么是优先事项,以及这些事情的当前状态是什么.

我从未见过项目与社区的关系处于更糟糕的状态.但这个社区拥有宝贵的专业知识; 封闭自己并不是解决问题的办法.我希望看到项目成员和社区成员之间重建相互信任和尊重的关系,而不是目前敌对和不满的情况.为此,我要感谢该项目的那些在过去几个月里与我联系并就设计问题进行交流的人员.