在上一章中,我们通过将可暂停任务(协程)实现为状态机,创建了属于我们自己的协程系统。我们为这些任务提供了一个通用的 API,要求它们实现 Future 特征。同时,我们展示了如何使用一些关键字来生成这些协程,并编程式地将其重写为状态机,而不需要手动实现这些状态机,这使得我们可以用类似通常编写代码的方式来实现这些程序。
如果我们暂停一下,站在更高的角度来看我们目前的成果,概念上其实很简单:我们有一个可暂停任务的接口(Future 特征),以及两个关键字(coroutine 和 wait),用来标记希望编译成状态机的代码段,从而将代码划分为可以暂停的部分。
然而,目前我们没有事件循环,也没有调度器。在本章中,我们将扩展我们的示例,添加一个运行时,使我们的程序运行得更加高效,并能够更高效地实现并发任务调度。
本章将带您逐步构建一个分阶段的运行时系统,使其逐渐变得更加实用、高效和强大。我们将首先简要介绍运行时的作用,以及理解其特性的原因。基于第 7 章的内容,我们将展示如何使其更高效,并利用第 4 章中学到的知识来避免不断地轮询 future 以使其取得进展。
接下来,我们将展示如何通过将运行时划分为执行器和反应器两个部分,使设计更加灵活且松耦合。
在本章中,您将学习到基础运行时设计、反应器、执行器、Waker 和任务生成的知识,同时也会巩固之前学习的知识。由于需要编写大量代码,这将是本书中篇幅较长的一章。但这并不是因为话题太复杂或难以理解,而是因为我会提供多个图解并详细解释,以便您构建良好的心智模型。虽然这并不是一个可以在睡前轻松翻阅的章节,但我保证,这一章的内容绝对值得您投入时间去深入理解。
本章将分为以下部分:
-
运行时简介以及为什么需要它们
-
改进我们基础示例
-
创建一个合适的运行时
- 第一步:通过添加反应器和 Waker 来改进运行时设计
- 第二步:实现一个合适的执行器
- 第三步:实现一个合适的反应器
-
在新的运行时上进行实验
让我们深入探讨这些内容!
技术要求
本章的示例将基于上一章的代码,因此要求是相同的。所有示例都是跨平台的,支持所有 Rust(支持平台)和 mio(支持平台)支持的平台。您只需要安装 Rust 并在本地下载与本书相关的代码库。所有代码都可以在 ch08 文件夹中找到。
要一步一步地跟随示例,您还需要在机器上安装 corofy。如果您在第 7 章没有安装它,现在可以进入代码库中的 ch08/corofy 文件夹,运行以下命令进行安装:
cargo install --force --path .
或者,您也可以在需要使用 corofy 重写 coroutine/wait 语法的部分直接复制代码库中的相关文件。代码库中会提供两种版本的代码。
在本章示例中,我们还会使用 delayserver,因此需要打开一个单独的终端,进入代码库根目录下的 delayserver 文件夹,运行以下命令:
cargo run
这样,delayserver 就可以随时运行以支持后续示例了。如有必要更改 delayserver 的监听端口,请记得在代码中也相应更改端口。
运行时介绍及其必要性
正如您现在所了解的,在 Rust 中运行和调度异步任务需要自带运行时。Rust 的运行时种类多样,从面向通用多任务的流行嵌入式运行时 Embassy(在许多平台上可以替代实时操作系统)到专注于非阻塞 I/O 的 Tokio(支持常见的服务器和桌面操作系统)。
在 Rust 中,所有的运行时至少要完成两件事:调度并驱动实现了 Rust 的 Future 特征的对象,直至其完成。在本章中,我们将主要关注支持 Windows、Linux 和 macOS 等常用桌面和服务器操作系统的非阻塞 I/O 运行时。这也是 Rust 程序员最常接触到的运行时类型。
控制任务的调度方式会带来强大的影响,几乎没有回头路可走。如果依赖用户态调度器来运行任务,就无法同时使用操作系统的调度器(除非采取一些特殊措施),因为在代码中混用这些调度方式会导致问题,甚至可能违背编写异步程序的初衷。
下图展示了不同的调度器:
一个让 OS 调度器接管的例子是使用默认的 std::net::TcpStream 或 std::thread::sleep 方法进行阻塞调用。甚至使用标准库提供的如 Mutex 这样的基本阻塞原语时,也可能将控制权交给 OS 调度器。这就是为什么异步编程往往会影响它所接触到的每个部分,因此很难仅对程序的一部分使用 async/await。
因此,运行时必须使用标准库的非阻塞版本。理论上,您可以为所有运行时创建一个通用的非阻塞版本,这也是 async_std 项目的目标之一(async_std 介绍)。然而,达成共识的难度较大,目前该目标还未完全实现。
在开始实现示例之前,我们将讨论 Rust 中典型异步运行时的整体设计。大多数运行时(如 Tokio、Smol 或 async-std)将其分为两个部分:
- Reactor(反应器) :负责跟踪正在等待的事件,并确保高效等待来自 OS 的通知。
- Executor(执行器) :负责调度任务并轮询这些任务,直至完成。
让我们先从一个高层次的视角了解这种设计,以便清楚我们将要实现的内容。
Reactors 和 Executors
将运行时分为两部分是合理的,因为 Rust 将异步任务建模为 Future 特性和 Waker 类型。Rust 提供了详细的文档来说明如何使用这些特性(Future 文档和 Waker 文档)。比如,在第 6 章中我们讨论过的 Future 特性是惰性的;此外,Waker::wake 的调用会保证至少对相应任务调用一次 Future::poll。因此,文档中已经包含了运行时如何运作的基本信息。
学习这种模式的原因在于,它与 Rust 的异步模型几乎是完美契合的。为了避免误解,我们先来解释这两个名称,因为它们可能会让人联想到不相关的概念。
- Reactor(反应器):不要将它想象成核反应堆或运行时的驱动力。反应器实际上是一个事件循环,负责响应一系列传入事件并将这些事件逐个分派给处理器。在我们的例子中,反应器将事件分派给执行器。反应器处理的事件可以是计时器超时、中断(在嵌入式系统中)或 I/O 事件(如
TcpStream上的 READABLE 事件)。同一个运行时中可以存在多个反应器。 - Executor(执行器):它并不是处决者(executioner)或可执行文件(executable)。在法律上,executor 指的是“遗嘱执行人”,负责执行遗嘱。在异步运行时中,executor 负责决定哪些任务获得 CPU 资源以推进执行,并调用
Future::poll来推进状态机。这是一种调度器。
理解这些术语有助于理解它们的功能。Reactors 响应事件,因此需要与事件源紧密集成。例如,在 TcpStream 上调用 read 或 write 时,反应器需要知道应在该源上跟踪哪些事件。因此,非阻塞 I/O 原语和反应器需要密切配合,并且 I/O 原语要么自带反应器,要么由反应器提供所需的 I/O 原语(如 sockets、ports 和 streams)。
现在我们了解了一些总体设计,可以开始写代码了。运行时的实现可能会很快变得复杂,因此我们将尽量保持代码简洁明了,避免处理错误。接下来的任务是改进我们在第 7 章中编写的第一个示例,不再需要主动轮询以推进任务,而是基于之前章节中学习到的非阻塞 I/O 和 epoll 来实现。
改进基础示例
我们将从第 7 章的第一个示例创建一个改进版本,因为这是最简单的起点。我们关注的是如何更高效地调度和运行时管理。步骤如下:
-
创建一个新项目,命名为
a-runtime(或者在书的资源库中进入ch08/a-runtime)。 -
将
src文件夹中的future.rs和http.rs文件从第 7 章创建的第一个项目a-coroutine复制到a-runtime项目的src文件夹中(或者从书的资源库ch07/a-coroutine文件夹中复制)。 -
确保在
Cargo.toml中添加mio作为依赖项,添加以下内容:[dependencies] mio = { version = "0.8", features = ["net", "os-poll"] } -
在
src文件夹中新建一个文件runtime.rs。 -
使用
corofy将以下coroutine/wait代码转换成状态机表示,以便能够运行。
在 src/main.rs 中添加以下代码:
mod future;
mod http;
mod runtime;
use future::{Future, PollState};
use runtime::Runtime;
fn main() {
let future = async_main();
let mut runtime = Runtime::new();
runtime.block_on(future);
}
coroutine fn async_main() {
println!("Program starting");
let txt = http::Http::get("/600/HelloAsyncAwait").wait;
println!("{txt}");
let txt = http::Http::get("/400/HelloAsyncAwait").wait;
println!("{txt}");
}
这个程序与我们在第 7 章创建的基本相同,但这次我们用 coroutine/wait 语法代替手动编写的状态机。接下来,使用 corofy 将其转换为代码,因为编译器不识别自定义的 coroutine/wait 语法。
在 a-runtime 项目的根目录下,运行 corofy ./src/main.rs。
此时,项目中应该生成一个 main_corofied.rs 文件。
- 删除
main.rs中的代码,并将main_corofied.rs的内容复制到main.rs中。 - 删除
main_corofied.rs,因为后续不再需要。
如果一切都设置正确,项目结构应如下所示:
src
|-- future.rs
|-- http.rs
|-- main.rs
|-- runtime.rs
提示:可以随时参考书的资源库以确保设置正确。正确的示例位于 ch08/a-runtime 文件夹中。在资源库的根文件夹中还有一个名为 main_orig.rs 的文件,其中包含 coroutine/wait 程序,如果您想重新运行或遇到问题,可以使用它。
设计
在继续之前,让我们可视化当前系统的工作流程,如果我们有两个由 coroutine/wait 创建的 Future,以及对 Http::get 的两个调用。在主函数中轮询 Future trait 的循环相当于执行器,而我们有一个 Future 链,包含以下部分:
- 由
async/await(在示例中为coroutine/wait)创建的非叶子Future,它仅调用下一个Future的poll,直到到达叶子Future。 - 叶子
Future会轮询实际的数据源,该源要么是Ready,要么是NotReady。
下图展示了当前设计的简化概览:
如果我们仔细观察 Future 链,可以看到,当一个 Future 被轮询时,它会轮询所有子 Future,直到到达代表实际等待内容的叶子 Future。如果该 Future 返回 NotReady,则会立即沿链向上传递该状态。然而,如果返回 Ready,状态机将会继续推进,直到下一个 Future 返回 NotReady 为止。顶层的 Future 只有在所有子 Future 都返回 Ready 时才会最终完成。
下图更详细地展示了 Future 链的工作流程的简化概览:
我们要做的第一个改进是避免对顶层 Future 进行持续轮询以推动其前进。
我们将更改设计,使其看起来如下图所示:
在此设计中,我们运用了第4章中的知识,但不再单纯依赖于 epoll,而是改用 mio 提供的跨平台抽象。其工作原理我们现在应该已非常熟悉,因为我们之前已经实现了其简化版本。
与持续轮询顶层 Future 不同,我们会向 Poll 实例注册兴趣,当得到 NotReady 结果时,我们将等待 Poll。这会使线程进入休眠状态,不再进行任何工作,直到操作系统再次唤醒我们,通知有我们等待的事件已准备就绪。
这种设计将更加高效且可扩展。
更改当前实现
现在我们已经了解了我们的设计,并知道要做什么,接下来我们可以进行必要的程序更改,因此让我们逐个文件地进行更改。我们将从 main.rs 开始。
main.rs
在我们运行 corofy 以更新协程/等待示例时,我们已经对 main.rs 做了一些更改。这里我将指出这些更改,以免你遗漏,因为实际上我们在这里不需要再做其他更改。
在主函数中,我们不再轮询 future,而是创建了一个新的 Runtime 结构体,并将 future 作为参数传递给 Runtime::block_on 方法。我们在此文件中不再需要其他更改。我们的主函数更改如下:
ch08/a-runtime/src/main.rs
fn main() {
let future = async_main();
let mut runtime = Runtime::new();
runtime.block_on(future);
}
我们原本在主函数中的逻辑现在已移至 runtime 模块中,我们也需要更改在此处轮询 future 完成的代码。
因此,下一步将是打开 runtime.rs。
runtime.rs
在 runtime.rs 中,我们首先需要引入一些依赖:
ch08/a-runtime/src/runtime.rs
use crate::future::{Future, PollState};
use mio::{Events, Poll, Registry};
use std::sync::OnceLock;
下一步是创建一个名为 REGISTRY 的静态变量。如果你还记得,Registry 是我们使用 Poll 实例注册事件的方法。我们希望在发出实际的 HTTP GET 请求时,对 TcpStream 上的事件注册兴趣。我们本可以让 Http::get 接受一个 Registry 结构体并存储它以供后续使用,但我们希望保持 API 的简洁,因此我们希望在 HttpGetFuture 内部访问 Registry,而无需将其作为引用传递:
ch08/a-runtime/src/runtime.rs
static REGISTRY: OnceLock<Registry> = OnceLock::new();
pub fn registry() -> &'static Registry {
REGISTRY.get().expect("Called outside a runtime context")
}
我们使用 std::sync::OnceLock 来初始化 REGISTRY,这样可以在运行时启动时进行初始化,防止任何人(包括我们自己)在没有运行时实例运行的情况下调用 Http::get。如果我们在没有初始化运行时的情况下调用 Http::get,它会出现 panic,因为在运行时模块外访问它的唯一公共方式是通过 pub fn registry(){...} 函数,而这个调用会失败。
注意
我们本可以使用标准库中的 thread_local! 宏创建一个线程局部静态变量,但当我们在本章稍后扩展示例时需要从多个线程访问它,因此我们从一开始就以此为设计目标。
接下来我们添加一个 Runtime 结构体:
ch08/a-runtime/src/runtime.rs
pub struct Runtime {
poll: Poll,
}
目前,我们的运行时只存储一个 Poll 实例。Runtime 的实现才是有趣的部分。由于代码不太长,我会呈现整个实现并逐步解释:
ch08/a-runtime/src/runtime.rs
impl Runtime {
pub fn new() -> Self {
let poll = Poll::new().unwrap();
let registry = poll.registry().try_clone().unwrap();
REGISTRY.set(registry).unwrap();
Self { poll }
}
pub fn block_on<F>(&mut self, future: F)
where
F: Future<Output = String>,
{
let mut future = future;
loop {
match future.poll() {
PollState::NotReady => {
println!("Schedule other tasks\n");
let mut events = Events::with_capacity(100);
self.poll.poll(&mut events, None).unwrap();
}
PollState::Ready(_) => break,
}
}
}
}
我们首先创建了一个 new 函数,用于初始化我们的运行时并设置所需的一切。我们创建了一个新的 Poll 实例,并从中获取一个拥有的 Registry 副本。如果你还记得第四章的内容,这是我们提到但没有在示例中实现的方法之一。不过在这里,我们利用了将两部分分开的能力。
我们将 Registry 存储在 REGISTRY 全局变量中,以便稍后可以从 http 模块中访问它,而无需引用运行时本身。
下一个函数是 block_on 函数,我将逐步解释它:
首先,该函数接收一个泛型参数,并将在任何实现我们 Future 特性且输出类型为 String 的内容上阻塞(请记住,这是目前唯一支持的 Future 特性类型,因此如果没有数据返回,我们将返回一个空字符串)。
为了避免传递 mut future 作为参数,我们在函数体中定义了一个可变的变量。这只是为了让 API 略显简洁,避免我们需要稍后做一些小改动。
接下来,我们创建一个循环。我们将一直循环,直到顶层的 future 返回 Ready。
如果 future 返回 NotReady,我们会输出一条消息,告诉我们在这一点上我们可以做其他事情,例如处理与 future 无关的内容,或者更可能地,如果我们的运行时支持多个顶层 future,则轮询另一个顶层 future(别担心——稍后会对此进行解释)。
需要注意的是,我们需要将 Events 集合传递给 mio 的 Poll::poll 方法,但由于只有一个顶层 future 运行,我们并不关心发生了哪个事件;我们只关心确实发生了一些事情,并且很可能意味着数据已准备好(请记住——无论如何我们总是必须考虑到伪唤醒)。
以上就是我们目前需要对运行时模块进行的所有更改。
最后,我们需要在 http 模块中,在向服务器发送请求后注册对读取事件的兴趣。
让我们打开 http.rs 并做一些更改。
http.rs
首先,让我们调整依赖关系以引入我们需要的所有内容:
ch08/a-runtime/src/http.rs
use crate::{future::PollState, runtime, Future};
use mio::{Interest, Token};
use std::io::{ErrorKind, Read, Write};
我们需要添加对 runtime 模块的依赖,以及 mio 中的一些类型。
我们只需要对这个文件做一个更改,就是在 Future::poll 的实现中,所以让我们继续找到它的位置:
这里我们做了一个重要的更改,我为你特别标出了这个更改。实现与之前完全相同,只有一个重要的不同:
ch08/a-runtime/src/http.rs
impl Future for HttpGetFuture {
type Output = String;
fn poll(&mut self) -> PollState<Self::Output> {
if self.stream.is_none() {
println!("FIRST POLL - START OPERATION");
self.write_request();
runtime::registry()
.register(self.stream.as_mut().unwrap(), Token(0), Interest::READABLE)
.unwrap();
}
let mut buff = vec![0u8; 4096];
loop {
match self.stream.as_mut().unwrap().read(&mut buff) {
Ok(0) => {
let s = String::from_utf8_lossy(&self.buffer);
break PollState::Ready(s.to_string());
}
Ok(n) => {
self.buffer.extend(&buff[0..n]);
continue;
}
Err(e) if e.kind() == ErrorKind::WouldBlock => {
break PollState::NotReady;
}
Err(e) => panic!("{e:?}"),
}
}
}
}
在第一次轮询时,在我们写入请求之后,我们对这个 TcpStream 上的可读事件注册兴趣。我们还删除了这一行:
return PollState::NotReady;
删除这一行后,我们将立即轮询 TcpStream,这是合理的,因为如果我们立即获得响应,我们并不希望将控制权交回给调度器。无论如何,这里无论选择哪种方式都不会出错,因为我们已经将 TcpStream 注册为反应器的事件源,无论如何都会得到唤醒。这些更改是我们需要使示例重新运行的最后部分。
如果你还记得第七章的版本,我们得到以下输出:
Program starting
FIRST POLL - START OPERATION
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Thu, 16 Nov xxxx xx:xx:xx GMT
HelloWorld1
FIRST POLL - START OPERATION
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
Schedule other tasks
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Thu, 16 Nov xxxx xx:xx:xx GMT
HelloWorld2
在我们改进后的新版本中,如果用 cargo run 运行,我们得到以下输出:
Program starting
FIRST POLL - START OPERATION
Schedule other tasks
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Thu, 16 Nov xxxx xx:xx:xx GMT
HelloAsyncAwait
FIRST POLL - START OPERATION
Schedule other tasks
HTTP/1.1 200 OK
content-length: 11
connection: close
content-type: text/plain; charset=utf-8
date: Thu, 16 Nov xxxx xx:xx:xx GMT
HelloAsyncAwait
注意
如果你在 Windows 上运行这个示例,你会看到每次有两个连续的 "Schedule other tasks" 消息。原因是,当服务器端的 TcpStream 被丢弃时,Windows 会发出一个额外的事件。这在 Linux 上不会发生。过滤掉这些事件非常简单,但我们不会在示例中专注于此,因为这只是一个优化,并不是我们的示例运行所必须的。
需要注意的是我们打印了多少次 "Schedule other tasks"。每次轮询并得到 NotReady 时,我们都会打印这条消息。在第一个版本中,我们每 100 毫秒打印一次这条消息,但那只是因为我们在每次休眠时必须延迟,以免被打印输出淹没。如果没有这个延迟,我们的 CPU 将 100% 用于轮询 future。
如果我们添加延迟,即使使延迟比 100 毫秒短很多,也会增加延迟,因为我们无法立即响应事件。
我们的新设计确保我们在事件准备好时立即响应,并且不会做任何不必要的工作。
因此,通过这些小的更改,我们已经创建了一个比之前更好、更可扩展的版本。
这个版本是完全单线程的,保持了简单性,避免了同步的复杂性和开销。当你使用 Tokio 的单线程调度器时,你会得到一个基于我们在这里展示的相同想法的调度器。
然而,我们的当前实现也有一些缺点,其中最明显的是它要求反应器部分和运行时的执行器部分围绕 Poll 进行非常紧密的集成。
我们希望在没有工作可做时让出给操作系统调度器,并在事件发生时让操作系统唤醒我们以继续处理。在当前的设计中,这是通过阻塞在 Poll::poll 上实现的。
因此,执行器(调度器)和反应器都必须了解 Poll。缺点是,如果你创建了一个完全适合特定用例的执行器,并希望允许用户使用不依赖 Poll 的不同反应器,你做不到。
更重要的是,你可能希望运行多个不同的反应器,因不同原因唤醒执行器。你可能发现 mio 不支持某些内容,因此你为这些任务创建了不同的反应器。当执行器阻塞在 mio::Poll::poll(...) 时,它们该如何唤醒执行器呢?
举几个例子,你可以使用一个独立的反应器来处理定时器(例如,当你希望任务休眠一段时间时),或者你希望实现一个线程池来处理 CPU 密集型或阻塞任务,作为唤醒相应 future 的反应器。
为了解决这些问题,我们需要通过找到一种不紧密耦合到单个反应器实现的方式来唤醒执行器,使反应器和执行器部分之间的耦合更加松散。
让我们看看如何通过创建更好的运行时设计来解决这个问题。
创建一个合适的运行时
所以,如果我们将运行时不同部分之间的依赖程度可视化,我们的当前设计可以这样描述:
如果我们想要实现反应器和执行器之间的松散耦合,我们需要提供一个接口,用于在某个允许 future 继续执行的事件发生时通知执行器醒来。在 Rust 的标准库中,这个类型被称为 Waker(doc.rust-lang.org/stable/std/…),这并非巧合。如果我们改变可视化来反映这一点,它会看起来像这样:
我们最终得到了与 Rust 今天所采用的设计相同的结果,这并非巧合。从 Rust 的角度来看,这是一个极简的设计,但它允许实现各种运行时设计,而不会对未来施加太多限制。
注意
尽管从语言的角度来看,当前的设计相对简洁,但未来计划会稳定更多与异步相关的特性和接口。
Rust 有一个工作小组负责将广泛使用的特性和接口包含到标准库中,你可以在这里找到更多相关信息:rust-lang.github.io/wg-async/we…。你还可以在这里查看他们正在处理的事项概览,并跟踪他们的进展:github.com/orgs/rust-l…。
也许在阅读完这本书后,你还想参与到改进 async Rust 的工作中来(rust-lang.github.io/wg-async/we…),使其对所有人更好?
如果我们修改系统图以反映我们在未来需要对运行时进行的更改,它将看起来像这样:
我们的系统中有两个部分,它们之间没有直接的依赖关系。我们有一个调度任务的执行器(Executor),它在轮询 future 时传递一个唤醒器(Waker),最终被反应器(Reactor)捕获并存储。当反应器收到事件就绪的通知时,它找到与该任务相关联的唤醒器,并调用 Wake::wake 来唤醒它。
这使我们能够:
- 运行多个操作系统线程,每个线程都有自己的执行器,但共享相同的反应器
- 拥有多个反应器,处理不同种类的底层 future,并在它们可以继续时确保唤醒正确的执行器
既然我们知道该做什么了,是时候将它编写成代码了。
步骤 1 – 通过添加反应器(Reactor)和唤醒器(Waker)来改进我们的运行时设计
在这一步中,我们将做以下更改:
- 更改项目结构,使其反映我们的新设计。
- 为执行器找到一种睡眠和唤醒的方式,该方式不直接依赖
Poll,并基于此创建一个唤醒器,使我们能够唤醒执行器并识别出哪个任务可以继续。 - 修改
Future特性的定义,使poll方法将&Waker作为参数。
提示
你可以在 ch08/b-reactor-executor 文件夹中找到这个示例。如果你按照书中的示例进行编写,建议你为这个示例创建一个名为 b-reactor-executor 的新项目,步骤如下:
- 创建一个名为
b-reactor-executor的新文件夹。 - 进入新创建的文件夹并运行
cargo init。 - 将前一个示例
a-runtime中src文件夹中的所有内容复制到新项目的src文件夹中。 - 将
Cargo.toml文件中的依赖部分复制到新项目的Cargo.toml文件中。
让我们首先对项目结构进行一些更改,以便为今后扩展打下基础。我们首先将 runtime 模块分为两个子模块:reactor 和 executor:
- 在
src文件夹中创建一个名为runtime的新子文件夹。 - 在
runtime文件夹中创建两个新文件,分别命名为reactor.rs和executor.rs。 - 在
runtime.rs文件中导入部分的下方,声明两个新模块,添加以下行:
mod executor;
mod reactor;
现在你的文件夹结构应如下所示:
src
|-- runtime
|-- executor.rs
|-- reactor.rs
|-- future.rs
|-- http.rs
|-- main.rs
|-- runtime.rs
为了设置一切,我们首先删除 runtime.rs 中的所有内容,并将其替换为以下代码:
ch08/b-reactor-executor/src/runtime.rs
pub use executor::{spawn, Executor, Waker};
pub use reactor::reactor;
mod executor;
mod reactor;
pub fn init() -> Executor {
reactor::start();
Executor::new()
}
runtime.rs 的新内容首先声明了两个子模块 executor 和 reactor。然后我们声明了一个名为 init 的函数,用于启动反应器并创建一个新的执行器并返回给调用者。
下一步是找到一种方法,使我们的执行器在需要时能够睡眠和唤醒,而不依赖于 Poll。
创建 Waker
因此,我们需要找到一种不直接依赖 Poll 的方式,让我们的执行器能够进入睡眠状态并被唤醒。
事实证明,这很简单。标准库提供了我们所需的功能来实现这一点。通过调用 std::thread::current(),我们可以获取一个 Thread 对象。该对象是对当前线程的句柄,允许我们使用一些方法,其中之一就是 unpark。
标准库还提供了一个名为 std::thread::park() 的方法,它可以简单地请求操作系统调度器暂停我们的线程,直到我们稍后请求解除暂停。
事实证明,如果将它们结合起来,我们就有了一种既可以暂停也可以解除暂停执行器的方法,这正是我们所需要的。
让我们基于此创建一个 Waker 类型。在我们的示例中,我们将在执行器模块中定义 Waker,因为这是我们创建这个特定类型的 Waker 的地方,但你也可以认为它属于 future 模块,因为它是 Future 特性的一部分。
重要提示
我们的 Waker 依赖于对标准库中的 Thread 类型调用 park/unpark。对于我们的示例来说,这样做可以理解,因为它简单易懂,但考虑到代码的任何部分(包括你使用的任何库)都可以通过调用 std::thread::current() 获取对同一线程的句柄,并调用 park/unpark,因此这不是一个健壮的解决方案。如果代码中的无关部分在同一线程上调用 park/unpark,我们可能会错过唤醒,甚至导致死锁。大多数生产级别的库会创建自己的 Parker 类型,或者依赖于类似 crossbeam::sync::Parker(docs.rs/crossbeam/l…
我们不会将 Waker 实现为特性,因为在示例中传递特性对象会显著增加复杂性,而且这也不符合 Rust 中 Future 和 Waker 的当前设计。
打开位于 runtime 文件夹中的 executor.rs 文件,让我们从一开始就添加所有需要的导入:
ch08/b-reactor-executor/src/runtime/executor.rs
use crate::future::{Future, PollState};
use std::{
cell::{Cell, RefCell},
collections::HashMap,
sync::{Arc, Mutex},
thread::{self, Thread},
};
接下来,我们添加 Waker:
ch08/b-reactor-executor/src/runtime/executor.rs
#[derive(Clone)]
pub struct Waker {
thread: Thread,
id: usize,
ready_queue: Arc<Mutex<Vec<usize>>>,
}
Waker 将包含以下三个部分:
thread:一个指向前面提到的Thread对象的句柄。id:一个usize,用于标识与此Waker关联的任务。ready_queue:这是一个可以在线程之间共享的对Vec<usize>的引用,其中usize表示准备队列中的任务的 ID。我们将该对象与执行器共享,以便在任务准备就绪时将与Waker关联的任务 ID 推入队列。
Waker 的实现相当简单:
ch08/b-reactor-executor/src/runtime/executor.rs
impl Waker {
pub fn wake(&self) {
self.ready_queue
.lock()
.map(|mut q| q.push(self.id))
.unwrap();
self.thread.unpark();
}
}
当调用 Waker::wake 时,我们首先对保护与执行器共享的准备队列的 Mutex 进行加锁。然后将标识与此 Waker 关联的任务的 id 值推入准备队列。
完成后,我们调用执行器线程的 unpark 方法并唤醒它。现在,执行器会在准备队列中找到与该 Waker 关联的任务并对其调用 poll。
值得一提的是,许多设计会对 future/task 本身采取共享引用(例如 Arc<…>),并将其推入队列。这样做可以跳过我们这里通过将任务表示为 usize 而不是传递对它的引用所引入的间接层次。
不过,我个人认为这样做的方式更容易理解和推理,最终结果是相同的。
我们创建的 Waker 与标准库中的 Waker 有何不同?
我们在这里创建的 Waker 将扮演与标准库中的 Waker 类型相同的角色。最大的区别是 std::task::Waker 方法被包装在一个 Context 结构中,并且当我们自己创建它时需要做一些额外的步骤。别担心——我们将在本书的结尾做这一切,但这些区别对于理解它所扮演的角色并不重要,所以我们暂时坚持使用我们自己的简化版异步 Rust。
最后,我们需要做的是更改 Future 特性的定义,使其将 &Waker 作为参数。
更改 Future 的定义
由于 Future 的定义在 future.rs 文件中,我们首先打开该文件。
第一步是引入 Waker,以便我们可以使用它。在文件的顶部添加以下代码:
ch08/b-reactor-executor/src/future.rs
use crate::runtime::Waker;
接下来,我们需要修改 Future 特性,使其接受 &Waker 作为参数:
ch08/b-reactor-executor/src/future.rs
pub trait Future {
type Output;
fn poll(&mut self, waker: &Waker) -> PollState<Self::Output>;
}
在这一步,你有一个选择。接下来我们不会使用 join_all 函数或 JoinAll<F: Future> 结构。
如果你不想保留它们,可以直接删除所有与 join_all 相关的内容,这样就完成了对 future.rs 的更改。
如果你想保留它们以供进一步实验,则需要修改 JoinAll 的 Future 实现,使其接受 waker: &Waker 参数,并在对合并的 futures 进行轮询时传递 Waker,例如在 match fut.poll(waker) 中。
步骤 1 中剩下的事情是对实现 Future 特性的地方做一些小改动。
让我们从 http.rs 开始。首先,我们调整依赖关系,以反映对 runtime 模块的更改,并添加对新 Waker 的依赖。用以下内容替换文件顶部的依赖部分:
ch08/b-reactor-executor/src/http.rs
use crate::{future::PollState, runtime::{self, reactor, Waker}, Future};
use mio::Interest;
use std::io::{ErrorKind, Read, Write};
编译器可能会抱怨找不到 reactor,但我们很快就会解决这个问题。
接下来,我们需要找到 impl Future for HttpGetFuture 块,并修改 poll 方法,使其接受 &Waker 参数:
ch08/b-reactor-executor/src/http.rs
impl Future for HttpGetFuture {
type Output = String;
fn poll(&mut self, waker: &Waker) -> PollState<Self::Output> {
…
我们需要更改的最后一个文件是 main.rs。由于 corofy 不知道 Waker 类型,我们需要对它在 main.rs 中为我们生成的协程进行一些修改。
首先,我们需要添加对新 Waker 的依赖,因此在文件的开头添加以下代码:
ch08/b-reactor-executor/src/main.rs
use runtime::Waker;
在 impl Future for Coroutine 块中,更改以下三行代码:
ch08/b-reactor-executor/src/main.rs
fn poll(&mut self, waker: &Waker)
match f1.poll(waker)
match f2.poll(waker)
至此,步骤 1 中需要做的所有事情就完成了。稍后我们将回到此文件中解决错误;现在,我们专注于与 Waker 相关的所有内容。
下一步是创建一个合适的执行器(Executor)。
步骤 2 – 实现一个合适的执行器(Executor)
在这一步中,我们将创建一个执行器,它将具备以下功能:
- 持有多个顶层的
future并在它们之间切换。 - 允许我们从异步程序的任何地方生成新的顶层
future。 - 提供
Waker类型,使它们在无事可做时进入睡眠状态,并在某个顶层future可以继续执行时唤醒。 - 通过使每个执行器运行在自己的操作系统线程上,实现多个执行器的运行。
注意
值得一提的是,我们的执行器并不会完全支持多线程,这意味着任务或 future 无法从一个线程发送到另一个线程,不同的 Executor 实例也无法相互知道。因此,执行器无法彼此偷取任务(没有工作窃取),我们也不能依赖执行器从全局任务队列中挑选任务。
原因是如果我们朝这个方向设计执行器,那么设计将变得更加复杂,不仅因为增加的逻辑,还因为我们需要增加约束,例如要求所有内容必须是 Send + Sync 的。
如今,Rust 中的异步系统的复杂性有一部分可以归因于许多运行时默认是多线程的,这使得异步 Rust 比正常 Rust 更复杂。
值得一提的是,由于 Rust 中的大多数生产运行时默认是多线程的,所以大多数也有工作窃取执行器。这类似于我们在第一章最后的调酒师示例,其中通过让调酒师“窃取”对方正在进行的任务,我们实现了效率的略微提升。
不过,这个示例应该仍能让你了解如何利用机器上的所有核心来运行异步任务,尽管它的能力有限,但依然可以实现并发和并行。
让我们从打开位于 runtime 子文件夹中的 executor.rs 开始。
该文件应该已经包含了我们的 Waker 和所需的依赖,因此让我们首先在依赖部分下方添加以下代码:
ch08/b-reactor-executor/src/runtime/executor.rs
type Task = Box<dyn Future<Output = String>>;
thread_local! {
static CURRENT_EXEC: ExecutorCore = ExecutorCore::default();
}
第一行是一个类型别名;它允许我们创建一个名为 Task 的别名,指向类型 Box<dyn Future<Output = String>>。这样可以使我们的代码更简洁。
接下来的代码可能对某些读者来说比较新。我们使用 thread_local! 宏定义了一个线程局部的静态变量。
thread_local! 宏允许我们定义一个静态变量,它对于首次调用它的线程来说是唯一的。这意味着我们创建的所有线程都会有自己的实例,并且一个线程无法访问另一个线程的 CURRENT_EXEC 变量。
我们将该变量命名为 CURRENT_EXEC,因为它持有当前在线程上运行的执行器。
接下来,我们在该文件中添加 ExecutorCore 的定义:
ch08/b-reactor-executor/src/runtime/executor.rs
#[derive(Default)]
struct ExecutorCore {
tasks: RefCell<HashMap<usize, Task>>,
ready_queue: Arc<Mutex<Vec<usize>>>,
next_id: Cell<usize>,
}
ExecutorCore 保存了执行器的所有状态:
tasks:这是一个以usize作为键,Task(记住之前创建的别名)作为数据的HashMap。它将保存与此线程的执行器相关联的所有顶层future,并允许我们给每个future一个id属性来标识它们。由于我们不能简单地改变静态变量的状态,因此需要内部可变性。由于这只会在一个线程中调用,RefCell足以满足需求,因为不需要同步。ready_queue:这是一个存储应该被执行器轮询的任务 ID 的简单Vec<usize>。如果我们回顾图 8.7,你会看到它如何适应我们在那里概述的设计。如前所述,我们可以在这里存储类似Arc<dyn Future<...>>的内容,但这会为我们的示例增加相当的复杂性。当前设计唯一的缺点是,我们需要从tasks集合中查找任务,而不是直接获得对任务的引用,这会花费一些时间。一个Arc<...>(共享引用)将被赋予每个由执行器创建的Waker。由于Waker可以(并且将会)被发送到不同的线程,并通过将任务的 ID 添加到ready_queue来通知某个特定任务已就绪,因此需要将其包装在Arc<Mutex<...>>中。next_id:这是一个计数器,用于提供下一个可用的 ID,确保不会为同一个执行器实例分配重复的 ID。我们将使用它为每个顶层future分配唯一的 ID。由于执行器实例只会在创建它的同一线程上访问,一个简单的Cell就足以提供我们所需的内部可变性。
ExecutorCore 派生了 Default 特性,因为我们这里不需要特殊的初始状态,这样可以使代码简洁。
下一个重要的函数是 spawn,它允许我们从程序的任何地方向执行器注册新的顶层 future:
ch08/b-reactor-executor/src/runtime/executor.rs
pub fn spawn<F>(future: F)
where
F: Future<Output = String> + 'static,
{
CURRENT_EXEC.with(|e| {
let id = e.next_id.get();
e.tasks.borrow_mut().insert(id, Box::new(future));
e.ready_queue.lock().map(|mut q| q.push(id)).unwrap();
e.next_id.set(id + 1);
});
}
spawn 函数执行了以下操作:
- 获取下一个可用的 ID。
- 将 ID 分配给收到的
future,并将其存储在HashMap中。 - 将代表此任务的 ID 添加到
ready_queue中,以便至少对其轮询一次(请记住,Rust 中的Future特性如果未被轮询至少一次,则不会执行任何操作)。 - 将 ID 计数器加 1。
通过调用 with 并传递一个闭包来访问 CURRENT_EXEC 的语法可能有些陌生,这是 Rust 中实现线程局部静态变量的一个结果。你还会注意到,我们必须使用一些特殊方法,因为我们使用了 RefCell 和 Cell 来实现 tasks 和 next_id 的内部可变性,但除了有些不熟悉之外,实际上并没有什么本质上的复杂性。
关于静态生命周期的简短说明
当 'static 生命周期用作特性约束时(如我们在这里所做的),它并不意味着我们传入的 Future 特性必须是静态的(即它必须存活到程序结束)。它意味着它必须能够持续到程序结束,或者换句话说,生命周期不能以任何方式受限。
大多数情况下,当你遇到需要 'static 约束的情况时,这只是意味着你必须给予你传入对象的所有权。如果你传入任何引用,它们必须具有 'static 生命周期。满足这个约束并不像你想象的那么难。
步骤 2 的最后部分是定义并实现 Executor 结构体本身。
Executor 结构体非常简单,只需要添加一行代码:
ch08/b-reactor-executor/src/runtime/executor.rs
pub struct Executor;
由于我们示例所需的所有状态都保存在一个线程局部静态变量 ExecutorCore 中,因此 Executor 结构体本身不需要任何状态。这也意味着我们实际上并不严格需要这个结构体,但为了使 API 看起来稍微熟悉一些,我们仍然这样做。
执行器的大部分实现是一些简单的辅助方法,最终集中在 block_on 函数中,而这里才是有趣的部分。
因为这些辅助方法短小且易于理解,所以我将在这里全部呈现,并简要说明它们的作用:
注意
我们在这里打开 impl Executor 块,但不会关闭它,直到我们完成 block_on 函数的实现。
ch08/b-reactor-executor/src/runtime/executor.rs
impl Executor {
pub fn new() -> Self {
Self {}
}
fn pop_ready(&self) -> Option<usize> {
CURRENT_EXEC.with(|q| q.ready_queue.lock().map(|mut q| q.pop()).unwrap())
}
fn get_future(&self, id: usize) -> Option<Task> {
CURRENT_EXEC.with(|q| q.tasks.borrow_mut().remove(&id))
}
fn get_waker(&self, id: usize) -> Waker {
Waker {
id,
thread: thread::current(),
ready_queue: CURRENT_EXEC.with(|q| q.ready_queue.clone()),
}
}
fn insert_task(&self, id: usize, task: Task) {
CURRENT_EXEC.with(|q| q.tasks.borrow_mut().insert(id, task));
}
fn task_count(&self) -> usize {
CURRENT_EXEC.with(|q| q.tasks.borrow().len())
}
}
我们有以下六个方法:
new:创建一个新的Executor实例。为简化起见,这里没有初始化,所有操作都通过thread_local!宏的设计按需延迟完成。pop_ready:该函数对ready_queue加锁,并从Vec的末尾弹出一个已就绪的 ID。调用pop也意味着我们将该项从集合中移除。值得一提的是,由于Waker将其 ID 添加到ready_queue的末尾,而我们也从末尾弹出,因此我们本质上得到了一个后进先出(LIFO)的队列。如果愿意更改这种行为,使用标准库中的VecDeque可以轻松地选择从队列中移除项的顺序。get_future:该函数将顶层future的 ID 作为参数,从tasks集合中移除该future并返回它(如果找到任务)。这意味着如果任务返回NotReady(表示尚未完成),我们需要记得将它重新添加到集合中。get_waker:该函数创建一个新的Waker实例。insert_task:该函数接收一个id属性和一个Task属性,并将它们插入到tasks集合中。task_count:该函数简单地返回队列中任务的数量。
执行器实现的最后部分是 block_on 函数。这也是我们关闭 impl Executor 块的地方:
ch08/b-reactor-executor/src/runtime/executor.rs
pub fn block_on<F>(&mut self, future: F)
where
F: Future<Output = String> + 'static,
{
spawn(future);
loop {
while let Some(id) = self.pop_ready() {
let mut future = match self.get_future(id) {
Some(f) => f,
// 防止虚假唤醒
None => continue,
};
let waker = self.get_waker(id);
match future.poll(&waker) {
PollState::NotReady => self.insert_task(id, future),
PollState::Ready(_) => continue,
}
}
let task_count = self.task_count();
let name = thread::current().name().unwrap_or_default().to_string();
if task_count > 0 {
println!("{name}: {task_count} pending tasks. Sleep until notified.");
thread::park();
} else {
println!("{name}: All tasks are finished");
break;
}
}
}
}
block_on 将是进入执行器的入口点。通常,你首先传入一个顶层 future,当顶层 future 继续执行时,它会向执行器生成新的顶层 future。当然,每个新 future 也可以向执行器生成新的 future,这就是异步程序基本的工作方式。
在许多方面,你可以将这个第一个顶层 future 看作是普通 Rust 程序中的主函数 (main)。spawn 类似于 thread::spawn,不同之处在于任务在这个示例中仍然停留在同一个操作系统线程上。这意味着任务无法并行运行,这反过来使我们不需要在任务之间进行同步来避免数据竞争。
让我们一步步地讲解这个函数:
第一步是将收到的 future 生成到执行器上。有很多种实现方式,但这是最简单的一种。
接下来,我们有一个循环,只要我们的异步程序在运行,它就会一直运行。
每次循环时,我们创建一个内部的 while let Some(…) 循环,只要 ready_queue 中有任务就会继续运行。
如果 ready_queue 中有任务,我们通过从集合中移除 Future 对象来获得其所有权。如果不再有 future,我们只需继续,以防止虚假唤醒(这意味着我们已经完成了它,但仍然得到了唤醒)。例如,这在 Windows 上会发生,因为当连接关闭时我们会收到一个 READABLE 事件,但即使我们可以过滤掉这些事件,mio 也不能保证不会发生虚假唤醒,所以无论如何我们都必须处理这种情况。
接下来,我们创建一个新的 Waker 实例,以便传递给 Future::poll()。请记住,这个 Waker 实例现在持有标识此特定 Future 特性的 id 属性,以及对当前运行线程的句柄。
下一步是调用 Future::poll。
如果返回 NotReady,我们将任务重新插入到 tasks 集合中。我想强调的是,当 Future 特性返回 NotReady 时,我们知道它会安排稍后调用 Waker::wake。追踪此 future 的就绪状态不是执行器的职责。
如果 Future 特性返回 Ready,我们只是继续处理就绪队列中的下一个项目。由于我们获得了 Future 特性的所有权,这将在我们进入 while let 循环的下一次迭代之前销毁对象。
在对就绪队列中的所有任务进行轮询之后,首先我们会获取任务计数,以查看剩下多少任务。
我们还获取了当前线程的名称,以备将来进行日志记录(这与执行器的工作方式无关)。
如果任务计数大于 0,我们向终端打印一条消息并调用 thread::park()。将线程暂停会将控制权交给操作系统调度器,执行器将不执行任何操作,直到再次被唤醒。
如果任务计数为 0,表示我们已经完成了异步程序,并退出主循环。
基本上这就是全部内容了。到目前为止,我们已经实现了步骤 2 的所有目标,因此我们可以继续到最后一步,为我们的运行时实现一个反应器(Reactor),使其在发生事件时唤醒执行器。
步骤 3 – 实现合适的反应器(Reactor)
我们示例的最后部分是反应器(Reactor)。我们的反应器将:
- 高效地等待和处理我们的运行时感兴趣的事件。
- 存储一组
Waker类型,并确保在收到跟踪源的通知时唤醒正确的Waker。 - 为类似
HttpGetFuture的叶子future提供注册和取消注册对事件的兴趣的必要机制。 - 为叶子
future提供存储最后接收到的Waker的方法。
完成这一步后,我们的运行时将具备所有所需功能,让我们开始吧。
首先打开 reactor.rs 文件。
第一步是添加所需的依赖:
ch08/b-reactor-executor/src/runtime/reactor.rs
use crate::runtime::Waker;
use mio::{net::TcpStream, Events, Interest, Poll, Registry, Token};
use std::{
collections::HashMap,
sync::{
atomic::{AtomicUsize, Ordering},
Arc, Mutex, OnceLock,
},
thread,
};
添加依赖后,我们创建了一个名为 Wakers 的类型别名,用于表示 wakers 集合的类型:
ch08/b-reactor-executor/src/runtime/reactor.rs
type Wakers = Arc<Mutex<HashMap<usize, Waker>>>;
接下来声明一个名为 REACTOR 的静态变量:
ch08/b-reactor-executor/src/runtime/reactor.rs
static REACTOR: OnceLock<Reactor> = OnceLock::new();
该变量将持有一个 OnceLock<Reactor>。与我们的 CURRENT_EXEC 静态变量不同,它可以从不同线程访问。OnceLock 允许我们定义一个静态变量,我们可以对其进行一次写入,从而在启动反应器时对其进行初始化。这样也可以确保我们的程序中只能运行该特定反应器的一个实例。
该变量对模块是私有的,因此我们创建了一个公共函数,使程序的其他部分能够访问它:
ch08/b-reactor-executor/src/runtime/reactor.rs
pub fn reactor() -> &'static Reactor {
REACTOR.get().expect("Called outside an runtime context")
}
接下来我们定义 Reactor 结构体:
ch08/b-reactor-executor/src/runtime/reactor.rs
pub struct Reactor {
wakers: Wakers,
registry: Registry,
next_id: AtomicUsize,
}
这将是 Reactor 结构体需要持有的所有状态:
wakers:一个包含Waker对象的HashMap,每个对象都由一个整数标识。registry:持有一个Registry实例,以便与mio的事件队列交互。next_id:存储下一个可用的 ID,以便我们跟踪哪个事件发生以及哪个Waker应该被唤醒。
Reactor 的实现其实很简单。它只有四个用于与 Reactor 实例交互的简短方法,因此我将在这里全部展示,并简要解释:
ch08/b-reactor-executor/src/runtime/reactor.rs
impl Reactor {
pub fn register(&self, stream: &mut TcpStream, interest: Interest, id: usize) {
self.registry.register(stream, Token(id), interest).unwrap();
}
pub fn set_waker(&self, waker: &Waker, id: usize) {
let _ = self
.wakers
.lock()
.map(|mut w| w.insert(id, waker.clone()).is_none())
.unwrap();
}
pub fn deregister(&self, stream: &mut TcpStream, id: usize) {
self.wakers.lock().map(|mut w| w.remove(&id)).unwrap();
self.registry.deregister(stream).unwrap();
}
pub fn next_id(&self) -> usize {
self.next_id.fetch_add(1, Ordering::Relaxed)
}
}
让我们简要说明这四个方法的作用:
register:这个方法是Registry::register的一个简单包装,我们在第四章中了解过。需要注意的一点是,我们传递了一个id属性,以便在稍后收到通知时识别发生的事件。set_waker:该方法使用提供的id属性作为键,将Waker添加到HashMap中。如果已经存在一个Waker,则替换它并删除旧的。重要的一点是我们应该始终存储最新的Waker,这样即使TcpStream已经有一个Waker,此函数也可以被多次调用。deregister:此函数执行两件事。首先,它从wakers集合中移除Waker。然后,它从Poll实例中注销TcpStream。
我想提醒你,虽然在我们的示例中我们只处理 TcpStream,但理论上可以对任何实现了 mio 的 Source 特性的内容执行这些操作,因此相同的思路在比我们这里处理的范围更广的上下文中也是适用的。
next_id:它只获取当前的next_id值,并原子地增加计数。我们不关心在这里发生的先发生/后发生关系;我们只关心不重复分配相同的值,因此Ordering::Relaxed足以应对这里的情况。原子操作中的内存顺序是一个复杂的话题,无法在本书中深入讨论,但如果你想了解 Rust 中不同内存顺序的含义,官方文档是一个很好的起点:doc.rust-lang.org/stable/std/…
现在,我们的反应器已设置好,只剩下两个简短的函数。第一个是 event_loop,它将包含等待和响应新事件的事件循环逻辑:
ch08/b-reactor-executor/src/runtime/reactor.rs
fn event_loop(mut poll: Poll, wakers: Wakers) {
let mut events = Events::with_capacity(100);
loop {
poll.poll(&mut events, None).unwrap();
for e in events.iter() {
let Token(id) = e.token();
let wakers = wakers.lock().unwrap();
if let Some(waker) = wakers.get(&id) {
waker.wake();
}
}
}
}
该函数接受一个 Poll 实例和一个 Wakers 集合作为参数。让我们逐步分析它:
- 首先,我们创建一个
events集合。这应该是熟悉的,因为我们在第四章中做了完全相同的事情。 - 接下来,我们创建一个无限循环。虽然这样使我们的示例变得简单,但缺点是,我们没有办法在启动后关闭事件循环。解决这个问题并不难,但由于我们的示例不需要,因此这里不做讨论。
- 在循环内部,我们调用
Poll::poll,超时时间为None,这意味着它不会超时,并且会一直阻塞,直到收到事件通知。 - 当调用返回时,我们遍历收到的每个事件。
- 如果我们收到一个事件,意味着我们注册的某个事件发生了,因此获取我们在最初注册
TcpStream时传递的id。 - 最后,我们尝试获取关联的
Waker并调用Waker::wake。我们要确保Waker可能已经从集合中移除了,这种情况下我们什么也不做。
需要注意的是,如果需要,我们可以在这里对事件进行过滤。Tokio 提供了一些方法用于检查事件对象报告的事件。对于我们的示例用途,我们不需要过滤事件。
最后一个函数是该模块中的第二个公共函数,也是初始化并启动运行时的函数:
ch08/b-reactor-executor/src/runtime/runtime.rs
pub fn start() {
use thread::spawn;
let wakers = Arc::new(Mutex::new(HashMap::new()));
let poll = Poll::new().unwrap();
let registry = poll.registry().try_clone().unwrap();
let next_id = AtomicUsize::new(1);
let reactor = Reactor {
wakers: wakers.clone(),
registry,
next_id,
};
REACTOR.set(reactor).ok().expect("Reactor already running");
spawn(move || event_loop(poll, wakers));
}
start 方法应该是相当容易理解的。首先,我们创建 Wakers 集合和 Poll 实例。从 Poll 实例中,我们获取了一个拥有的 Registry 副本。我们将 next_id 初始化为 1(为了调试目的,我想将它初始化为与执行器不同的起始值),并创建我们的 Reactor 对象。
然后,我们通过将 Reactor 实例赋予 REACTOR 来设置静态变量。
最后一步可能是最重要的。我们生成一个新的操作系统线程,并在该线程上启动 event_loop 函数。这也意味着我们将 Poll 实例传递给事件循环线程。
现在,最佳实践是存储 spawn 返回的 JoinHandle,以便稍后可以连接线程,但由于我们的线程无法关闭事件循环,因此稍后连接它没有多大意义,我们就简单地丢弃了该句柄。
我不知道你是否同意我的看法,但当我们将逻辑分解为更小的部分时,这里的逻辑并不复杂。由于我们已经知道 epoll 和 mio 是如何工作的,其余的部分相对容易理解。
现在,我们还没有完成。我们仍需对 HttpGetFuture 叶子 future 进行一些小改动,因为它目前没有向反应器注册。让我们来解决这个问题。
首先打开 http.rs 文件。
由于我们在打开文件以适应新的 Future 接口时已经添加了正确的导入,因此这里只需更改几个地方,以使这个叶子 future 能够很好地集成到反应器中。
第一步是为 HttpGetFuture 提供一个标识。它是我们希望用反应器跟踪的事件源,因此在完成之前,我们希望它具有相同的 ID:
ch08/b-reactor-executor/src/http.rs
struct HttpGetFuture {
stream: Option<mio::net::TcpStream>,
buffer: Vec<u8>,
path: String,
id: usize,
}
在创建 future 时,我们还需要从反应器中获取一个新的 ID:
ch08/b-reactor-executor/src/http.rs
impl HttpGetFuture {
fn new(path: String) -> Self {
let id = reactor().next_id();
Self {
stream: None,
buffer: vec![],
path,
id,
}
}
接下来,我们需要找到 HttpGetFuture 的 poll 实现。
首先,我们需要确保在 future 第一次被轮询时,我们向 Poll 实例注册兴趣,并使用 Reactor 注册收到的 Waker。由于我们不再直接在 Registry 上进行注册,因此移除那行代码,改为添加以下新代码:
ch08/b-reactor-executor/src/http.rs
if self.stream.is_none() {
println!("FIRST POLL - START OPERATION");
self.write_request();
let stream = self.stream.as_mut().unwrap();
runtime::reactor().register(stream, Interest::READABLE, self.id);
runtime::reactor().set_waker(waker, self.id);
}
最后,我们需要对从 TcpStream 读取时处理不同条件的方式进行一些小改动:
ch08/b-reactor-executor/src/http.rs
match self.stream.as_mut().unwrap().read(&mut buff) {
Ok(0) => {
let s = String::from_utf8_lossy(&self.buffer);
runtime::reactor().deregister(self.stream.as_mut().unwrap(), self.id);
break PollState::Ready(s.to_string());
}
Ok(n) => {
self.buffer.extend(&buff[0..n]);
continue;
}
Err(e) if e.kind() == ErrorKind::WouldBlock => {
runtime::reactor().set_waker(waker, self.id);
break PollState::NotReady;
}
Err(e) => panic!("{e:?}"),
}
第一个改动是在完成任务时从 Poll 实例中注销流。
第二个改动稍微微妙一些。如果你仔细阅读 Rust 中 Future::poll 的文档(doc.rust-lang.org/stable/std/… Waker 唤醒。这意味着每次遇到 WouldBlock 错误时,我们都需要确保存储最近的 Waker。
原因是 future 在调用之间可能会转移到不同的执行器上,我们需要唤醒正确的执行器(在我们的示例中,future 不会被移动,但我们仍然遵循相同的规则)。
就这些了!
恭喜你!你现在已经基于反应器-执行器模式创建了一个完全可运行的运行时。做得很好!
现在,是时候测试它并进行一些实验了。
让我们回到 main.rs,更改主函数,以使我们的程序能够在新运行时中正确运行。
首先,移除对 Runtime 结构体的依赖,并确保我们的导入如下所示:
ch08/b-reactor-executor/src/main.rs
mod future;
mod http;
mod runtime;
use future::{Future, PollState};
use runtime::Waker;
接下来,确保初始化运行时并将 future 传递给 executor.block_on。我们的主函数应如下所示:
ch08/b-reactor-executor/src/main.rs
fn main() {
let mut executor = runtime::init();
executor.block_on(async_main());
}
最后,让我们尝试运行它:
cargo run
你应该会得到以下输出:
Program starting
FIRST POLL - START OPERATION
main: 1 pending tasks. Sleep until notified.
HTTP/1.1 200 OK
content-length: 15
connection: close
content-type: text/plain; charset=utf-8
date: Thu, xx xxx xxxx 15:38:08 GMT
HelloAsyncAwait
FIRST POLL - START OPERATION
main: 1 pending tasks. Sleep until notified.
HTTP/1.1 200 OK
content-length: 15
connection: close
content-type: text/plain; charset=utf-8
date: Thu, xx xxx xxxx 15:38:08 GMT
HelloAsyncAwait
main: All tasks are finished
太好了——它按预期工作了!!!
不过,我们还没有真正使用到运行时的新功能,所以在结束本章之前,让我们来享受一些乐趣,看看它还能做些什么。
使用我们的新运行时进行实验
如果你还记得第七章,我们实现了一个 join_all 方法,用来让多个 future 并发运行。在像 Tokio 这样的库中,你也会找到一个 join_all 函数,以及一个稍微更灵活的 FuturesUnordered API,它允许你将一组预定义的 future 进行合并并并发运行。
这些方法非常方便,但它们确实要求你提前知道要并发运行的 future。如果你使用 join_all 运行的 future 想生成新的与其“父” future 并发运行的 future,那么仅使用这些方法是无法做到的。
然而,我们新创建的 spawn 功能恰好可以实现这一点。让我们来测试一下!
使用并发的示例
注意
这个程序的完全相同的版本可以在 ch08/c-runtime-executor 文件夹中找到。
让我们尝试一个新程序,代码如下:
fn main() {
let mut executor = runtime::init();
executor.block_on(async_main());
}
coro fn request(i: usize) {
let path = format!("/{}/HelloWorld{i}", i * 1000);
let txt = Http::get(&path).wait;
println!("{txt}");
}
coro fn async_main() {
println!("Program starting");
for i in 0..5 {
let future = request(i);
runtime::spawn(future);
}
}
这与我们在第七章中演示 join_all 工作原理的示例几乎相同,只不过这次我们将它们作为顶层 future 生成。
要运行这个示例,请按照以下步骤操作:
- 用上述代码替换
main.rs中导入部分下的所有内容。 - 运行
corofy ./src/main.rs。 - 将
main_corofied.rs中的所有内容复制到main.rs,并删除main_corofied.rs。 - 修复
corofy不知道我们更改了future,使其接受waker: &Waker作为参数的问题。最简单的方法是直接运行cargo check,让编译器引导你更改相应的地方。
现在,你可以运行这个示例,看到任务像在第七章中使用 join_all 一样并发运行。如果你测量了任务运行所需的时间,你会发现总共耗时大约为 4 秒。这是合理的,因为你刚刚生成了 5 个 future 并发运行。单个 future 的最长等待时间为 4 秒。
现在,让我们以另一个有趣的示例结束本章。
同时并发和并行运行多个 future
这一次,我们生成多个线程,并为每个线程提供自己的 Executor,这样就可以在所有 Executor 实例中使用相同的 Reactor 来同时并行运行之前的示例。
我们还会对打印输出做一些小调整,以免数据过多让人应接不暇。
我们的新程序如下所示:
mod future;
mod http;
mod runtime;
use crate::http::Http;
use future::{Future, PollState};
use runtime::{Executor, Waker};
use std::thread::Builder;
fn main() {
let mut executor = runtime::init();
let mut handles = vec![];
for i in 1..12 {
let name = format!("exec-{i}");
let h = Builder::new().name(name).spawn(move || {
let mut executor = Executor::new();
executor.block_on(async_main());
}).unwrap();
handles.push(h);
}
executor.block_on(async_main());
handles.into_iter().for_each(|h| h.join().unwrap());
}
coroutine fn request(i: usize) {
let path = format!("/{}/HelloWorld{i}", i * 1000);
let txt = Http::get(&path).wait;
let txt = txt.lines().last().unwrap_or_default();
println!("{txt}");
}
coroutine fn async_main() {
println!("Program starting");
for i in 0..5 {
let future = request(i);
runtime::spawn(future);
}
}
我当前运行的机器有 12 个核心,所以当我创建 11 个新线程来运行相同的异步任务时,我将使用机器上的所有核心。正如你所注意到的,我们还为每个线程提供了一个唯一的名称,用于日志记录,以便更容易追踪幕后发生的事情。
注意
虽然我使用了 12 个核心,但你应该使用你机器上的核心数量。如果我们将这个数字增加得过多,操作系统将无法为我们的程序提供更多核心来并行运行,反而会开始暂停/恢复我们创建的线程,由于我们在异步运行时中自己处理并发方面,因此这对我们来说并没有任何价值。
你需要执行与上一个示例相同的步骤:
- 用上述代码替换
main.rs中的所有代码。 - 运行
corofy ./src/main.rs。 - 将
main_corofied.rs中的所有内容复制到main.rs,然后删除main_corofied.rs。 - 修复
corofy不知道我们更改了future,使其接受waker: &Waker作为参数的问题。最简单的方法是运行cargo check,让编译器引导你更改相应的地方。
现在,如果你运行程序,你会看到它仍然只需要大约 4 秒的时间来运行,但这一次我们进行了 60 次 GET 请求,而不是 5 次。这次,我们的 future 不仅是并发运行的,而且是并行运行的。
此时,你可以继续通过缩短延迟或增加请求来实验,看看系统崩溃之前你可以运行多少个并发任务。
很快,向标准输出的打印输出就会成为瓶颈,但你可以禁用这些。可以使用操作系统线程创建一个阻塞版本,并查看在系统崩溃之前你可以同时运行多少线程,与当前版本进行比较。
唯一的限制就是你的想象力,不过在继续下一章之前,请花点时间尽情享受你所创建的成果。
唯一需要注意的是,通过发送此类请求到你无法控制的随机服务器来测试系统的并发极限可能会让该服务器不堪重负,从而给他人带来问题。
总结
多么令人激动的旅程!正如我在本章的介绍中所说的,这是本书中篇幅最大的一章之一,但即使你可能还没有意识到,你已经比大多数人更好地理解了异步 Rust 的工作原理。做得非常棒!
在本章中,你学到了很多关于运行时的知识,以及 Rust 为什么以这种方式设计 Future 特性和 Waker。你还了解了反应器(Reactor)和执行器(Executor)、Waker 类型、Future 特性,以及通过 join_all 函数和在执行器上生成新的顶层 future 来实现并发的不同方式。
到目前为止,你还对如何通过将自己的运行时与操作系统线程相结合来实现并发和并行有了一定的理解。
现在,我们创建了自己的异步世界,包括 coro/wait、我们自己的 Future 特性、我们自己的 Waker 定义,以及我们自己的运行时。我确保我们没有偏离 Rust 异步编程背后的核心思想,这样一来,所学的内容可以直接应用于日常编程中的 async/await、Future 特性、Waker 类型和运行时。
目前,我们已经进入了本书的最后阶段。最后一章将最终把我们的示例转换为使用真正的 Future 特性、Waker、async/await 等等,而不是我们自己的版本。在那一章中,我们还将保留一些空间来讨论当今异步 Rust 的状态,包括一些最流行的运行时。但是,在到达那一步之前,还有一个话题我想要讨论:Pinning。
Pinning 似乎是最难理解且与其他所有语言中概念最不同的主题之一。在编写异步 Rust 时,你最终需要处理 Future 特性在轮询之前必须进行固定(Pinned)的事实。
所以,下一章将以一种实用的方式解释 Rust 中的 Pinning,以便你理解为什么需要它,它的作用是什么,以及如何进行 Pinning。
不过,在进入本书的最后部分之前,你绝对值得休息一下。所以,出去呼吸些新鲜空气,睡个好觉,清理一下思绪,然后在进入本书的最后几章之前,来杯咖啡放松一下吧。