为什么选择 async Rust

  • 资料来源:

    https://without.boats/blog/why-async-rust/

  • 更新

    1
    2024.03.07 初始

导语

gpt 译文, 翻译质量已足够可读, 仅校正少量;

正文

Rust 中的 async/await 语法最初发布时,受到了极大的欢呼和兴奋.引用当时 Hacker News 上的 评论:

这将打开闸门.我确信很多人都在等待这一时刻来采用 Rust.我就是其中之一.

这里面包含了所有的优点: 开源、高质量的工程、开放的设计、为一个复杂的软件做出巨大贡献的众多贡献者.真正鼓舞人心!

最近,对此的反响则喜忧参半.再次引用 Hacker News 上关于近期一篇相关博文的 评论:

我真的无法理解,有人怎么能看着 Rust 的异步这一团糟,还认为这是一个已经臭名昭著的复杂语言的好设计.

我试图去理解它,我真的尝试了,但天啊,那真是一团糟.而且它污染了所有与之接触的东西.我真的很喜欢 Rust,这些天我的大部分编码都在用它,但每当我遇到大量使用异步的 Rust 代码时,我的下巴就会紧绷,视线模糊.

当然,这两条评论都不能完全代表大众: 即使在四年前,一些人也提出了担忧.而在这条关于下巴紧绷、视线模糊的评论所在的讨论串中,也有许多人以同样的热情为 async Rust 辩护.但我认为,随着时间的推移,否定者变得更加众多,语气也更加尖锐,这一点应该不会有太大争议.在某种程度上,这只是炒作周期的自然发展,但我也认为,随着我们与最初的设计过程渐行渐远,一些背景已经丢失了.

在 2017 年至 2019 年间,我与他人合作,在前人工作的基础上,推动了 async/await 语法的设计.原谅我会对有人说 “ 不知道有谁能看着那个 ‘ 乱七八糟 ‘ 的东西还 ‘ 认为这是一个好设计 ‘“ 感到不快,请允许我在这篇组织不太完善、冗长的文章中解释 async Rust 是如何诞生的,它的目的是什么,以及为什么在我看来,对于 Rust 而言别无选择.我希望在此过程中,我能够稍微更广泛、更深入地阐明 Rust 的设计,而不仅仅是重复过去的论证.

关于术语的一些背景

在这场辩论中,争议的核心是 Rust 决定使用 “ 无栈协程 “(stackless coroutine) 方法来实现用户空间并发 (user-space concurrency).讨论中抛出了很多术语,不熟悉这些术语很正常.

我们需要弄清楚的第一个概念是这个特性的目的:” 用户空间并发 “.主要的操作系统提供了一组相当相似的接口来实现并发: 你可以生成线程,并在这些线程上使用系统调用执行 IO,这会阻塞该线程直到完成.这些接口的问题在于,它们涉及某些开销,当你想达到某些性能目标时,这些开销可能会成为限制因素.这有两个方面:

  1. 内核和用户空间之间的上下文切换在 CPU 周期方面很昂贵.
  2. OS 线程有一个大的预分配堆栈,这增加了每线程的内存开销.

这些限制在某种程度上是可以接受的,但对于大规模并发程序来说,它们并不适用.解决方案是使用非阻塞 IO 接口,并在单个 OS 线程上调度许多并发操作.程序员可以 “ 手工 “ 完成这一点,但现代语言经常提供一些工具来简化这一过程.从抽象上讲,语言有一些方法将工作划分为任务,并将这些任务调度到线程上.Rust 的系统就是 async/await.

在这个设计空间中,第一个选择是协作式 (cooperative) 调度和抢占式 (preemptive) 调度? 任务是否必须 “ 协作式地 “ 将控制权交还给调度子系统,还是可以在运行时的某个点被 “ 抢占式地 “ 停止,而任务本身并不知情?

在这些讨论中经常被提及的一个术语是协程 (coroutine),它的使用方式有些矛盾.协程是一个可以暂停然后恢复的函数.最大的歧义在于,有些人使用 “ 协程 “ 这个术语来指代一个函数,它有显式的语法来暂停和恢复自己 (这对应于协作式调度的任务),而有些人用它来指代任何可以暂停的函数,即使暂停是由语言运行时隐式执行的 (这也包括抢占式调度的任务).我更倾向于第一个定义,因为它引入了某种有意义的区分.

另一方面,Goroutine 是一个 Go 语言特性,它支持并发的、抢占式调度的任务.它们有一个与线程相同的 API,但它是作为语言的一部分实现的,而不是作为操作系统原语,在其他语言中,它们通常被称为虚拟线程 (virtual thread) 或绿色线程 (green thread).所以按照我的定义,Goroutine 不是协程,但其他人使用更广泛的定义,说 Goroutine 是一种协程.我将把这种方法称为绿色线程,因为这是 Rust 中使用的术语.

第二个选择轴是有栈协程 (stackful coroutine) 和无栈协程 (stackless coroutine) 之间的选择.有栈协程以与 OS 线程相同的方式拥有一个程序栈: 当作为协程的一部分调用函数时,它们的帧被压入堆栈; 当协程让出时,堆栈的状态被保存,以便可以从同一位置恢复.另一方面,无栈协程以不同的方式存储它需要恢复的状态,例如在 延续 (continuation) 或状态机中.当它让出时,它正在使用的堆栈被接管它的操作使用,当它恢复时,它重新获得对堆栈的控制,该延续或状态机用于从它停止的地方恢复协程.

在 async/await 中 (在 Rust 和其他语言中) 经常提到的一个问题是 “ 函数染色问题 “(function coloring problem) - 有人抱怨,为了获得异步函数的结果,你需要使用不同的操作 (例如 await),而不是正常调用它.绿色线程和有栈协程机制都可以避免这种情况,因为正是那种特殊语法被用来表明某些特殊的事情正在发生,以管理协程的无栈状态 (具体取决于语言).

Rust 的 async/await 语法是无栈协程机制的一个例子: 异步函数被编译成返回 Future 的函数,该 Future 用于在协程让出控制时存储其状态.这场辩论的基本问题是,Rust 采用这种方法是否正确,或者它是否应该采用更类似 Go 的 “ 有栈 “ 或 “ 绿色线程 “ 方法,最好没有明确的语法来 “ 染色 “ 函数.

Async Rust 的发展

绿色线程

第三条 Hacker News 评论 很好地代表了我经常在这场辩论中看到的那种言论:

人们想要的替代并发模型是通过有栈协程和通道实现的结构化并发, 底层交由工作窃取执行器调度.

在有人实现了这个原型并将其与 async/await 和 future 进行比较之前,我认为没有任何有成效的讨论可以进行.

暂且不提对结构化并发、通道和工作窃取执行器的引用 (完全是关注的焦点),令人不解的是,像这样的评论所说,Rust 最初确实有一种有栈协程机制,以绿色线程的形式存在.它在 2014 年底,也就是 1.0 版发布前不久被移除了.了解其中的原因将有助于我们深入了解 Rust 为何推出 async/await 语法.

对于任何绿色线程系统 - Rust 的、Go 的或任何其他语言的 - 一个大问题是如何处理这些线程的程序栈.请记住,用户空间并发机制的目标之一是减少 OS 线程使用的大型预分配栈带来的内存开销.因此,绿色线程库倾向于尝试采用一种机制来生成具有更小堆栈的线程,并且只在需要时才增加堆栈大小.

一种实现这一点的方法是所谓的 “ 分段栈 “(segmented stack),其中栈是一个小栈段的链表; 当栈增长超过其段的界限时,一个新的段被添加到列表中,当它收缩时,该段被删除.这种技术的问题是,它在将栈帧压入栈的成本方面引入了很高的可变性.如果帧适合当前段,这基本上是无成本的.如果不适合,就需要分配一个新段.这种情况下一个特化版本是,当一个热循环中的函数调用需要分配一个新段时.这会在每次循环迭代中添加一次分配和释放,对性能产生重大影响.而且它对用户来说完全不透明,因为用户不知道在调用函数时堆栈会有多深.Rust 和 Go 都从分段栈开始,然后由于这些原因放弃了这种方法.

另一种方法被称为 “ 栈复制 “(stack copying).在这种情况下,栈更像是一个 Vec 而不是一个链表: 当栈达到其限制时,它被重新分配得更大,这样就不会触及限制.这允许堆栈从小开始,并根据需要增长,而没有分段堆栈的缺点.这样做的问题是,重新分配堆栈意味着复制它,这意味着堆栈现在将位于内存中的新位置.任何指向堆栈的指针现在都无效,需要有某种机制来更新它们.

Go 使用栈复制,并受益于在 Go 中,指向栈的指针只能存在于同一个栈中,所以它只需要扫描那个栈来重写指针. 然而这点却无法在 Rust 中实现, 首先 Rust 不保留的运行时类型信息, 其次 Rust 也允许指向栈的指针不存储在该栈内 (它们可能在堆的某个地方,或者在另一个线程的栈中). 跟踪这些指针的问题最终与垃圾收集的问题相同,只是跟踪指针后并不释放内存,而是移动内存. 因为 Rust 没有垃圾收集器, 所以最终 Rust 不能采用栈复制.相反,Rust 通过使其绿色线程变大来解决分段堆栈的问题,就像 OS 线程一样.但这消除了绿色线程的一个关键优势.

即使在像 Go 这样可以有可调整大小堆栈的情况下,在试图与用其他语言编写的库集成时,绿色线程也会带来某些不可避免的成本.带有 OS 堆栈的 C ABI 是每种语言的共享最小值.将代码从在绿色线程上执行切换到在 OS 线程堆栈上运行,对于 FFI 来说可能非常昂贵.Go 只是接受这种 FFI 成本;C# 最近因为这个原因 中止了一个绿色线程实验.

这对 Rust 来说尤其成问题,因为 Rust 旨在支持将 Rust 库嵌入到用另一种语言编写的二进制文件中,并在没有时钟周期或内存来操作虚拟线程运行时的嵌入式系统上运行的用例.为了尝试解决这个问题,绿色线程运行时被设计为可选的,Rust 可以改为编译为在本机线程上运行,使用阻塞 IO.这被设计为由最终二进制文件在编译时做出的决定.因此,有一段时间,有两种 Rust 变体,一种使用阻塞 IO 和本机线程,另一种使用非阻塞 IO 和绿色线程,所有代码都旨在与这两种变体兼容.这并没有很好地发挥作用,绿色线程最终因为 RFC 230 而被移除,其中列举了原因:

  1. 在绿色线程和本机线程之上的抽象并非 “ 零成本 “,导致在执行 IO 时出现不可避免的虚拟调用和分配,这对本机代码来说尤其不可接受.
  2. 它迫使本机线程和绿色线程支持相同的 API,即使这没有意义.
  3. 它并非完全可互操作,因为仍然可能通过 FFI 调用本机 IO,即使在绿色线程上也是如此.

移除了绿色线程,但高性能用户空间并发的问题仍然存在.Future trait 和后来的 async/await 语法就是为了解决这个问题而开发的.但要理解这条路径,我们需要再退一步,看看 Rust 对另一个问题的解决方案.

迭代器

我认为通往 async Rust 之路的真正起点,要追溯到 2013 年一位名叫 Daniel Micay 的前贡献者在邮件列表上的一篇 帖子.这篇帖子与 async/await 或 future 或非阻塞 IO 无关: 它是关于迭代器的.Micay 提议将 Rust 转向使用所谓的 “ 外部 “ 迭代器,正是这一转变 - 以及它与 Rust 的所有权和借用模型相结合的效果 - 让 Rust 不可避免地走上了通往 async/await 的道路.显然,当时没有人知道这一点.

Rust 一直禁止通过与另一个变量别名的绑定来改变状态 - 这条 “ mutable XOR aliased “ 的规则在早期的 Rust 中与今天一样重要.但最初它是用不同的机制来强制执行的,而不是用生命周期分析.当时,引用只是 “ 参数修饰符 “,在概念上类似于 Swift 中的 “inout” 修饰符.2012 年,Niko Matsakis 提出并实现了 Rust 生命周期分析的第一个版本,将引用提升为真正的类型,并允许将它们嵌入到结构体中.

尽管人们正确地认识到,转向生命周期分析对 Rust 的巨大影响使其成为今天的样子,但它与外部迭代器的共生互动,以及该 API 对 Rust 定位到其当前领域的基础性作用,还没有得到足够的重视.在采用 “ 外部 “ 迭代器之前,Rust 使用一种基于回调的方法来定义迭代器,在现代 Rust 中,它看起来像这样:

1
2
3
4
5
6
7
8
9
10
enum ControlFlow {
Break,
Continue,
}

trait Iterator {
type Item;

fn iterate(self, f: impl FnMut(Self::Item) -> ControlFlow) -> ControlFlow;
}

以这种方式定义的迭代器在集合的每个元素上调用它们的回调,除非它返回 ControlFlow::Break,在这种情况下,它们应该停止迭代.for 循环的主体被编译成一个闭包,传递给被循环的迭代器.这种迭代器比外部迭代器更容易编写,但这种方法有两个关键问题:

  1. 语言无法保证当循环说要 break 时迭代实际上会停止,所以你不能依赖它来确保内存安全.这意味着从循环中返回引用是不可能的,因为循环实际上可能会继续.
  2. 它们不能用于实现交错多个迭代器的通用组合器,如 zip,因为 API 不支持交替地迭代一个迭代器,然后迭代另一个.

相反,Daniel Micay 提议将 Rust 转向使用 “ 外部 “ 迭代器,它完全解决了这些问题,并具有 Rust 用户今天习惯的接口:

1
2
3
4
5
trait Iterator {
type Item;

fn next(&mut self) -> Option<Self::Item>;
}

外部迭代器与 Rust 的所有权和借用系统完美结合,因为它们本质上编译成一个结构体,将迭代状态保存在自身内部,因此可以像任何其他结构体一样包含对被迭代数据结构的引用.而且由于单态化,通过组装多个组合器构建的复杂迭代器也被编译成单个结构体,使其对优化器透明.唯一的问题是,它们很难手工编写,因为你需要定义将用于迭代的状态机.预示着未来的发展,Daniel Micay 当时写道:

将来,Rust 可以使用 yield 语句实现像 C# 一样的生成器,编译成快速的状态机,而无需上下文切换、虚函数甚至闭包.这将消除手工编写外部迭代器进行递归遍历的难度.

生成器的进展并不迅速,尽管最近发布了一个令人兴奋的 RFC,这表明我们可能很快就会看到这个特性.

即使没有生成器,外部迭代器也被证明是一个巨大的成功,这种技术的总体价值得到了认可.例如,Aria Beingessner 在访问映射条目的 “Entry API” 中使用了类似的方法.值得注意的是,在该 API 的 RFC 中,她将其称为 “ 类似迭代器 “.她的意思是,该 API 通过一系列组合器构建状态机,将自身呈现给编译器,从而具有高度可读性和可优化性.这种技术很有前景.

Future

当 Aaron Turon 和 Alex Crichton 需要替换绿色线程时,他们首先复制了许多其他语言中使用的 API,这个 API 后来被称为 future 或 promise.这样的 API 基于所谓的 “ 延续传递风格 “(continuation passing style).以这种方式定义的 future 将回调作为附加参数,称为延续,并在 future 完成时将延续作为最后一个操作调用.这就是大多数语言中这种抽象的定义方式,大多数语言的 async/await 语法都被编译成这种延续传递风格.

在 Rust 中,那种 API 看起来像这样:

1
2
3
4
5
trait Future {
type Output;

fn schedule(self, continuation: impl FnOnce(Self::Output));
}

Aaron Turon 和 Alex Crichton 尝试了这种方法,但正如 Aaron Turon 在一篇启发性的博客文章中所写,他们很快遇到了一个问题,即使用延续传递风格通常需要分配回调.Turon 举了一个 join 的例子:join 接受两个 future,并同时运行它们.join 的延续需要由两个子 future 共同拥有,因为无论哪个 future 最后完成,都需要执行它.这最终需要引用计数和分配来实现,这被认为对 Rust 来说是不可接受的.

相反,他们研究了 C 程序员倾向于如何实现异步编程: 在 C 中,程序员通过构建状态机来处理非阻塞 IO.他们想要的是一个 Future 的定义,可以编译成 C 程序员手工编写的那种状态机.经过一些实验,他们得出了他们称之为 “ 基于就绪性 “(readiness-based) 的方法:

1
2
3
4
5
6
7
8
9
10
enum Poll<T> {
Ready(T),
Pending,
}

trait Future {
type Output;

fn poll(&mut self) -> Poll<Self::Output>;
}

不同于存储延续,future 由某个外部执行器轮询.当 future 处于 pending 状态时,它存储一种唤醒该执行器的方式,当它准备好再次被轮询时,它将执行该操作.通过这种方式反转控制,他们不再需要在 future 完成时存储回调,这允许他们将 future 表示为单个状态机.他们在这个接口之上构建了一个组合器库,所有这些组合器都将被编译成单个状态机.

从基于回调的方法转向外部驱动程序,将一组组合器编译成单个状态机,甚至这两个 API 的确切规范: 如果你读过前一节,所有这些听起来应该非常熟悉.从延续到轮询的转变与 2013 年迭代器的转变完全相同!再一次,正是 Rust 处理带有生命周期的结构体的能力,从而处理借用外部状态的无栈协程的能力,使其能够在不违反内存安全的情况下,以最佳方式将 future 表示为状态机.无论应用于迭代器还是 future,这种从较小的组件构建单对象状态机的模式都是 Rust 工作方式的关键部分.它几乎自然地从语言中产生.

我要暂停一下,强调迭代器和 future 之间的一个区别: 像 Zip 这样交错两个迭代器的组合器,使用基于回调的方法根本不可能,除非你的语言有某种你正在构建的协程的原生支持.另一方面,如果你想交错两个 future,像 Join,基于延续的方法可以支持这一点: 它只是带来一些运行时成本.这解释了为什么外部迭代器在其他语言中很常见,但 Rust 在将这种转换应用于 future 方面是独特的.

在最初的迭代中,future 库的设计原则是,用户将以与构建迭代器大致相同的方式构建 future: 低级库作者将使用 Future trait,而编写应用程序的用户将使用 future 库提供的一组组合器,从更简单的组件构建更复杂的 future.不幸的是,当用户试图遵循这种方法时,他们立即面临令人沮丧的编译器错误.问题是,当 future 被生成时,需要从周围的上下文中 “ 逃逸 “,因此不能借用该上下文中的状态: 任务必须拥有其所有状态.

这对 future 组合器来说是一个问题,因为通常需要在构成 future 的一系列操作中的多个组合器中访问该状态.例如,用户通常会在一个对象上调用一个 “ 异步 “ 方法,然后再调用另一个方法,这将写成这样:

1
foo.bar().and_then(|result| foo.baz(result))

问题是 foo 既在 bar 方法中被借用,又在传递给 and_then 的闭包中被借用.本质上,用户想要做的是一个 await 点前后存储状态 (await 点由 future 组合器的链接形成). 这通常会导致令人困惑的借用检查器错误.最容易的解决方案是将该状态存储在 ArcMutex 中,首先这并非零成本,更重要的是,随着系统复杂性的增加,这非常笨拙和尴尬.例如:

1
2
3
let foo = Arc::new(Mutex::new(foo));
foo.clone().lock().bar()
.and_then(move |result| foo.lock().baz(result))

尽管 future 在最初的实验中表现出了很好的基准测试结果,但这个限制的结果是,用户无法使用它们来构建复杂的系统.这就是我开始的地方.

Async/await

2017 年末,很明显由于用户体验不佳,future 生态系统未能启动.future 项目的最终目标一直是实现所谓的 “ 无栈协程转换 “,其中使用 asyncawait 语法运算符的函数可以转换为计算 future 的函数,避免用户必须手动编写 future.Alex Crichton 开发了一个基于宏的 async/await 实现作为 ,但这几乎没有引起任何关注. 改变需要发生了.

Alex Crichton 的宏的最大问题之一是,如果用户试图持有一个跨 await 点的对 future 状态的引用,它会产生一个错误.这实际上与用户在 future 组合器中遇到的借用问题是同一个问题,再次出现在新的语法中.future 不可能在 Pending 同时 持有对自身状态的引用,因为这需要把 future 编译成一个自引用结构体,而 Rust 不支持这一点.

将此与绿色线程的问题进行比较很有趣.我们解释 future 编译为状态机的一种方式是说状态机是一个 “ 完美大小的栈 “ - 与绿色线程的栈不同,绿色线程的栈必须增长以适应任何线程栈可能具有的未知大小的状态,编译后的 future(无论是手动实现、使用组合器或使用异步函数) 的栈大小已经确定, 所以我们没有在运行时增长这个栈的问题.

绿色线程中需要保存的栈现在成为了 future 的状态机, future 状态机 被表示为一个结构体.Rust 层面 移动结构体始终是安全的.这意味着即使我们不需要在执行 future 时移动它,根据 Rust 的规则,我们也需要能够在运行时移动 future. 因此,类比绿色线程中遇到的栈指针问题, 新系统中又出现了指针问题重新出现.不过这一次,我们有一个优势,那就是我们不需要能够移动 future, 我们只需要表达 future 是不可移动的.

最初尝试实现这一点的方法是尝试定义一个新的 trait,称为 Move,用于从可以移动它们的 API 中排除协程.这在向后兼容性方面遇到了一些问题,我之前已经 记录 过了. async/await 有三个主要需求:

  1. Rust 需要引入 async/await 语法,以便用户可以使用类似协程的函数构建复杂的 future.
  2. Async/await 语法需要支持将这些函数编译为自引用结构体,以便用户可以在协程中使用引用.
  3. 这个特性需要尽快发布.

这三点的结合让我寻找替代 Move trait 的解决方案,一个可以在不对语言进行任何重大破坏性更改的情况下实现的解决方案.

我最初实现比我们最终得到的要差得多.我提议我们干脆让 poll 方法不安全,并包含一个不变量,即一旦开始轮询 future,就不能再移动它.这很简单,可以立即实现,而且非常强制: 它会使每个手写的 future 都不安全,并强加一个难以验证的要求,而编译器不提供任何帮助. 它可能最终会在某个还未发现的问题上碰到钉子,而且肯定会引起极大的争议.

所以 Eddy Burtescu 提出了一些意见,引导我走向一个更好的 API,这真是太好了,它可以让我们以更细粒度的方式强制执行所需的不变量.这最终成为 Pin 类型.Pin 类型本身已经引起了相当多的恼怒,但我认为它无疑是对我们当时正在考虑的其他选择的改进,因为它是有针对性的、可执行的,而且也可以按时发布.

回顾过去,固定 (pinning) 方法有两类问题:

  1. 向后兼容性: 一些已经存在的接口 (特别是 IteratorDrop) 由于各种原因应该支持不可移动类型,这限制了进一步开发语言的选择.
  2. 对最终用户的暴露: 我们的意图是编写普通异步 Rust 的用户永远不必处理 Pin.大多数情况下这是正确的,但也有一些值得注意的例外.其中几乎所有的问题都可以通过一些语法改进来解决.唯一真正糟糕 (也让我个人感到尴尬) 的是,你需要 pin 一个 future trait 对象才能 await 它.这是一个不必要的错误,现在修复它将是一个破坏性的改变.

关于 async/await 唯一要做的其他决定是语法上的,我不会在这篇已经过长的文章中再次讨论.

组织上的考虑

我探索所有这些历史的原因是为了证明,一系列关于 Rust 的事实不可避免地将我们引向一个特定的设计空间.

  • 第一个是 Rust 缺乏运行时,这使得绿色线程成为一个不可行的解决方案,因为 Rust 需要支持嵌入 (无论是嵌入到其他应用程序还是在嵌入式系统上运行),并且因为 Rust 无法执行绿色线程所需的内存管理.
  • 第二个是 Rust 具有将协程编译为高度可优化的状态机的天然能力,同时仍然保持内存安全,我们不仅为 future 而且为迭代器利用了这一点.

但这段历史还有另一面: 为什么我们追求用户空间并发的运行时系统?为什么要有 future 和 async/await?这个论点通常有两种形式:

  • 一方面,你有习惯于 “ 手动 “ 管理用户空间并发的人,使用像 epoll 这样的接口; 这些人有时会嘲笑 async/await 语法是 “ 网络垃圾 “.
  • 另一方面,有些人只是说 “ 你不需要它 “,并建议使用更简单的 OS 并发,如线程和阻塞 IO.

在没有用户空间并发工具的语言 (如 C) 中实现高性能网络服务的人倾向于使用手写状态机来实现它们.这正是 Future 抽象被设计用来编译的东西,但不必手写状态机: 协程转换的全部意义在于: 顺序方式编写异步代码,状态转换交由编译器生成.这样做的好处是不容忽视的.最近的一个 curl CVE 最终是由于未能识别状态转换期间需要保存的状态而引起的.在手动实现状态机时,这种逻辑错误很容易犯.

在 Rust 中发布 async/await 语法的目标是发布一个特性,避免这些错误,同时仍然具有相同的性能特征.考虑到我们提供的控制级别以及缺乏内存管理运行时,这些通常用 C 或 C++ 编写的系统被认为完全在我们的目标受众之内.

2018 年初,Rust 项目致力于发布一个新的 “ 版本 “,以解决 1.0 出现的一些语法问题.还决定利用这个版本作为一个机会,宣传 Rust 已经准备好黄金时段的叙事;Mozilla 团队主要是编译器黑客和类型理论家,但我们对营销有一些基本的想法,并认识到这个版本是吸引人们对产品关注的机会.我向 Aaron Turon 提议,我们应该关注四个基本用户故事,这似乎是 Rust 的增长机会.它们是:

  1. 嵌入式系统
  2. WebAssembly
  3. 命令行接口
  4. 网络服务

这个评论是创建 “ 领域工作组 “ 的起点,这些工作组旨在成为关注特定使用 “ 领域 “ 的跨职能团队 (与控制某些技术或组织领域的现有 “ 团队 “ 相比).Rust 项目中工作组的概念从那时起发生了变化,基本上失去了这种意义,但我跑题了.

async/await 的工作由 “ 网络服务 “ 工作组 (最终被称为异步工作组,今天仍以这个名字存在) 率先开展.然而,我们也非常清楚,鉴于它缺乏运行时依赖性,异步 Rust 也可以在其他领域 (特别是嵌入式系统) 发挥巨大作用.我们在设计该功能时,考虑了这两个用例.

很明显,尽管通常没有说出来,Rust 需要的是行业采用,这样即使 Mozilla 不再愿意资助一种实验性的新语言,它也能继续获得支持.而且很明显,短期内最有可能被行业采用的途径是网络服务,尤其是那些性能要求当时迫使它们用 C/C++ 编写的服务.这个用例完美地适合 Rust 的定位 - 这些系统需要高度的控制来实现其性能要求,但避免可利用的内存错误至关重要,因为它们暴露在网络上.

网络服务的另一个优势是,这个软件行业领域有灵活性和胃口来快速采用像 Rust 这样的新技术.其他领域 - 现在仍然如此! - 被视为不会那么快地采用新技术 (嵌入式),依赖于一个自身尚未得到广泛采用的新平台 (WebAssembly),或者不是一个特别有利可图的工业应用,可以为语言提供资金 (CLI).我努力推动 async/await,勤奋而热忱地假设 Rust 的生存取决于这个特性.

在这方面,async/await 取得了巨大的成功.许多 Rust 基金会最著名的赞助商,尤其是那些支付开发者的赞助商,都依赖 async/await 在 Rust 中编写高性能网络服务,这是他们资助的主要使用案例之一.将 async/await 用于嵌入式系统或内核编程也是一个越来越受关注的领域,前景光明.Async/await 如此成功,以至于对它最常见的抱怨是生态系统过于以它为中心,而不是 “ 普通 “ 的 Rust.

我不知道该对那些宁愿只使用线程和阻塞 IO 的用户说什么.当然,我认为对于许多系统来说,这是一种合理的方法.Rust 语言中没有任何东西阻止他们这样做.他们的反对意见似乎是 crates.io 上的生态系统,特别是用于编写网络服务的生态系统,以使用 async/await 为中心.偶尔,我会看到一个库以 “ 货物崇拜 “ 的方式使用 async/await,但大多数情况下,似乎可以安全地假设该库的作者实际上想要执行非阻塞 IO 并获得用户空间并发的性能优势.

我们谁也无法控制其他人决定做什么,事实就是大多数在 crates.io 上发布与网络相关的库的人都想要使用异步 Rust,无论是出于商业原因还是仅仅出于兴趣.我希望能够更轻松地在非异步上下文中使用这些库 (例如,通过将类似 pollster 的 API 引入标准库),但很难知道该对那些抱怨的人说什么,他们抱怨在网上免费提供代码的人与他们的用例不完全相同.

未完待续

尽管我认为 Rust 别无选择,但我不认为 async/await 是任何语言的正确选择.特别是,我认为有可能出现一种语言,它提供与 Rust 相同的可靠性保证,但对值的运行时表示的控制较少,它使用有栈协程而不是无栈协程.我甚至认为 - 如果这种语言以这样一种方式支持这种协程,即它们可以同时用于迭代和并发 - 该语言可以完全不使用生命周期,同时仍然消除由别名可变性引起的错误.如果你读了他的 笔记,你可以看到这就是 Graydon Hoare 最初的目标,在 Rust 改变方向成为一种可以与 C 和 C++ 竞争的系统语言之前.

我认为有一些 Rust 用户,如果这种语言存在,他们会很高兴使用它,我理解他们为什么不喜欢必须处理低级细节固有的复杂性.过去这些用户常常抱怨众多的字符串类型,现在他们更有可能抱怨异步.我希望有一种语言能为这个用例提供与 Rust 相同种类的保证,但问题不在于 Rust.

尽管我相信 async/await 是 Rust 的正确方法,但我也认为对当今异步生态系统的状态感到不满是合理的.我们在 2019 年发布了一个 MVP,tokio 在 2020 年发布了 1.0 版,此后的发展比任何参与者希望的都要停滞.在后续文章中,我想讨论当今异步生态系统的状态,以及我认为该项目可以做些什么来改善用户体验.但这已经是我发表过的最长的博文了,所以现在我就到此为止.