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 | async fn learn_song() -> Song { ... } |
async
.await
两个语法元素是理解Rust异步的关键。
async
语法将函数标记为异步函数,表明在这些函数中可能由于等待IO等原因让出CPU给其他异步函数执行,并在有条件时恢复执行。这类函数调用时并不实际执行,而是返回一个Future。因为这种行为,我们称Rust的异步函数是“惰性的”。
.await
语法则实现了对异步函数结果的等待。这种等待将底层的“回调”逻辑包装成“顺序执行”这种线性的控制流。这种等待会推进异步函数返回的Future,使异步函数得到真正的执行。
如果只看async
和.await
的功能,那么所有逻辑似乎全部回到了同步阻塞模式。这样,”让出CPU给其他异步函数“似乎就失去了意义,因为在让出CPU后,这个执行流也没有其他可执行的代码。join!
等分叉逻辑的存在则使得这些异步逻辑更有作用。如async_main
函数中,join!
要求learn_and_sing
和dance
并发执行,那么如果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 | trait SimpleFuture { |
其中poll方法可用于推进Future的执行。它执行后在让出CPU时,返回一个Poll结果。这个结果可能有完成和未完成两种状态。在不考虑回调,使用最粗暴的方案时,可以在循环中不断调用poll方法推进Future的执行,直到结果变成完成状态。而wake的存在则提供了一个在内部触发poll方法调用的可能。因此,我们不需要使用循环来推进,而是将推进的工作放入wake中,只在“可推进”时进行推进。
通过在Future中存储多个其他Future,并在poll方法中根据条件有选择地推进存储的其他Future,就可以实现Future的串行或并行组合。
通过这些组合的嵌套,最终形成复杂的异步调用链条。
系统IO
为了提高整个系统的效率,势必要构建一个事件驱动的系统IO,实现仅在能推进状态时调用Future进行推进。
这些功能都由异步运行时来提供。
总结
在Rust异步原理中,涉及Waker等相对复杂的概念。这些概念的理解对于异步运行时的编写来说是必须的。
但是在应用上,往往只需按业务要求组合异步运行时提供的异步系统IO,并基于这些IO实现自己的逻辑,即可实现所需的业务。