async fn,impl 返回值,以及其他
在去年 12 月 21 日,Rust 宣布 trait 中的 async fn 和 impl 作为返回值两个特性稳定。
在之前的一些 blog 里,我们曾经讨论了 Rust 中 impl 返回值、async fn、以及 GATs 等一些问题,但那些文章其实讨论得并不完整。在庆祝这次重大进步的同时,我想从头梳理 Rust 中关于 async 的一些东西。
Rust 的异步到底是什么
Rust 的异步是一种并行模式的抽象。这一点需要明确指出。
我们讨论并行时(也许)往往在三个层面上分别进行,分别是进程、线程、协程。进程、线程机制由系统在内核态向程序提供,一个进程可以包含多个线程,并由系统调度,协程则是一种在用户态实现的线程。有些语言声称自己并行为协程,这些语言会将自己的语法与运行时捆绑在一起,例如 Golang,在这些语言的语境里,「协程」的名字实际上隐含了一个运行时的存在(但这的确不影响学习这些语言)。
Rust 的异步不能在这类分类上探讨,因为它本身不提供运行时。在曾经的 Rust 中提供过「绿色线程」,这又是一种……较为混乱的概念,它介于线程和协程之间,属于将线程的切换原理实现于用户态的运行时,从而节省部分切换开销[1]。不过后来它被删掉了,取而代之的是现在的描述异步代码的模型。你的 Rust 异步代码到底运行在什么级别,取决于你实际使用的运行时。例如最常用的 Tokio 实现了 work-stealing scheduler,在多个线程上调度和均衡多个并行任务。
我们对抽象都有一些认识,抽象层次越高就意味着更复杂的理解难度和技术难度。Rust 为了自己的系统级零成本 async 异步模型,可以说煞费苦心。
Rust 异步抽象很难
Rust 的异步主要由两个关键字提供:async await。这也是很多现代语言所推崇的语法,似乎一开始来自 C#。
(至少)对于 Rust 来讲,其内部实现为自引用的 Generator,或者说,是一个状态机[2]。
我们先从 Generator 和它的状态机实现开始。
Generator
如果你使用过 Python,那么应该对这个关键字很熟悉:yield
。简单来讲,它可以让一段过程式代码在不同位置依次返回数个值。
1 |
|
上述代码会在每次调用 fab 后 generate 一个数列出来。相同的语法同样可以在 Rust 中实现:
1 |
|
为什么异步代码可以实现为 Generator 呢?其实较为好理解。Rust 的异步代码返回 Future
,内含这段代码的运行状态(正在进行、等待、结束)。代码运行到某个 await 和在不同 await 上的等待过程与 Generator 的 yield 所维护的状态非常类似。
1 |
|
很好,那问题在哪
对于编译器来说,一段 Generator 代码会被展开为类似于下段的代码。
1 |
|
是否发现问题了?Generator 维护了自己捕获的上下文(此处不是指栈、寄存器的概念),同时还在维护上下文的引用,导致了自引用结构的出现。
1 |
|
于是,我们就遇到了一个严重的问题:如果这个结构体在内存中的位置变了,borrowed
就成了 dangling pointer,Rust 只能和内存安全说再见。
但在编程中移动内存区域很常见。怎么办?这是第一个需要解决的问题。
Pin
& Unpin
一个让人头疼的概念出现了:钉住内存。不过在读完上面的内容后,应该没那么难理解。它的出现就是为了阻止自引用结构的内存位置被随意改变。
与之相对的是 Unpin
,声明了 Unpin
的类型无法被 Pin
住。Pin
对大部分类型都没有效果,只有这么几个特殊的,其中就包括 async 的 Future
。一个是 async block 本身可能捕获外界变量所有权,再就是 await 代码可能会使用另一个 await 代码的返回值,二者都导致出现自引用结构。
Future 的类型是什么
async 实际上就是一个语法糖,它将你的返回值展开为一个实现了 impl Future
类型的某个不为人知的 Generator 状态机。
1 |
|
1 |
|
于是我们就遇到了另一个问题:您这 trait 里的签名返回值不能写 impl
啊。
1 |
|
但是 trait 不是有 type 么?下面的写法不可以么?
1 |
|
可以。虽然这段代码使用了另一种高阶特性 GATs [3],曾经不行,但至少现在它确实可以了。不过问题仍然没有解决:下面代码的类型无法描述
1 |
|
我们不知道底层类型到底是什么(也不该知道),没法描述这个 type,所以上述 trait 抽象依然是失败的。
async fn in trait 必须要在 impl 作为 trait 中签名返回值被实现后才有可能成功。于是这次,两个特性作为一次更新出现。
怎么给一个 impl 返回值增加新 bound?
对于一个 async trait
1 |
|
由于其实际的签名中的返回值是一个 impl
1 |
|
我们会遇到另一个问题:怎么给一个不知道名字的类型增加新 bound,让它实现别的 trait?
有人可能好奇为何需要该特性,虽然有用但似乎并没有那么必要。一个最直接的需求出现在选用不同 runtime 时所需要的 Send
trait。Tokio 这样的 work-stealing scheduler 可能会在不同线程间移动 Future,所以需要数据满足 Send
。不过我们不能假设所有人都必须用 Send
,因此理想情况下,用户需要在上述签名上自行加 Send
bound。
在上节中我们已经知道了,type 在这里完全没有用,那该怎么办?只能增加新的语法。
1 |
|
但是这也太长了。如果我们不止使用一个方法,岂不是要每个方法都写一遍?于是这里还有一个语法糖。
1 |
|
不过本次稳定的特性并不包含这个新语法。对于上文提及的常见 case Send
,官方暂时提供了折中方案 trait-variant。在后续更新里可能会考虑新语法。
总之……
以下语法可以使用了
1 |
|
1 |
|
参考
- https://stevenbai.top/books-futures-explained/book/3_generators_async_await.html
- https://folyd.com/blog/rust-pin-unpin/
- https://blog.rust-lang.org/inside-rust/2022/11/17/async-fn-in-trait-nightly.html
- https://blog.rust-lang.org/2023/12/21/async-fn-rpit-in-traits.html
- 如果将协程和绿色线程放在一起比较,这种时候我们往往需要明确前者的另一个特性:协程没有「现场」的概念,也就是无须维护上下文,所以我们讲协程是「零成本」的。不过要注意的是,存在有栈协程。 ↩
- 这意味着 Rust 的异步无上下文。 ↩
- https://blog.rust-lang.org/2022/10/28/gats-stabilization.html ↩