原文:Why async Rust?
译者:兔子不咬人


Rust 中的 async/await 语法发布之初备受关注和鼓舞!引用当时 Hacker News 的说法:

它将掀起新的序幕。我相信有很多人正等待该特性被 Rust 采用的一刻。我本人也绝对是其中一个。

此外,它保持了所有优点:开源、高质量的工程、开放的设计,大批贡献者为一个复杂的软件做出贡献。真是鼓舞人心!

最近,对它的接受程度却有些褒贬不一。这里引用近期关于该话题的一篇博文的评论,还是 Hacker News 上的:

我真的无法理解怎么有人能看到 Rust 的异步部分这堆烂摊子后还认为它是一个好的设计,何况是针对一个已经被认为非常复杂的语言来说。

我试过掌握它,真的试过了,俺滴个玉皇大帝啊,这也太混乱了。况且它还会肆意传染。我真的很喜欢 Rust,这阵子我大部分时间都在用它编码,但每每遇到异步密集的 Rust 代码时,我便下颌紧绷,视线逐渐模糊。

当然,这两条评论并不能完全代表所有人:其实在四年前便已经有人提出了担忧。在讨论“下颌紧绷,视线逐渐模糊”的这条评论的同一跟帖中,也有很多人以相同的热情捍卫着异步 Rust。但我觉得可以这么说,抱怨者的数量正越来越多,而且他们的口气也变得更为强硬。在某种程度上讲,这只是热度周期的自然发展过程,但同时我也认为随着与设计初衷越来越远,一些背景信息早已丢失。

2017 年至 2019 年期间,我在前人工作的基础上,与他人合作推动了 async/await 语法的设计。当有人说,他们不知道怎么会有人看到这个“烂摊子”还“认为它是个好设计”时,请原谅我有点不以为然。请容许我在这个组织地不够完美且过于冗长的篇幅中解释异步 Rust 是如何产生的,其目的及动因为何。在我看来,对于 Rust 没有其他可行选择。我希望在解释的过程中,至少可以在某种程度上更广泛、更深入地阐述 Rust 的设计,而不仅仅是重复过往的辩解。

术语背景简介

在这场辩论中争议的基本问题是,Rust 决定使用“无栈协程”方式来实现用户空间并发。本次讨论中使用了诸多术语,若不了解所有术语也在情理之中。

首先需要搞清楚的概念是该功能特性的主要目的:“用户空间并发”。主流操作系统提供了一组相当类似的接口来实现并发:你可以创建线程,并在该线程上使用系统调用来执行 IO 操作,这些 IO 操作在完成前会一直阻塞该线程。此类接口的问题在于它们需要一定的开销,当你在追求特定性能目标时,这些开销很可能会成为限制因素。体现在两个方面:

  1. 内核和用户空间之间的上下文切换在 CPU 周期中十分昂贵。
  2. 操作系统线程具有大量预分配的堆栈,增加了各线程的内存开销。

以上限制在一定程度上是可以接受的,但对于大规模并发的程序来说就不好使了。解决方案是使用非阻塞 IO 接口,并在单个操作系统线程上调度多个并发操作。程序员可以“手动”完成这项操作,但现代编程语言通常提供了更便利的功能来完成相同的工作。从抽象层面看,编程语言用某种方式将整个工作分解成任务,并将任务调度到线程上。体现到 Rust 上则是通过 async/await 实现的。

在该设计空间中,第一个抉择是采用协作式调度还是抢占式调度。任务是否“协作地”将控制权交还给调度子系统,或者运行期间可以在某个点“抢占地”停止它,且使任务并不知晓?

在相关讨论中经常提到的一个术语是协程,而且它被用在了一些互相矛盾的地方。协程是一个可以暂停继而恢复的函数。关于它最大的分歧是,有些人会使用术语“协程”来表示那些具有显式的暂停及恢复语法的函数(对应协作式调度任务),有些人则使用它来表示任意可以暂停的函数,即便暂停是由语言运行时隐式执行的(还包括抢占式调度任务)。我更喜欢第一种定义,毕竟它带来了一些有价值的区分。

另一方面,Goroutines 是 Go 语言的一项功能,它可以实现并发的、抢占式调度的任务。其具有与线程相同的 API,不过它被实现为语言的一部分,而不再作为操作系统原语。这在其他语言中通常被称为虚拟线程绿色线程。故按我的定义,Goroutines 不算是协程,但也有人使用更广泛的定义说 Goroutines 是一种协程。我倾向于将此称为绿色线程,因为这是 Rust 中使用的术语。

第二个抉择是采用堆栈式还是无堆栈式协程。堆栈式协程与操作系统线程类似,它具有程序堆栈:当函数作为协程的一部分被调用时,它们的栈帧被推到堆栈上;当协程暂停时,堆栈状态被保存,以便可以从相同的位置恢复。另一方面,无堆栈式协程则以不同的方式存储需要恢复的状态,比如在续体或状态机中。暂停时,其所使用的堆栈被接管操作使用,恢复时,其重新控制堆栈,并由续体或状态机恢复至协程的中断位置。

一个常被提及的话题是 async/await(在 Rust 和其他语言中)的“函数着色问题[1]” —— 一种对“为了获取异步函数的结果,需要使用不同的操作(例如等待它)而不是正常调用它”的怨言。绿色线程和堆栈式协程机制均可以避免此问题,毕竟它们使用了特殊的语法(具体取决于语言)来指示正在发生某种特殊事件以期管理协程的无堆栈状态。

Rust 的 async/await 语法是无堆栈式协程机制中的一例:异步函数被编译为返回 Future 的函数,该 Future 用于在协程暂停时存储其状态。回到辩论的基本问题: Rust 是否正确采用了此方法,或者是否应该索性采用更类似 Go 的“堆栈式”或“绿色线程”方法,进而最好不使用“着色”函数的显式语法。

异步 Rust 的开发过程

绿色线程

第三个 Hacker News 上的评论很好地代表了在这场辩论中我经常听到的一种声音:

人们想要的替代并发模型是通过工作窃取执行器上的堆栈式协程及通道来实现的结构化并发。

除非有人做了演示,并将其与基于 futures 的 async/await 进行比较,否则我认为无法展开任何建设性讨论。

暂时不考虑结构化并发、通道和工作窃取执行器(完全无关的问题),像这类令人困惑的评论的问题所在是,最初 Rust 确实有一种以绿色线程的形式拥有堆栈式协程的机制。它在 2014 年底被移除,就在 1.0 版本发布之前。了解移除的原因将有助于理解为什么 Rust 推出了 async/await 语法。

对于任何绿色线程系统——无论是 Rust 的、Go 的还是任何其他语言的——一个重大问题就是如何处理这些线程的程序堆栈。切记,用户空间并发机制的目标之一就是减少操作系统线程所使用的大型、预分配堆栈的内存开销。因此,绿色线程库往往倾向于采用某种机制来生成占用较小堆栈的线程,并仅在需要时扩展它们。

实现此目标的一种方法是所谓的“分段堆栈”,它将堆栈做成一系列小堆栈段的链表;当堆栈增长并超出段的上限时,新的段将被添加到链表中,当堆栈缩小时,该段被移除。该技术的问题在于将栈帧推送到堆栈中的成本是极度不确定的。如果栈帧恰在当前段中,基本上是零开销。否则,则需要分配一个新的段。针对这一问题有种特别恶劣的情况:在热循环中的函数调用需要分配一个新段。这将在该循环的每次迭代中形成一次内存分配和释放,严重影响性能。而这一切对于用户来说完全不透明,因为用户并不知晓在调用函数时堆栈的深度。Rust 和 Go 开始时都采用了分段堆栈,然后出于这些原因放弃了此做法。

另一种方法被称为“堆栈复制”。在这种情况下,堆栈更像是个 Vec 而非链表:当堆栈即将达到上限时,它会被重新分配为更大的空间,以避免达到限制。这种做法允许堆栈从较小容量开始并根据需要增长,而且不存在分段堆栈的缺点。但问题在于,重新分配堆栈意味着要复制它们,也就意味着堆栈将会分配到内存中新的位置。任何指向堆栈的指针都会失效,因而需要某种机制来更新它们。

Go 使用了堆栈复制,受益于 Go 中指向堆栈的那些指针只能存在于同一堆栈中,故而只需扫描这一堆栈以重写指针即可。尽管这需要依赖运行时类型信息,而 Rust 并不保留这类信息,但 Rust 仍然允许指向堆栈的指针不存储在同一特定堆栈内——可以在堆中的某处,也可以在另一个线程的栈中。追踪这些指针最终变得像是垃圾回收问题,只不过它是在移动内存而不是释放内存。Rust 不可能采用该方式,因为 Rust 没有垃圾回收器,继而最终无法采用堆栈复制。相反,Rust 解决分段堆栈问题的方法是通过使其绿色线程足够大,就像操作系统线程一样,可这也就消除了绿色线程的一个关键优势。

即便采用了像 Go 那样堆栈大小可调整的解决方案,当尝试与其他语言编写的库集成时,绿色线程仍会带来一些无法避免的成本。C 语言的 ABI 及其操作系统堆栈是各语言的共享最低标准。将代码从绿色线程切换到操作系统线程堆栈上运行可能会造成高的可怕的 FFI 成本,而 Go 选择了接受这一 FFI 成本。最近,C# 因为该原因中止了关于绿色线程的实验。

对于 Rust 来说这是个特别棘手的问题,因为 Rust 的设计旨在支持像将 Rust 库嵌入到另一种语言编写的二进制文件中,并且在没有时钟周期或没有内存来运行虚拟线程运行时的嵌入式系统上运行。为了尝试解决该问题,绿色线程运行时变得可选,而 Rust 可以被编译为使用阻塞 IO 在原生线程上运行。这被设计为由最终二进制文件在编译期决定。因此,在一段时间内 Rust 出现了两种变体,一种使用阻塞 IO 及原生线程,另一种则使用非阻塞 IO 及绿色线程,且所有代码均旨在与两种变体兼容。然而事情并不顺利,绿色线程由于 RFC 230 被从 Rust 中移除,其中列举了移除原因:

  • 绿色线程和原生线程之间的抽象并非“零成本”,执行 IO 操作时会导致无可避免的虚函数调用及内存分配,这是不可接受的,尤其是对于原生代码而言。
  • 它迫使原生线程和绿色线程支持相同的 API,即便在某些不合理的情况下。
  • 它并非完全可互操作的,因为即使在绿色线程上,仍然可以通过 FFI 调用原生 IO。

一旦绿色线程被移除,高性能的用户空间并发问题便仍待解决。这就是 Future trait 以及随后开发出的 async/await 语法。但为了理解这一过程,我们需要再退一步,看看 Rust 对另一个问题的解决方案。

迭代器

我觉得异步 Rust 之旅真正的起点可以追溯到 2013 年,前贡献者 Daniel Micay 在一份旧邮件列表上发表了一篇文章。该文章与 async/await、future 或非阻塞 IO 无关:它是一篇关于迭代器的文章。Micay 提议将 Rust 转向使用所谓的“外部”迭代器,正是这一转变——以及它与 Rust 的所有权、借用模型相结合后的效果——使 Rust 不可避免地走上了 async/await 的道路。显然,当时还没人意识到这点。

一直以来 Rust 禁止通过绑定变量别名来修改状态——这一“可变或别名[2]”的原则对早期乃至今日的 Rust 一样至关重要。但最初该原则不是通过生命周期分析来保障的,而是通过不同的机制。当时,引用还只是“参数修饰符”,概念上类似于 Swift 中的“inout”修饰符[3]。2012年,Niko Matsakis 提出并实现了第一个版本的 Rust 生命周期分析,将引用提升为真正的类型,并允许将其嵌入到结构体中。

尽管转为生命周期分析对今日 Rust 的巨大影响已得到认可,但它与外部迭代器的共生交互,以及该 API 对 Rust 稳固当前地位的重要性,却没有得到足够的重视。在采用“外部”迭代器之前,Rust 使用基于回调的方式来定义迭代器,用现在的 Rust 来演示的话看起来大概类似:

enum ControlFlow {
    Break,
    Continue,
}

trait Iterator {
    type Item;

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

这种方式定义的迭代器会在集合的每个元素上调用其回调函数,除非返回 ControlFlow::Break 来停止迭代。for 循环的主体部分会被编译为闭包并传递给正在循环中的迭代器。这种迭代器比外部迭代器容易编写得多[4],但它存在两个关键问题:

  1. 当要求中断循环时,语言无法保证迭代实际上会停止运行,因此也就无法基于它来保障内存安全。这意味着不可能出现像从循环中返回引用这样的操作,毕竟实际上循环可能还在继续运行。
  2. 无法实现用于交叉多个迭代器的适配器,例如 zip,因为该 API 不支持交替地依次迭代多个迭代器。

相反,Daniel Micay 建议将 Rust 改为使用“外部迭代器”,这样就能彻底解决以上问题,并采用 Rust 用户现已习惯的接口:

trait Iterator {
    type Item;

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

外部迭代器能够与 Rust 的所有权和借用系统完美结合,它在底层会被编译为一个结构体(内部保存着迭代状态),因此可以像其他结构体一样包含对正在迭代的数据结构的引用。由于采用了单态化技术,由多个组合器组合而成的复杂迭代器也能编译成单个结构体,进而对优化器来说一切也是透明的。唯一的问题是,由于需要定义用于迭代的状态机,它变得更难于手工编写。当时,Daniel Micay 预言了未来的发展:

未来,Rust 可以像 C# 一样使用 yield 生成器,它会被编译成一个高效的状态机,无需上下文切换、虚函数甚至闭包。这将消除使用外部迭代器手工编写递归遍历的困难。

然而,生成器方面的进展并不迅速,尽管最近发布的一份令人兴奋的 RFC 表明我们可能很快就会看到这一功能。

即使没有生成器,外部迭代器也取得了巨大的成功,该技术的价值也已得到了认可。例如,Aria Beingessner 在 “Entry API” 中使用了类似的方法来访问 map。值得注意的是,在该 API 的 RFC 中,她将其称为“类迭代器[5]”。她的意思是,API 通过一系列组合器构建了一个状态机,在编译器看来这是高度可读的,因此也是可优化的。该技术经久不衰。

Futures

当开始着手替换绿色线程时,Aaron Turon 和 Alex Crichton 参考了许多其他语言使用的 API,这类 API 后来被称为 futures 或 promises,它们基于所谓的“续体传递风格[6]”构建。以这种方式定义的 future 将回调作为一个额外的参数,即续体,并在 future 完成时调用该续体作为最终操作。这是大多数语言中定义此种抽象的方式,而大多数语言的 async/await 语法也会编译为续体传递风格。

在 Rust 中,上述 API 可能看起来会像这样:

trait Future {
    type Output;

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

Aaron Turon 和 Alex Crichton 尝试了该方法,但正如 Aaron Turon 在一篇富有启发性的博文[7]中所写,他们很快就遇到了一个问题,即频繁使用续体传递风格通常需要为回调分配内存。Turon 以 join 为例:join 接收两个 future 并同时运行它们。这两个子 future 均需持有 join 的续体,因为无论最终是哪个 future 完成,都需要执行它。这就导致了需要引用计数并分配内存来实现它,Rust 对此表示不接受。

之后,他们研究了 C 语言程序员如何实现异步编程:在 C 语言中,程序员通过创建状态机来处理非阻塞 IO。于是他们想通过一种对 Future 的定义来解决该问题,依赖该定义要能够编译成形如 C 语言程序员手工编写的那种状态机。经过一番尝试后,他们采用了所谓的“基于就绪状态”的方法:

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 表示为状态机。这种从较小的组件中构建单对象状态机的模式,是 Rust 工作方式的关键部分,无论是应用于迭代器还是 future。它几乎是自然而然地从语言中脱胎。

在此,我想强调一下迭代器和 future 之间的一个区别:除非编程语言对建立其上的协程有某种原生支持,否则像 Zip 这样交错使用两个迭代器的组合器根本无法采用类似回调的方法。另一方面,如果你想交错使用两个 future,比如 Join,基于续体的方法就可以支持:只不过会产生一些运行时成本。这就解释了为什么外部迭代器在其他语言中很常见,但 Rust 却独一无二地将这种转换应用于 future。

在最初的迭代中,future 库的设计原则是,让用户以与构造迭代器几乎相同的方式构造 future:底层库的作者使用 Future trait,而编写应用程序的用户将使用 futures 库提供的一组组合器,通过较简单的组件构建更复杂的 future。不幸的是,当用户试图遵循这种方式时,他们立即遇到了令人沮丧的编译错误。问题在于,future 在启动时需要“逃离”周围的上下文,因此不能从上下文中借用状态:任务必须拥有其完整状态。

这对 future 组合器来说成了问题,因为在多个组合器中访问状态通常很有必要,而这些组合器又构成了构造 future 的操作链的一部分。例如,用户通常会在对象上先调用一个“异步”方法,然后再调用另一个,写法如下:

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

问题在于 foo 已在 bar 方法中被借用,随后又在传递给 and_then 的闭包中被借用了。从本质上讲,用户想要做的是在“跨 await 点”之间存储状态,而 await 点由 future 组合器链构成;这通常会导致扑朔迷离的借用检查错误。最直接的解决方案是将状态存储在 ArcMutex 中,但它们并不是零成本的,更重要的是,随着系统复杂度的增加,这种方法会变得非常笨拙和不便。例如:

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 项目的最终目标始终是实现所谓的“无栈式协程转换”——使用 async 和 await 语法操作符的函数,可以转换为返回结果为 future 的函数,从而避免用户手工编写 future。Alex Crichton 开发了一套基于宏的 async/await 实现库,但几乎没有引起任何关注。因此,必须有所改变。

Alex Crichton 的宏的最大问题之一是,如果用户尝试在 await 点保持对 future 状态的引用,就会产生错误。这实际上与用户在使用 future 组合器时遇到的借用问题相同,只不过变成了在新的语法中复现而已。future 在等待时不可能持有对自身状态的引用,因为那样需要编译成自引用结构,而 Rust 并不支持自引用结构。

把这点与绿色线程的问题进行比较会很有趣。我们解释过将 future 编译为状态机的一种方式,是说状态机是个“大小恰好的堆栈”——与绿色线程的堆栈不同,绿色线程的堆栈必须能够增长,以容纳任意线程所可能具有的未知大小的堆栈状态,而编译后的 future(无论是手动实现、使用组合器还是使用 async 函数)恰好是它所需要的大小。因此,运行期堆栈增长的问题不再存在。

然而,这一堆栈被表示为结构体,而在 Rust 中移动结构体应当总是安全的。意味着,即使执行中的 future 不需要移动,但根据 Rust 的规则必须能够支持移动。因此,在绿色线程中遇到的堆栈指针问题在新系统中还会再次出现。不过,这次的优势在于,我们不需要真的移动 future,我们只需要表达 future 是不可移动的。

最初试图实现这点的做法是定义一个名为 Move 的新 trait,以便将协程从可以移动的 API 中排除掉。该做法遇到了些向后兼容的问题,我之前已经记录过这些问题[8]。关于 async/await 的论点我主要有三个:

  1. 需要在语言中提供 async/await 语法,以便用户使用类似于协程的函数来构建复杂的 future。
  2. async/await 语法需要支持将这些函数编译为自引用的结构体,以便用户可以在协程中使用引用。
  3. 该 feature 功能需尽快发布。

综合以上三点,我开始寻找 Move trait 的替代方案,一种可以在不对语言进行任何重大破坏性更改即可实现的解决方案。

我最初的计划比如今最终得到的方案要糟糕得多。我提议将 poll 方法标记为 unsafe,并增加一个不变条件:“一旦开始轮询 future,就不能再移动它”。该方案很简单,马上就能实现,也极其粗暴:它会让每个手写的 future 都变得不安全,并强加了一个编译器也无法提供帮助的难以验证的规则。该方案最终可能会因为稳定性问题而搁浅,也肯定会引起极大的争议。

接下来,Eddy Burtescu 提出的几点意见引导我找到了更好的 API,新的 API 能够以更精细的方式执行所需的不变条件。最终形成了 Pin 类型。尽管 Pin 类型本身已经引起了相当大的争议,但我认为,与当时其他备选之策相比,它无疑是一大改进,因为它具有针对性、可执行,也能按时发布。

事后看来,Pin 方式存在两个问题:

  1. 向后兼容性:出于种种原因,一些已经存在的接口(尤其是 IteratorDrop)本应支持不可移动类型,这限制了进一步开发语言时的选择。
  2. 暴露给最终用户:我们的初衷是让编写“普通异步 Rust”的用户永远不必与 Pin 打交道。大多数情况下也确实如此,但也有几处值得关注的例外,而所有这些情况几乎都可以通过语法改进来解决。唯一一个非常糟糕(也使我个人感到尴尬)的问题是,在 await 一个 future trait 对象前你必须先 pin 住它。这是个不必要的错误,而现在修复它却会导致破坏性的变更。

其他关于 async/await 的决策都是语法上的,我就不在这篇文章中赘述了,它已经够长了。

组织结构方面的考虑

我之所以要探究这些历史,是为了证明 Rust 的一系列事实不可避免地将我们带入到一个特定的设计空间。首先,Rust 缺乏运行时,使得绿色线程成为不可行的解决方案,而且 Rust 需要支持嵌入(既支持嵌入到其他应用程序中,也支持在嵌入式系统上运行),无法为绿色线程的执行提供必要的内存管理。其次,Rust 天然具有将协程编译为高度可优化的状态机的能力,同时仍能保证内存安全,我们不仅利用这一点来处理future,也用于处理迭代器。

但这段历史还有另一面:为什么要为用户空间并发设计运行时系统?为什么要引入 future 和 async/await 呢?这种争论通常有两种形式:一方面,有些人习惯于“手动”管理用户空间并发,直接使用像 epoll 这样的接口,他们有时会嘲笑 async/await 语法是“网页垃圾”。另一方面,有些人只是说 “并不需要它”,并建议使用线程和阻塞 IO 等更简单的操作系统并发功能。

在没有用户空间并发功能的语言(如 C)中,编写高性能网络服务的人往往会采用手写状态机来实现它。而这正是 Future 被设计用来编译的目标,无需手工编写状态机:协程转换的要义在于编写命令式代码,“就好像你的函数永远不会产生”,但会让编译器生成状态转换,以便在阻塞时挂起它。这样做可不是只有微不足道的好处,最近的 curl CVE 漏洞就是因为在状态转换过程中无法识别需要保存的状态而导致的。这类逻辑错误在手工实现状态机时很容易发生。

Rust 推出 async/await 语法的目的是发布这样一种功能,它可以避免该类错误,同时仍能保持相同的性能指标。鉴于我们提供的可控级别以及没有运行时内存管理,像这样的系统(通常用 C 或 C++ 编写)完全符合我们的目标受众。

2018 年初,Rust 项目致力于在当年发布一个新的“版本”,以修复 1.0 版本中出现的语法问题。同时决定以该版本为契机,宣布 Rust 已经准备好投入实际应用;Mozilla 团队大多是编译器方面的极客和类型方面的理论家,但我们对市场营销也有一定的了解,并认识到这一版本是让更多人关注产品的机会。我向 Aaron Turon 提议,我们应该把重点放在四个基本应用场景中,这似乎是 Rust 的发展机会。它们是:

  • 嵌入式系统
  • WebAssembly
  • CLIs
  • 网络服务

这一提议成为创建“领域工作组”的发端,这些工作组旨在成为专注于特定“领域”(相较与管控技术或组织的现有“团队”)的跨职能小组。Rust 项目中的工作组概念自那时起发生了变化,而且大部分已失去了本意,但我还是想说点题外话。

有关 async/await 的工作是由所谓的“网络服务”工作组率先开展的,该工作组最终被简称为 async 工作组(至今沿用此名称)。不过,我们也敏锐地意识到,由于缺乏运行时依赖,异步 Rust 也可以在其他领域,尤其是嵌入式系统中发挥重要作用。我们在设计 feature 功能时同时考虑了这两种用例。

Rust 要取得成功就需要被业界采用,这是不言而喻的,这样一旦 Mozilla 不再愿意资助这个实验性的新语言时,Rust 仍能继续得到支持。很显然,网络服务是短期内被行业采用的最可能的方向,尤其是那些当时为了性能而不得不用 C/C++ 编写的服务。这种场景完全符合 Rust 的定位——需要高度控制以满足其性能要求的、由于暴露在网络中而使得避免可利用的内存漏洞至关重要的系统。

定位于网络服务的另一个优势在于,软件行业的这一分支具备灵活性和接受新技术的热情,能够迅速采纳诸如 Rust 这样的新技术。对于 Rust 来说,其他领域虽也存在着长线机会,但现在看来,那些领域要么接纳新技术的速度太慢(嵌入式),要么依赖于尚未广泛应用的新平台(WebAssembly),或者不是特别有利可图的、无法带来资金的工业应用(CLIs)。我抱着Rust的生存取决于异步/等待特性的坚定信念,积极投身于相关工作中。

async/await 在网络服务方向上取得了巨大成功。Rust 基金会的许多著名赞助商,尤其是那些付钱给开发者的赞助商,都依赖 async/await 来编写高性能的网络服务,并将其作为证明他们投资合理性的主要用例之一。在嵌入式系统或内核编程中使用 async/await 也日渐成为热点。async/await 如此成功,最常见的抱怨反而是它的生态系统过于以它为中心,不太像“常规的” Rust。

对于那些更愿意使用线程和阻塞式 IO 的用户,我不知道该说些什么。当然,我知道对很多系统来说那也是合理的做法。而且 Rust 语言本身也不会阻止他们这样做。这些人的反对意见似乎在于,crates.io 上的生态系统,尤其是编写网络服务的生态系统,都是围绕着使用 async/await 特性展开的。我偶尔也会看到一些库使用 async/await 的方式近乎“货物崇拜[9]”,但大多数情况下,我们可以放心地假设库的作者实际上是想执行非阻塞 IO 并获取用户空间并发的性能优势。

谁都无法左右别人的决定,但事实情况是,无论出于商业原因还是兴趣,大多数在 crates.io 上发布网络相关库的人都愿意使用异步 Rust。我希望能够在非异步上下文中更容易地使用这些库(例如,在标准库中引入类似 pollster 的 API),但对于那些抱怨没有在线上找到符合其用例场景的免费代码的人们,我无话可说。

未完待续

尽管我坚持认为 Rust 没有其他选择,但我并不认为 async/await 是所有语言的最佳替代方案。特别地,我认为有可能存在某种语言,它在提供与 Rust 相同的可靠性保障的同时,对值的运行时表示方式控制较少,并且使用堆栈式协程而不是无堆栈式协程。我甚至认为,如果该语言能够以支持迭代和并发的方式支持这样的协程,那么它甚至可以完全不需要生命周期,同时消除因别名可变性引起的错误。如果你读过 Graydon Hoare 的笔记[10],就会看到这正是他最初的目标,但是在 Rust 改弦更张为可以与 C 和 C++ 竞争的系统语言之前。

我认为 Rust 的用户非常乐意使用这门语言,我也理解他们为什么不乐意处理那些底层细节所固有的复杂性。以往这些用户总是在抱怨字符串类型繁多,而现在他们又转嫁于抱怨异步问题。我希望有某种语言能搞定这个问题,又像 Rust 一样为其提供保障,但问题并不出在 Rust 身上。

尽管我相信 async/await 是 Rust 的正确选择,但我也认为对 async 生态系统现状感到不满是合理的。我们在 2019 年发布了 MVP,tokio 在 2020 年发布了 1.0 版本,但自那以后,事情就停滞不前了,我想这是所有相关人员都不愿意看到的。在后续文章中,我想讨论一下 async 生态系统的现状,以及我认为项目可以做些什么来改善用户体验。这已经是我发表过的最长的博文了,所以我得就此打住。


译注:
[1] 异步函数是红色,同步函数是蓝色。详细内容可以参考 https://journal.stuffwithstuff.com/2015/02/01/what-color-is-y...
[2] 原文中的“或”使用的是“XOR”,即“异或”,意指“值可以是别名或可变的,但不能同时是两者”。
[3] Swift 中的 inout 可以让值类型以引用方式传递给函数,此时函数可以更改函数外变量的值。
[4] 这里所谓的容易编写,指的是实现回调迭代器的编译器更容易编写,不是指用户侧代码。
[5] 是“类似”的“类”,不是“类型”的“类”。 :-)
[6] CPS(continuation-passing style)一般翻译为“延续传递风格”,也可译成“续体传递风格”,是函数式编程中的常见术语,新手可以拿异步回调的方式做简单理解,只是它的定义更为广泛和抽象。我在这里采用后一种译法,是为了将 continuation 单独出现时译作“续体”,这样更容易被读者当作名词接纳,而“延续”很难在不同语境中区分其是名词或动词。
[7] 该文章地址为 https://aturon.github.io/blog/2016/09/07/futures-design/
[8] 该记录的地址为 https://without.boats/blog/changing-the-rules-of-rust/
[9] 又译“货物运动”,是一种宗教形式,尤其出现于一些与世隔绝的落后土著之中。当货物崇拜者看见外来的先进科技物品,便会将之当作神祇般崇拜。比较有趣的是 "cargo" 这个词在 Rust 语境下的双关含义。
[10] 该笔记地址为 https://graydon2.dreamwidth.org/307291.html


Qiang
271 声望25 粉丝

Hello segmentfault