这是 Withoutboats 在 2019 年 4 月的 Rust Latam 上所做报告的一个整理。这个报告主要介绍他参与开发了一年半的语言特性,包括 Rust 异步 I/O 的发展历程,以及目前已经稳定的零成本抽象的async/await 语法的关键实现原理。

Withoutboats 是就职于 Mozilla 的一名研究员,主要从事 Rust 语言开发。他开发的这个语言特性叫做 async/await,这可能是本年度我们在 Rust 语言上做的最重要的事。这解决了困扰我们很久的问题,即我们如何能在 Rust 中拥有零成本抽象的异步IO。

注:因讲稿篇幅较长,所以分成上下两部分;上主要介绍 Rust 异步 I/O 的发展历程,下主要介绍目前的零成本抽象的实现原理;因个人水平有限,翻译和整理难免有错误或疏漏之处,欢迎读者批评指正。


async/await


首先,介绍一下 async/await。


async 是一个修饰符,它可以应用在函数上,这种函数不会在调用时一句句运行完成,而是立即返回一个 Future 对象,这个 Future 对象最终将给出这个函数的实际返回结果。而在一个这样的 async 函数中,我们可以使用await运算符,将它用在其它会返回 Future 的函数上,直到那些 Future 返回实际结果。通过这种方法,异步并发开发更加方便了。

let user = await db.get_user("withoutboats");


impl Database {
async fn get_user(&mut self, user: &str) -> User {
let sql = format!("select FROM users WHERE username = {}", user);
let db_response = await self.query(&sql);
User::from(db_response)
}
}


这是一段简短的代码样例,我们具体解释一下 Future 。这段代码基本上做的就是一种类似于 ORM 框架所作的事。你有一个叫 get_user 的函数,它接受一个字符串类型的用户名参数,并通过在数据库中查找对应用户的记录来返回一个User对象。它使用的是异步 I/O ,这意味着它得是一个异步函数,而不是普通函数,因此当你调用它时,你可以异步等待(await)它;然后我们看一下函数的实现,首先是用用户名参数拼接出要执行的 SQL 语句,然后是查询数据库,这就是我们实际执行 I/O 的地方,所以这个查询(query)返回的是 Future ,因为它使用的是异步 I/O 。所以在查询数据库时,你只需要使用异步等待(await)来等待响应,在获得响应后就可以从中解析出用户。这个函数看起来像个玩具,但我想强调的是,它与使用阻塞式 I/O 的唯一区别就是这些注解(指async/await)了,你只需将函数标记为异步(async),并在调用它们时加上 await 就行了,开发的心智负担很小,以至于你会忘了自己是在写异步 I/O 而不是阻塞 I/O 。而 Rust 的这种实现让我尤其感到兴奋的是,它的 async/await 和 Future 都是零成本抽象的。


零成本抽象


零成本抽象是 Rust 比较独特的一项准则,这是使 Rust 与其他许多语言相区别的原因之一。在添加新功能时,我们非常关心这些新功能是不是零成本的。不过这并不是我们想出来的点子,它在 C++ 中也很重要,所以我认为最好的解释是 Bjarne Stroustrup 的这句话:


零成本抽象意味着你不使用的东西,你不用为它付出任何代价,进一步讲,你使用的东西,你无法写出比这更好的代码。

Zero Cost Abstractions: What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.


也就是说零成本抽象有两个方面:


  1. 该功能不会给不使用该功能的用户增加成本,因此我们不能为了增加新的特性而增加那些会减慢所有程序运行的全局性开销。
  2. 当你确实要使用该功能时,它的速度不会比不使用它的速度慢。如果你觉得,我想使用这个非常好用的功能把开发工作变得轻松,但是它会使我的程序变慢,所以我打算自己造一个,那么这实际上是带来了更大的痛苦。


所以,我将回顾一下我们如何尝试解决异步 I/O 和 Rust 的问题,以及在我们实现这一目标的过程中,某些未能通过这两项零成本测试的特性。


绿色线程的尝试


我们要解决的问题是 异步 I/O 。通常 I/O 处于阻塞状态,因此当你使用 I/O 时,它会阻塞线程,中止你的程序,然后必须通过操作系统重新调度。阻塞式 I/O 的问题是当你尝试通过同一程序提供大量连接时,它无法真正实现扩展。因此对于真正的大规模网络服务,你需要某种形式的非阻塞的或者说异步的 I/O 尤其是 Rust 是针对具有这些真正高性能要求而设计的语言,它是一种系统编程语言,面向那些真正在乎计算资源的人。要在网络的世界中真正取得成功,我们就需要某种解决方案来解决这个异步 I/O 问题。

但是 异步 I/O 的最大问题是它的工作方式 :在你调用 I/O 时,系统调用会立即返回,然后你可以继续进行其他工作,但你的程序需要决定如何回到调用该异步 I/O 暂停的那个任务线上,这就使得在编码上,异步 I/O 的代码要比阻塞 I/O 的代码复杂得多。所以,很多,尤其是以可扩展的网络服务这类特性为目标的语言,一直在试图解决这个问题。比如,让它不再是最终用户需要解决的问题,而是编程语言的一部分或者某个库的一部分等等。


Rust 最初使用的第一个解决方案是 绿色线程,它已经在许多语言中获得成功。绿色线程基本上就像阻塞式 I/O 一样,使用的时候就像是普通的线程,它们会在执行 I/O 时阻塞,一切看起来就跟你在使用操作系统的原生方式一样。但是,它们被设计为语言运行时的一部分,来对那些需要同时运行成千上万甚至数百万个绿色线程的网络服务用例进行优化。一个使用该模型的典型的成功案例就是 Go 语言,它的绿色线程被称为 goroutine。对于 Go 程序来说,同时运行成千上万个 goroutine 是很正常的,因为与操作系统线程不同,创建它们的成本很低。



操作系统线程

绿色线程

内存开销

较大的堆栈,增加大量内存占用

初始堆栈非常小

CPU开销

上下文切换至操作系统的调度器,成本很高

由程序本身的运行调度


即 绿色线程的优点 在于,产生操作系统线程时的内存开销要高得多,因为每个操作系统线程会创建一个很大的堆栈,而绿色线程通常的工作方式是,你将产生一个以很小的堆栈,它只会随着时间的推移而增长,而产生一堆不使用大量内存的新线程并不便宜;并且使用类似操作系统原语的问题还在于你依赖于操作系统调度,这意味着你必须从程序的内存空间切换到内核空间,如果成千上万的线程都在快速切换,上下文切换就会增加很多开销。而将调度保持在同一程序中,你将避免使用这些上下文,进而减少开销。所以我相信绿色线程是一个非常好的模型,适用于许多语言,包括 Go 和 Java。
 
在很长一段时间内, Rust 都有绿色线程,但是在 1.0 版本之前删掉了。我们删掉它是因为它不是零成本抽象的,准确的说就是我在第一个问题中谈到的,它给那些不需要它的人增加了成本。比如你只想编写一个不是网络服务的屏幕打印的 Rust 程序,你必须引入负责调度所有绿色线程的语言运行时。这种方法,尤其是对于试图把 Rust 集成到一个大的 C 应用程序中的人来说,就成为一个问题。很多 Rust 的采用者拥有一些大型C程序,他们想开始使用 Rust 并将 Rust 集成到他们的程序中,只是一小段 Rust 代码。问题是,如果你必须设置运行时才能调用 Rust ,那么这一小部分的 Rust 程序的成本就太高了。因此从 1.0 开始,我们就从语言中删除了绿色线程,并删除了语言的运行时。现在我们都知道它的运行时与 C 基本上相同,这就使得在 Rust 和 C 之间调用非常容易,而且成本很低,这是使 Rust 真正成功的关键因素之一。删除了绿色线程,我们还是需要某种异步 I/O 解决方案;但是我们意识到 这应该是一个基于库的解决方案,我们需要为异步 I/O 提供良好的抽象,它不是语言的一部分,也不是每个程序附带的运行时的一部分,只是可选的并按需使用的库。


Future 的解决方案


最成功的库解决方案是一个叫做 Future 的概念,在 JavaScript 中也叫做 Promise。Future 表示一个尚未得出的值,你可以在它被解决(resolved)以得出那个值之前对它进行各种操作。在许多语言中,对 Future 所做的工作并不多,这种实现支持很多特性比如组合器(Combinator),尤其是能让我们在此基础上实现更符合人体工程学的 async/await 语法。


Future 可以表示各种各样的东西,尤其适用于表示异步 I/O :当你发起一次网络请求时,你将立即获得一个 Future 对象,而一旦网络请求完成,它将返回任何响应可能包含的值;你也可以表示诸如“超时”之类的东西,“超时”其实就是一个在过了特定时间后被解决的 Future ;甚至不属于 I/O 的工作或者需要放到某个线程池中运行的CPU密集型的工作,也可以通过一个 Future 来表示,这个 Future 将会在线程池完成工作后被解决。


trait Future {
type Output;
fn schedule<F>(self, callback: F)
where F: FnOnce(Self::Output);
}


Future 存在的问题 是它在大多数语言中的表示方式是这种基于回调的方法,使用这种方式时,你可以指定在 Future 被解决之后运行什么回调函数。也就是说, Future 负责弄清楚什么时候被解决,无论你的回调是什么,它都会运行;而所有的不便也都建立在此模型上,它非常难用,因为已经有很多开发者进行了大量的尝试,发现他们不得不写很多分配性的代码以及使用动态派发;实际上,你尝试调度的每个回调都必须获得自己独立的存储空间,例如 crate 对象、堆内存分配,这些分配以及动态派发无处不在。这种方法没有满足零成本抽象的第二个原则,如果你要使用它,它将比你自己写要慢很多,那你为什么还要用它。


耿腾兄的投稿,感谢辛苦的付出。