您现在的位置是:首页 >技术交流 >rust异步编程以及kv server的异步处理和重构网站首页技术交流

rust异步编程以及kv server的异步处理和重构

explore翔 2023-05-30 12:00:01
简介rust异步编程以及kv server的异步处理和重构

为什么需要异步?
异步模型有哪些?
1、操作系统的线程。不需要编程模型作任何改动,这使得表达并发很容易。然而,线程间同步(如何实现线程同步的C++)可能会很困难,并且性能开销很大线程池可以减少一部分开销,但是不足够支持超大量 IO 密集负载
2.事件驱动编程,以及回调,可以变得高性能,但倾向于导致冗长,“非线性”的控制流**。数据流和错误传播通常就变得很难跟进了。** 一般来说我理解的线程建立时参数的那个函数就是回调函数。
3、协程,就像线程,但不需要改变编程模型,于是他们变得便于使用。像异步,他们可以支持大量的任务。同步写法写出异步效果。
4、actor 模型 把所有的并发计算分割成称为 actor 的单元,相互之间通过易错的消息传递进行沟通,非常类似于分布式系统。actor 模型能够很高效地实现,但是它还很多没有解答的实践问题,例如流程控制和重入逻辑。

Rust 的异步 vs 其他语言的
尽管很多语言都支持异步编程,但实现细节上有很多不一样。Rust 的异步实现和大部分语言的在以下方面有区别:
Rust 中 Futures 是惰性的,并且只有被轮询await才会进一步执行。丢弃(Dropping)一个 future 可以阻止它继续执行。
Rust 中的 异步是零成本的,这意味着你只需要为你所使用的东西付出代价。特别来说,你使用异步时可以不需要堆分配或动态分发,这对性能来说是好事!这也使得你能够在约束环境下使用异步,例如嵌入式系统。
Rust 不提供内置运行时。相反,运行时由社区维护的库提供。而GO就是提供一个语言层面的调度器。
Rust里 单线程的和多线程的运行时都可用,而他们会有不同的优劣。

Rust 中的异步 vs 线程
Rust 中异步的首选替代是使用 OS 线程,可以直接通过 std::thread 或者间接通过线程池来使用。从线程模型迁移到异步模型,或者反过来,通常需要一系列重构的工作,既包括内部实现也包括任何暴露的公开接口(如果你在构建一个库)。因此,尽早地选择适合你需要的模型能够节约大量的开发事件。
OS 线程 适合少量任务,因为线程会有 CPU 和内存开销。生成和切换线程是代价相当昂贵,甚至闲置的线程也会消耗系统资源。一个线程池库可以减轻这些开销,但并不能全部健康。然而,线程能让你重新利用存在的同步代码,而不需要大改源代码——不需要特别的编程模型。一些操作系统中,你也可以改变线程的优先级,这对于驱动或者其他延迟敏感的应用很有用。

异步 极大地降低了 CPU 和内存开销,尤其是再负载大量越过IO 边界的任务,例如服务器和数据库。同样,你可以处理比 OS 线程更高数量级的任务,因为异步运行时使用少量(昂贵的)线程来处理大量(便宜的)任务。然而,异步 Rust 会导致更大的二进制体积,因为异步函数会生成状态机,并且每个可执行文件都会绑定一个异步运行时。

最后一点,异步编程并没有 更优于 线程模型,不过它们是不一样的。如果你不需要由于性能原因使用异步,线程通常是个更简单的替换。
比如并发下载两个文件,如果创建2个线程,下载网页是小任务,为了这么少量工作创建线程相当浪费。对更大的应用来说,这很容易就会变成瓶颈。在异步 Rust,我们能够并发地运行这些任务而不需要额外的线程。

async fn get_two_sites_async() {
    // 创建两个不同的 "futures", 当创建完成之后将异步下载网页.
    let future_one = download_async("https:://www.foo.com");
    let future_two = download_async("https:://www.bar.com");

    // 同时运行两个 "futures" 直到完成.
    join!(future_one, future_two);
}

这里没有创建额外的线程。此外,所有函数调用都是静态分发的,也没有堆分配!

async/await 和 Future原理,rust怎么实现异步的

async/.await初步
你可以使用async fn语法创建异步函数:
async fn do_something() { … }
async fn函数返回实现了Future的类型。为了执行这个Future,我们需要执行器(executor)
在async fn函数中, 你可以使用.await来等待其他实现了Future trait 的类型完成,例如 另外一个async fn的输出。和block_on不同,.await不会阻塞当前线程,而是异步地等待 future完成,在当前future无法进行下去时,允许其他任务运行。
一个“学,唱,跳舞”的方法,就是分别阻塞这些函数:

fn main() {
    let song = block_on(learn_song());
    block_on(sing_song(song));
    block_on(dance());
}

然而,这样性能并不是最优——我们一次只能干一件事!显然我们必须在唱歌之前学会它,但是学唱 同时也可以跳舞。为了做到这样,我们可以创建两个独立可并发执行的async fn:

async fn learn_and_sing() {
    // Wait until the song has been learned before singing it.
    // We use `.await` here rather than `block_on` to prevent blocking the
    // thread, which makes it possible to `dance` at the same time.
    let song = learn_song().await;
    sing_song(song).await;
}

async fn async_main() {
    let f1 = learn_and_sing();
    let f2 = dance();

    // `join!` is like `.await` but can wait for multiple futures concurrently.
    // If we're temporarily blocked in the `learn_and_sing` future, the `dance`
    // future will take over the current thread. If `dance` becomes blocked,
    // `learn_and_sing` can take back over. If both futures are blocked, then
    // `async_main` is blocked and will yield to the executor.
    futures::join!(f1, f2);
}

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

这个示例里,唱歌之前必须要学习唱这首歌,但是学习唱歌和唱歌都可以和跳舞同时发生。如果我们 用了block_on(learning_song())而不是learn_and_sing中的learn_song().await, 那么当learn_song在执行时线程将无法做别的事,也让同时跳舞变得不可能。但是通过.await 执行learn_song的future,我们就可以在learn_song阻塞时(比如io操作)让其他任务来掌控当前线程。 这样就可以做到在单线程并发执行多个future到完成状态。

体会一下这里只有一个线程,通过.await 可以让当前的future执行, 并且阻塞时线程就会执行别的future。当有事件发生,就让阻塞回到就绪队列准备执行。(联系一下我们项目实现的有栈协程,需要自己实现协程,以及调度器,而且对性能似乎没有帮助相比于多线程,只是hook让同步代码写出异步效果)

目前,还不知道future怎么调度的。
future有一个POLL枚举体,里面是pending和ready(T),还有一个poll方法。
future通过里面的poll方法(注册了wake回调)执行,比如一个读数据的异步,poll方法就是:如果有数据返回,就执行读取数据并返回 poll::Ready(data),如果没准备好就设置pending状态,代表阻塞让出控制权线程可执行别的,然后注册一个wake函数,在事件到达时唤醒将future送入就绪队列等待执行。(送入就绪队列就代表执行吗?不一定吧)当有IO操作时就让出CPU,给别的就绪的future。实际上一个线程还是只能同时执行一个任务的。
运行 Future 最常见的方法是 .await 它。当 .await 在 Future 上调用时,它会尝试把 future 跑到完成状态。如果 Future 被阻塞了,它会让出当前线程的控制权。能取得进展时,执行器就会捡起这个 Future 并继续执行,让 .await 求解。
在多线程执行器中 .await
提醒一下,在使用多线程的 Future 执行器时,一个 Future 可能在线程间移动,所以任何在 async 体中使用的变量必须能够穿过线程(实现send trait),因为任何 .await 都有可能导致线程切换。
(体现了这是不对称的协程,调度随机,不像我们实现的对称协程,只能回到主协程上。但是这种随机的切换实现也更加麻烦,涉及到状态机。)
整理一遍:对于CPU而言,同一时间只有一个进程获取到CPU使用权,为了实现并发,就需要分时间片,根据时间片调度。这个就是上下文切换,代价昂贵,因为需要把运行状态,堆栈,寄存器等信息保存重新加载,而且换完后cache命中率也会变低。
而协程就是把这种上下文切换交给应用程序控制,用户态线程,**没有上下文切换以及内核用户态的损失,而且可以用同步写异步代码。**不过感觉之前实现的有栈的对称协程切换就是寄存器切换,性能没有更高,只是同步方式写异步代码。而rust这种,既可以同步写异步,又可以提高性能。

poll第二个参数就是wake。如果运行时对所有的future都poll,当返回pending时就继续轮询下一个,所以cpu可能一直处于空转,wake作用就是告诉运行时,future已经准备好了,可以进行poll了。
实际上,future实现会产生复杂的代码,保存状态,产生不同状态机。以及重要的Pin特性。
Pin是为了解决自引用结构体的,因为状态定义基本都是自引用结构体(当状态在内存移动到新的内存位置,里面的自引用指针没有更新,无效指针)。使用Pin可以保证数据指针总是指向正确地址。

Runtime
Runtime 由两部分组成,Executor和Reactor。
Executor为执行器,没有任何阻塞的等待,循环执行一系列就绪的Future,当Future返回pending的时候,会将Future转移到Reactor上等待进一步的唤醒。常来说,Executor的实现可以是单线程与线程池两个版本,两种实现间各有优劣,单线程少了数据的竞争,但是吞吐量却容易达到瓶颈,线程池的实现可以提高吞吐量,但是却要处理数据的竞争冲突。

Reactor为反应器(唤醒器),轮询并唤醒挂载的事件,并执行对应的wake方法,通常来说,wake会将Future的状态变更为就绪,同时将Future放到Executor的队列中等待执行。Reactor作为反应器,上面同时挂载了成千上万个待唤醒的事件, 这里使用了mio统一封装了操作系统的多路复用API。在Linux中使用的是Epoll,在Mac中使用的则是Kqueue,具体的实现在此不多说。

Async/Await
上面所有的概念共同组成了Rust的异步生态,那么现在想象一下,如何获取一个Future运行的结果呢。一个可能的做法如下:

loop {
match f::poll(cx) {
Poll::Ready(x) => return x;
Poll::Pending => {}
}
}
如果每次都要用户这么做的话,将会是多么痛苦的一件事儿呀,还不如用注册回调函数来实现异步呢!
有没有更精炼的方式来获取Future的值呢,这就是async/await出现的原因了。本质上来说,async/await就是上面代码段的一个语法糖,是用户使用起来更加的自然。上面的代码可以替换成:

let x = f.await;
总结
虽然上面提到了各种各样的概念,但是仔细捋一下,便会发现整个异步可以分为三层:
Future/Stream/Sink,Reactor/Executor直接作用于前面的三种类型。此层是为底层,一般用户很少接触,库的开发者接触较多。
组合子层,为了提供更为复杂的操作,诞生了一系列的异步组合子,使得异步变得更利于使用,用户会使用这些组合子来完成各种各样的逻辑。
async/await,准确的说,这层远没有上面两层来的重要,但是依然不可或缺,这层使得异步的开发变得轻而易举。

异步IO相关

异步的 Stream trait
Stream trait 类似于 Future trait,但 Stream 在完成前可以生成多个值,这种行为跟标准库中的 Iterator trait 类似。不过和 Future 已经在标准库稳定下来不同,Stream trait 目前还只能在 nightly 版本使用。一般跟 Stream 打交道,会使用 futures 库。

Iterator 可以不断调用 next() 方法,获得新的值,直到 Iterator 返回 None。但是 Iterator 是阻塞式返回数据的,每次调用 next(),必然 独占CPU 直到得到一个结果,而异步的 Stream 是非阻塞的,在等待的过程中会空出 CPU 做其他事情
Stream 的 poll_next() 方法,它跟 Future 的 poll() 方法很像,和 Iterator 版本的 next() 的作用类似。然而,poll_next() 调用起来不方便,我们需要自己处理 Poll 状态,所以,StreamExt 提供了 next() 方法,返回一个实现了 Future trait 的 Next 结构,这样就可以直接通过 stream.next().await来获取下一个值了。(现在知道前面的stream的作用原理了)
stream用来抽象源源不断的数据源,比如connection抽象源源不断接收客户端连接,比如tcp中业务数据包都可以看成stream.
异步 IO 接口
所有同步的IO,如 Read / Write / Seek trait,前面加一个 Async,就构成了对应的异步 IO 接口(也就是会返回一个Poll结构,内部实现是状态机的迁移)。异步 IO 主要应用在文件处理、网络处理等场合,而这些场合的数据结构都已经实现了对应的接口,比如 File 或者 TcpStream,它们也已经实现了 AsyncRead / AsyncWrite,所以基本上不用自己实现异步 IO 接口。不过有些情况,可能会把已有的数据结构封装在自己的数据结构中,此时需要自己实现相应的异步 IO 接口。(比如你封装了一个file或者tcp,但是不想暴露出去,就需要自己实现)

kv server的异步处理
看之前写的 ProstServerStream 的 process() 函数,比较一下它和 async_prost 库的 AsyncProst 的调用逻辑:

// process() 函数的内在逻辑
while let Ok(cmd) = self.recv().await {
    info!("Got a new command: {:?}", cmd);
    let res = self.service.execute(cmd);
    self.send(res).await?;
}

// async_prost 库的 AsyncProst 的调用逻辑
while let Some(Ok(cmd)) = stream.next().await {
    info!("Got a new command: {:?}", cmd);
    let res = svc.execute(cmd);
    stream.send(res).await.unwrap();
}

可以看到由于 AsyncProst 实现了 Stream 和 Sink,能更加自然地调用 StreamExt trait 的 next() 方法和 SinkExt trait 的 send() 方法,来处理数据的收发,而 ProstServerStream 则自己额外实现了函数 recv() 和 send()。虽然从代码对比的角度,这两段代码几乎一样,但未来的可扩展性,和整个异步生态的融洽性上,AsyncProst 还是更胜一筹。

所以今天我们就构造一个 ProstStream 结构,让它实现 Stream 和 Sink 这两个 trait,然后让 ProstServerStream 和 ProstClientStream 使用它。(所以一个点就是让proststream可以实现stream,sink,方便使用异步IO接口,实现异步)

所以今天我们就构造一个 ProstStream 结构,让它实现 Stream 和 Sink 这两个 trait,然后让 ProstServerStream 和 ProstClientStream 使用它。
先来简单复习一下 Stream trait 和 Sink trait:


// 可以类比 Iterator
pub trait Stream {
    // 从 Stream 中读取到的数据类型
    type Item;

  // 从 stream 里读取下一个数据
    fn poll_next(
    self: Pin<&mut Self>, cx: &mut Context<'_>
    ) -> Poll<Option<Self::Item>>;
}

// 
pub trait Sink<Item> {
    type Error;
    fn poll_ready(
        self: Pin<&mut Self>, 
        cx: &mut Context<'_>
    ) -> Poll<Result<(), Self::Error>>;
    fn start_send(self: Pin<&mut Self>, item: Item) -> Result<(), Self::Error>;
    fn poll_flush(
        self: Pin<&mut Self>, 
        cx: &mut Context<'_>
    ) -> Poll<Result<(), Self::Error>>;
    fn poll_close(
        self: Pin<&mut Self>, 
        cx: &mut Context<'_>
    ) -> Poll<Result<(), Self::Error>>;
}
(是future,stream的结合,代表一次或者多次的异步值

那么 ProstStream 具体需要包含什么类型呢?
因为它的主要职责是从底下的 stream 中读取或者发送数据,所以一个支持 AsyncRead 和 AsyncWrite 的泛型参数 S 是必然需要的。
另外 Stream trait 和 Sink 都各需要一个 Item 类型,对于我们的系统来说,Item 是 CommandRequest 或者 CommandResponse,但为了灵活性,我们可以用 In 和 Out 这两个泛型参数来表示。
当然,在处理 Stream 和 Sink 时还需要 read buffer 和 write buffer。


pub struct ProstStream<S, In, Out> {
    // innner stream
    stream: S,
    // 写缓存
    wbuf: BytesMut,
    // 读缓存
    rbuf: BytesMut,
}

use bytes::BytesMut;
use futures::{Sink, Stream};
use std::{
    marker::PhantomData,
    pin::Pin,
    task::{Context, Poll},
};
use tokio::io::{AsyncRead, AsyncWrite};

use crate::{FrameCoder, KvError};

/// 处理 KV server prost frame 的 stream
pub struct ProstStream<S, In, Out> where {
    // innner stream
    stream: S,
    // 写缓存
    wbuf: BytesMut,
    // 读缓存
    rbuf: BytesMut,

    // 类型占位符
    _in: PhantomData<In>,
    _out: PhantomData<Out>,
}

impl<S, In, Out> Stream for ProstStream<S, In, Out>
where
    S: AsyncRead + AsyncWrite + Unpin + Send,
    In: Unpin + Send + FrameCoder,
    Out: Unpin + Send,
{
    /// 当调用 next() 时,得到 Result<In, KvError>
    type Item = Result<In, KvError>;

    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
        todo!()
    }
}

/// 当调用 send() 时,会把 Out 发出去
impl<S, In, Out> Sink<Out> for ProstStream<S, In, Out>
where
    S: AsyncRead + AsyncWrite + Unpin,
    In: Unpin + Send,
    Out: Unpin + Send + FrameCoder,
{
    /// 如果发送出错,会返回 KvError
    type Error = KvError;

    fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        todo!()
    }

    fn start_send(self: Pin<&mut Self>, item: Out) -> Result<(), Self::Error> {
        todo!()
    }

    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        todo!()
    }

    fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        todo!()
    }
}

先来实现 Stream 的 poll_next() 方法。poll_next() 可以直接调用我们之前写好的 read_frame(),然后再用 decode_frame() 来解包:


fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
    // 上一次调用结束后 rbuf 应该为空
    assert!(self.rbuf.len() == 0);

    // 从 rbuf 中分离出 rest(摆脱对 self 的引用)
    let mut rest = self.rbuf.split_off(0);

    // 使用 read_frame 来获取数据
    let fut = read_frame(&mut self.stream, &mut rest);
    ready!(Box::pin(fut).poll_unpin(cx))?;
    //至于 ready! 宏,它会在 Pending 时直接 return Pending,而在 Ready 时,返回 Ready 的值:

    // 拿到一个 frame 的数据,把 buffer 合并回去
    self.rbuf.unsplit(rest);

    // 调用 decode_frame 获取解包后的数据
    Poll::Ready(Some(In::decode_frame(&mut self.rbuf)))
}

再写 Sink,看上去要实现好几个方法,其实也不算复杂。四个方法 poll_ready、start_send()、poll_flush 和 poll_close 我们再回顾一下。
poll_ready() 是做背压的,你可以根据负载来决定要不要返回 Poll::Ready。对于我们的网络层来说,可以先不关心背压,依靠操作系统的 TCP 协议栈提供背压处理即可,所以这里直接返回 Poll::Ready(Ok(())),也就是说,上层想写数据,可以随时写。


fn poll_ready(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
    Poll::Ready(Ok(()))
}

当 poll_ready() 返回 Ready 后,Sink 就走到 start_send()。我们在 start_send() 里就把必要的数据准备好。这里把 item 封包成字节流,存入 wbuf 中:


fn start_send(self: Pin<&mut Self>, item: Out) -> Result<(), Self::Error> {
    let this = self.get_mut();
    item.encode_frame(&mut this.wbuf)?;

    Ok(())
}

然后在 poll_flush() 中,我们开始写数据。这里需要记录当前写到哪里,所以需要在 ProstStream 里加一个字段 written,记录写入了多少字节:有了这个 written 字段, 就可以循环写入:


fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
    let this = self.get_mut();

    // 循环写入 stream 中
    while this.written != this.wbuf.len() {
        let n = ready!(Pin::new(&mut this.stream).poll_write(cx, &this.wbuf[this.written..]))?;
        this.written += n;
    }

    // 清除 wbuf
    this.wbuf.clear();
    this.written = 0;

    // 调用 stream 的 poll_flush 确保写入
    ready!(Pin::new(&mut this.stream).poll_flush(cx)?);
    Poll::Ready(Ok(()))
}

最后是 poll_close(),我们只需要调用 stream 的 flush 和 shutdown 方法,确保数据写完并且 stream 关闭:


fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
    // 调用 stream 的 poll_flush 确保写入
    ready!(self.as_mut().poll_flush(cx))?;

    // 调用 stream 的 poll_shutdown 确保 stream 关闭
    ready!(Pin::new(&mut self.stream).poll_shutdown(cx))?;
    Poll::Ready(Ok(()))
}

ProstStream 的创建我们的 ProstStream 目前已经实现了 Stream 和 Sink,为了方便使用,再构建一些辅助方法,比如 new():


impl<S, In, Out> ProstStream<S, In, Out>
where
    S: AsyncRead + AsyncWrite + Send + Unpin,
{
    /// 创建一个 ProstStream
    pub fn new(stream: S) -> Self {
        Self {
            stream,
            written: 0,
            wbuf: BytesMut::new(),
            rbuf: BytesMut::new(),
            _in: PhantomData::default(),
            _out: PhantomData::default(),
        }
    }
}

// 一般来说,如果我们的 Stream 是 Unpin,最好实现一下
impl<S, Req, Res> Unpin for ProstStream<S, Req, Res> where S: Unpin {}

接下来,我们可以让 ProstServerStream 和 ProstClientStream 使用新定义的 ProstStream 了,你可以参考下面的对比,看看二者的区别:


// 旧的接口
// pub struct ProstServerStream<S> {
//     inner: S,
//     service: Service,
// }

pub struct ProstServerStream<S> {
    inner: ProstStream<S, CommandRequest, CommandResponse>,
    service: Service,
}

// 旧的接口
// pub struct ProstClientStream<S> {
//     inner: S,
// }

pub struct ProstClientStream<S> {
    inner: ProstStream<S, CommandResponse, CommandRequest>,
}

然后删除 send() / recv() 函数,并修改 process() / execute() 函数使其使用 next() 方法和 send() 方法。主要的改动如下:


/// 处理服务器端的某个 accept 下来的 socket 的读写
pub struct ProstServerStream<S> {
    inner: ProstStream<S, CommandRequest, CommandResponse>,
    service: Service,
}

/// 处理客户端 socket 的读写
pub struct ProstClientStream<S> {
    inner: ProstStream<S, CommandResponse, CommandRequest>,
}

impl<S> ProstServerStream<S>
where
    S: AsyncRead + AsyncWrite + Unpin + Send,
{
    pub fn new(stream: S, service: Service) -> Self {
        Self {
            inner: ProstStream::new(stream),
            service,
        }
    }

    pub async fn process(mut self) -> Result<(), KvError> {
        let stream = &mut self.inner;
        while let Some(Ok(cmd)) = stream.next().await {
            info!("Got a new command: {:?}", cmd);
            let res = self.service.execute(cmd);
            stream.send(res).await.unwrap();
        }

        Ok(())
    }
}

impl<S> ProstClientStream<S>
where
    S: AsyncRead + AsyncWrite + Unpin + Send,
{
    pub fn new(stream: S) -> Self {
        Self {
            inner: ProstStream::new(stream),
        }
    }

    pub async fn execute(&mut self, cmd: CommandRequest) -> Result<CommandResponse, KvError> {
        let stream = &mut self.inner;
        stream.send(cmd).await?;

        match stream.next().await {
            Some(v) => v,
            None => Err(KvError::Internal("Didn't get any response".into())),
        }
    }
}

今天我们做了个稍微大一些的重构,为已有的代码提供更加符合异步 IO 接口的功能。从对外使用的角度来说,它并没有提供或者满足任何额外的需求,但是从代码结构和质量的角度,它使得我们的 ProstStream 可以更方便和更直观地被其它接口调用,也更容易跟整个 Rust 的现有生态结合起来。
###################################################
async/await 和 Future
async/await 是 Rust 的异步编程模型,是产生和运行并发任务的手段。
一般而言,async 定义了一个可以并发执行的任务,而 await 则触发这个任务并发执行。Rust 中,async 用来创建 Future,await 来触发 Future 的调度和执行,并等待Future执行完毕。async/await 只是一个语法糖,它使用状态机将 Future 包装起来进行处理。
C++并发编程也有类似的std::future。JavaScript 也是通过 async 的方式提供了异步编程,Rust 的 Future 跟 JavaScript 的 Promise 非常类似。(注意一下C++并发编程的实现)
它们的区别:
JavaScript 的 Promise 和线程类似,一旦创建就开始执行,对 Promise 的 await 只是等待这个Promise执行完成并得到结果
Rust 的 Future,只有在主动 await 后才开始执行

下面分别用同步,多线程,异步来实现一个例子,展现异步的优秀之处。
实现读写文件的需求:读取 Cargo.toml 和 Cargo.lock 并将它们转换成 yaml 写入 /tmp 文件夹下

fn main() -> Result<()> {
    // 读取 Cargo.toml,IO 操作 1
    let content1 = fs::read_to_string("./Cargo.toml")?;
    // 读取 Cargo.lock,IO 操作 2
    let content2 = fs::read_to_string("./Cargo.lock")?;

    // 计算
    let yaml1 = toml2yaml(&content1)?;
    let yaml2 = toml2yaml(&content2)?;

    // 写入 /tmp/Cargo.yml,IO 操作 3
    fs::write("/tmp/Cargo.yml", &yaml1)?;
    // 写入 /tmp/Cargo.lock,IO 操作 4
    fs::write("/tmp/Cargo.lock", &yaml2)?;

    println!("{}", yaml1);
    println!("{}", yaml2);

    Ok(())
}

fn toml2yaml(content: &str) -> Result<String> {
    let value: Value = toml::from_str(&content)?;
    Ok(serde_yaml::to_string(&value)?)
}

可以看到,读写文件的时候CPU一直在闲置,利用率很低,总共用时是读1+读2+计算1+计算2+写1+写2

使用多线程的方式实现
此方式把文件读取和写入操作放入单独的线程中执行
优点:读取两个文件是并发执行(写入也类似),大大缩短等待时间,读取的总共等待的时间是 max(time_for_file1, time_for_file2)
缺点:不适用于同时读太多文件的场景;因为每读一个文件会创建一个线程,在操作系统中,线程的数量是有限的,创建过多的线程会大大增加系统的开销(线程池?)

使用 async/await 异步实现

use anyhow::Result;
use serde_yaml::Value;
use tokio::{fs, try_join};

#[tokio::main]
async fn main() -> Result<()> {
    let f1 = fs::read_to_string("./Cargo.toml");
    let f2 = fs::read_to_string("./Cargo.lock");

    // 等待两个异步io操作完成
    let (content1, content2) = try_join!(f1, f2)?;

    // 计算
    let yaml1 = toml2yaml(&content1)?;
    let yaml2 = toml2yaml(&content2)?;

    let f3 = fs::write("/tmp/Cargo.yml", &yaml1);
    let f4 = fs::write("/tmp/Cargo.lock", &yaml2);

    try_join!(f3, f4)?;

    println!("{}", yaml1);
    println!("{}", yaml2);

    Ok(())
}

fn toml2yaml(content: &str) -> Result<String> {
    let value: Value = toml::from_str(&content)?;
    Ok(serde_yaml::to_string(&value)?)
}

这里使用了tokio::fs,而不是 std::fs**,tokio::fs 的文件操作都会返回一个 Future,然后用 try_ join 轮询这些Future,得到它们运行后的结果。**此时文件读取的总时间是 max(time_for_file1, time_for_file2),性能和使用线程的版本几乎一致,但是消耗的线程资源要少很多。

try_join 和 join 宏的作用:是用来轮询多个 Future ,它会依次处理每个 Future,遇到阻塞就处理下一个,直到所有 Future 产生结果(类似JavaScript的Promise.all)。

可以看到异步性能和多线程一样,但是消耗线程资源很少,怎么实现的呢?
注意代码不能写成以下方式:

// 读取 Cargo.toml,IO 操作 1
let content1 = fs::read_to_string(“./Cargo.toml”).await?;
// 读取 Cargo.lock,IO 操作 2
let content1 = fs::read_to_string(“./Cargo.lock”).await?;
因为 .await 会运行 Future 一直到 该Future 执行结束,所以此写法依旧是先读取 Cargo.toml,再读取 Cargo.lock,并没有达到并发的效果,这样和同步的版本没有区别。
.await 的作用:在 async fn 函数中使用.await可以等待另一个异步调用的完成,使用同步的方式实现了异步的执行效果。.await 不会阻塞当前的线程,而是异步的等待 Future A的完成,在等待的过程中,该线程还可以继续执行 Future B,最终实现了并发处理的效果。

Future 是 Rust 异步编程的核心, Future trait的定义:我们会继续围绕着 Future 这个简约却又并不简单的接口,来探讨一些原理性的东西,主要是 Context 和 Pin 这两个结构:

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

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

Future 有一个关联类型 Output;还有一个 poll() 方法,它返回 PollSelf::Output。Poll 是个枚举,有 Ready 和 Pending 两个状态。通过调用 poll() 方法可以推进 Future 的进一步执行,直到被切走为止

在当前 poll 中,若 Future 完成了,则返回 Poll::Ready(result),即得到 Future 的值并返回;若Future 还没完成,则返回 Poll::Pending(),此时 Future 会被挂起,需要等某个事件将其唤醒(wake唤醒函数)

executor 调度器
executor 是一个 Future 的调度器。操作系统负责调度线程,但它不会去调度用户态的协程(比如 Future),所以任何使用了协程来处理并发的程序,都需要有一个 executor 来负责协程的调度。

Rust 的 Future 是惰性的:只有在被 poll 轮询时才会运行。其中一个推动它的方式就是在 async 函数中使用 .await 来调用另一个 async 函数,但是这个只能解决 async 内部的问题,那些最外层的 async 函数,需要靠执行器 executor 来推动 。

Rust 虽然提供 Future 这样的协程,但它在语言层面并不提供 executor,当不需要使用协程时,不需要引入任何运行时;而需要使用协程时,可以在生态系统中选择最合适的 executor。
Golang也支持协程,但在语言层面自带了一个用户态的调度器
Rust 有如下4中常见的 executor :

futures:这个库自带了很简单的 executor
tokio:提供 executor,当使用 #[tokio::main] 时,就隐含引入了 tokio 的 executor
async-std :提供 executor,和 tokio 类似
smol :提供 async-executor,主要提供了 block_on

wake通知机制
executor 会管理一批 Future (最外层的 async 函数),然后通过不停地 poll 推动它们直到完成。 最开始,执行器会先 poll 一次 Future ,后面就不会主动去 poll 了,如果 poll 方法返回 Poll::Pending,就挂起 Future,直到收到某个事件后,通过 wake()函数去唤醒被挂起 Future,Future 就可以去主动通知执行器,它才会继续去 poll,执行器就可以执行该 Future。这种 wake 通知然后 poll 的方式会不断重复,直到 Future 完成。
Waker 提供了 wake() 方法:其作用是可以告诉执行器,相关的任务可以被唤醒了,此时执行器就可以对相应的 Future 再次进行 poll 操作。

Rust 异步处理流程(Reactor Pattern模式)
Reactor pattern 包含三部分:
task:待处理的任务。任务可以被打断,并且把控制权交给 executor,等待之后的调度
executor:一个调度器。维护等待运行的任务(ready queue),以及被阻塞的任务(wait queue)
reactor:维护事件队列。当事件来临时,通知 executor 唤醒某个任务等待运行
executor 会调度执行待处理的任务,当任务无法继续进行却又没有完成时,它会挂起任务,并设置好合适的唤醒条件。之后,如果 reactor 得到了满足条件的事件,它会唤醒之前挂起的任务,然后 executor 就有机会继续执行这个任务。这样一直循环下去,直到任务执行完毕。
(联系一下之前学的reactor模式,也是收到事件后,根据事件类型(连接事件,读写事件)分配。
(tokio 的调度器会运行在多个线程上,运行线程上自己的 ready queue 上的任务(Future),如果没有,就去别的线程的调度器上偷一些过来运行(work-stealing 调度机制)。当某个任务无法再继续取得进展,此时 Future 运行的结果是 Poll::Pending,那么调度器会挂起任务,并设置好合适的唤醒条件(Waker),等待被 reactor 唤醒。而reactor 会利用操作系统提供的异步 I/O(如epoll / kqueue / IOCP),来监听操作系统提供的 IO 事件,当遇到满足条件的事件时,就会调用 Waker.wake() 唤醒被挂起的 Future,这个 Future 会回到 ready queue 等待执行。)

关于更好地实现IO密集型App的吞吐量和CPU利用率,程序并行化的过程经历了:多进程=>多线程=>协程/异步 这样的发展趋势。
异步则是协程的一种变种,Rust中选用了这种形式,并且贴近于无栈协程,具体后面会展开。
Rust的异步无法直接运行,依赖于运行时进行调度,由于官方的运行时性能欠缺,所以更多使用的是Tokio,具体见Tokio文档。
Rust仅仅定义了异步Task的生成和异步的唤醒方式,并没有定义异步的调度,对于底层操作的封装(比如IO,定时器,锁等)和唤醒器的实现,所以这就给第三方提供了很多的可能,也为我们自己实现自己的异步运行时提供了机会。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。