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
2
3
4
5
6
7
8
9
def fab(max): 
n, a, b = 0, 0, 1
while n < max:
yield b # 使用 yield
a, b = b, a + b
n = n + 1

for n in fab(5):
print n

上述代码会在每次调用 fab 后 generate 一个数列出来。相同的语法同样可以在 Rust 中实现:

1
2
3
4
5
6
let to_borrow = String::from("Hello");
let mut generator = move || {
let borrowed = &to_borrow;
yield borrowed.len();
println!("{} world!", borrowed);
};

为什么异步代码可以实现为 Generator 呢?其实较为好理解。Rust 的异步代码返回 Future,内含这段代码的运行状态(正在进行、等待、结束)。代码运行到某个 await 和在不同 await 上的等待过程与 Generator 的 yield 所维护的状态非常类似。

1
2
3
4
5
6
let to_borrow = String::from("Hello");
let mut fut = async {
let borrowed = &to_borrow;
SomeResource::some_task().await;
println!("{} world!", borrowed);
};

很好,那问题在哪

对于编译器来说,一段 Generator 代码会被展开为类似于下段的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
enum GeneratorState<Y, R> {
Yielded(Y),
Complete(R),
}

trait Generator {
type Yield;
type Return;
fn resume(&mut self) -> GeneratorState<Self::Yield, Self::Return>;
}

enum GeneratorA {
Enter,
Yield1 {
to_borrow: String,
borrowed: &String,
},
Exit,
}

impl GeneratorA {
fn start() -> Self {
GeneratorA::Enter
}
}

impl Generator for GeneratorA {
type Yield = usize;
type Return = ();
fn resume(&mut self) -> GeneratorState<Self::Yield, Self::Return> {
// lets us get ownership over current state
match std::mem::replace(self, GeneratorA::Exit) {
GeneratorA::Enter => {
let to_borrow = String::from("Hello"); // 更严格的来讲,这个 to_borrow 由外界捕获,这里就先这样写了
let borrowed = &to_borrow;
let res = borrowed.len();

*self = GeneratorA::Yield1 {to_borrow, borrowed};
GeneratorState::Yielded(res)
}

GeneratorA::Yield1 {to_borrow, borrowed} => {
println!("Hello {}", borrowed);
*self = GeneratorA::Exit;
GeneratorState::Complete(())
}
GeneratorA::Exit => panic!("Can't advance an exited generator!"),
}
}
}

是否发现问题了?Generator 维护了自己捕获的上下文(此处不是指栈、寄存器的概念),同时还在维护上下文的引用,导致了自引用结构的出现。

1
2
3
4
5
6
7
8
9
enum GeneratorA {
Enter,
Yield1 {
to_borrow: String,
borrowed: &String, // 你好,我引用了我自己。这段代码应该不能通过编译。
},
Exit,
}

于是,我们就遇到了一个严重的问题:如果这个结构体在内存中的位置变了borrowed 就成了 dangling pointer,Rust 只能和内存安全说再见。

但在编程中移动内存区域很常见。怎么办?这是第一个需要解决的问题。

Pin & Unpin

一个让人头疼的概念出现了:钉住内存。不过在读完上面的内容后,应该没那么难理解。它的出现就是为了阻止自引用结构的内存位置被随意改变。

与之相对的是 Unpin,声明了 Unpin 的类型无法被 Pin 住。Pin 对大部分类型都没有效果,只有这么几个特殊的,其中就包括 async 的 Future。一个是 async block 本身可能捕获外界变量所有权,再就是 await 代码可能会使用另一个 await 代码的返回值,二者都导致出现自引用结构。

Future 的类型是什么

async 实际上就是一个语法糖,它将你的返回值展开为一个实现了 impl Future 类型的某个不为人知的 Generator 状态机。

1
async fn fetch_data(db: &MyDb) -> String { ... }
1
2
3
fn fetch_data<'a>(db: &'a MyDb) -> impl Future<Output = String> + 'a {
async move { ... }
}

于是我们就遇到了另一个问题:您这 trait 里的签名返回值不能写 impl 啊。

1
2
3
4
// 曾经这种写法是不行的
trait Container {
fn items(&self) -> impl Iterator<Item = Widget>;
}

但是 trait 不是有 type 么?下面的写法不可以么?

1
2
3
4
trait Database {
type FetchData<'a>: Future<Output = String> + 'a where Self: 'a;
fn fetch_data<'a>(&'a self) -> FetchData<'a>;
}

可以。虽然这段代码使用了另一种高阶特性 GATs [3],曾经不行,但至少现在它确实可以了。不过问题仍然没有解决:下面代码的类型无法描述

1
2
3
4
impl Database for MyDb {
type FetchData<'a> = /* what type goes here??? */;
fn fetch_data<'a>(&'a self) -> FetchData<'a> { async move { ... } }
}

我们不知道底层类型到底是什么(也不该知道),没法描述这个 type,所以上述 trait 抽象依然是失败的。

async fn in trait 必须要在 impl 作为 trait 中签名返回值被实现后才有可能成功。于是这次,两个特性作为一次更新出现。

怎么给一个 impl 返回值增加新 bound?

对于一个 async trait

1
2
3
trait HealthCheck {
async fn check(&mut self) -> bool;
}

由于其实际的签名中的返回值是一个 impl

1
fn check(&mut self) -> impl Future<Output=bool>;

我们会遇到另一个问题:怎么给一个不知道名字的类型增加新 bound,让它实现别的 trait?

有人可能好奇为何需要该特性,虽然有用但似乎并没有那么必要。一个最直接的需求出现在选用不同 runtime 时所需要的 Send trait。Tokio 这样的 work-stealing scheduler 可能会在不同线程间移动 Future,所以需要数据满足 Send。不过我们不能假设所有人都必须用 Send,因此理想情况下,用户需要在上述签名上自行加 Send bound。

在上节中我们已经知道了,type 在这里完全没有用,那该怎么办?只能增加新的语法。

1
2
3
4
5
6
7
8
9
async fn do_health_check_par(hc: impl HealthCheck) {
}

async fn do_health_check_par<HC>(hc: HC)
where
HC: HealthCheck + Send + 'static,
HC::check(): Send, // <--
{
}

但是这也太长了。如果我们不止使用一个方法,岂不是要每个方法都写一遍?于是这里还有一个语法糖。

1
2
3
async fn do_health_check_par(hc: impl HealthCheck<check(): Send> + Send + 'static) {
// -------------
}

不过本次稳定的特性并不包含这个新语法。对于上文提及的常见 case Send,官方暂时提供了折中方案 trait-variant。在后续更新里可能会考虑新语法。

总之……

以下语法可以使用了

1
2
3
trait Container {
fn items(&self) -> impl Iterator<Item = Widget>;
}
1
2
3
trait HttpService {
async fn fetch(&self, url: Url) -> HtmlBody;
}

参考

  1. https://stevenbai.top/books-futures-explained/book/3_generators_async_await.html
  2. https://folyd.com/blog/rust-pin-unpin/
  3. https://blog.rust-lang.org/inside-rust/2022/11/17/async-fn-in-trait-nightly.html
  4. https://blog.rust-lang.org/2023/12/21/async-fn-rpit-in-traits.html
  1. 如果将协程和绿色线程放在一起比较,这种时候我们往往需要明确前者的另一个特性:协程没有「现场」的概念,也就是无须维护上下文,所以我们讲协程是「零成本」的。不过要注意的是,存在有栈协程
  2. 这意味着 Rust 的异步无上下文。
  3. https://blog.rust-lang.org/2022/10/28/gats-stabilization.html

async fn,impl 返回值,以及其他
https://blog.chenc.me/2024/03/01/note-stabilizing-async-fn-and-more/
作者
CC
发布于
2024年3月1日
许可协议