Let futures be futures:关于Rust异步并发模型的再思考

  • 资料来源:

    https://without.boats/blog/let-futures-be-futures/

  • 更新

    1
    2024.02.10 初始

导语

偶然翻到了 Let futures be futures , 于是赶工了这篇翻译,当然几乎是 gpt 代劳😅😅, 质量一般,可读.

Let Futures Be Futures

2024 年 2 月 3 日

在 2010 年代早中期,探索并发新方法的语言出现了一个复兴时期。在这个复兴时期的中间,开发出了一种实现并发操作的抽象概念,称为 “future” 或 “promise” 抽象,它代表一个可能最终会完成的工作单元,允许程序员利用这一点来操作程序中的控制流。在此基础上,引入了称为 “async/await” 的语法糖,其将 futures 塑造成更常见的普通、线性控制流。这种方法已经被许多主流语言采用,这一系列的发展在实践者中引起了争议。

在那个时期有两篇优秀的文章非常好地阐述了这场争论的两个方面。我强烈推荐阅读每篇文章的全部内容:

  • “Futures aren’t ersatz threads” 由 Marius Eriksen 在 2013 年 4 月 2 日发表
  • “What color is your function?” 由 Bob Nystrom 在 2015 年 2 月 1 日发表

Eriksen 的文章的论点是,futures 提供了一种与线程根本不同的并发模型。线程提供了一个模型,在这个模型中,所有操作都是 “ 同步 “ 发生的,因为程序的执行被建模为一堆函数调用,这些调用在需要等待并发执行的操作完成时会阻塞。相比之下,通过将并发操作表示为异步完成的 “futures”,futures 模型使得几个优点成为可能。这些是 Eriksen 提到的我特别认同的几点:

  1. 执行异步操作的函数与 “ 纯 “ 函数有不同的类型,因为它必须返回一个 future 而不仅仅是一个值。这种区别是有用的,因为它让你知道一个函数是执行 IO 操作还是仅仅是纯计算,这有着深远的影响。
  2. 由于它们创建了要执行的工作单元的直接表示,futures 可以以多种方式组合,无论是顺序还是并发。阻塞的函数调用只能顺序组合,除非启动一个新的线程。
  3. 由于 futures 可以并发组合,因此可以编写更直接表达正在发生什么逻辑的并发代码。可以编写代表特定并发模式的抽象,允许将业务逻辑从跨线程调度工作的机制中提取出来。Eriksen 给出了如 flatMap 操作符的例子,以在一个初始网络请求后串联多个并发网络请求。

Nystrom 则持相反立场。他从想象一种所有函数都有 “ 颜色 “ 的语言开始,函数要么是蓝色(BLUE) ,要么是红色(RED) 。在他的想象语言中,两种颜色的函数之间的重要区别是,RED 函数只能从其他 RED 函数中调用。他认为这种区别对于语言的用户来说是一个很大的挫折,因为必须跟踪两种不同的函数是烦人的,在他的语言中,RED 函数必须使用一种让人烦恼的复杂语法来调用。当然,他所指的是同步函数和异步函数之间的区别。正是 Eriksen 认为是 futures 优势的东西——返回 futures 的函数与不返回 futures 的函数不同——对于 Nystrom 来说,这正是它的最大弱点。

Nystrom 的一些评论并不适用于 async Rust。例如,他说如果你像调用另一种颜色的函数一样调用一个函数,会发生糟糕的事情:

在调用一个函数时,你需要使用与它的颜色相对应的调用方式。如果你搞错了……它会做一些不好的事情。从你童年时期那些早已被遗忘的噩梦中挖掘出一些东西,比如床底下藏着一只胳膊是蛇的小丑。它会从你的显示器里跳出来,吸干你的玻璃体。

这在 JavaScript 中是合理的,一个无类型的语言以其荒谬的语义而著名,但在像 Rust 这样的静态类型语言中,你会得到一个编译器错误,你可以修复它然后继续。

他的主要观点之一也是,调用 RED 函数比调用 BLUE 函数 要 “ 痛苦 “ 得多。正如 Nystrom 在他的文章后面详细阐述的那样,他是在提及 2015 年 JavaScript 中常用的基于回调的 API,他说 async/await 语法解决了这个问题:

[Async/await] 让你做异步调用就像做同步调用一样容易,只是增加了一个可爱的小关键词。你可以在表达式中嵌套 await 调用,在异常处理代码中使用它们,将它们塞入控制流中。

当然,他还说了这个,这是关于 “ 函数颜色问题 “ 的争论的核心:

但是……你仍然把世界分成了两半。那些异步函数写起来更容易了,但它们仍然是异步函数。

你仍然有两种颜色。Async-await 解决了烦人的规则#4:它们使得 red 函数调用起来和 blue 函数差不多。但所有其他规则仍然存在。

Futures 以不同于同步操作的方式代表异步操作。对于 Eriksen 来说,这提供了额外的便利,这是 futures 的关键优势。对于 Nystrom 来说,这只是另一个调用返回 futures 而不是阻塞的函数的障碍。

正如你可能预料的那样,如果你熟悉这个博客,我非常坚定地站在 Eriksen 这边。因此,发现 Nystrom 的观点在 Hacker News 上评论或在互联网上写愤怒、过度自信的咆哮文章的人中更受欢迎,对我来说并不容易。几个月前,我写了一篇文章,探讨了 Rust 是如何拥有 futures 抽象和在其上的 async/await 语法的历史,以及一篇后续文章,描述了我希望看到添加到 async Rust 中的功能,以使其更容易使用。

现在,我想退后一步,重新审视 async Rust 的设计,在关于 futures 并发模型的效用问题上下文中。在 async Rust 中使用 futures 实际上给我们带来了什么?我希望我们能想象一个世界,在这个世界中,使用 futures 的困难已经被缓解或解决,并且它们提供的额外便利使得 async Rust 不仅仅像非异步 Rust 一样容易使用,而且实际上是一个更好的体验。

Async Tasks Aren’t Ersatz Threads

通常来说,futures 的优势是以性能改进的形式向用户解释的:启动线程是昂贵的,切换线程也是如此,所以能够在单个线程上复用许多并发操作将允许你在一台机器上执行更多的并发操作。像 Eriksen 一样,我认为 “ 基于线程 IO” 与 “ 基于事件 IO” 之间性能的二分法是一个转移注意力的问题。所有 Eriksen 关于 futures 对于构建你的代码的好处的观点都同样适用于 Rust。

Eriksen 是在持续传递风格(continuation-passing style)作为其 future 抽象基础的语言背景下写作的。正如我在关于 async Rust 历史的文章中所写,这不是 Rust 采用的方法。独特的是,Rust 采用了基于逆转持续传递方法的系统:future 不是在完成时调用一个持续的操作,而是被外部轮询(polled)至完成。为了理解这一点的相关性,我们需要退一步讨论 “ 任务 “。

当我写任务时,我不仅仅是指 “ 一项工作 “。在 async Rust 中,” 任务 “ 是一个特定的艺术术语。异步工作的基础抽象是 future,它实现了 Future trait,这通常是通过一个 async 函数或 async 块实现的。但为了执行任何异步代码,我们还需要使用一个 “ 执行器 “(executor),它可以执行 “ 任务 “。通常,这是由提供其他事物的 “ 运行时 “(runtime)提供给我们的,比如进行异步 IO 的类型。最广泛使用的运行时是 tokio。

这些定义有点绕。一个 “ 任务 “ 只是在执行器上调度的任何 future。大多数执行器,像 tokio 那样,可以同时运行多个任务。他们可能使用一个线程来做这件事,或者他们使用多个线程并在这些线程之间平衡任务(哪种更好一直是一些额外争议的主题)。能够同时运行多个任务的执行器通常会暴露一个像 spawn 这样的 API 名称,它 “ 产生一个任务 “。还存在其他只能一次运行一个任务的执行器(像 pollster):这些通常暴露一个像 block_on 这样的 API 名称。

所有任务都是 futures,但并非所有 futures 都是任务。当它们被传递给执行器执行时,futures 变成了任务。最常见的是,一个任务会由许多较小的 futures 组合在一起:当你在一个 async 范围内等待一个 future 时,正在等待的 future 的状态直接组合到 async 范围评估的 future 中。通常情况下,你会使用 spawn 多次,所以你的大多数 futures 不会是我所说的任务。但在类型级别上,future 和任务之间没有区别:任何 future 都可以通过在执行器上运行它来变成一个任务。

关于 futures 和任务之间的区别的重要之处在于,执行任务所需的所有状态都将作为一个单独的对象分配,紧密排列在内存中;每个单独使用的 future 将不需要单独的分配。我们经常将这种状态机描述为任务的 “ 完美大小的堆栈 “;它足够大,足以包含这个任务在 yield 时可能需要的所有状态。

(另一个由这种设计引出的含义是,没有显式盒装递归调用的情况下,写一个递归 async 函数是不可能的。这就是为什么没有盒装递归嵌入类型的情况下,写一个递归结构体定义不可能的原因。)

这一切对于在 async Rust 中表示并发操作有一些有趣的含义。我想引入一个区别,在任务模型中可以实现的两种并发类型:多任务并发,其中并发操作表现为分离的任务,以及任务内并发,其中单个任务并发执行多个操作。

多任务并发

如果你想让两个操作并发进行,一种实现方式是为每个操作产生一个分开的任务。这就是 “ 多任务并发 “,通过使用多个并发任务来实现的并发。

对于许多用户来说,多任务并发是 async Rust 中最容易接近的并发方式,因为它与基于线程的并发方法最为相似。就像你可以在非 async Rust 中产生线程实现并发一样,在 async Rust 中你可以产生任务。这让它对于那些已经习惯了线程并发的用户来说非常熟悉。

一旦你有了多个异步任务,你可能需要某种方式在它们之间传递信息。这种 “ 任务间通信 “ 是通过使用某种同步原语来实现的,比如锁或通道。对于异步任务,有每种类型的阻塞同步原语的异步等价物:异步互斥锁(Mutex),异步读写锁(RwLock),异步 mpsc 通道等等。许多运行时甚至提供了没有标准库类比的异步同步原语。当存在类比时,两个原语的界面通常有非常强的相似性:在便利性方面,一个异步 Mutex 实际上就像一个阻塞 Mutex,不同之处在于 lock 方法是异步的,而不是阻塞的。这是 async-std 运行时的概念基础。

然而,值得注意的是,这些东西的每一种实现都是完全不同的。当你产生一个异步任务时运行的代码与产生一个线程完全不同,一个异步锁的定义和实现(例如)与一个阻塞锁非常不同:通常它们会在底层使用基于原子操作的锁,再加上一个等待锁的任务队列。代替阻塞线程,它们将这个任务放入队列并 yield;当锁被释放时,它们唤醒队列中的第一个任务以允许它再次取得锁。

对于这些 API 的用户来说,多任务并发与多线程并发非常相似。然而,它并不是 futures 抽象所启用的唯一类型的并发。

任务内并发

虽然多任务并发具有与多线程并发相同的 API 表面(除了散布的 async 和 await 关键字外),futures 抽象还启用了另一种没有线程上下文类比的并发类型:” 任务内并发 “。这意味着同一个任务在并发执行多个异步操作。与其为你的每一个并发操作分配分开的任务,你可以使用同一个任务对象执行这些操作,改善内存局部性,节省分配开销并增加优化的机会。

要具体说明这一点,我的意思是,当你使用一个任务内并发原语(比如 select!),在其上操作的两个 futures 的状态将直接嵌入在操作它们的父 future 中。对于 async 原语我之前讨论过的广为人知的任务内并发原语对应于以下表格:它们是 Futureselectjoin,以及 AsyncIteratormergezip

they are select and join for Future and merge and zip for AsyncIterator:

SUMPRODUCT
FUTUREselect!join!
ASYNCITERATORmerge!join!

使用线程,你可以提供这样的 API,但只能通过产生新的线程并使用通道或 join 句柄将它们的结果传回父线程。这引入了许多开销,而任务内实现这些原语的开销在 Rust 中尽可能的小。

事实上,消除这些组合子的开销正是 Rust 从持续传递风格转向基于就绪的 futures 抽象的整个原因。当 Aaron Turon 写关于在持续传递风格下需要堆分配的 futures 时,他的例子是 join。正是那些嵌入并发操作的 futures 需要共享所有权的持续操作(以在任何并发操作完成时必要地调用持续操作)。因此,正是为任务内并发而设计的这些组合子,基于就绪的 futures 被设计用来优化。

正如 Rain 在过去有力地争辩的那样,” 不同质的选择是 async Rust 的重点。” 具体来说,就是你可以选择不同类型的 futures,并等待其中任何一个首先完成,并且来自单个任务,无需额外分配,这是 async Rust 与非异步 Rust 相比的独特属性,也是它最强大的功能之一。

一个常见的 async Rust 服务器架构是为每个套接字产生一个任务。这些任务经常在该套接字上内部多路复用入站和出站的读写,以及来自其他任务的给套接字另一端的服务的消息。为此,它们可能会在一些 futures 之间选择或将事件流合并在一起,具体取决于它们的生命周期细节。这可以有一个非常高级的表现,并且在许多方面它类似于异步并发的 actor 模型,但由于任务内并发,它会编译成每个套接字的单个状态机,这在运行时表示与用 C 语言编写的手工异步服务器非常相似。

这种架构(以及其他类似的架构)结合了多任务并发适用于最合适的情况,以及任务内并发适用于更好的方式。认识到这些场景之间的差异是掌握 async Rust 的关键技能。任务内并发有一些限制:如果你的算法可以遵循这些限制,它可能是一个很好的适配。

第一个限制是,只有静态数量的并发能够通过任务内并发实现。也就是说,你不能用任务内并发来 join(或 select 等)任意数量的 futures:数量必须在编译时确定。这是因为编译器需要能够在父 future 的状态中布局每个并发 future 的状态,并且每个 future 需要有一个静态确定的最大大小。这实际上和不能在栈上有一个动态大小的对象集合是同样的原理,但需要使用像堆分配 Vec 这样的东西来有一个动态数量的对象。

第二个限制是,这些并发操作不会相互独立地执行,也不会独立于等待它们的父任务执行。这意味着两件事。首先,任务内并发不实现任何并行性:最终是单个任务,有一个单独的 poll 方法,多个线程不能同时 poll 那个任务。这使得任务内并发不适合计算密集型工作。

(所有广泛使用的异步运行时也不适合计算密集型工作;我不认为这是异步模型的本质,但这是目前可用于使用异步的库的一个事实。)
其次,如果用户对这个任务不再感兴趣,所有子操作都必然被取消,因为它们都是同一个任务的一部分。因此,如果你希望这些操作即使在这项工作被取消后也能继续,它们必须是分别产生的任务。

函数非着色问题

我想暂时离题,回到 Nystrom 的文章,并在这个讨论中引入一个完全不同的线索。我保证这些线索将在未来重新连接,我甚至希望它们将融合一致。

我建议我们继续用有色函数的语言的思维实验,并想象语言的设计者已经阅读了 Nystrom 的批评,并试图减轻 REDBLUE 函数的痛苦。在语言设计者经常不知道何时停止的经典倾向下,他们添加了第三种颜色的函数,称为 GREEN 函数,他们希望这将结束所有人的抱怨。当然,这些也来自它们自己的一套规则。

一. GREEN 函数可以像 BLUE 函数一样被调用。

RED 函数不同,没有特殊语法用于 GREEN 函数:你可以在任何地方使用与 BLUE 函数完全相同的语法来调用它们。事实上,从它们的签名和使用方式来看,没有办法告诉它们有任何不同。只是在文档中会注明该函数是 GREEN ,或者如果作者认为不必包括这些信息的话可能根本没有。

这太好了!只要你坚持使用 BLUEGREEN 函数,你就不必担心函数的颜色了。

二. 对于每个原始的 RED 函数,都有一个 GREEN 等价物。
当然,为了真正实现这一点,你需要能够在不调用 RED 函数的情况下实现你的程序。所以语言作者在他们的标准库中为每个只能使用 RED 函数才能进行的操作添加了一个 GREEN 函数。

实现在某些与性能有关的方式上有所不同,这可能对你的用例来说是材料或不是材料,但我们已经决定在这个思维实验中忽略诸如代码的实际语义之类的事情,所以至少现在我们不想过多地纠结于此。

三. 有一个将任何 RED 函数包裹起来并调用它的 GREEN 函数。

尽管标准库中存在 GREEN 函数,用户可能仍然会遇到使用 RED 函数编写的库。所以语言设计者巧妙地想出了一个解决办法:有一个高阶的 GREEN 函数,它接受一个 RED 函数作为参数。它基本上只是调用那个 RED 函数,不考虑技术细节。因为 GREEN 函数可以从任何地方调用,它解决了无法从 BLUE 函数内部调用 RED 函数的问题。

四. 在 RED 函数内部调用一个 GREEN 函数是非常糟糕的。 当然,总是有缺点。你永远不应该在 RED 函数内部调用 GREEN 函数。这并不是 “ 鼻涕虫 “ 未定义行为级别的糟糕,也不是 “ 小丑手臂是蛇 “ 的 JavaScript 级别的糟糕,但它肯定会减慢你的程序速度,而在最坏的情况下,但它肯定会减慢你的程序速度,而在最坏的情况下,甚至可能导致死锁。使用 RED 函数的程序员绝对应该避免 GREEN 函数,以免造成混乱。

但这种语言增加了这些功能的问题在于:因为它们与 BLUE 函数完全相同,所以无法分辨出它们来!你只能从文档中知道所有的 GREEN 函数是什么,并且你必须确保永远不要在 RED 函数内部调用它们。

阻塞函数没有颜色

现在我已经让我的博客像圣诞树一样五彩斑斓,让我们再谈谈 Rust。你可能已经猜到了 GREEN 函数是什么:绿色函数是任何阻塞当前线程的函数。没有特殊的语法或类型来区分一个等待某些并发事件发生而阻塞的线程:这正是 Nystrom 所说的阻塞函数的伟大之处。与许多具有异步函数的语言不同,Rust 也支持阻塞函数:有一套 API 可以通过阻塞线程来执行任何类型的 IO 或线程同步,还有一个 block_on API 可以接受任何 Future 并阻塞此线程直到它准备好,所以你可以像调用阻塞性的库一样调用异步库。

不支持阻塞操作的语言没有这个问题:相反,它们有 Nystrom 抱怨的问题,你必须知道异步函数和非异步函数之间的区别。但由于 Rust 可以做到所有事情,不想使用 futures 的用户几乎可以完全避免它们:他们唯一的问题是,有时开源库(免费提供给他们,没有保证!)会使用 async Rust,他们将需要使用 block_on 来从他们的代码中与之交互。一些人仍然会频繁且热切地抱怨这种情况。

而 async Rust 的用户得到的待遇最糟糕,他们不仅要处理 async Rust,还必须处理他们绝不能在其异步代码中调用阻塞函数的事实。然而,阻塞函数与普通函数完全无法区分!这正是根据 Nystrom 的说法使它们如此好的原因。

很久以前(在 async/await 刚出来后),我提议添加一个属性,可以放在阻塞函数上,以引入某种针对在异步上下文中调用它们的 linting,帮助用户捕捉到这种性质的任何错误。Rust 项目没有追求这个想法的原因,我不知道。我希望看到更多的工作来帮助用户捕捉这个错误。

对于异步 IO 最阴险的阻塞 API 是阻塞 Mutex。在异步函数中使用阻塞 Mutex 在某些特定但仍然相当常见的情况下是可以的:

  1. 它在所有使用场景中都只锁定很短的时间。
  2. 它从不在 await 点上持有。

然而,这里是真正糟糕的地方,如果一个 Mutex 在 await 点上持有,它可能很容易让你的线程死锁,因为同一个线程上的其他任务试图在一个挂起的任务持有它时取得锁(标准库 Mutex 不是可重入的)。这意味着它有时完全可以使用,但有时不仅仅是不好,而是绝对灾难性的。这不是一个很好的结果!

I Don’t want Fast Threads, I want Futures

以上两节探讨了两个相当独立的想法:

第一,Rust 的 futures 模型使得特定类型的高度优化的 “ 任务内并发 “ 成为可能,这是线程模型无法实现的。

第二,阻塞函数与普通函数无法区分,这给 async Rust 带来了问题。

将这两个讨论统一起来的是,async 函数和阻塞函数之间的差异是 Future trait 的额外便利性。这使得一个异步任务可以同时执行多个操作,而线程则不能。而缺乏这种便利性使得阻塞函数在 async 代码中调用时存在问题,因为它们不能 yield,它们只能阻塞。我的 async Rust 设计原则是:我们实现了这种便利性是有充分理由的,并应该充分利用它。正如 Henry de Valence 在 Twitter 上写的:” 我不想要快速的线程,我想要的是 futures。”

这个想法一点也不新鲜。在删除 Rust 中的绿色线程库的 RFC 中,Aaron Turon 争论说,试图让异步 IO 和阻塞 IO 的 API 相同限制了 async Rust 的潜力:

在今天的设计中,绿色和原生线程模型必须始终提供相同的 I/O API。但是,有些功能仅在其中一种线程模型中适用或有效。

例如,最轻量级的 M:N 任务模型本质上只是闭包的集合,并不提供任何特殊的 I/O 支持。这种轻量级任务在 Servo 中使用,但也出现在 java.util.concurrent 的执行器和 Haskell 的 par monad 中,以及许多其他地方。这些更轻量的模型不适合当前的运行时系统。

Turon 接着开发了 Rust 今天存在的基于就绪的 futures API,它的起源可以在这些评论中看到。我认为随着我们在 future 抽象之上增加 async/await 语法(以及 Rust 的贡献者变动),这个想法已经被淡化并有些丢失了。现在,思维方式是,async Rust 和阻塞 Rust 应该尽可能相似。但这放弃了 async Rust 的额外便利性,除了用户空间调度可能带来的性能改进。

重要的是要理解 async/await 如何适应这个世界,并且它不是全部。Futures 提供了选项,但没有要求,在任务中多路复用并发操作。这个选项对于你需要执行它的少数时候至关重要,但大多数时间你都乐意让你的代码继续进行,” 一件接一件事 “。await 操作符让你可以做到这一点,没有高度嵌套的回调或组合器链。这降低了为 Futures 的可选性付出代价的成本,到了它们将世界分为异步和非异步函数的事实,而没有使用上的额外困难。但正是在你真正行使那个选择的那些时刻——你没有等待一个 future——最为重要!

Futures 给你能力,在单个线程上多路复用任意多的完美大小任务,并在单个任务内多路复用固定数量的并发操作。这样它们使用户能够逻辑上结构化并发代码,而不是到处都需要包含有关产生线程的并发相关的样板。并且它们以更好的性能特性这样做,这在有非常高并发度的场景中可能是至关重要的。这本身就值得入场费,在我看来,但我们也可以想象其他的优点。

让我们回到持有锁在 await 点上的问题。一些用户使用的模式是确保在执行任何可能耗时的异步操作(如 IO)之前放弃锁,以便其他并发操作可以取得锁而不是等待。(这需要小心:你需要确保你的代码对在执行 IO 时可能发生的受保护状态的潜在变化是健壮的。)Async/await 已经使这比阻塞 IO 更容易,因为可能进行长时间运行工作的点由 await 关键字标记。对于阻塞 IO,没有语法上的东西表明阻塞,使得错过需要放弃锁的点变得更容易。但 async Rust 可以做得比这更好。

David Barsky 提出了他所谓的 “ 生命周期 “ 特质:当持有对象的 future 暂停和恢复时执行的类似于 Drop 的接口。他对这个概念感兴趣,特别是对于跟踪,这包括有关被执行任务的所有日志消息中的信息,因此需要知道何时发生变化。它也可以被用来启用一个锁定原语,这个原语会在 future 放弃控制权时自动放弃其租约,并在它重新开始时重新取得。这将确保用户永远不会在等待时意外地未能放弃锁,而且它甚至会比手动版本更优化:当你的任务实际上没有暂停(因为 future 立即准备好了),你就不需要放弃锁并重新取得。

maybe(async)

如果我没有提到 Rust 项目内部讨论的一个功能,那我就太过失职了,我认为这与我的思路完全相反:maybe(async) 的概念。这是一个功能(语法待定),用于编写是否异步抽象的代码。换句话说,任何 maybe(async) 函数都可以实例化为两个变体:一个是异步的(并在其中等待所有 future)和一个不是异步的(并且假定在异步版本中返回 future 的函数将代替阻塞)。

这个想法的最大问题是,它只能适用于多任务并发。如我已经写过的,多任务并发代码与基于多线程并发的代码有直接的类比。但是任务内并发在基于线程的并发系统中没有等价物,因为它依赖于 futures 的便利性。因此,任何尝试使用 maybe(async) 将限制在严格使用多任务并发的代码部分。问题是,对于任何足够重要的代码片段,都会有关键部分利用了任务内并发,因此不适合用 maybe(async) 进行抽象。

最近,Mario Ortiz Manero 写了关于尝试编写一个库的困难,这个库支持阻塞或异步 IO 的使用。这篇博客文章在我看来是我能想到的 maybe(async) 最有力的案例,所以我想更彻底地分析它。

他们的用例是一个包装器,它将 Rust 方法调用转换为 Spotify API 的 HTTP 请求。他们希望从同一源代码支持库的阻塞和异步版本,使用 reqwest 作为异步 HTTP 客户端和 ureq 作为阻塞 HTTP 客户端。他们写了现在这样做有多困难,这当然是真的。

首先,值得注意的是 reqwest 库实际上包含了自己的阻塞 HTTP 客户端以及异步版本。为了实现这一点,它在后台线程上启动所有对该客户端的请求将被异步地进行,将它们在同一线程上多路复用。Ortiz Manero 因为以下原因拒绝了这种方法:

不幸的是,这个解决方案仍然有相当大的开销。你需要引入像 futures 或 tokio 这样的大型依赖,并将它们包含在你的二进制文件中。所有这些,最终为了……实际上最终写出阻塞代码。所以这不仅仅是运行时的成本,还包括编译时的成本。对我来说,这感觉是错误的。

在这里,Ortiz Manero 似乎是指这些依赖项的构建时间开销,而不是运行时开销。但我们应该问为什么 reqwest 即使 “ 感觉不对 “ 也要引入这些依赖?在阻塞 reqwest 中,tokio 被用来在单个线程上多路复用所有对同一客户端的请求。这种阻塞 reqwest 与 ureq(相反,它执行阻塞 IO 从发出请求的线程)的架构差异似乎比一个依赖于 tokio 而另一个不依赖的事实更重要。我希望看到针对不同工作负载比较两种方法的基准测试,而不是仅仅因为它们的依赖树而排除其中一种。

reqwest 支持的一个功能是 ureq 不支持的 HTTP/2。HTTP/2 被设计为允许用户在同一 TCP 连接上多路复用不同的请求。相比之下,ureq 仅提供(非管道化的)HTTP/1。如果一个用户对 TCP 连接进行请求,它会阻塞线程直到该请求完成。因此,使用 ureq,你可以对一个服务进行的并发网络请求的数量受到该服务允许你建立的打开 TCP 连接数量的限制,并且对于每个新连接,你将需要进行新的 TCP(并且可能是 TLS)握手。

如果 ureq 想支持 HTTP/2 及其多路复用,它会发现需要以某种方式实现在单个 TCP 连接上的多路复用。它可能不会使用 async Rust,但如果它想使用阻塞 IO 来实现这一点,并且仍然提供现在的 API,它仍然需要运行一个后台线程和通道,以便多个线程的并发请求可以在单个 TCP 连接上多路复用。换句话说,架构将开始看起来就像 reqwest 所拥有的架构。通过使用 async Rust,reqwest 更容易抽象出使用 HTTP/1 多路复用请求到多个连接和使用 HTTP/2 多路复用到同一连接的区别。这是一个巨大的优势,因为用户通常不知道他们想要通信的服务是否支持 HTTP/2。

即使如此,你可能会说,即使他们从 ureq 切换到 reqwest 的阻塞 API,maybe(async) 也可能对这位作者有一些用处,因为它将允许他们节省实现库的异步版本和在其上的阻塞 API 的样板。但由于 maybe(async) 可以被抽象的内容的限制,这实际上只对严格是低级库 “ 映射 “ 语义的特定类型的库是真的。这可能是,如在这个例子中,一个将 HTTP RPC 调用翻译成 Rust 对象和方法的库,或者可能是一个根据 TCP 等字节接口定义线路协议的库。当库有自己的演变状态需要管理时(就像它们下面的 HTTP 或 IO 库一样),两个实现就会有意义地分离,不能再用 maybe(async) 从同一源代码实现。因为对于那些库来说,维护两个版本只是样板代码,也许有更好的方法来支持这一点,而不是添加一个新的抽象概念。一个方法是使用宏系统,它可以用来从一个异步接口生成类似于 reqwest 的阻塞接口(生成在后台线程上产生阻塞函数并将其映射为向该线程发送消息的代码)。像 Spotify 客户端这样的库可以使用这个宏来避免支持它们的阻塞 API 的样板代码,代价是在后台线程上使用异步运行时作为它们的实现。但这同样适用于无状态和有状态的库,不像 maybe(async) 那样。

另一种方法是所谓的 “ 无 IO(sans-IO)”。例如,ureq 的作者也维护了一个名为 str0m 的 WebRTC 库,采用了这种风格,它避免了阻塞和非阻塞 IO 的问题,因为根本没有在库中处理实际的 IO 操作。一个类似写法的库是 Cloudflare 的 quiche,它实现了 QUIC 的状态机,但没有执行 IO。基于这个概念,我们可以想象一种将 IO 问题完全从这些库中抬升出来的方式,相反,它们是针对一个抽象接口编写的,允许它们针对任何实现的 UDP、TCP、HTTP 或它们依赖的任何东西执行。如何将这种方法推广到通用仍然有待确定。

A Final Digression about Coroutines

这篇文章已经太长了,但我知道它可能会在 Rust 社区之外引起关注,我可以预测某些负面的反应:我所提到的 futures 的便利性并不仅仅可以通过 futures 来实现!这些便利性可以由任何类型的协程来提供。Rust 使用的是无栈协程,这带来了一些不愉快的限制,但具有有栈协程的语言也可以提供同样的便利性,头疼会少一些。

我其实同意这一点。回到虚构的语言世界,人们可以想象一种所有函数都是协程的语言,这意味着所有函数都可以 yield。没有函数着色!” 纯净 “ 的函数将是一个从不实际 yield 的函数,而一个 “ 不纯净 “ 的函数将是一个 yield Pending(或者来自运行时的一些其他魔法类型表示你正在等待外部事件)的函数。不纯净的函数将是默认情况,所有函数都是协程,所以调用操作符会自动将 Pending 值向外传递。你仍然可以通过某种方式标记纯净的函数,以确保你没有进行任何类型的 IO 或同步操作。

这种语言还将希望有一种方式来实例化协程对象,并且可以恢复它而不是调用它到完成。使用那个操作符,你可以实现像 select 和 join 这样的并发组合器。而且语言需要一些方法来将协程作为全新的、并发的任务产生。所有这些都不需要 async/await:这就是有栈协程能给你的。

你甚至可能会将这个协程功能扩展到表示其他事物。例如,可迭代的可以被表示为 yield 有意义值的协程。for 循环将接受那个协程对象,并依次处理每个值。异步迭代器只需 yield 那个值或 Pending。你还可以用同样的方式模拟异常,yield 一个错误(可能你会有一个单独的 “ 路径 “ 从 yield 和 return 中,认识到一个抛出异常的函数不能被重新启动)。我没有完全规划好整个语言,但所有这些听起来都是合理的。

(实际上,这些不必通过协程来完成。你也可以用一种颠倒的方式,所以你为其中每一件事注册一个栈中的点来返回:一个等待 IO 操作的 Pending,一个为抛出的异常,一个为从可迭代中 yield 的项目,等等。你可以称那个栈中的点为这些 “ 效应 “ 的 “ 处理器 “,换句话说,是一种 “ 代数效应处理器 “。我想说的是,这两个语言概念,效应处理器和协程,至少在某些方面是同构的。)

我还相信,但不确定,这样的语言可以实现 Rust 相同的保证,即引用不会同时是可变和别名,而不需要在表面语法中添加生命周期。只要协程能够用引用 yield 和被恢复,引用可以变成不能嵌入在对象类型中的修饰符,它们的生命周期可以完全推断出来。它不会允许像 Rust 那样优化的代码表示(用之前文章的术语,没有访问 “ 低级寄存器 “),但它仍然会给出相同的正确性保证。

为什么 Rust 没有这样做呢?起初它确实做了!但它让位于其他需求。上周在 lobste.rs 上有一个非常好的评论,比我说得更好:

Async 语言特性是在你的执行模型与 1:1 C ABI、C 标准库和 C 运行时本地兼容与 M:N 执行模型之间的妥协。C++ async 也遭受相同问题,只是在生命周期安全性方面不那么严格(不是好事)。为了与 C/system 运行时本地兼容而付出的成本是 “ 函数着色 “ 问题。

Rust 有与现有 C 运行时兼容的先决承诺。这意味着 Rust 代码由一系列子程序的栈组成,可以取得栈中项目的地址,并不仅仅存储在该栈中,还可以存储在程序内存的其他区域。Rust 选择了这种方法,是为了与大量使用该模型编写的现有 C 和 C++ 代码实现零成本 FFI,并且因为 C 运行时是所有主流平台的共同最小分母。但这种运行时模型与有栈协程不兼容,因此 Rust 需要引入一种无栈协程机制。每种带有 async/await 的主流语言都同样受制于无法表示有栈协程的现有运行时,如果不是 C 的,那就是某些虚拟机运行时。唯一的事情是 C 运行时如此普遍,以至于许多程序员甚至没有意识到它存在,并不是一种自然发生的现象。

再说一句:

如果你是一位有一定声望的语言设计师,你可能会说服一家大型且富有的技术公司资助你开发一种新语言,这种语言不那么受制于 C 运行时,特别是如果你是一位系统工程师,有深厚的 C 和 UNIX 知识,并能利用那些(以及公司的声誉)来迅速推广你的语言。如果你确实取得了这样的影响力,你可能会引入一个新的范式,比如有栈协程或效应处理器,将程序员从线程与 futures 之间的错误选择中解放出来。如果莱布尼茨是对的,我们生活在最好的所有可能的世界中,那么这肯定是你会用这个千载难逢的机会去做的事情。(如果你这样做了,我希望你至少不会上台说你采用这种方法的原因是因为你的用户 “ 没有能力理解一门出色的语言 “!)

在一个不太理想的世界里,你可能会决定做一些不那么有灵感的事情。你可能会利用脱离 C 运行时的机会,然后只是再次实现具有基本相同语义的线程,只是它们是在用户空间调度的。你的用户将被要求用线程、锁和通道来实现并发,就像他们过去一直在做的那样。你也可能决定你的语言应该有其他经典特性,如空指针、默认构造函数、数据竞争和 GOTO,原因只有你知道。也许你还会在添加泛型方面磨蹭多年,尽管用户经常提出请求。你可能会那样做,在一个不太理想的世界里。

唉。当我感到悲观时,我认为我们的行业陷入了某种停滞,以至于我们每个十年都会用新语言重新编写具有相同行为的新程序,这些新语言在语义上与旧语言相似,只是在性能特性上有轻微的差异,更适合当前硬件考虑。这是一种悲观的命运,但也许很快将成为大型语言模型而不是程序员的命运。我之所以热衷于推广 Rust,是因为它让我对编程感到乐观:Rust 致力于相信主流编程语言可以真正为好而进化。

尽管 Rust 是一次进步,但它并不是那个摆脱了 C 运行时的语言。它是那个语言的低级和更难的表亲:你可以获得相同的保证,但 “ 需要一些组装 “。我们应该尽我们所能,在我们的硬性要求下减少必要的组装,并且我们已经为此奠定了基础。我们应该不是简化系统,通过隐藏 futures 和线程之间的差异,而是寻找正确的 API 集和语言特性,这些建立在 futures 的便利性之上,使得比以前更多种类的工程成为可能。现在我们只有基础,但这已是从之前手工卷动状态机和直接管理你的事件循环的世界巨大的飞跃。如果我们让 futures 成为 futures,并在这个基础上建设,那么将有更多的可能性成为现实。