Rust的异步

599 阅读6分钟

为什么需要异步

异步编程(异步,async),是一种并发编程模型

并发

当有多个线程在操作时,如果系统只有一个CPU,则它根本不可能真正同时进行一个以上的线程,它只能把CPU运行时间划分成若干个时间片,再将时间片分配给各个线程执行,在一个时间片的线程代码运行时,其它线程处于挂起状态。这种方式我们称之为并发(Concurrent)

相似概念:并行

当系统有一个以上CPU时,则线程的操作有可能非并发。当一个CPU执行一个线程时,另一个CPU可以执行另一个线程,两个线程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)

模型

模型,物理上看是一种具有外在条框的骨架,在软件上,我认为它像是在要求我们:如果要用这个模型实现“并发”的功能,我们需要在代码中首先支棱起这些条框(或者说在这些条框下填充我们的内容)。

所以,异步编程,就是我们在某个框架中填充多个具体的、同时发生的业务代码。

有越来越多的语言开始支持异步编程,Rust中使用async/await语法来编写异步程序,这让我们的代码看上去和普通的串行编程很像,也很简洁。

异步与其它常见的并发模型

异步是一种并发编程模型,还有许多其它种类的并发编程模型,就常见的几个我们稍微探究一下。

  • 线程:使用操作系统的线程实现并发不需要对编程模型进行任何更改,这使得用它实现并发变得很容易。但是它也有一些缺点,线程之间的同步可能比较困难,性能开销较大。线程池或许可以减少一些开销。线程的方案不太适合IO操作密集型的程序(比如网络应用)。

  • 事件驱动模型:事件驱动模型需要结合回调,能够获得很好的性能,但往往其中的业务控制流程会变得冗长,数据流和错误传播也很难跟踪。

  • 协程:像线程一样也不需要更改编程模型,这使得它也易于使用。它还与异步一样,可以支持大量的任务。协程还对底层操作的细节进行了抽象,这对于系统编程和自定义运行时开发者们具有非常重要的意义。

  • actor模型 actor模型将所有并发计算划分为称为actor的单元,它们通过错误消息传递进行通信,就像在分布式系统中一样。actor模型留下了许多实际问题尚待解决,如流控制和重试逻辑。

总之,异步编程很适合于像Rust这样的语言来编写高性能的应用程序,同时还提供了线程和协程的大部分优点。

Rust中的异步和其它其它语言的异步

Rust中对异步编程的实现方式,与大部分实现了异步的语言不同,主要有下面几点原因:

  • Futures 在Rust中表现出的惰性,只有在它被轮询时才会得到执行。Drop一个Future会立即停止它后续执行。
  • 零成本 异步在Rust中是零成本的,这意味着您只需为您用到的东西内容付钱。具体来说,您可以在没有堆分配和动态调度的情况下使用异步,这对性能非常有用!这还允许您在受限环境(例如嵌入式系统)中使用异步。
  • 没有内建的运行时。
  • Rust中提供了单线程和多线程运行时,它们具有各自不同的优缺点。

Rust的异步和线程

Rust中异步的主要替代方案是使用线程,线程的创建可以直接通过std::thread或(间接)通过线程池来使用。从线程迁移到异步(反之亦然)通常需要在实现和公共接口方面进行大量的重构工作。因此,应尽早选择适合您的业务需求的模型,这样可以节省大量开发时间。

  • 线程 线程适用于少量任务,因为线程会带来CPU和内存开销。线程之间的生成和切换也需要很大的成本,即使是空闲线程也会消耗系统资源。线程池库可以帮助减轻其中一些成本,但不是全部。但是,线程允许您重用现有的同步代码而无需对代码进行重大更改——因为不需要特定的编程模型。在某些操作系统中,您还可以更改线程的优先级,这对驱动程序和其他对延迟敏感的应用程序很有用。
  • 异步 能够显著降低CPU和内存开销,特别是对于具有大量IO操作的任务,例如服务器和数据库。在其他条件相同的情况下,您可以拥有比操作系统线程多几个数量级的任务,因为异步运行时使用少量(昂贵的)线程来处理大量(廉价的)任务。然而,由于异步函数生成的状态机以及每个可执行文件都捆绑了一个异步运行时,异步 Rust程序的大小比普通的应用程序更大。

最后一点,异步编程并不一定比线程好。如果您无需考虑性能因素,您一般不需要使用异步,线程通常是更简单的选择。

举个栗子

需求:我们的服务器应用程序需要同时下载两个网页。

下面是线程方式实现:

fn get_two_sites() {
    // 生成2个线程去干活
    let thread_one = thread::spawn(|| download("https://www.foo.com"));
    let thread_two = thread::spawn(|| download("https://www.bar.com"));

    // 等待线程结束
    thread_one.join().expect("thread one panicked");
    thread_two.join().expect("thread two panicked");
}

下载网页是个简单的任务,相比较而言创建、切换、销毁线程反而成为一块巨大的成本。对应服务器程序来说,这会成为一个性能瓶颈!

异步编程则不需要额外的线程。 下面是异步方式实现:

async fn get_two_sites() {
    // 创建2个future,当它们运行完毕就表示下载完网页了
    let future_one = download_async("https://www.foo.com");
    let future_two = download_async("https://www.bar.com");

    join!(future_one,future_two);
}

从上面这个例子,可以看到异步编程方式没有创建额外的线程。 此外,所有函数调用都是静态调度的,并且没有堆内存分配!