Why Async Rust
昨天的文章里,我介绍了 Async Rust 当前的实现以及一个实现 Async Runtime 需要了解的概念和现有的一些实践。
文章发出后,有评论称 Async Rust 创造性工作,没有可借鉴的经验。诚然,Rust 系统编程语言的定位就决定了它与其他有运行时的语言在设计时存在巨大的不同,同类语言 C 和 C++ 在异步编程方面,受限于语言的历史包袱,相关的支持往往以三方库而不是语言级的支持出现。
不过,Rust 至少可以借鉴自己一路走来的经验。Async Rust 的主要开发者之一 @withoutboats 在今年十月份的时候写了一篇 Why Async Rust 的博文,介绍了 Async Rust 一路发展的沿革和背后的设计决策。也是这一篇文章,帮助我理解了大量 Async Rust 设计的理由,从而融会贯通地写出昨天的文章。
随着软件系统越来越复杂,一个完整的复杂软件动辄几十上百万、甚至上千万行代码,提交记录少说几千,动辄几万甚至几十万。个人再也很难有足够的时间,单纯依靠读源代码和提交历史来理解系统运行的机制。这个时候,设计文档和作者现身说法讨论设计过程,尤其是失败的设计过程,就显得尤为重要。
实际上,我认为国内软件行业前沿的水平已经能跟世界前沿竞争,差的是时间积累的底蕴和人才的绝对数量。例如,全球第一波做数据库的人,到现在几乎都是三五十年经验起步,他们通过师父带徒弟甚至带出徒孙的形式,培养出了一大批领域专业人才。这一壁垒是很难走旁门左道超越的。
所幸开源运动的发展使得我们能够无国界的接触最前沿的软件设计思想和技术实践,我们于是可以对照一个完全开放的软件系统,根据核心开源开发者的介绍,理解这些智慧和经验的结晶。
以下原文,文中的“我”是 @withoutboats 的自称。
Rust 语言首次发布 async/await 语法时引起了很大的轰动。引用当时 Hacker News 的评论:
这将打开新世界的大门!我确信很多人都在等待这一特性,我自己就是其中之一。
此外,这个新趋势具备一切优点:开源、高质量的工程代码、公开设计,许多贡献者为这个复杂的软件做出了巨大贡献。真是鼓舞人心!
最近,Rust 语言中的 async/await 语法变得有些褒贬不一。再次引用一条在 Hacker News 上的评论:
我真的无法理解那些认为 Rust 的 async 设计很好的人。这简直是一团糟,更不用说 Rust 语言早已有着广为人知的陡峭学习曲线。
我竭尽全力尝试理解它,真的。但是,那真是一团巨大的混乱。而且它还会污染它接触到的一切。我真的很喜欢 Rust 语言,现在大部分时间都在用它编码,但每次遇到大量异步逻辑的 Rust 代码时,我都会全身紧张,视力模糊。
当然,这些评论都不能完全代表全部观点:早在四年前就有人提出了一些担忧。在同一条评论中,有人抱怨异步代码让自己全身紧张、视力模糊,但也有很多人同样热情地为异步 Rust 进行辩护。但我认为,可以说反对者越来越多,他们的口气也越来越强硬。在某种程度上,这只是炒作周期的自然进展。但是,我也认为随着时间推移,很多人未必记得或者了解异步 Rust 最初的设计过程,一些背景信息已经丢失了。
2017 年到 2019 年间,我与他人合作,在前人的工作基础上推动了 async/await 语法的设计。如果有人说,真不知道怎么会有人认为这团乱麻是个好的设计,我可能会有点不高兴。因此,请原谅我在这里用这篇不完善且过长的文章,解释异步 Rust 的诞生过程,异步 Rust 的目的,以及为什么在我看来,Rust 没有其他可行的选择。希望在这个过程中,我能够在某种程度上以更广泛、更深入的方式阐明 Rust 的设计,而不仅是重复过去的辩解。
关于术语的一些背景
在这场关于异步 Rust 的辩论中,争议的焦点之一是 Rust 决定使用所谓无栈协程(Stackless coroutine)的技术来实现用户空间并发。相关讨论充斥着大量术语,不熟悉所有术语是可以理解的。
我们首先需要明确的概念是这个特性的目的:用户空间并发。主流操作系统提供了一组相似的接口来实现并发:你可以创建线程,并在这些线程上依赖系统调用进行 IO 操作,这会阻塞线程直到操作完成。这些接口的问题在于它们存在一些不可避免的开销,如果你想要达到某些性能目标,这些开销可能成为限制因素。这些开销主要有两个方面:
- 在内核和用户空间之间进行上下文切换会消耗可观的 CPU 运行周期。
- 操作系统线程具有不可忽视的预分配线程栈,这增加了每个线程的内存开销。
这些限制在一定程度上是可以接受的。但是对于大规模并发的程序来说,它们并不适用。解决方案是使用非阻塞的 IO 接口,并在单个操作系统线程上调度海量并发操作。
开发者当然可以手动编写调度代码,但是现代编程语言通常提供现成的语法和标准库支持来简化这个过程。从抽象的角度来看,这意味着编程语言设计了一种把工作划分为多个任务,并将这些任务调度到线程上的方式。对应到 Rust 语言的设计,这就是 async/await 语法。
在这个问题的设计空间中,第一个选择的维度,是选择协作式调度还是抢占式调度,即任务是“协作地”将控制权交还给调度子系统,还是在运行过程中在某个时刻被“抢占”,而任务本身并不知道这一点?
关于这个主题的讨论中,经常被提及的一个术语是协程(coroutine),它的使用方式有些矛盾。协程是一种可以被暂停,并在稍后恢复的函数。其中一个重要的模糊之处,是有些人使用“协程”一词来指代具有显式语法以暂停和恢复的函数(这对应于协作调度的任务),而有些人则使用它来指代任何可以暂停的函数,即使暂停是由语言运行时隐式执行的(这也包括抢占式调度的任务)。我更倾向于第一个定义,因为这个定义做了一些有意义的区分。
另一方面,Goroutines 是 Go 语言的一项特性,它实现了抢占式调度的并发任务。Goroutines 具有与线程相同的接口,但是它是作为语言的一部分而不是作为操作系统原语来实现的,在其他语言中,这种特性通常被称为虚拟线程或者绿色线程。所以按照我的定义,Goroutines 不是协程。但是其他人使用更广泛的定义说 Goroutines 是一种协程,而我将这种特性称为绿色线程,因为在 Rust 中一直使用这个术语。
第二个选择的维度,是选择有栈协程还是无栈协程。
有栈协程和操作系统线程一样有程序栈:当函数作为协程的一部分被调用时,它们的栈帧会被推入栈;当协程暂停时,栈的状态会被保存,以便从同一位置恢复。
无栈协程采用不同的方式存储需要恢复的状态。它通常将状态存储在一个延续(continuation)或状态机中。当协程暂停时,它所使用的栈被接替协程的操作使用;当协程恢复执行时,协程重新持有当前栈,并使用先前存储的延续或状态机,从上一次暂停的位点恢复协程。
在 Rust 和其他编程语言中,关于 async/await 语法,经常提到的一个问题是“函数着色问题”。这个问题说的是,为了获得异步函数的结果,开发者需要使用不同的操作(例如使用 .await
语法)而不是正常调用它。绿色线程和有堆协程没有这个问题,因为只有无栈协程才需要特殊语法来管理无栈协程的状态,具体特殊语法表示什么行为则取决于语言的设计。
Rust 的 async/await 语法是无栈协程机制的一个例子。异步函数被编译为返回 Future 的函数,而该 Future 用于存储协程在暂停时的状态。关于异步 Rust 的辩论的基本问题是,Rust 是否正确采用了这种方法,或者它是否应该采用更类似 Go 的有栈协程或绿色线程方法。理想情况下,开发者希望不需要显式语法来“着色”函数。
异步 Rust 的开发历程
绿色线程
Hacker News 的另一个评论很好的展示了这场辩论中我经常看到的一种言论:
开发者期望的并发模型是基于有栈协程和管道实现的结构化并发,底层实现由工作窃取执行器来优化负载。
除非有人实现这个模型的一个原型,并将之与当前的 async/await 语法加 Future 的实现相比较,否则我认为不会有任何建设性的讨论。
抛开上面提到的结构化并发、通道和工作窃取执行器(因为这是完全是不相关的问题),这种言论令人困惑的地方在于,最初的 Rust 确实实现了有栈协程,即绿色线程。但在 2014 年底,也就是 1.0 版本发布即将之前,它被移除了。了解其中的原因将有助于我们弄清楚为什么 Rust 推出了 async/await 语法。
对于任何绿色线程系统(无论是 Rust、Go 还是其他语言),一个重要问题是如何处理这些线程的程序栈。请记住,用户空间并发机制的一个目标是减少操作系统线程使用的大型预分配栈的内存开销。因此,绿色线程库往往会尝试以较小的栈启动线程,并仅在需要时进行扩展。
实现这一目标的一种方法是所谓的分段栈,其中程序栈是一系列小栈段的链表;当栈增长超出段的边界时,会向列表中添加一个新段,而当栈缩小时,该段将被移除。
这种技术的问题在于,它引入了高度不可控的将栈帧推入栈的成本。如果帧适合当前段,这基本上没什么开销。但是一旦不适合,程序需要分配一个新段。最恶劣的情况是在热循环中的函数调用需要分配一个新段,这时,该循环的每次迭代都需要进行一次分配和释放,从而对性能产生重大影响。而且,这些开销对用户来说完全不透明,因为用户不知道在调用函数时栈的深度。Rust 和 Go 都开始使用分段栈,然后因为这些原因放弃了这种方法。
另一种方法称为栈复制。在这种情况下,栈更像是一个数组而不是链表:当栈达到限制大小时,它会重新分配一个更大的栈。这样可以使初始栈保持尽量小,只根据需要进行扩展。这种技术的问题在于,重新分配栈意味着对其进行复制,且新分配的栈位于内存中的新位置。同时,任何指向栈的指针在栈重新分配后就无效了,程序需要一些额外机制来更新这些指针。
Go 使用栈复制技术。这主要得益于以下事实:Go 中指向栈的指针只能存在于同一个栈中。因此,Go 运行时只需要扫描该栈以重写指针。然而,做到这点至少需要运行时类型信息,而 Rust 并不保留这些信息。此外,Rust 也允许指向栈外的栈指针,这些指针可能在堆中的某个位置,或者在另一个线程的栈中。跟踪这些指针的问题最终与垃圾回收的问题相同,只不过这次的目标不是释放内存,而是移动它。显然,Rust 没有垃圾回收器,也不打算内置某种垃圾回收器。上面两个理由的任一个都导致 Rust 无法采用栈复制的方法。
早期 Rust 采用分段栈的方案,并通过增加绿色线程的大小来缓解频繁分配分段栈的问题,这也是操作系统线程的做法。当然,这种方案失去了绿色线程的一个关键优势:比操作系统线程更小的栈帧。
即使 Go 使用了可调整大小的栈来解决开销问题,当 Go 程序尝试与其他语言编写的库集成时,绿色线程仍会带来一定的无法避免的成本。C ABI 和操作系统栈是任何编程语言共享的最低要求。把代码从绿色线程切换到运行在操作系统线程栈上,可能会导致无法承受的 FFI 成本。Go 只是接受了这种 FFI 成本,这也成为 Cgo 饱受诟病的一个问题。最近,C# 因为这个局限性中止了绿色线程的实验。
这个问题对 Rust 同样是致命的。因为 Rust 的设计目标之一,是支持将 Rust 库嵌入到用其他语言编写的二进制文件中;另一个设计目标,是能够在资源有限的嵌入式设备上运行,即使这些设备没有足够的时钟周期或内存运行虚拟线程运行时。
早期 Rust 为了解决这个问题,曾经将绿色线程运行时设置成可选的:Rust 可以在编译时选择将绿色线程编译成在原生线程上使用使用阻塞式 IO 运行的形式。因此,有一段时间,Rust 有两种变体:一种使用阻塞式 IO 和原生线程,另一种使用非阻塞式 IO 和绿色线程,并且所有代码都打算与两种变体兼容。
然而,实际结果并不理想,出于 RFC 230 的原因,从 Rust 移除了绿色线程的支持:
- 在绿色线程和原生线程之间的抽象并不是零成本的。执行 IO 操作时,会有无法避免的虚拟调用和内存分配,这对于特别是原生代码来说是不可接受的。
- 这一设计强制原生线程和绿色线程支持相同的接口,即使在某些情况下这并没有意义。
- 绿色线程并不完全支持互操作,因为仍然可以通过 FFI 原生的 IO 接口,即使是在绿色线程的上下文里。
绿色线程被移除了,但高性能用户空间并发的问题仍然存在。为了解决这个问题,Rust 团队开发了 Future trait 和后来的 async/await 语法。但要理解这条道路,我们需要先介绍 Rust 对另一个问题的解决方案。
迭代器
我认为 Rust 开始目前的异步功能设计,其真正起点可以追溯到 2013 年一位名叫 Daniel Micay 的开发者在邮件列表中发布的一篇帖子。这篇帖子与 async/await 语法、Future trait 或非阻塞 IO 没有任何关系,它是关于迭代器的。Micay 提议 Rust 转向使用所谓的外部迭代器,正是这种转变以及它与 Rust 的所有权和借用模型的有效结合,不可避免地将 Rust 引向了 async/await 的道路。当然,当时没有人知道这一点。
Rust 一直禁止使用变量的别名绑定来修改状态。这个常被称为 mutable XOR aliased 的约束在早期 Rust 中和今天一样重要。但是,最初它是通过不同于生命周期分析的机制来实施的。当时,引用只是某种参数修饰符,类似于 Swift 中的 inout 修饰符。2012 年,Niko Matsakis 提出并实现了 Rust 生命周期分析的第一个版本,将引用提升为真正的类型,并使其能够嵌入到结构体中。
尽管人们公认采用生命周期分析对塑造现代 Rust 的巨大影响,但是这一功能与外部迭代器的共生发展,以及这一功能对于 Rust 定位到当前领域的支柱型作用,并没有得到足够的关注。
在采用外部迭代器之前,Rust 使用了一种基于回调的方法来定义迭代器,这在现代 Rust 中大致可以表示为:
1 | enum ControlFlow { |
以这种方式定义的迭代器,将在集合的每个元素上调用 iterate 方法传入的回调函数。在回调函数返回 ControlFlow::Break
之前,迭代会不断产生下一个结果。for
循环的主体被编译为传递给正在循环遍历的迭代器的闭包。这种迭代器比外部迭代器更容易编写,但是这种方法存在两个关键问题:
- 语言无法保证当循环中断时迭代实际上停止运行。因此,语言无法依赖此功能来确保内存安全。这意味着无法从循环中返回引用,因为循环实际上可能会继续执行。
- 无法实现交错多个迭代器的通用组合子,例如
zip
方法,因为这种传递回调函数的设计不支持交替迭代两个不同的迭代器。
相反,Daniel Micay 建议 Rust 改用外部迭代器模式,从而完全解决了这些问题。外部迭代器的方式对应了 Rust 用户今天所熟悉的接口:
1 | trait Iterator { |
原注:深入了解底层细节的读者可能会知道,Rust 的 Iterator 提供了
try_fold
的方法,该方法在功能上与内部迭代器的 API 非常相似,并且用在其他一些迭代器组合子中,因为它可以生成更好的代码。但try_fold
不是定义迭代器的核心底层方法。
外部迭代器与 Rust 的所有权和借用系统完美地集成在一起,因为它们本质上编译为一个结构体,该结构体在自身内部保存迭代的状态,并且因此可以像任何其他结构体一样包含对正在迭代的数据结构的引用。而且由于单态化(monomorphization)的存在,通过结合多个组合子构建的复杂迭代器也编译成单个结构体,从而使优化器可以透明地优化这些结构。唯一的问题是,手动编写这些结构体更困难,因为开发者现在需要定义将用于迭代的状态机。
当时,Daniel Micay 当时写下这样一段话预示未来的发展:
在未来,Rust 可以使用像 C# 那样的 yield 语句来实现生成器。生成器被编译成一个快速的状态机,而无需进行上下文切换或依赖虚拟函数,甚至不需要闭包。这将消除使用外部迭代器模式时,手动编码递归遍历的难题。
生成器的进展不算快。不过,最近发布了一份关于 gen
语法的 RFC 提案,或许我们可能很快就会看到这个功能。
即使没有生成器,外部迭代器仍然被证明是非常成功的,人们普遍认识到了这项技术的价值。例如,Aria Beingessner 在设计字典的 Entry API 时就使用了类似的方法。值得注意的是,在 Entry API 的 RFC 中,她说这种 API 的设计“类似于迭代器”。她的意思是,Entry API 通过一系列组合子构建了一个状态机,这使得该 API 对编译器高度可读,从而能够进行深度优化。这项技术很有潜力。
Futures
在 Rust 团队寻找绿色线程的替代的过程里,Aaron Turon 和 Alex Crichton 首先复制了许多其他语言中使用的 Future/Promise 接口。这类接口基于所谓的传递延续风格(continuation passing style)设计。开发者可以向以这种方式实现的 Future 注册一个回调。这个回调被称为延续,它会在 Future 完成时被调用。
这就是大多数语言中定义 Future/Promise 的方式。这些语言会将 async/await 语法编译成传递延续风格的代码。
在 Rust 中,这类 API 大致会被定义成:
1 | trait Future { |
Aaron Turon 和 Alex Crichton 尝试了这种方法,但正如 Aaron Turon 在一篇具有启发性的博文中写的,他们很快遇到了一个问题,即使用传递延续风格需要为回调分配空间。Turon 举了一个 join
的例子:
1 | fn join<F, G>(f: F, g: G) -> impl Future<Item = (F::Item, G::Item)> |
在这个 join 函数的定义中,注册到返回值 Future 上的回调需要被两个子 Future 拥有,因为只有后完成的子 Future 后才应该执行它。这最终需要实现某种引用计数,以及依赖内存分配来实现。而对于 Rust 来说,这些开销都是不可接受的。
于是,他们研究了 C 开发者实现异步编程的常见做法。在 C 语言的世界里,开发者通过构建状态机来处理非阻塞 IO 操作。由此,Aaron Turon 和 Alex Crichton 希望能够设计出一种可以将 Future 编译成 C 开发者手动编写的状态机的接口。经过几番尝试,他们最终采用了一种称为基于就绪状态(readiness-based)的方法:
1 | enum Poll<T> { |
不同于存储延续的方案,在这个新方案中,Future 由某个外部执行器进行轮询。当一个 Future 处于挂起状态时,它会保存一种唤醒执行器的方式。当 Future 准备好被再次轮询时,执行该方式以通知执行器再次进行轮询。
译注:这里说得有点抽象。最终 Rust 的实现是 poll 方法接收一个 Context 参数,Context 持有一个 Waker 实例,如果 poll 返回 Pending 结果,那么返回前它需要以某种方式保存 Waker 实例。异步运行时需要实现一个监听状态变化的逻辑,在挂起的计算可以继续执行时,取出对应的 Waker 实例,调用 wake 或 wake_by_ref 方法通知执行器再次轮询这个 Future 计算。
通过以这种方式反转控制,Rust 不再需要为 Future 存储完成时需要调用的回调,而只需要将 Future 表示为一个单独的状态机。他们在这个接口之上构建了一系列组合子库,所有这些组合子都会被编译成一个单一的状态机。
从基于回调的方法切换到外部驱动程序,将一组组合子编译成单一的状态机,甚至是这两个 API 的确切规范:如果你是按顺序读到这一段的,所有这些都应该非常熟悉。从传递延续到外部轮询的转变,与 2013 年迭代器的转变完全相同!
再一次地,正是由于 Rust 能够处理具有生命周期的结构体,才使得它能够处理从外部借用状态的无栈协程,进而能够在保证内存安全的前提下,以最佳方式将 Future 表示为状态机。无论是应用于迭代器还是 Future 接口,将较小的组件组合成单个对象状态机的模式,是 Rust 的经典设计模式。这一模式几乎自然地融入到了语言当中。
我稍微停顿一下,强调迭代器和 Future 之间的一个区别:像 zip
这样交错两个迭代器的组合子,在基于回调的方法中甚至是不可能的,除非编程语言另外构建了某种协程的原生支持。另一方面,如果你想交错两个 Future 实例,比如实现 join
方法,基于延续的方法可以支持,只是会带来一些运行时成本。这就解释了为什么外部迭代器在其他语言中很常见,但 Rust 将这种转换应用于 Future 的做法却是独一无二的。
在最初的版本中,futures 库的设计原则是,尽可能使用户以类似构造迭代器的方式来构造 Future 实例:原语级别的 Future 库的作者将直接实现 Future trait 来定义原语级的 Future 结构,而编写应用程序的用户将使用 futures 库提供的一系列组合子,将简单的 futures 组合构建成更复杂的 Future 实例。不幸的是,当用户尝试遵循这种方法时,他们立即遇到了令人沮丧的编译器错误。问题在于,当 futures 被生成时,它们需要逃离(escape)当前上下文,因此无法从该上下文中借用状态。相反,任务必须拥有它的所有状态。
这对于 futures 组合子来说是个棘手的问题。因为通常情况下,任务状态需要在构成 Future 的一系列组合子中都能被访问。例如,用户通常会先调用一个 async 方法,然后调用另一个:
1 | foo.bar().and_then(|result| foo.baz(result)) |
问题在于,foo 在 bar 方法中被借用,然后在传递给 and_then 的闭包中再次被借用。实际上,用户想要做的是在 await 点之间存储状态,这个 await 点是由 Future 组合子的链接形成的,而这通常会导致令人困惑和费解的借用检查错误。最容易理解的解决方案是将该状态存储在 Arc 和 Mutex 中。但使用 Arc 和 Mutex 不是零成本的,而且,随着系统变得越来越复杂,这种方法变得非常笨重和不灵活。例如:
1 | let foo = Arc::new(Mutex::new(foo)); |
尽管最初的实验中 futures 展示了出色的基准测试结果,但由于这个限制,用户无法使用它们来构建复杂的系统。这个时候,我加入到 futures 的开发当中,着力改进这个状况。
async/await
在 2017 年末,由于上节末尾提到的用户体验不佳的原因,futures 生态系统并未得到好的反响。futures 项目的最终目标始终是实现所谓的无栈协程转换,即使用 async 和 await 语法的函数,可以转换为求值为 Future 的函数,避免用户手动构造 Future 实例及调用其方法。Alex Crichton 曾经开发了一个基于宏的 async/await 实现,但几乎没有引起任何关注。
是时候做出一些改变了。
Alex Crichton 实现的宏的核心问题之一,是一旦用户尝试在 await 点上保持对 Future 状态的引用,编译器就会报错。这实际上与用户在 futures 组合子中遇到的借用问题是相同的:在等待期间,Future 无法持有对自身状态的引用。因为这要求把 Future 编译成一个自引用的结构体,而 Rust 不支持这种实现。
如果把这个问题跟前文提到的绿色线程的问题进行比较,我们会得到一些有趣的结论。例如,我们说把 Future 编译成状态机的优势之一,是编译出来的状态机就是一个完美大小的栈。无论是手动实现、使用组合子或使用 async 函数构造出 Future 实例,编译后的 Future 大小都是它所需的最小大小。反观绿色线程,绿色线程的栈必须不断增长,以容纳任何线程栈可能具有的未知大小的状态。Future 状态机的大小在编译时确定后不再需要变化,因此,我们不会在运行时遇到栈增长的问题。
可以说,绿色线程的栈现在成了 Future 状态机结构保存的状态。由于 Rust 的语言约束要求移动结构体必须总是安全的,因此即使在执行期间,我们不会移动 Future 状态机,但是 Rust 的语言约束会要求它是能被移动的。这样,我们在状态机的方案里又遇到了绿色线程方案中遇到的栈指针问题。不过,这一次,我们有一个新的优势:我们不需要真的能够移动 Future 状态机,我们只需要表达 Future 状态机是不可移动的。
为了实现这一点,最初的尝试是引入 Move trait 的定义,这个 trait 用于将协程从可以移动它们的 API 中排除。不过,引入一个新的语言行为层面的 trait 遇到了我之前记录过的后向兼容性问题。关于 async/await 语法,当时有三个核心需求:
- Rust 语言层面就要实现 async/await 语法,从而支持用户使用类似协程的函数构建复杂的 Future 实例。
- async/await 语法需要支持将这些函数编译为自引用的结构体,从而支持用户在协程中使用引用。
- 这个功能需要尽快发布。
这三个需求的结合促使我寻找一种不对语言进行任何重大破坏性改变的替代方案。
我最初的计划比我们最终实现的方案要糟糕得多。我提议将 poll 方法标记为不安全的(unsafe),然后增加一个不变式,即一旦开始对一个 Future 进行轮询,就不能再次移动它。这个方案非常简单,可以立即实现,但是同时它也非常粗暴:它会使每个手写的 Future 都不安全,并且会施加一个难以验证的要求。更糟糕的是,编译器无法不安全的代码提供任何帮助。它很可能最终会遇到一些正确性问题,并且肯定会引起极大的争议。
所以,幸运的是,Eddy Burtescu 的几句话引导我思考另一个接口设计方向,这个 API 可以以更精细的方式强制执行所需的不变条件,也就是现在你所看到的 Pin
类型。Pin
类型本身有着一定的学习曲线,但我认为它还是显著优于当时我们考虑的其他选项,因为它是有针对性的、可强制执行的,而且,按照这个方案,我能按时发布 async/await 功能。
现在回顾起来,Pin 方案存在两类问题:
- 后向兼容性:一些已经存在的接口(特别是 Iterator 和 Drop 等)本应该支持不可移动类型。由于后向兼容性的要求,现在其实没有支持,这限制了语言进一步发展的可选项。
- 向终端用户暴露:我们的设计意图是,编写普通的异步 Rust 代码的开发者不需要处理 Pin 类型。在大多数情况下,这是正确的,但有一些典型的例外。几乎所有这些例外都可以通过一些语法改进来解决。唯一真正糟糕的问题是当前实现必须 Future 实例 Pin 住才能使用
.await
语法,这是一个不必要的错误,现在要修复它将是一个破坏性的改变(这着实让我感到尴尬)。
现在,关于 async/await 的设计决策只剩下语法的确定,我不会在这篇已经过长的文章中进行详细讨论。
组织上的考虑
我探索这段历史的原因是要证明一系列关于 Rust 的事实不可避免地将我们引入了特定的设计领域。
首先,Rust 没有运行时,所以绿色线程不是一个可行的解决方案。这不仅因为 Rust 需要支持嵌入到其他应用程序,或在嵌入式系统上运行,也因为 Rust 无法执行绿色线程所需的内存管理。
其次,Rust 的设计能够自然地把协程编译为高度可优化状态机,同时仍然保持内存安全性。我们不仅在 futures 和迭代器的设计中都利用了这一点。
但是,这段历史还有另一面:为什么我们要追求用户空间并发的运行时系统?为什么要引入 futures 和 async/await 语法?
提出这些问题的人通常有两种观点:
- 有些人习惯于手动管理用户空间并发,直接使用像 epoll 这样的接口;这些人有时会嘲笑 async/await 语法为“网络废物”。
- 另一些人只是说“你不需要它”,并提议使用更简单的操作系统并发机制,如线程和阻塞 IO 等。
在没有用户空间并发支持的语言,比如 C 语言中,实现高性能网络服务的人通常会使用手写的状态机。这正是 Future 抽象的设计目标,它可以自动编译成状态机,而无需手动编写状态机。实现无栈协程转换的目的,就是用户可以用顺序编程的方式编写代码,而编译器会在需要时生成状态转换以暂停执行。这样做有很大的好处。
例如,最近的一个 curl CVE 的底层原因就是代码在状态转换期间未能识别需要保存的状态。手动实现的状态机很容易出现这种逻辑错误,而 Rust 语言的 async/await 语法能在不损失性能的前提下避免这类逻辑错误。
2018 年初,Rust 团队决定在当年发布一个新的版本(edition),以解决一些在 1.0 版本中出现的语法问题。同时,Rust 团队计划以这个版本作为 Rust 进入主流市场的第一印象。Mozilla 团队主要由编译器黑客和类型理论学家组成,但我们对市场营销有一些基本的想法,并认识到这一版本可以成为产品广受关注的契机。我向 Aaron Turon 提议,我们应该专注于四个基本的用户故事,这些故事似乎是 Rust 的增长机会。这些故事包括:
- 嵌入式系统
- WebAssembly
- 命令行接口(CLI)
- 网络服务
这个建议成为建立领域工作组(Domain Working Groups)的起点,这些工作组旨在成为专注于特定使用领域的跨职能团队,区别于关注在具体技术或组织工作的团队(teams)。Rust 项目中工作组的概念后来有了很多改变,基本不再是最初设计的定义了,但我偏离了主题。
async/await 的工作开始于网络服务工作组,后来简称为 async 工作组并延续至今。然而,我们也非常清楚,由于 Rust 没有运行时依赖,Async Rust 也可以在其他领域发挥重要作用,尤其是嵌入式系统。我们设计这个功能时考虑到了这两种用例。
然而,尽管很少有人明说,Rust 要想成功,明显需要得到行业的采用。这样,即使 Mozilla 不再愿意资助一个实验性的新语言,Rust 也还能生存下去。而短期内行业采用的最有可能的途径,是在网络服务领域,特别是那些在当时被迫使用 C/C++ 编写的性能要求很高的系统。这个用例完全适合 Rust 的定位:这些系统需要细致的底层控制来满足性能要求,但是避免内存错误同样至关重要,因为它们面向网络。
网络服务的另一个优势是,这个软件行业领域具有快速采用新技术的灵活性和需求。其他领域当然也是 Rust 的长期发展的机会,但是它们不太愿意迅速采用新技术(嵌入式)、依赖于尚未广泛采用的新平台(WebAssembly)或者不是特别有利可图的工业应用,无法为 Rust 语言的发展带来资金支持(命令行界面)。
我以坚定的热情推动 async/await 的采用,因为我认为 Rust 的生存取决于这个功能。
在这方面,async/await 取得了巨大的成功。许多 Rust Foundation 最重要的赞助商,特别是那些雇佣开发者编写 Rust 代码的赞助商,重度依赖 async/await 编写高性能网络服务,这是他们愿意出资金支持的主要用例之一。在嵌入式系统或内核编程中使用 async/await 也是一个具有光明未来的领域。
async/await 在生态方面取得的巨大成功,甚至让开发者抱怨 Rust 生态系统滥用 async/await 而不是尽可能写“正常”的 Rust 代码。
我无法评价那些更愿意使用线程和阻塞 IO 的用户。当然,我认为有很多系统可以合理地采用这种方法,而且 Rust 语言本身并不阻止他们这样做。他们的反对意见似乎是,crates.io 上的生态系统,特别是用于编写网络服务的库,滥用 async/await 语法。我偶尔会看到一些使用 async/await 近乎船货崇拜(cargo cult)的库,但大多数情况下,可以合理地假设库作者实际上想要执行非阻塞 IO 以获得用户空间并发的性能优势。
我们无法控制每个人决定从事的工作。事实就是,大多数在 crates.io 上发布网络库的人都希望使用 async Rust 的能力,无论是出于商业原因还是出于兴趣。
当然,我希望在异步上下文以外也能方便的使用 async Rust 写成的库。这或许可以通过将类似于 pollster 的 API 引入标准库来实现。
不过,对于那些抱怨其他人发布的开源软件库不能恰好解决自己问题的人,我无言以对。
待续
虽然我认为 Rust 没有更好的 Async 解决方案,但是我并不认为 async/await 就是所有编程语言的最优解。特别是,我认为有可能有一种语言,它提供与 Rust 同等的可靠性保证,但是提供更少的对值的运行时表示的控制,同时使用有栈协程。我甚至认为,如果这种语言以一种可以同时用于迭代和并发的方式支持这些协程,那么该语言完全可以在不使用生命周期的情况下消除由于别名可变性而引起的错误。如果您阅读 Graydon Hoare 的笔记,您会发现这种语言正是他最初追求的目标。
我认为如果这种语言存在的话,有些 Rust 用户会非常愿意使用它,并且我理解他们为什么不喜欢处理底层细节的固有复杂性。以前,这些用户抱怨 Rust 语言有一大堆字符串类型,现在他们更有可能抱怨异步编程。我希望有一种提供与 Rust 同等保证的用于这些场景的语言存在,但是这不是 Rust 的问题。
尽管我认为 async/await 是 Rust 的正确演进路径,但我也同意对于当前的异步生态系统状况感到不满是合理的。我们在 2019 年发布了一个 MVP 实现,tokio 在 2020 年发布了 1.0 版本,自那时以来,Async Rust 的发展有些停滞不前。在后续的博文中,我希望讨论一下当前异步生态系统的状况,以及我认为项目可以做些什么来改善用户体验。但这已经是我发布过的最长的博客文章了,所以现在我只能到此为止了。