Rust中的异步编程——Rust 中的 Futures

290 阅读10分钟

第5章中,我们介绍了一种在编程语言中实现并发的流行方式:纤程(fibers)或绿色线程(green threads),它们是有栈协程(stackful coroutines)的示例。另一种流行的异步编程方式是使用无栈协程(stackless coroutines),而将 Rust 的 futures 结合 async/await 就是这种方式的一个例子。我们将在接下来的章节中详细介绍这一内容。

在本章中,我们将引入 Rust 的 futures,主要目标包括:

  • 为您提供 Rust 中并发的高级概述
  • 解释 Rust 语言和标准库在处理异步代码时提供的内容及其局限
  • 了解为何 Rust 需要一个运行时库
  • 理解叶子 future(leaf future)和非叶子 future(non-leaf future)的区别
  • 探讨如何处理 CPU 密集型任务

为达成这些目标,本章将分为以下几节:

  1. 什么是 future?
  2. 叶子 future
  3. 非叶子 future
  4. 运行时
  5. 异步运行时的心智模型
  6. Rust 语言和标准库处理的任务
  7. I/O 与 CPU 密集型任务的区别
  8. Rust 异步模型的优缺点

这些主题将逐步带您了解 Rust 的异步编程模型,并帮助您在未来的章节中深入探索。

什么是 Future?

Future 表示一种将在未来完成的操作。

在 Rust 中,异步编程采用了一种基于轮询的方式。异步任务的执行分为三个阶段:

  1. 轮询阶段:Future 被轮询,任务将推进到不能继续前进的节点。我们通常称轮询 Future 的运行时部分为执行器(executor)。
  2. 等待阶段:一个事件源(通常称为反应器 reactor)会记录 Future 正在等待的事件,并确保当事件准备好时能够唤醒这个 Future。
  3. 唤醒阶段:事件发生,Future 被唤醒。接下来,由在步骤1中轮询 Future 的执行器重新调度 Future 进行下一次轮询,直到任务完成或再次进入等待阶段,循环往复。

在讨论 futures 时,区分叶子 Future 和非叶子 Future 很有帮助,因为它们在实际使用中的表现非常不同。

叶子 Future

运行时创建叶子 Future,这些 Future 通常表示某种资源,比如一个套接字。这是一个叶子 Future 的示例:

let mut stream = tokio::net::TcpStream::connect("127.0.0.1:3000");

对这些资源的操作(如从套接字读取数据)是非阻塞的,返回一个 Future。我们称之为叶子 Future,因为它是实际等待的那个 Future。除非编写一个新的运行时,否则您不太可能自己实现叶子 Future,但我们在本书中会介绍它们的构建方式。

不过,很少会将叶子 Future 单独传递给运行时并运行至完成,这一点将在后续的解释中更加清晰。

非叶子 Future

非叶子 Future 是我们在运行时中使用 async 关键字创建的可暂停任务。异步程序的大部分由非叶子 Future 组成,它们代表一组可暂停的计算操作。这种 Future 通常在其任务中等待一个叶子 Future 的完成。

这是一个非叶子 Future 的示例:

let non_leaf = async {
    let mut stream = TcpStream::connect("127.0.0.1:3000").await.unwrap();
    println!("connected!");
    let result = stream.write(b"hello world\n").await;
    println!("message sent!");
    ...
};

这里的两个 .await 表示暂停点,在这些地方会将控制权交还给运行时,并在稍后恢复执行。与叶子 Future 不同,这种 Future 本身不表示 I/O 资源。当我们对它进行轮询时,它会一直运行到一个返回 Pending 的叶子 Future,然后将控制权交还给调度器(运行时的一部分)。

运行时

一些语言(如 C#、JavaScript、Java、Go 等)自带并发处理的运行时。对习惯这些语言的人来说,Rust 的处理可能显得有些不同。Rust 不自带并发运行时,因此需要使用一个提供该功能的库。

Future 的复杂性很大程度上源于运行时的复杂性,创建一个高效的运行时是很难的。正确使用一个运行时也需要付出不少努力,但这种运行时间有许多相似之处,熟悉一个可以让学习其他运行时变得更容易。

Rust 的独特之处在于,需要手动选择合适的运行时;而在其他语言中,通常会直接使用内置的运行时。

异步运行时的心智模型

为了更好地理解 futures 的工作原理,可以构建一个高层次的心智模型,这里我们引入一个用于推动 futures 完成的运行时概念。

注意
此处构建的心智模型并非完成 futures 的唯一方式,Rust 的 futures 也不对实现方式做任何限制。

在 Rust 中,一个完整的异步系统可以分为三个部分:

  1. 反应器(Reactor) :负责通知 I/O 事件的发生。
  2. 执行器(Executor) :充当调度器。
  3. Future:一个任务,它可以在特定点暂停并恢复。

那么,这三部分是如何协同工作的呢?

以下是一个异步运行时的简化概览图:

异步运行时的工作原理
  1. 反应器监控 I/O 事件。当一个 Future 进入等待状态(例如等待网络数据时),它会将事件注册到反应器,反应器会监控该事件。
  2. 事件触发后,反应器通知执行器。一旦监控的 I/O 事件完成,反应器会唤醒 Future,并将其提交给执行器。
  3. 执行器轮询 Future。执行器负责调度任务,确保 Future 被轮询并推进至下一个暂停点或完成状态。
  4. 重复循环。该循环会持续进行,直到所有任务完成或进入新的等待阶段。

通过这个模型可以看到,反应器负责检测事件,执行器负责调度任务,而 Future 则在任务执行时在特定点暂停或继续运行。

image.png

在图中的第 1 步中,执行器持有一个 futures 列表。它通过轮询来尝试运行这些 futures(即“轮询阶段”),并在此过程中传递一个 Waker。future 要么返回 Poll::Ready(表示已完成),要么返回 Poll::Pending(表示未完成,但当前无法进一步推进)。当执行器接收到结果时,就知道可以开始轮询其他 futures。我们称这些将控制权交还给执行器的点为“让步点”。

在第 2 步中,反应器保存了执行器在轮询时传递给 future 的 Waker 副本。反应器会通过事件队列(我们在第 4 章中学到过这种队列)来追踪该 I/O 源上的事件。

在第 3 步,当反应器收到某个监控的事件源发生事件的通知时,它找到与该事件源关联的 Waker,并调用 Waker::wake。这样执行器就会被通知该 future 可以继续推进,因此会再次对其进行轮询。

用伪代码编写一个简短的异步程序如下所示:

async fn foo() {
    println!("Start!");
    let txt = io::read_to_string().await.unwrap();
    println!("{txt}");
}

await 的那一行将控制权返回给调度器。这通常被称为“让步点”,因为它会返回 Poll::PendingPoll::Ready(通常第一次轮询 future 时会返回 Poll::Pending)。

由于所有执行器的 Waker 都是相同的,理论上反应器可以完全不关心执行器的类型,反之亦然。执行器和反应器不需要直接相互通信。这种设计赋予了 futures 框架极大的灵活性和强大功能,使得 Rust 标准库能够提供一种无成本的简洁抽象供我们使用。

注意
这里引入了反应器和执行器的概念,可能看起来像是所有人都了解的内容。但不用担心,我们将在下一章详细讲解这些内容。

Rust语言和标准库的处理内容

Rust只提供了必要的工具来在语言中建模异步操作。基本上,Rust提供了以下内容:

  • 通过 Future trait 提供一个代表未来会完成的操作的通用接口。
  • 提供创建任务的简洁方法(严格来说是无栈协程),可以通过 asyncawait 关键字来挂起和恢复任务。
  • 通过 Waker 类型定义了唤醒挂起任务的接口。

Rust标准库的功能也就到此为止了。正如你看到的,标准库并没有定义非阻塞 I/O,任务的创建方式或运行方式。Rust中没有非阻塞版的标准库,因此要实际运行一个异步程序,必须创建或选择一个特定的运行时。

I/O 与 CPU密集型任务

正如你现在知道的,通常编写的代码是所谓的非叶子 future。让我们通过一个伪 Rust 示例来看看这个异步块:

let non_leaf = async {
    let mut stream = TcpStream::connect("127.0.0.1:3000").await.unwrap();
    // 请求一个大型数据集
    let result = stream.write(get_dataset_request).await.unwrap();
    // 等待数据集的响应
    let mut response = vec![];
    stream.read(&mut response).await.unwrap();
    // 对数据集进行一些CPU密集型分析
    let report = analyzer::analyze_data(response).unwrap();
    // 将结果发送回去
    stream.write(report).await.unwrap();
};

在示例中,标出的部分表示我们将控制权交还给运行时的执行器。需要注意的是,await 之间的代码在与执行器相同的线程上运行。这意味着当 analyzer 正在处理数据集时,执行器会忙于进行计算,而无法处理新的请求。

幸运的是,有几种方法可以解决这个问题,不过需要注意以下几种方式:

  1. 创建一个新的叶子 future,将任务发送到另一个线程,并在任务完成时解析。可以像处理其他 future 一样 await 这个叶子 future。
  2. 运行时可以有某种监督机制,监控不同任务的耗时,并将执行器自身移动到另一个线程上,以便继续运行,即使 analyzer 任务正在阻塞原始执行器线程。
  3. 可以自己创建一个与运行时兼容的反应器,以任意你觉得合适的方式进行分析,并返回一个可以被 await 的 future。

通常第1种方法是最常用的,一些执行器也实现了第2种方法。第2种方法的问题在于,如果更换运行时,需要确保新运行时支持这种监督机制,否则会导致执行器阻塞。

第3种方法在理论上有重要意义,但一般可以通过大多数运行时提供的线程池完成。大多数执行器提供了类似 spawn_blocking 的方法来实现第1种方式,这些方法将任务发送到由运行时创建的线程池,可以在其中执行CPU密集型任务或非运行时支持的阻塞任务。

总结

在这一简短的章节中,我们向你介绍了Rust中的futures。现在你应该对Rust的异步设计有了基本的了解,明白了语言自身提供的功能和需要从其他地方获取的内容。你也应该对叶子future和非叶子future有所了解。

这些方面都是Rust设计中的重要决策。正如我们已经了解到的,Rust使用无栈协程来实现异步操作。然而,协程本身并不能自动完成任何任务,因此,如何调度和运行这些协程的选择就交给开发者了。

在接下来的章节中,我们将更详细地解释这一切的工作原理。接下来,我们将深入理解futures的概念,并探讨它们如何与Rust中的协程和async/await关键字相连。我们将看到它们如何代表可暂停和恢复的任务,这是实现多任务并发的先决条件,并了解它们与第五章中实现的可暂停/恢复的fiber(绿色线程)任务的不同之处。