为什么Rust异步设计成这样 ?

Rust 中的 Async/await 语法最初发布时引起了热烈的关注和兴奋。引用当时的Hacker News

这将打开防洪闸门。我相信很多人都在等待 Rust 采用的这一刻。我肯定是在这艘船上的。

此外,它还具有所有优点:开源、高质量工程、开放设计、复杂软件的大量贡献者。真正鼓舞人心!

最近,人们的反应有点复杂。再次引用 Hacker News 上的评论,讨论最近关于该主题的博客文章:

我真的无法理解为什么有人会看到 Rust 的异步混乱,并认为对于一种已经以编写起来非常复杂而闻名的语言来说,这是一个很好的设计。

我试图得到它,我真的做到了,但天哪,那真是一团糟。它也会污染它所接触到的一切。我真的很喜欢 Rust,这些天我的大部分编码都是用它来完成的,但每次我遇到异步密集型 Rust 代码时,我都会咬紧牙关,视力模糊。

当然,这些言论都不具有完全代表性:即使在四年前,也有人提出过尖锐的担忧。在这条关于咬紧牙关和视力模糊的评论的同一条线索中,有很多人以同样的热情捍卫异步 Rust。但我不认为我会自掏腰包地说,随着时间的推移,反对者越来越多,他们的语气也越来越尖锐。在某种程度上,这只是炒作周期的自然进展,但我也认为,随着我们距离最初的设计过程越来越远,一些当时的参考的上下文背景已经丢失。

2017 年至 2019 年间,我与其他人合作并在前人工作的基础上推动了 async/await 语法的设计。如果有人说他们不知道怎么会有人看到这种“混乱”并“认为这是一个很好的设计”,如果我有点犹豫,请原谅我,请让我沉浸在这个组织不完善且过于冗长的解释中 异步 Rust 是如何存在的,它的目的是什么,以及为什么在我看来,Rust 没有可行的替代方案。我希望一路走来,我可以在更广泛、更深入的意义上(至少是轻微地)对 Rust 的设计有更多的了解,而不仅仅是重复过去的理由。

一些术语的背景

这场争论的基本问题是 Rust 决定使用 “无栈协程(stackless coroutine)” 方法来实现用户空间并发。这次讨论中出现了很多术语,不熟悉所有术语也是合理的。

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

  1. 就 CPU 周期而言,内核和用户空间之间的上下文切换非常昂贵。
  2. 操作系统 每个线程有一个大的预分配栈(大约2-3M),这增加了每个线程的内存开销。

这些限制在某种规模程度上是可以接受的,但对于大规模并发程序来说它们不起作用。解决方案是使用 非阻塞 IO 接口并在单个操作系统线程(select,poll, epoll之类) 上安排许多并发操作。这可以由程序员“手动”完成,但现代语言经常提供使这变得更容易的工具。抽象地说,语言有某种方法将工作划分为 任务并将这些任务调度到线程上。 在Rust 的系统中是 async/await。

该设计空间中的第一个选择轴协作调度和抢占式调度。任务是否必须“协作 cooperatively”地将控制权交还给调度子系统,或者它们是否可以在运行时的某个时刻“抢先preemptively”停止,而任务却没有意识到?

在这些讨论中经常出现的一个术语是协程,它的使用方式有些矛盾。协程是一个可以暂停然后恢复的函数。最大的歧义在于,有些人使用术语“协程”来表示具有用于暂停和恢复的明确语法的函数(这对应于协作计划的任务),而有些人则使用它来表示 任何 可以暂停的函数,甚至是如果暂停是由语言运行时隐式执行的(这还包括抢占式计划任务)。我更喜欢第一个定义,因为它引入了某种有意义的区别方式。

另一方面,Goroutines 是 Go 语言的一项功能,它支持并发、抢占式调度任务。它们具有与线程相同的 API,但它是作为语言的一部分而不是操作系统原语实现的,在其他语言中,它们通常称为 虚拟线程或绿色线程。所以根据我的定义,goroutine 不是协程,但其他人使用更广泛的定义并说 goroutine 是一种协程。我将这种方法称为绿色线程,因为这是 Rust 中使用的术语。

选择的第二个轴是在 有栈协程和无栈协程 之间。有栈协程具有程序栈,就像操作系统线程具有程序栈一样:当函数作为协程的一部分被调用时,它们的帧被推送到栈上;当协程让出时,栈的状态将被保存,以便可以从同一位置恢复。另一方面,无栈协程以不同的方式存储它需要恢复的状态,例如在延续(continuation)或状态机中。当它 让出 时,它所使用的栈将由接管 它的操作使用,当它恢复时,它会收回对栈的控制,并且该延续(continuation)或状态机用于从中断处恢复协程。

(在 Rust 和其他语言中)async/await经常出现的一个问题是“函数着色问题(function coloring problem)”——抱怨为了获得异步函数的结果,您需要使用不同的操作(例如await它)而不是正常调用它。绿色线程和有栈协程机制都可以避免这种结果,因为它是一种特殊的语法,用于指示正​​在发生一些特殊的事情来管理协程的无栈状态(具体取决于语言)。

Rust 的 async/await 语法是无栈协程机制的一个示例:异步函数被编译为返回 Future 的函数,而 future 用于在协程产生时存储协程的状态控制。这场争论中的基本问题是 Rust 采用这种方法是否正确,或者它是否应该采用更像 Go 的“有栈”或“绿色线程”方法,理想情况下没有“颜色”功能的显式语法。

异步 Rust 的发展

绿色线程

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

人们想要的另一种并发模型是通过工作窃取执行器之上的有栈协程和通道来实现结构化并发(structured concurrency)

在有人进行演示并将其与 async/await 和 future 进行比较之前,我认为不会有任何富有成效的讨论。

抛开对结构化并发、通道和工作窃取执行器(完全正交的关注点)的引用,像这样的注释的令人困惑的事情是,最初 Rust 确实有一个有栈协程机制,以绿色线程的形式它于 2014 年底在 1.0 版本发布前不久被删除。理解其中的原因将帮助我们深入了解 Rust 为何提供 async/await 语法。

对于任何绿色线程系统(Rust、Go 或任何其他语言)来说,一个大问题是如何处理这些线程的程序栈。请记住,用户空间并发机制的目标之一是减少操作系统线程使用的大型预分配栈的内存开销。因此,绿色线程库倾向于尝试采用一种机制来生成具有较小栈的线程,并仅根据需要增长它们

实现此目的的一种方法是所谓的“分段栈 segmented stacks”,其中栈是 小栈 段的链表;当栈增长超出其段的边界时,一个新段将添加到列表中,而当堆栈缩小时,该段将被删除。该技术的问题在于将 栈帧 push 到栈的成本存在很大的可变性。如果 栈帧 适合当前段,则这基本上是免费的。如果没有,则需要分配一个新段。一个特别有害的版本是当热循环中的函数调用需要分配新段时。这会为该循环的每次迭代添加分配和释放,从而对性能产生重大影响。而且它对用户来说是完全不透明的,因为用户不知道调用函数时栈有多深。 Rust 和 Go 都是从分段栈开始的,然后由于这些原因放弃了这种方法。

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

Go 使用栈复制,并受益于这样一个事实:在 Go 中,指向栈的指针只能存在于 同一个栈中,因此它只需要扫描该栈即可重写指针。这需要运行时类型信息的支持 而 Rust 并没有保留这些,而且 Rust 也允许指向未存储在栈内的栈的指针 - 它们可能位于堆中的某个位置,或者位于另一个线程的栈中。跟踪这些指针的问题 最终与垃圾收集的问题相同,只不过不是释放内存而是 移动内存 。 Rust无法采用这种方式,因为Rust没有垃圾收集器,所以最终它无法采用栈复制。相反,Rust 通过增大绿色线程来解决分段栈的问题,就像操作系统线程一样。但这消除了绿色线程的关键优势之一。

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

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

  • 对绿色线程和本机线程的抽象并不是“零成本”,并且在执行 IO 时导致不可避免的虚拟调用和分配,这对于native 代码来说尤其是不可接受的。
  • 它强制native 线程和绿色线程支持相同的 API,即使这没有意义。
  • 它不完全具有互操作性,因为即使在绿色线程上,仍然可以通过 FFI 调用本机 IO。

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

迭代器

我认为异步 Rust 之旅的真正开始可以在一位名叫 Daniel Micay 的前贡献者 2013 年的旧 mailing list post中找到。这篇文章与 async/await 或 futures 或非阻塞 IO 无关:这是一篇关于迭代器的文章。 Micay 提议将 Rust 转变为使用所谓的“外部”迭代器,正是这种转变 - 及其与 Rust 的所有权和借用模型相结合的有效性 - 让 Rust 义无反顾地走上了异步/等待的道路。显然,当时没有人知道这一点

Rust 始终禁止通过与另一个变量别名的绑定来改变状态 - 这条“可变mutable XOR 别名aliased”法令对于早期 Rust 来说和今天一样重要。但最初它通过不同的机制来强制执行,而不是通过生命周期分析。当时,引用只是“参数修饰符”,在概念上类似于 Swift 中的“inout”修饰符。 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 循环的主体被编译为一个闭包,该闭包被传递给正在循环的迭代器。这种迭代器比外部迭代器更容易编写,但这种方法有两个关键问题:

  1. 该语言无法保证当循环中断时迭代实际上会停止运行,因此您不能依赖它来保证内存安全。这意味着从循环返回引用之类的事情是不可能的,因为循环实际上可以继续。
  2. 它们不能用于实现交错多个迭代器的通用组合器,例如 zip ,因为 API 不支持交替迭代一个迭代器和另一个迭代器。

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

trait Iterator {
    type Item;

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

(消息灵通的读者会知道,Rust 的迭代器有一个名为 try_fold 的提供方法,该方法在功能上与内部迭代器 API 非常相似,并且在其他一些迭代器组合器的定义中使用,因为它可以导致更好的代码生成。但它不是定义所有迭代器的关键底层方法。)

外部迭代器与 Rust 的所有权和借用系统完美集成,因为它们本质上编译为一个struct,该 struct在其内部保存迭代状态,因此可以包含对正在迭代的数据结构的引用,就像任何其他结构一样。由于单态化,通过组装多个组合器构建的复杂迭代器也被编译成单个结构,使其对优化器透明。唯一的问题是它们更难手工编写,因为您需要定义将用于迭代的状态机。丹尼尔·米凯当时写道,预示了未来的发展:

将来,Rust 可以像 C# 一样使用 yield 语句生成生成器,编译为快速状态机,而不需要上下文切换、虚拟函数甚至闭包。这将消除使用外部迭代器手动编码递归遍历的困难。

尽管最近发布了令人兴奋的 RFC,表明我们可能很快就会看到此功能,但生成器的进展并没有很快。

即使没有生成器,外部迭代器也被证明是巨大的成功,并且该技术的普遍价值得到了认可。例如,Aria Beingessner 在“Entry API”中使用了类似的方法来访问地图条目。很明显,在 API 的 RFC 中,她将其称为“iterator-like”。她的意思是,API 通过一系列组合器构建一个状态机,该状态机向编译器呈现出高度易读性,因此可优化。这种技术是有道理的。

Futures

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

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

trait Future {
    type Output;

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

Aaron Turon 和 Alex Crichton 尝试了这种方法,但正如 Aaron Turon 在一篇具有启发性的 blog post文章中所写的那样,他们很快就遇到了一个问题,即使用 continuation 传递样式 经常需要分配回调。 Turon 给出了 join 的示例: join 需要两个 future,并同时运行它们。 join 的延续需要由两个子 future 拥有,因为无论哪一个最后完成都需要执行它。这最终需要引用计数和分配来实现,这对于 Rust 来说是不可接受的。

相反,他们研究了 C 程序员如何实现异步编程:在 C 中,程序员通过构建状态机来处理非阻塞 IO。他们想要的是 Future 的定义,可以编译成 C 程序员手写的那种状态机。经过一些实验,他们找到了所谓的“基于准备 readiness-based”的方法:

enum Poll<T> {
    Ready(T),
    Pending,
}

trait Future {
    type Output;

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

future 不是存储延续,而是由某个外部执行器轮询。当 future 待处理时,它会存储一种唤醒该执行器的方法,当准备好再次轮询时,它将执行该方法。通过以这种方式反转控制,他们不再需要存储future完成时的回调,这使他们能够将 future 表示为 单个状态机。他们在此接口之上构建了一个组合器库,所有组合器都将被编译成一个状态机。

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

我将暂停一会儿,强调迭代器和 future 之间的一个区别:交错两个迭代器的组合器(如 Zip )甚至不可能使用类似回调的方法,除非您的语言具有某种native 支持您正在构建的协程。另一方面,如果您想交错两个 future,例如 Join ,基于延续的方法可以支持这一点:它只带来一些运行时成本。这解释了为什么外部迭代器在其他语言中很常见,但 Rust 在将这种转换应用于 future 方面是独一无二的。

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

这对于future 组合器来说是一个问题,因为通常需要在多个组合器中访问该状态,这些组合器构成构成未来的动作链的一部分。例如,用户通常会在对象上调用一个“异步”方法,然后调用另一个方法,其编写方式如下:

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

问题是 foobar 方法中被借用,然后在传递给 and_then 的闭包中被借用。本质上,用户想要做的是“跨等待点 across an await point”存储状态,等待点是由未来组合器的链接形成的;这通常会导致令人困惑的借用检查错误。最简单的解决方案是将状态存储在 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生态系统显然未能启动。 futures 项目的最终目标始终是实现所谓的“无栈协程转换”,其中使用 async 和 wait 语法运算符的函数可以转换为计算 future 值的函数,从而避免用户必须手动编写 futures。 Alex Crichton 开发了一个基于宏的异步/等待实现作为library,但这几乎没有获得任何关注。有些事情需要改变。

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

将此与绿线程问题进行比较很有趣。我们解释 futures 到状态机的编译的一种方式是,状态机是一个“完美大小的栈 perfectly sized stack” - 与绿色线程的栈不同,绿色线程的栈必须增长以适应任何线程栈的未知大小的状态可能有,编译后的 future(手动实现,使用组合器或异步函数)恰好与它需要的一样大。所以我们不存在在运行时增加这个栈的问题。

然而,这个栈被表示为一个结构体,并且在 Rust 中移动结构体总是安全的。这意味着,即使我们不需要在执行时 移动 future,但根据 Rust 的规则,我们需要能够这样做。因此,我们在绿色线程中遇到的栈指针问题在新系统中再次出现。不过,这一次,我们的优势在于我们不需要能够改变future,我们只需要表达future是不可改变的。

实现这一点的最初尝试是尝试定义一个名为 Move 的新特征,该特征将用于从可以移动协程的 API 中排除协程。这遇到了我之前 documented过的一些向后兼容性问题。我的 async/await 论文有三个要点:

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

这三点的结合促使我寻找一种替代 Move 特征的解决方案,该解决方案可以在不对语言进行任何重大破坏性更改的情况下实现。

我最初的计划比我们最终得到的结果要糟糕得多。我建议让 poll 方法变得不安全,并包含一个不变量,即一旦开始轮询未来,就无法再次移动它。这很简单,可以立即实现,而且极其暴力:它会使每个手写的 future 都不安全,并且在没有编译器帮助的情况下强加难以验证的要求。它最终可能会因某些健全性问题而搁浅,而且肯定会引起极大的争议。

因此,Eddy Burtescu 的一些评论让我朝着更好的 API 的方向发展,这真是太棒了,这将使我们能够以更细粒度的方式强制执行所需的不变量。这最终将成为 Pin 类型。Pin 类型本身引起了相当多的惊愕,但我认为它相对于我们当时考虑的其他选项来说是不可否认的改进,因为它具有针对性、可执行性,而且可以准时发布。

回顾起来,pin方法存在两类问题:

  1. 向后兼容性:由于各种原因,一些已经存在的接口(特别是 Iterator 和 Drop)应该支持不可移动类型,这限制了进一步开发语言的选项。

暴露给最终用户:我们的目的是编写“普通异步 Rust”的用户永远不必处理 Pin 。大多数情况下这是事实,但也有一些值得注意的例外。几乎所有这些问题都可以通过一些语法改进来修复。唯一真正糟糕的(对我个人来说也很尴尬)是你需要pin一个future的特征对象来等待它。这是一个非受迫性错误,现在需要进行重大更改来修复。

关于 async/await 唯一需要做出的其他决定是语法,我不会在这篇已经过长的文章中扰乱这一点。

组织考虑

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

但这段历史还有另一面:为什么我们要追求用户空间并发的运行时系统?为什么要有 future 和 async/await 呢?这种争论通常采用两种形式之一:一方面,人们习惯于“手动”管理用户空间并发,直接使用 epoll 等接口;另一方面,人们习惯于直接使用 epoll 等接口来“手动”管理用户空间并发性。这些人有时嘲笑 async/await 语法为“webcrap”。另一方面,有些人只是说“你不会需要它”,并建议使用更简单的操作系统并发性,如线程和阻塞 IO。

人们使用没有像 C 这样的用户空间并发设施的语言来实现高性能网络服务,倾向于使用手写的状态机来实现它们。这正是 Future 抽象被设计为编译成的内容,但无需手动编写状态机:协程转换的全部要点是编写命令式代码“就好像您的函数永远不会产生一样, ”但是让编译器生成状态转换以在它阻塞时将其挂起。这样做的好处并不小。最近的一个curl CVE最终是由于无法识别状态转换期间需要保存的状态而导致的。手工实现状态机时很容易出现这种逻辑错误。

在 Rust 中提供 async/await 语法的目标是提供一个功能,避免这些错误,同时仍然具有相同的性能配置文件。考虑到我们提供的控制级别以及缺乏内存管理运行时,此类系统通常是用 C 或 C++ 编写的,因此被认为很适合我们的目标受众。

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

  • Embedded systems 嵌入式系统
  • WebAssembly
  • Command-line interfaces 命令行界面
  • Network services 网络服务

这句话是创建“领域工作组”的起点,该工作组旨在成为专注于特定用途“领域”的跨职能小组(与控制某些技术或技术的现有“团队”形成鲜明对比)组织辖区)。从那时起,Rust 项目中工作组的概念已经发生了变化,并且基本上失去了这种意义,但我离题了。

async/await 的工作是由“网络服务”工作组开创的,该工作组最终被简称为异步工作组(并且今天仍然以这个名称存在)。然而,我们也敏锐地意识到,由于缺乏运行时依赖,异步 Rust 也可以在其他领域发挥重要作用,尤其是嵌入式系统。我们在设计该功能时考虑到了这两个用例。

很明显,尽管通常没有说出来,Rust 想要成功需要行业的采用,这样一旦 Mozilla 不再愿意资助一种实验性新语言,它就可以继续获得支持。很明显,短期行业采用的最有可能的途径是网络服务,特别是那些当时必须用 C/C++ 编写的性能配置文件。这个用例非常适合 Rust 的利基市场——这些系统需要高度的控制来实现其性能要求,但避免可利用的内存错误至关重要,因为它们暴露在网络中。

网络服务的另一个优势是,软件行业的这一分支具有快速采用 Rust 等新技术的灵活性和兴趣。其他域过去和现在都是! - Rust 的长期机会,但人们认为它们采用新技术(嵌入式)的速度不那么快,依赖于尚未广泛采用的新平台(WebAssembly),或者不是一个特别有利可图的工业应用程序可能会为该语言(CLI)提供资金。我满怀热情地使用 async/await,假设 Rust 的生存依赖于此功能。

在这方面,async/await 取得了惊人的成功。 Rust 基金会的许多最著名的赞助商,尤其是那些向开发人员付费的赞助商,依赖 async/await 在 Rust 中编写高性能网络服务,作为证明其资金合理性的主要用例之一。在嵌入式系统或内核编程中使用 async/await 也是一个日益受到关注且前景光明的领域。 Async/await 如此成功,以至于最常见的抱怨是生态系统过于以它为中心,而不是“正常”的 Rust。

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

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

待续…

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

我认为,如果 Rust 存在的话,有些用户会非常乐意使用这种语言,并且我理解为什么他们不喜欢必须处理低级细节的固有复杂性。过去,这些用户抱怨无数的字符串类型,现在他们更有可能抱怨异步。我希望针对此用例的语言也存在与 Rust 相同的保证,但这里的问题不在于 Rust。

尽管我相信 async/await 是 Rust 的正确方法,但我也认为对当今异步生态系统的状态不满意是合理的。我们在 2019 年发布了 MVP,tokio 在 2020 年发布了 1.0,从那时起事情就变得比我认为任何相关人员都希望的更加停滞。在后续文章中,我想讨论当今异步生态系统的状况,以及我认为该项目可以做些什么来改善用户体验。但这已经是我发表过的最长的博客文章了,所以现在我不得不把它留在那里。


Why async Rust?


更多阅读:

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐