第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 密集型任务
为达成这些目标,本章将分为以下几节:
- 什么是 future?
- 叶子 future
- 非叶子 future
- 运行时
- 异步运行时的心智模型
- Rust 语言和标准库处理的任务
- I/O 与 CPU 密集型任务的区别
- Rust 异步模型的优缺点
这些主题将逐步带您了解 Rust 的异步编程模型,并帮助您在未来的章节中深入探索。
什么是 Future?
Future 表示一种将在未来完成的操作。
在 Rust 中,异步编程采用了一种基于轮询的方式。异步任务的执行分为三个阶段:
- 轮询阶段:Future 被轮询,任务将推进到不能继续前进的节点。我们通常称轮询 Future 的运行时部分为执行器(executor)。
- 等待阶段:一个事件源(通常称为反应器 reactor)会记录 Future 正在等待的事件,并确保当事件准备好时能够唤醒这个 Future。
- 唤醒阶段:事件发生,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 中,一个完整的异步系统可以分为三个部分:
- 反应器(Reactor) :负责通知 I/O 事件的发生。
- 执行器(Executor) :充当调度器。
- Future:一个任务,它可以在特定点暂停并恢复。
那么,这三部分是如何协同工作的呢?
以下是一个异步运行时的简化概览图:
异步运行时的工作原理
- 反应器监控 I/O 事件。当一个 Future 进入等待状态(例如等待网络数据时),它会将事件注册到反应器,反应器会监控该事件。
- 事件触发后,反应器通知执行器。一旦监控的 I/O 事件完成,反应器会唤醒 Future,并将其提交给执行器。
- 执行器轮询 Future。执行器负责调度任务,确保 Future 被轮询并推进至下一个暂停点或完成状态。
- 重复循环。该循环会持续进行,直到所有任务完成或进入新的等待阶段。
通过这个模型可以看到,反应器负责检测事件,执行器负责调度任务,而 Future 则在任务执行时在特定点暂停或继续运行。
在图中的第 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::Pending 或 Poll::Ready(通常第一次轮询 future 时会返回 Poll::Pending)。
由于所有执行器的 Waker 都是相同的,理论上反应器可以完全不关心执行器的类型,反之亦然。执行器和反应器不需要直接相互通信。这种设计赋予了 futures 框架极大的灵活性和强大功能,使得 Rust 标准库能够提供一种无成本的简洁抽象供我们使用。
注意
这里引入了反应器和执行器的概念,可能看起来像是所有人都了解的内容。但不用担心,我们将在下一章详细讲解这些内容。
Rust语言和标准库的处理内容
Rust只提供了必要的工具来在语言中建模异步操作。基本上,Rust提供了以下内容:
- 通过
Futuretrait 提供一个代表未来会完成的操作的通用接口。 - 提供创建任务的简洁方法(严格来说是无栈协程),可以通过
async和await关键字来挂起和恢复任务。 - 通过
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 正在处理数据集时,执行器会忙于进行计算,而无法处理新的请求。
幸运的是,有几种方法可以解决这个问题,不过需要注意以下几种方式:
- 创建一个新的叶子 future,将任务发送到另一个线程,并在任务完成时解析。可以像处理其他 future 一样
await这个叶子 future。 - 运行时可以有某种监督机制,监控不同任务的耗时,并将执行器自身移动到另一个线程上,以便继续运行,即使
analyzer任务正在阻塞原始执行器线程。 - 可以自己创建一个与运行时兼容的反应器,以任意你觉得合适的方式进行分析,并返回一个可以被
await的 future。
通常第1种方法是最常用的,一些执行器也实现了第2种方法。第2种方法的问题在于,如果更换运行时,需要确保新运行时支持这种监督机制,否则会导致执行器阻塞。
第3种方法在理论上有重要意义,但一般可以通过大多数运行时提供的线程池完成。大多数执行器提供了类似 spawn_blocking 的方法来实现第1种方式,这些方法将任务发送到由运行时创建的线程池,可以在其中执行CPU密集型任务或非运行时支持的阻塞任务。
总结
在这一简短的章节中,我们向你介绍了Rust中的futures。现在你应该对Rust的异步设计有了基本的了解,明白了语言自身提供的功能和需要从其他地方获取的内容。你也应该对叶子future和非叶子future有所了解。
这些方面都是Rust设计中的重要决策。正如我们已经了解到的,Rust使用无栈协程来实现异步操作。然而,协程本身并不能自动完成任何任务,因此,如何调度和运行这些协程的选择就交给开发者了。
在接下来的章节中,我们将更详细地解释这一切的工作原理。接下来,我们将深入理解futures的概念,并探讨它们如何与Rust中的协程和async/await关键字相连。我们将看到它们如何代表可暂停和恢复的任务,这是实现多任务并发的先决条件,并了解它们与第五章中实现的可暂停/恢复的fiber(绿色线程)任务的不同之处。