Rust异步教程

网络编程中,对多个连接的并发处理是一个非常常见的需求。

要并发处理多个连接,最简单的思路就是使用OS线程,每个线程处理一个连接。这样每个控制流只处理一个连接上的数据收发,与大多数人的思维模式匹配,因此编写起来最容易,心智负担最低。但这种模式需要大量OS线程,消耗大量的系统资源,因此限制了这种模型的并发处理能力。

基于事件驱动编程则采用另一种思路,将连接的处理流程打乱,基于连接上发生的事件及存储的状态判断所需的处理逻辑。这种方案下,单个线程即可处理大量连接,效率极高。但由于这种模式下非线性的控制流,要做到正确编写,需要较高的水平。

基于“程序首先是给人看的,其次才是给机器看的”这一思路,心智负担较低的1:1模型必然更受开发人员欢迎,只是OS线程的性能损失阻止了这种方案的广泛应用。而Golang中协程的使用极大地降低了1:1模型的性能损失,因此得到了广泛的使用。

Golang提供的有栈协程虽然比OS线程便宜,但终究要付出额外的成本。Rust提供的async/.await异步却是“零成本抽象”的无栈协程,使用异步不需要使用堆分配或动态分发等。(异步本身是零成本的,但提供异步I/O的异步运行时可能有成本,所以性能低于其他方案也不是没有可能。)基于async/.await的Rust异步在高性能的同时,仍具有类似于1:1模型的线性控制流。

Rust异步初探

我们首先以一个简单的例子来看async/.await语法下的Rust异步编程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
async fn learn_song() -> Song { ... }
async fn sing_song(song: Song) { ... }
async fn dance() { ... }

async fn learn_and_sing() {
let song = learn_song().await;
sing_song(song).await;
}

async fn async_main() {
let f1 = learn_and_sing();
let f2 = dance();
futures::join!(f1, f2);
}

fn main() {
block_on(async_main());
}

async .await 两个语法元素是理解Rust异步的关键。

async 语法将函数标记为异步函数,表明在这些函数中可能由于等待IO等原因让出CPU给其他异步函数执行,并在有条件时恢复执行。这类函数调用时并不实际执行,而是返回一个Future。因为这种行为,我们称Rust的异步函数是“惰性的”。

.await 语法则实现了对异步函数结果的等待。这种等待将底层的“回调”逻辑包装成“顺序执行”这种线性的控制流。这种等待会推进异步函数返回的Future,使异步函数得到真正的执行。

如果只看async.await的功能,那么所有逻辑似乎全部回到了同步阻塞模式。这样,”让出CPU给其他异步函数“似乎就失去了意义,因为在让出CPU后,这个执行流也没有其他可执行的代码。join!等分叉逻辑的存在则使得这些异步逻辑更有作用。如async_main函数中,join!要求learn_and_singdance并发执行,那么如果learn_and_sing在等待learn_song时让出CPU,dance就可以利用这个机会执行。如果这些让出的CPU时间原本用于等待IO,那么便利用异步降低了整个流程耗费的总时间。(与之相对的,如果让出的CPU时间后依旧是执行CPU运算,那么这个中断就没有意义,反而由于丢失缓存等原因降低效率。幸运的是,Rust异步并不会在这种情况下让出CPU给其他异步函数。)

Rust异步原理

在Rust的整个异步生态中:标准库提供了Future trait等基础的trait、类型、函数;编译器提供了async/.await语法;futures库提供了工具类型、宏和函数等;Tokio等异步运行时提供执行器、IO、任务生成等。

Rust编译器在编译时将async代码转为状态机。这个状态机将在.await点上切换状态,并根据这些状态切换推进时执行的逻辑。这与手动编写的回调函数具有相同的模式,有着相同的性能。因此,这种抽象可以称为零成本抽象。

Future trait

async代码返回的状态机都实现了Future trait,一个简化的Future trait可以通过下面的方式来表示:

1
2
3
4
5
6
7
8
9
trait SimpleFuture {
type Output;
fn poll(&mut self, wake: fn()) -> Poll<Self::Output>;
}

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

其中poll方法可用于推进Future的执行。它执行后在让出CPU时,返回一个Poll结果。这个结果可能有完成和未完成两种状态。在不考虑回调,使用最粗暴的方案时,可以在循环中不断调用poll方法推进Future的执行,直到结果变成完成状态。而wake的存在则提供了一个在内部触发poll方法调用的可能。因此,我们不需要使用循环来推进,而是将推进的工作放入wake中,只在“可推进”时进行推进。

通过在Future中存储多个其他Future,并在poll方法中根据条件有选择地推进存储的其他Future,就可以实现Future的串行或并行组合。

通过这些组合的嵌套,最终形成复杂的异步调用链条。

系统IO

为了提高整个系统的效率,势必要构建一个事件驱动的系统IO,实现仅在能推进状态时调用Future进行推进。

这些功能都由异步运行时来提供。

总结

在Rust异步原理中,涉及Waker等相对复杂的概念。这些概念的理解对于异步运行时的编写来说是必须的。

但是在应用上,往往只需按业务要求组合异步运行时提供的异步系统IO,并基于这些IO实现自己的逻辑,即可实现所需的业务。

参考资料