在过去的几章中,我们涵盖了许多与 Rust 异步编程相关的方面,但我们是通过实现与目前 Rust 中存在的抽象不同且更简单的抽象来完成这些工作的。本书的最后一章将专注于缩小这一差距,通过改变我们的运行时,使其能够与 Rust 的 futures 和 async/await 一起工作,而不是我们自己的 futures 和 coroutine/wait。
由于我们几乎已经涵盖了协程、状态机、futures、wakers、运行时以及 pinning 的所有知识,调整我们目前所做的内容将是一项相对容易的任务。
当我们使所有内容都能够运行后,我们将对我们的运行时进行一些实验,以展示和讨论使得今天的异步 Rust 对新手来说有些困难的一些方面。
我们还会花一些时间讨论关于异步 Rust 的未来可能会有怎样的变化,然后总结我们在本书中所做的事情和学到的知识。
我们将涵盖以下主要主题:
- 使用 futures 和
async/await创建我们自己的运行时 - 对我们的运行时进行实验
- 异步 Rust 面临的挑战
- 异步 Rust 的未来
技术要求
在本章中,示例将基于上一章的代码,因此要求是相同的。示例是跨平台的,并将在 Rust (doc.rust-lang.org/beta/rustc/…) 和 mio (github.com/tokio-rs/mi…) 支持的所有平台上运行。你需要的只是安装了 Rust 以及下载本书的 GitHub 仓库。本章的所有代码可以在 ch10 文件夹中找到。
在本示例中,我们仍然使用 delayserver,因此你需要打开一个单独的终端,进入仓库根目录中的 delayserver 文件夹,并输入 cargo run 以使其准备好供后续示例使用。如果由于某种原因更改了 delayserver 监听的端口,记得在代码中修改相应的端口。
使用 futures 和 async/await 创建我们自己的运行时
好了,我们接近终点了;我们要做的最后一件事是修改我们的运行时,使其使用 Rust 的 Future trait、Waker 以及 async/await。现在,既然我们已经通过自己构建所有内容来涵盖了 Rust 异步编程中最复杂的方面,这对于我们来说将是一项相对简单的任务。我们甚至详细讨论了 Rust 在这一过程中所需的设计决策。
Rust 目前的异步编程模型是一个演进过程的结果。Rust 在早期阶段曾使用过绿色线程(green threads),但这是在达到 1.0 版本之前。在达到 1.0 版本时,Rust 的标准库中完全没有 futures 或异步操作的概念。这部分内容在 futures-rs crate (github.com/rust-lang/f…) 中得到了探索,该库至今仍然是异步抽象的孵化地。然而,没过多久,Rust 就围绕着一个类似于我们今天使用的 Future trait 版本达成了一致,该版本通常被称为 futures 0.1。在那个时候,支持由 async/await 创建的协程的工作已经在进行中,但经过了几年的时间,设计才达到了最终阶段并进入了稳定版的标准库。
因此,许多我们在异步实现中必须做出的选择是真正的 Rust 在发展过程中所做的选择。然而,这一切都将我们带到了这一点,所以让我们开始着手适应我们的运行时,使其能够与 Rust futures 一起工作。
在我们进入示例之前,让我们先来看看与我们当前的实现相比,有哪些不同之处:
- Rust 使用的 Future trait 与我们现在使用的稍有不同。最大的区别在于它使用一个名为 Context 的参数,而不是 Waker。另一个区别是它返回一个名为 Poll 的枚举,而不是 PollState。
- Context 是对 Rust 的 Waker 类型的封装。它的唯一目的是为将来提供 API 的扩展性,以便在不更改与 Waker 相关的任何内容的情况下可以添加额外的数据。
- Poll 枚举返回两种状态之一:Ready(T) 或 Pending。这与我们当前实现中的 PollState 略有不同,但这两个状态的含义与我们现在的 Ready(T)/NotReady 相同。
- Rust 中的 Waker 的创建比我们目前使用的 Waker 略微复杂一些。我们将在本章稍后讨论其原因和方式。
- 除了上面列出的不同之处,其他所有内容几乎都可以保持不变。这次我们主要是在重命名和重构。
现在我们已经对需要做的事情有了大致了解,是时候设置所有内容,以便让我们的新示例运行起来。
注意:尽管我们在 Rust 中创建了一个可以正常运行 futures 的运行时,但我们仍然尽量保持简单,避免进行错误处理,也不专注于让我们的运行时更灵活。改进我们的运行时当然是可行的,尽管有时在正确使用类型系统和通过借用检查器方面会有些棘手,但这与异步 Rust 的关系相对较小,而更多是 Rust 本身的特性。
设置示例
提示:你可以在本书的 GitHub 仓库中的 ch10/a-rust-futures 文件夹中找到这个示例。
我们将从上一个章节的内容继续开始,因此请将所有内容复制到一个新项目中:
- 创建一个名为
a-rust-futures的新文件夹。 - 将上一章中的示例复制过来。如果你遵循了我建议的命名方式,它将存储在
e-coroutines-pin文件夹中。 - 现在你应该有一个包含我们上一个示例的副本的文件夹,接下来要做的最后一件事是在 Cargo.toml 中将项目名称改为
a-rust-futures。
好了,让我们开始实现我们要运行的程序。打开 main.rs。
main.rs
我们将回到程序的最简单版本,并在尝试更复杂的功能之前让它运行。打开 main.rs,并将该文件中的所有代码替换为以下代码:
mod http;
mod runtime;
use crate::http::Http;
fn main() {
let mut executor = runtime::init();
executor.block_on(async_main());
}
async fn async_main() {
println!("Program starting");
let txt = Http::get("/600/HelloAsyncAwait").await;
println!("{txt}");
let txt = Http::get("/400/HelloAsyncAwait").await;
println!("{txt}");
}
这次不需要 corofy 或任何特殊工具,编译器将为我们重写代码。
注意:注意我们已经移除了
future模块的声明,因为我们不再需要它。唯一的例外是,如果你想保留并使用我们创建的join_all函数来将多个 futures 一起连接起来。你可以尝试自己重写它,或者在仓库中找到ch10/a-rust-futures-bonus/src/future.rs文件,在那里你会发现包含join_all函数的相同版本示例,它可以与 Rust futures 一起工作。
future.rs
你可以完全删除这个文件,因为我们不再需要我们自己的 Future trait。
接下来我们看看 http.rs 需要做哪些更改。
http.rs
首先我们需要更改的是依赖项。我们将不再依赖我们自己的 Future、Waker 和 PollState,而是依赖标准库中的 Future、Context 和 Poll。依赖项现在应该是这样的:
use crate::runtime::{self, reactor};
use mio::Interest;
use std::{
future::Future,
io::{ErrorKind, Read, Write},
pin::Pin,
task::{Context, Poll},
};
我们需要对 HttpGetFuture 的 poll 实现进行一些小的重构。
首先,我们需要更改 poll 函数的签名,使其符合新的 Future trait:
fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>
由于我们将新参数命名为 cx,我们需要更改传递给 set_waker 的内容,修改如下:
runtime::reactor().set_waker(cx, self.id);
接下来,我们需要更改我们的 future 实现,使其返回 Poll 而不是 PollState。为此,找到 poll 方法,并从更改签名开始,使其匹配标准库中的 Future trait:
fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>
接下来,我们需要在函数的返回点更改返回类型(这里只展示了函数体的相关部分):
loop {
match self.stream.as_mut().unwrap().read(&mut buff) {
Ok(0) => {
let s = String::from_utf8_lossy(&self.buffer).to_string();
runtime::reactor().deregister(self.stream.as_mut().unwrap(), id);
break Poll::Ready(s.to_string());
}
Ok(n) => {
self.buffer.extend(&buff[0..n]);
continue;
}
Err(e) if e.kind() == ErrorKind::WouldBlock => {
// 始终存储最近提供的 Waker
runtime::reactor().set_waker(cx, self.id);
break Poll::Pending;
}
Err(e) => panic!("{e:?}"),
}
}
这就是文件的所有更改。看起来不难吧?接下来让我们看看在 executor.rs 中需要更改的内容。
executor.rs
首先,我们需要更改 executor.rs 中的依赖项。这次,我们仅依赖标准库中的类型,因此依赖项部分现在应该如下所示: ch10/a-rust-futures/src/runtime/executor.rs
use std::{
cell::{Cell, RefCell},
collections::HashMap,
future::Future,
pin::Pin,
sync::{Arc, Mutex},
task::{Poll, Context, Wake, Waker},
thread::{self, Thread},
};
我们的协程将不再仅限于输出 String,因此我们可以更安全地为顶层的 futures 使用更合适的输出类型:
type Task = Pin<Box<dyn Future<Output = ()>>>;
接下来,我们直接进入 Waker,因为我们在这里所做的更改会导致文件中的其他部分进行一些更改。
在 Rust 中创建 waker 可能是一项相当复杂的任务,因为 Rust 希望为我们提供实现 waker 的最大灵活性。这样做的原因有两个:
- Wakers 必须在服务器和微控制器上同样有效。
- Waker 必须是零开销的抽象。
认识到大多数程序员从不需要创建自己的 wakers,因此缺乏方便性的代价被认为是可以接受的。
直到最近,在 Rust 中构造 waker 的唯一方法是创建一个非常类似于 trait 对象的东西,但它不是 trait 对象。为此,你必须经过一个相当复杂的过程来构建虚表(v-table,一组函数指针),将其与 waker 存储的数据指针结合起来,然后创建 RawWaker。
幸运的是,我们现在不必再经历这个过程,因为 Rust 现在有了 Wake trait。如果我们创建的 Waker 类型放在 Arc 中,那么 Wake trait 就可以正常工作。
将 Waker 包装在 Arc 中会导致堆分配,但对于我们在本书中讨论的这类系统上的大多数 Waker 实现来说,这完全没问题,也是大多数生产运行时所采用的方式。这使得我们的实现更加简单。
信息:这是 Rust 采用生态系统中被证明是最佳实践的一个例子。很长一段时间内,一种流行的构造 wakers 的方法是实现
futurescrate 提供的ArcWaketrait (github.com/rust-lang/f…futures%E3%80%82%60futures%60) crate 不是语言的一部分,但它在rust-lang仓库中,可以被视为工具箱和可能最终进入语言的抽象的孵化器。
为了避免同名的混淆,我们将具体的 Waker 类型重命名为 MyWaker: ch10/a-rust-futures/src/runtime/executor.rs
#[derive(Clone)]
pub struct MyWaker {
thread: Thread,
id: usize,
ready_queue: Arc<Mutex<Vec<usize>>>,
}
我们可以保留 wake 函数的实现,但将其放在 Wake trait 的实现中,而不是仅在 MyWaker 上拥有一个 wake 函数:
impl Wake for MyWaker {
fn wake(self: Arc<Self>) {
self.ready_queue
.lock()
.map(|mut q| q.push(self.id))
.unwrap();
self.thread.unpark();
}
}
你会注意到 wake 函数接受 self: Arc<Self> 参数,这与我们使用 Pin 类型时看到的类似。以这种方式编写函数签名意味着 wake 只能在被 Arc 包装的 MyWaker 实例上调用。
由于我们的 waker 略有更改,因此有几个地方需要进行一些小的更正。首先是在 get_waker 函数中:
fn get_waker(&self, id: usize) -> Arc<MyWaker> {
Arc::new(MyWaker {
id,
thread: thread::current(),
ready_queue: CURRENT_EXEC.with(|q| q.ready_queue.clone()),
})
}
这里的更改不大。唯一的区别是我们通过将 waker 放在 Arc 中将其分配到堆上。
接下来需要更改的地方是在 block_on 函数中。
首先,我们需要更改其签名,以匹配顶层 future 的新定义:
pub fn block_on<F>(&mut self, future: F)
where
F: Future<Output = ()> + 'static,
{
接下来是更改我们如何在 block_on 函数中创建 waker 并将其包装在 Context 结构中:
...
// guard against false wakeups
None => continue,
};
let waker: Waker = self.get_waker(id).into();
let mut cx = Context::from_waker(&waker);
match future.as_mut().poll(&mut cx) {
...
这个更改有点复杂,我们逐步解释:
- 首先,通过调用
get_waker函数获取Arc<MyWaker>,就像之前一样。 - 我们通过指定期望的类型
let waker: Waker并在MyWaker上调用into()将MyWaker转换为一个简单的Waker。由于每个MyWaker实例也是某种Waker,这将其转换为标准库中定义的Waker类型,这正是我们所需要的。 - 由于
Future::poll期望接收Context而不是Waker,我们使用刚创建的waker的引用创建了一个新的Context结构。
最后一个需要更改的地方是 spawn 函数的签名,使其也能接收顶层 future 的新定义:
pub fn spawn<F>(future: F)
where
F: Future<Output = ()> + 'static,
这就是我们在 executor 中需要更改的最后一部分,我们快完成了。我们需要对运行时进行的最后更改是在 reactor.rs 中,所以让我们打开 reactor.rs 并继续进行。
reactor.rs
首先,我们需要确保我们的依赖项是正确的。我们必须移除对旧 Waker 实现的依赖,并从标准库中引入以下类型。依赖项部分应如下所示: ch10/a-rust-futures/src/runtime/reactor.rs
use mio::{net::TcpStream, Events, Interest, Poll, Registry, Token};
use std::{
collections::HashMap,
sync::{
atomic::{AtomicUsize, Ordering},
Arc, Mutex, OnceLock,
},
thread, task::{Context, Waker},
};
我们需要进行两个小的更改。首先是 set_waker 函数现在接受 Context,并从中获取一个 Waker 对象: ch10/a-rust-futures/src/runtime/reactor.rs
pub fn set_waker(&self, cx: &Context, id: usize) {
let _ = self
.wakers
.lock()
.map(|mut w| w.insert(id, cx.waker().clone()).is_none())
.unwrap();
}
最后一个更改是在 event_loop 函数中调用 wake 时,需要调用稍微不同的方法: ch10/a-rust-futures/src/runtime/reactor.rs
if let Some(waker) = wakers.get(&id) {
waker.wake_by_ref();
}
由于现在调用 wake 会消耗 self,我们调用了接收 &self 的版本,因为我们想保留这个 waker 以供以后使用。
就这些了。现在,我们的运行时可以运行并充分利用异步 Rust 的全部功能。让我们在终端中输入 cargo run 来尝试一下。
我们应该看到与之前相同的输出:
Program starting
FIRST POLL - START OPERATION
main: 1 pending tasks. Sleep until notified.
HTTP/1.1 200 OK
content-length: 15
[==== ABBREVIATED ====]
HelloAsyncAwait
main: All tasks are finished
这很酷,不是吗?
现在,我们创建了自己的异步运行时,它使用 Rust 的 Future、Waker、Context 和 async/await。
既然我们可以自豪地称自己为运行时实现者,是时候做一些实验了。我会选择一些实验,这些实验还会教我们一些关于 Rust 中运行时和 futures 的知识。我们还没有完全结束学习呢。
实验我们的运行时
注意 你可以在本书的 GitHub 仓库中的 ch10/b-rust-futures-experiments 文件夹中找到这个示例。不同的实验将作为不同版本的 async_main 函数按时间顺序实现。我会在代码段的标题中指出哪个函数对应于仓库示例中的哪个函数。
在开始实验之前,让我们将现有的所有内容复制到一个新文件夹中:
- 创建一个名为
b-rust-futures-experiments的新文件夹。 - 将所有内容从
a-rust-futures文件夹复制到新文件夹中。 - 打开
Cargo.toml并将name属性更改为b-rust-futures-experiments。
第一个实验是将我们非常有限的 HTTP 客户端换成一个合适的客户端。
最简单的方法是选择一个支持异步 Rust 的生产质量的 HTTP 客户端库来代替我们自己的客户端。查看了最受欢迎的高层次 HTTP 客户端库后,我们发现 reqwest 在顶部,这对我们的目的可能很合适,所以我们先尝试它。
首先,我们通过以下命令将 reqwest 添加为 Cargo.toml 中的依赖项:
cargo add reqwest@0.11
接下来,让我们更改 async_main 函数以使用 reqwest 替代我们自己的 HTTP 客户端: ch10/b-rust-futures-examples/src/main.rs (async_main2)
async fn async_main() {
println!("Program starting");
let url = "http://127.0.0.1:8080/600/HelloAsyncAwait1";
let res = reqwest::get(url).await.unwrap();
let txt = res.text().await.unwrap();
println!("{txt}");
let url = "http://127.0.0.1:8080/400/HelloAsyncAwait2";
let res = reqwest::get(url).await.unwrap();
let txt = res.text().await.unwrap();
println!("{txt}");
}
除了使用 reqwest API 外,我还更改了我们发送的消息。大多数 HTTP 客户端不会返回原始 HTTP 响应,而是通常只提供一种方便的方法来获取响应体,这与我们之前的请求相似。
这应该就是我们需要更改的所有内容,所以让我们通过键入 cargo run 来运行程序:
Running `target\debug\a-rust-futures.exe`
Program starting
thread 'main' panicked at C:\Users\cf.cargo\registry\src\index.crates.io-6f17d22bba15001f\tokio-1.35.0\src\net\tcp\stream.rs:160:18:
there is no reactor running, must be called from the context of a Tokio 1.x runtime
好吧,错误告诉我们没有运行中的 reactor,并且它必须从 Tokio 1.x 运行时的上下文中调用。我们知道有一个 reactor 正在运行,只是它不是 reqwest 期望的那个,因此让我们看看如何解决这个问题。
我们显然需要将 Tokio 添加到我们的程序中,并且由于 Tokio 是高度功能隔离的(默认启用的功能很少),我们为了简单起见启用所有功能:
cargo add tokio@1 --features full
根据文档,我们需要启动一个 Tokio 运行时并显式进入它以启用 reactor。enter 函数将返回一个 EnterGuard,我们可以持有它,只要我们需要运行 reactor。
将此添加到 async_main 函数的顶部应可行: ch10/b-rust-futures-examples/src/main.rs (async_main2)
use tokio::runtime::Runtime;
async fn async_main() {
let rt = Runtime::new().unwrap();
let _guard = rt.enter();
println!("Program starting");
let url = "http://127.0.0.1:8080/600/HelloAsyncAwait1";
// ...
}
注意 调用 Runtime::new 会创建一个多线程的 Tokio 运行时,但 Tokio 也有一个单线程的运行时,可以通过使用运行时构建器来创建:Builder::new_current_thread().enable_all().build().unwrap()。如果你这样做,你会遇到一个奇特的问题:死锁。这种情况的原因很有趣,也是你应该了解的。
Tokio 的单线程运行时只使用调用它的线程来同时执行 executor 和 reactor,这非常类似于我们在第 8 章的第一个版本的运行时中所做的。当 reactor 和 executor 在同一线程上执行时,它们必须共享相同的机制来挂起自身并等待新事件,这意味着它们之间会有紧密的耦合。如果 executor 通过调用 thread::park 挂起自身(就像我们做的那样),reactor 也会挂起,并且永远不会唤醒,因为它们在同一线程上运行。要使它们正常工作,executor 必须使用与 reactor 共享的东西进行挂起(就像我们使用 Poll 那样)。由于我们没有与 Tokio 紧密集成,因此只会导致死锁。
现在,如果我们再次尝试运行程序,我们会得到以下输出:
Program starting
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
HelloAsyncAwait1
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
HelloAsyncAwait2
main: All tasks are finished
好吧,现在一切如预期那样正常工作了。唯一的不同是我们被唤醒了几次,但程序完成并产生了预期结果。
在讨论我们刚刚看到的现象之前,让我们再做一个实验。
Isahc 是一个承诺与执行器无关的 HTTP 客户端库,意思是它不依赖于任何特定的执行器。让我们验证一下这一点。
首先,我们通过键入以下命令添加对 isahc 的依赖:
cargo add isahc@1.7
然后,我们重写 main 函数,使其看起来像这样: ch10/b-rust-futures-examples/src/main.rs (async_main3)
use isahc::prelude::*;
async fn async_main() {
println!("Program starting");
let url = "http://127.0.0.1:8080/600/HelloAsyncAwait1";
let mut res = isahc::get_async(url).await.unwrap();
let txt = res.text().await.unwrap();
println!("{txt}");
let url = "http://127.0.0.1:8080/400/HelloAsyncAwait2";
let mut res = isahc::get_async(url).await.unwrap();
let txt = res.text().await.unwrap();
println!("{txt}");
}
现在,如果我们通过编写 cargo run 来运行程序,我们得到以下输出:
Program starting
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
HelloAsyncAwait1
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
main: 1 pending tasks. Sleep until notified.
HelloAsyncAwait2
main: All tasks are finished
所以,我们得到了预期的输出,并且不需要做任何额外的工作。
为什么这一切都如此不直观?
答案将我们带到了使用异步 Rust 时面临的一些常见挑战,因此让我们来讨论一些最明显的挑战,并解释它们存在的原因,以便我们能找到最好的应对方法。
Rust 异步编程的挑战
虽然我们亲眼看到执行器和反应器可以松耦合,这意味着理论上你可以混合搭配不同的反应器和执行器,但问题是,为什么在尝试这样做时会遇到这么多摩擦?
大多数使用异步 Rust 的程序员都遇到过由不兼容的异步库引起的问题,我们之前也看到了这样一种错误消息的示例。
为了理解这个问题,我们需要深入了解现有的 Rust 异步运行时,特别是那些我们通常用于桌面和服务器应用程序的运行时。
显式与隐式的反应器实例化
提示 接下来我们讨论的 future 类型是叶子 future,即那些实际代表 I/O 操作的 future(例如,HttpGetFuture)。
当你在 Rust 中创建运行时时,还需要创建 Rust 标准库中的非阻塞原语。互斥锁、通道、计时器、TcpStream 等都需要有异步等价物。
这些大多数可以实现为不同类型的反应器,但接下来出现的问题是:如何启动这个反应器?
在我们自己的运行时和 Tokio 中,反应器是作为运行时初始化的一部分被启动的。我们有一个 runtime::init() 函数来调用 reactor::start(),Tokio 则有 Runtime::new() 和 Runtime::enter() 函数。
如果我们在反应器启动之前尝试创建叶子 future(我们自己创建的唯一一个叶子 future 是 HttpGetFuture),无论是在我们的运行时还是在 Tokio 中,都会发生 panic。反应器必须显式实例化。
另一方面,Isahc 自带了一种反应器。Isahc 基于 libcurl,这是一个高度可移植的 C 库,用于多协议文件传输。对于我们来说,相关的地方在于 libcurl 接受一个回调函数,当某个操作就绪时调用这个回调。所以 Isahc 会将它收到的 waker 传递给这个回调,并确保在回调执行时调用 Waker::wake。虽然这是简化后的描述,但本质上就是这样。
实际上,这意味着 Isahc 自带了反应器,因为它具有存储 wakers 并在操作准备就绪时调用 wake 的机制。这个反应器是隐式启动的。
顺便说一下,这也是 async_std 和 Tokio 之间的主要区别之一。Tokio 需要显式实例化,而 async_std 依赖隐式实例化。
我不只是为了好玩才深入这个细节;虽然这看起来像一个小小的区别,但它对 Rust 中异步编程的直观性有很大影响。
这个问题主要出现在你开始使用与 Tokio 不同的运行时进行编程,并且需要使用一个内部依赖于 Tokio 反应器的库时。
由于在同一线程上不能同时运行两个 Tokio 实例,因此库不能隐式启动一个 Tokio 反应器。相反,通常的情况是你尝试使用该库时遇到类似于前面示例中的错误。
此时,你需要通过自己启动一个 Tokio 反应器来解决问题,或者使用某人创建的兼容性包装器,或者查看你使用的运行时是否有内置的机制来运行依赖于 Tokio 反应器的 futures。
对于大多数不了解反应器、执行器和不同类型叶子 future 的人来说,这可能相当不直观并引起不少挫败感。
注意 我们描述的问题非常常见,情况并没有因为异步库的稀缺或对它们所使用的运行时的解释不明确而得到帮助。某些库可能只会在 README 文件中提到它们是基于 Tokio 的,而有些库可能只是简单地声明它们是基于 Hyper 的,假设你知道 Hyper 默认情况下是基于 Tokio 的。
但是现在,你知道在避免意外时应该检查这些信息,而且如果你遇到这个问题,你会知道这到底是怎么回事。
易用性与效率和灵活性的权衡
Rust 在提高易用性和效率方面做得非常出色,但这也让人几乎忘记了,当 Rust 面临选择是更高效还是更易用时,它会选择更高效。生态系统中最受欢迎的很多库也反映了这些价值观,包括异步运行时。
有些任务如果与执行器紧密集成,可以变得更高效,因此,如果在你的库中使用它们,你将依赖于特定的运行时。
我们以计时器为例,但任务通知(即任务 A 通知任务 B 可以继续)也是有类似权衡的另一示例。
任务(Tasks)
我们使用了任务和 future 这两个术语,但没有明确区分它们的不同之处,所以这里让我们来澄清一下。我们在第一章中首先介绍了任务的概念,并且它们仍然保留着相同的一般含义,但在讨论 Rust 的运行时时,它们有了更具体的定义。任务是一个顶层 future,是我们放到执行器上执行的那个 future。执行器负责在不同的任务之间调度。运行时中的任务在很多方面表示了与操作系统中的线程相同的抽象。根据这种定义,每个任务都是 Rust 中的一个 future,但不是每个 future 都是任务。
你可以将 thread::sleep 视为一个计时器,在异步上下文中,我们通常需要类似的东西,因此我们的异步运行时需要有一个等效的异步休眠函数,以便告诉执行器将此任务暂停指定的时间段。
我们可以将其实现为一个反应器,使用一个单独的操作系统线程进行指定时长的睡眠,然后唤醒正确的 Waker。这样的方法简单且与执行器无关,因为执行器对发生的情况一无所知,只关心在调用 Waker::wake 时调度任务。然而,对于所有工作负载来说,这并不是最优化的方案(即使我们使用同一个线程来处理所有计时器)。
另一种、更常见的解决方法是将该任务委托给执行器。在我们的运行时中,这可以通过让执行器存储一个按顺序排列的时刻列表以及相应的 Waker 来完成,以此判断在调用 thread::park 之前是否有计时器已过期。如果没有计时器过期,我们可以计算到下一个计时器过期的时长,然后使用类似 thread::park_timeout 的方法,确保我们至少在处理该计时器时被唤醒。
用于存储计时器的算法可以进行深入优化,并且通过这种方式你可以避免为计时器设置一个额外线程的需要,同时避免了这些线程之间的同步开销,仅仅为了标记计时器已过期。在多线程运行时中,当多个执行器频繁向同一个反应器添加计时器时,甚至可能出现争用。
有些计时器以反应器风格实现为单独的库,对于许多任务来说,这样做已经足够了。这里要强调的一点是,通过使用默认实现,你最终会与某个特定的运行时绑定在一起。如果你想避免你的库与特定运行时紧密耦合,就必须进行仔细的考虑。
通用特性共识的缺乏
最后一个导致异步 Rust 使用上摩擦的问题是缺乏对典型异步操作的一致认可的特性和接口。
我想在这里先指出,这个领域正在日益改善,而在 futures-rs 库(github.com/rust-lang/f…)中有用于异步 Rust 的特性和抽象的实验场。然而,由于异步 Rust 仍处于早期阶段,因此在这样的书中提到它是很有意义的。
以任务生成(spawning)为例。当你在 Rust 中编写一个高层次的异步库(比如一个 Web 服务器)时,通常希望能够生成新任务(顶级 futures)。例如,每个连接到服务器的请求很可能会成为你想要放入执行器中的一个新任务。
现在,任务生成与每个执行器特定的实现相关,而 Rust 并没有一个定义如何生成任务的特性。虽然在 futures-rs 库中建议了一个用于任务生成的特性,但创建一个既零成本又足够灵活以支持各种运行时的任务生成特性其实非常困难。
有一些绕过这个问题的方法。比如,流行的 HTTP 库 Hyper 使用一个特性来表示执行器,并在内部用该特性生成新任务。这使得用户可以为不同的执行器实现这个特性,并将其传回给 Hyper。通过为不同的执行器实现这个特性,Hyper 将使用不同的生成器,而不是其默认的生成器(默认选项是 Tokio 执行器中的生成器)。这是一个示例,展示了如何在 Hyper 中为 async_std 实现这个特性:github.com/async-rs/as…。
然而,由于没有一种通用的方式让这一切都正常工作,大多数依赖于特定执行器功能的库采取以下两种做法之一:
- 选择一个运行时并坚持使用它。
- 实现支持不同受欢迎运行时的两个版本的库,用户可以通过启用正确的特性来选择。
异步销毁(Async Drop)
异步销毁或异步析构函数是异步 Rust 中在撰写本书时尚未完全解决的一个方面。Rust 使用了一种称为 RAII 的模式(资源获取即初始化),这意味着当一个类型被创建时,其资源也会被创建,当一个类型被销毁时,其资源也会被释放。当对象超出其作用域时,编译器会自动插入对 drop 的调用。
以我们的运行时为例,当资源被销毁时,它们会以阻塞方式被释放。通常这不会成为大问题,因为销毁操作通常不会让执行器阻塞太久,但情况并非总是如此。
如果我们有一个需要很长时间才能完成的销毁实现(例如,销毁需要管理 I/O,或者进行阻塞调用 OS 内核,在 Rust 中这既是合法的,有时甚至是不可避免的),这就有可能阻塞执行器。因此,异步销毁在这种情况下需要能够某种程度上让出给调度器,而目前这是不可能的。
这不是你作为异步库用户可能遇到的异步 Rust 的一个显著问题,但它值得注意,因为目前,确保它不会引起问题的唯一方法就是谨慎对待你在异步上下文中使用的类型的 drop 实现。
尽管这不是所有导致异步 Rust 摩擦问题的详尽清单,但这些问题是我觉得最值得注意和需要了解的。
在结束本章之前,我们花点时间讨论一下关于 Rust 中的异步编程,未来我们可以期待什么。
异步 Rust 的未来
让异步 Rust 与其他语言不同的某些特性是无法避免的。由于 Rust 的设计和核心价值观,异步 Rust 非常高效、低延迟,并且得益于强大的类型系统。
然而,如今许多感受到的复杂性更多地与生态系统有关,源自许多程序员在没有正式结构的情况下需要就解决不同问题的最佳方式达成一致。这导致了生态系统的短暂分裂,再加上异步编程对于很多程序员来说本身就是一个困难的话题,这些最终增加了与异步 Rust 相关的认知负担。
本章提到的所有问题和痛点都在不断改进。一些几年前可能会列入此清单的问题,今天已经不值得一提。
越来越多的通用特性和抽象将最终进入标准库,这将使异步 Rust 更加符合人体工程学,因为使用这些特性的代码将“直接工作”。
随着各种实验和设计中某些比其他获得更多认可,它们逐渐成为事实上的标准。尽管在编写异步 Rust 时你仍有很多选择,但对于那些希望“直接工作”的人来说,将有一些导致最小摩擦的路径。
对于那些对异步 Rust 和异步编程本身有足够了解的人来说,我在这里提到的问题相对较小,而且由于你已经比大多数程序员更了解异步 Rust,我很难想象这些问题会对你造成太多困扰。
这并不意味着这不是值得了解的事情,因为你的同事可能会在某个时候遇到这些问题。
总结
在本章中,我们做了两件事。首先,我们对运行时进行了较小的更改,使其可以作为 Rust futures 的实际运行时。然后我们使用两个外部 HTTP 客户端库测试了运行时,以学习有关反应器、运行时和 Rust 中异步库的一些内容。
接下来,我们讨论了一些使得异步 Rust 对许多来自其他语言的程序员来说很难的问题。最后,我们还讨论了未来的预期。
根据你的学习进度和对我们一路上创建的示例的实验程度,如果你想了解更多,可以自己选择要承担的项目。
学习中的一个重要方面就是自己动手实验。将所有东西拆解,看看会破坏什么,以及如何修复它。改进我们创建的简单运行时,以学习新的东西。
有很多有趣的项目可以选择,以下是一些建议:
- 更换我们使用的
thread::park的阻塞实现,换成一个适当的阻塞器。你可以选择使用一个库中的阻塞器,或者自己创建一个(在 ch10 文件夹末尾有一个名为 parker-bonus 的小补充,包含一个简单的阻塞器实现)。 - 使用你自己创建的运行时实现一个简单的
delayserver。为此,你需要编写一些原始 HTTP 响应并创建一个简单的服务器。如果你看过 Rust 的免费入门书《The Rust Programming Language》,在最后几章之一中你创建了一个简单的服务器(doc.rust-lang.org/book/ch20-0…),这为你提供了所需的基础知识。你还需要创建一个定时器,正如我们在上面讨论的那样,或者使用现有的异步定时器库。 - 你可以创建一个“真正的”多线程运行时,并探索拥有全局任务队列带来的可能性,或者作为替代,创建一个工作窃取调度器,允许执行器在完成自己的任务后,从其他执行器的本地队列中窃取任务。
能做什么只有你的想象力限制。重要的是要注意,做某事只是因为你可以,或者只是为了好玩,这其中有某种快乐。我希望你能从中获得与我相同的享受。
最后,我想在此为你如何让自己的异步编程生活尽可能轻松提供一些建议。
首先要意识到,异步运行时不仅仅是你使用的另一个库。它非常具有侵入性,几乎影响到程序中的每个部分。它是一个重写、调度任务的层次,并重新排序程序流。
如果你对学习运行时不特别感兴趣,或者没有非常具体的需求,我的明确建议是选择一个运行时并坚持使用一段时间。学习它的全部内容——不一定从一开始就全学会,但随着你需要更多功能,你最终会了解所有内容。这几乎像是熟悉 Rust 标准库的所有内容。
你开始选择哪个运行时,取决于你最常使用哪些 crate。smol 和 async-std 共享了许多实现细节,行为也相似。它们的最大卖点是 API 力求尽可能接近标准库。结合隐式启动反应器的特性,这可以带来稍微更直观的体验和更温和的学习曲线。它们都是生产级的运行时并被广泛使用。smol 最初的目标是创建一个程序员易于理解和学习的代码库,我认为今天依然如此。
话虽如此,对于那些正在寻找通用运行时的用户来说,截止到写作时最受欢迎的替代方案是 Tokio。Tokio 是 Rust 中最古老的异步运行时之一。它的开发活跃且社区友好而积极。文档也非常优秀。作为最受欢迎的运行时之一,这意味着你很可能找到支持 Tokio 的库,做你所需的功能。出于以上原因,我个人也倾向于选择 Tokio,但除非你有非常特定的需求,否则使用这些运行时中的任何一个都不会出错。
最后,我们不应忘记提到 futures-rs crate(github.com/rust-lang/f…)。我之前提到过这个 crate,但它确实非常有用,因为它包含了很多针对异步 Rust 的特性、抽象和执行器(docs.rs/futures/lat…)。它在许多情况下作为异步工具箱非常有用。
结语
你已经走到了终点。首先,恭喜你!你已经走完了一段相当艰辛的旅程!
我们从第一章中谈论并发和并行开始,甚至覆盖了一些历史、CPU 和操作系统、硬件和中断的内容。第二章中,我们讨论了编程语言如何建模异步程序流。我们介绍了协程以及堆栈协程与无堆栈协程的区别。我们讨论了操作系统线程、纤程/绿色线程和回调的优缺点。
然后,在第三章中,我们研究了操作系统支持的事件队列,例如 epoll、kqueue 和 IOCP。我们还深入研究了系统调用和跨平台抽象。
在第四章中,我们遇到了一些非常困难的部分,尝试用 epoll 实现了自己的类 mio 事件队列。我们甚至还学习了边沿触发和电平触发事件的区别。
如果第四章是比较困难的部分,那么第五章更像是攀登珠穆朗玛峰。没有人期望你记住第五章中涵盖的所有内容,但你已经读完了,并且有一个可以用来实验的工作示例。我们实现了自己的纤程/绿色线程,在此过程中,我们还学到了一些关于处理器架构、ISA、ABI 和调用约定的知识。我们甚至学到了相当多的关于 Rust 中内联汇编的知识。如果你之前对堆栈与堆的区别感到不确定,那么现在你肯定了解了,因为你亲自创建了栈,并让 CPU 自己跳到我们创建的栈上。
在第六章中,我们得到了关于异步 Rust 的高级介绍,然后从第七章开始深入研究,从创建自己的协程和协程/等待语法开始。第八章中,我们创建了第一个自己的运行时版本,并讨论了基础的运行时设计。我们还深入研究了反应器、执行器和唤醒器。
在第九章中,我们改进了运行时,发现了 Rust 中自引用结构体的危险。然后,我们深入研究了 Rust 中的 pinning,以及它如何帮助我们解决遇到的问题。
最后,在第十章中,我们发现通过一些较小的更改,我们的运行时成为了一个可以运行 Rust futures 的完整运行时。我们以讨论异步 Rust 的一些已知挑战和对未来的期望结束了这一切。
Rust 社区非常包容和友好,我们也很乐意欢迎你加入,如果你对这个话题感兴趣并想学到更多的话。异步 Rust 变得更好的一种方式就是通过来自各个经验水平的人的贡献。如果你想参与,那么异步工作组(rust-lang.github.io/wg-async/we…)是一个不错的起点。围绕 Tokio 项目(github.com/tokio-rs/to…)也有一个非常活跃的社区,根据你想深入研究的特定领域,还有许多其他的选择。不要害怕加入不同的频道并提问。
现在我们已经到了终点,我想感谢你阅读到最后。我希望这本书给人的感觉更像是一段我们一起经历的旅程,而不是一场讲座。我希望你是焦点,而不是我。
我希望我成功做到了这一点,并且真心希望你学到了一些你觉得有用并能在未来带走的知识。如果是这样,那么我真诚地感到高兴,因为我的工作对你有价值。我祝你在未来的异步编程中一切顺利。
直到下次再见!