在本章中,我们将通过增加在状态变化时存储变量的能力来改进我们的协程。我们将看到,这将导致我们的协程需要引用它们自身,并由此引发的一些问题。之所以专门为这个主题编写整整一章,是因为它是让 Rust 中的 async/await 工作的重要部分,同时也是一个相对较难深入理解的话题。
Pinning 的整个概念对于许多开发者来说是陌生的,就像 Rust 的所有权系统一样,需要一些时间来形成正确而有效的心智模型。
幸运的是,Pinning 的概念本身并不难理解,但它在语言中的实现方式以及与 Rust 类型系统的交互是抽象且难以掌握的。
虽然在本章中我们不会涵盖关于 Pinning 的所有内容,但我们会尽力对其有一个全面而合理的理解。本章的主要目标是让你对这个主题有信心,并理解为什么我们需要它以及如何使用它。
如前所述,本章不仅仅讨论 Rust 中的 Pinning,因此我们首先会对第八章最后的示例进行一些重要的改进。
然后,我们将解释什么是自引用结构体,以及它们如何与 future 相关联,接着我们会解释 Pinning 如何解决这些问题。
本章将涵盖以下主要内容:
- 改进我们的示例 1 – 变量
- 改进我们的示例 2 – 引用
- 改进我们的示例 3 – 这可不是个好情况……
- 发现自引用结构体
- Rust 中的 Pinning
- 改进我们的示例 4 – Pinning 来救场
技术要求
本章的示例将基于上一章的代码,因此要求相同。这些示例都是跨平台的,并且可以在所有 Rust(doc.rust-lang.org/stable/rust…)和 mio(github.com/tokio-rs/mi…)支持的平台上运行。你只需要安装 Rust,并在本地下载本书的 GitHub 仓库即可。本章的所有代码都可以在 ch09 文件夹中找到。
为了逐步跟随示例,你还需要在机器上安装 corofy。如果你在第七章没有安装它,现在请进入仓库中的 ch07/corofy 文件夹,运行以下命令进行安装:
cargo install --force --path .
我们还将在这个示例中使用 delayserver,因此你需要打开一个单独的终端,进入仓库根目录下的 delayserver 文件夹,运行 cargo run,以便它可以为接下来的示例做好准备并可用。如果你必须更改 delayserver 监听的端口号,请记得在代码中也相应修改端口号。
改进我们的示例 1 – 变量
让我们通过继续上一个章节的内容,回顾一下我们目前所拥有的内容。我们有以下内容:
- 一个
Future特性 - 一个使用协程/等待语法和预处理器的协程实现
- 一个基于
mio::Poll的反应器 - 一个执行器,可以生成任意数量的顶层任务并调度准备好运行的任务
- 一个 HTTP 客户端,只对本地
delayserver实例发起 HTTP GET 请求
还不错——虽然我们可以说我们的 HTTP 客户端有点受限,但这不是本书的重点,所以我们可以接受。然而,我们的协程实现存在严重的局限性。让我们看看如何使我们的协程变得更加实用。
当前实现的最大缺点是没有任何内容可以跨越等待点生存——真的,什么都不行。因此,首先解决这个问题是有意义的。
让我们开始设置我们的示例。
我们将使用第八章 d-multiple-threads 示例中的“库”代码(即我们的最后版本示例),但会通过添加一个更短、更简单的示例来修改 main.rs 文件。
让我们设置本章中要迭代和改进的基础示例。
设置基础示例
注意
你可以在本书的 GitHub 仓库中找到这个示例,路径是 ch09/a-coroutines-variables。
执行以下步骤:
-
创建一个名为
a-coroutines-variables的文件夹。 -
进入该文件夹并运行
cargo init。 -
删除默认的
main.rs文件,并将ch08/d-multiple-threads/src文件夹中的所有内容复制到ch09/a-coroutines-variables/src文件夹中。 -
打开
Cargo.toml,在dependencies部分添加对mio的依赖:mio = {version = "0.8", features = ["net", "os-poll"]}
你现在应该有一个如下结构的文件夹:
src
|-- runtime
|-- executor.rs
|-- reactor.rs
|-- future.rs
|-- http.rs
|-- main.rs
|-- runtime.rs
我们将最后一次使用 corofy 来为我们生成样板状态机。将以下代码复制到 main.rs 中:
ch09/a-coroutines-variables/src/main.rs
mod future;
mod http;
mod runtime;
use crate::http::Http;
use future::{Future, PollState};
use runtime::Waker;
fn main() {
let mut executor = runtime::init();
executor.block_on(async_main());
}
coroutine fn async_main() {
println!("Program starting");
let txt = Http::get("/600/HelloAsyncAwait").wait;
println!("{txt}");
let txt = Http::get("/400/HelloAsyncAwait").wait;
println!("{txt}");
}
这次,我们采用快捷方式,直接将 corofied 文件写回 main.rs,因为我们已经多次对比了这些文件。假设你在基础文件夹 a-coroutine-variables 中,运行以下命令:
corofy ./src/main.rs ./src/main.rs
最后一步是修复 corofy 对 Waker 的不了解问题。你可以通过运行 cargo check 让编译器引导你进行更改,不过为了帮助你更快找到问题,有三处小改动需要做(注意,行号是通过重写我们之前写的代码时报告的行号):
- 64:
fn poll(&mut self, waker: &Waker) - 82:
match f1.poll(waker) - 102:
match f2.poll(waker)
现在,通过运行 cargo run 检查一切是否按预期工作。
你应该看到以下输出(为了节省空间,输出已被简化):
Program starting
FIRST POLL - START OPERATION
main: 1 pending tasks. Sleep until notified.
HTTP/1.1 200 OK
[==== ABBREVIATED ====]
HelloAsyncAwait
main: All tasks are finished
注意
记住,我们需要在终端窗口中运行 delayserver,以便能够对我们的 HTTP GET 请求进行响应。有关更多信息,请参见“技术要求”部分。
现在我们已经处理好样板代码,是时候开始进行我们讨论过的改进了。
改进基础示例
我们希望改进我们的状态机,以便它能够在等待点之间保存变量。为此,我们需要将这些变量存储在某个地方,并在进入状态机中的每个状态时恢复所需的变量。
提示
假装这些重写是由 corofy(或编译器)完成的。虽然 corofy 不能进行这些重写,但这个过程也是可以自动化的。
我们的协程/等待程序看起来像这样:
coroutine fn async_main() {
println!("Program starting");
let txt = Http::get("/600/HelloAsyncAwait").wait;
println!("{txt}");
let txt = Http::get("/400/HelloAsyncAwait").wait;
println!("{txt}");
}
我们希望将其更改为如下所示:
coroutine fn async_main() {
let mut counter = 0;
println!("Program starting");
let txt = http::Http::get("/600/HelloAsyncAwait").wait;
println!("{txt}");
counter += 1;
let txt = http::Http::get("/400/HelloAsyncAwait").wait;
println!("{txt}");
counter += 1;
println!("Received {} responses.", counter);
}
在这个版本中,我们简单地在 async_main 函数的顶部创建一个 counter 变量,并在每次从服务器接收到响应时递增计数器。最后,我们打印出接收到的响应数量。
注意
为了简洁起见,接下来的部分中我不会展示完整的代码库,而只会展示相关的新增内容和更改。记住,你可以随时参考本书 GitHub 仓库中的相同示例。
我们实现这一功能的方法是向 Coroutine0 结构体添加一个名为 stack 的新字段:
ch09/a-coroutines-variables/src/main.rs
struct Coroutine0 {
stack: Stack0,
state: State0,
}
stack 字段包含我们还需要定义的 Stack0 结构体:
ch09/a-coroutines-variables/src/main.rs
#[derive(Default)]
struct Stack0 {
counter: Option<usize>,
}
由于我们只有一个变量,因此这个结构体只包含一个字段。该字段类型为 Option<usize>。我们还为这个结构体派生了 Default 特性,以便轻松进行初始化。
注意
Rust 中通过 async/await 创建的 Future 以更高效的方式存储数据。在我们的示例中,我们将每个变量存储在单独的结构体中,因为这样更容易理解,但这也意味着需要存储的变量越多,协程需要的空间也就越大。它会随着需要在状态变化之间存储/恢复的变量数量线性增长,可能会需要大量的数据。例如,如果我们有 100 个状态变化,每个状态变化需要存储一个 i64 大小的变量,则会需要占用 100 * 8B = 800 字节的内存。
Rust 通过将协程实现为枚举(enum)来优化这一点,其中每个状态仅包含它在下一个状态中需要恢复的数据。这样,协程的大小不取决于变量的总数,只取决于需要保存/恢复的最大状态所需的大小。在前面的示例中,大小会减少到 8 个字节,因为每次状态变化所需的最大空间只需要容纳一个 i64 大小的变量。同一个空间会被重复利用。
这种设计允许进行这种优化,这也是无栈协程在内存效率方面优于有栈协程的一个优点。
接下来我们需要更改的是 Coroutine0 上的 new 方法:
ch09/a-coroutines-variables/src/main.rs
impl Coroutine0 {
fn new() -> Self {
Self {
state: State0::Start,
stack: Stack0::default(),
}
}
}
stack 的默认值对我们来说并不重要,因为我们无论如何都会覆盖它。
接下来的几步是我们最感兴趣的。在 Coroutine0 的 Future 实现中,我们假装 corofy 为我们添加了以下代码,用于初始化、存储和恢复堆栈变量。让我们来看看第一次调用 poll 时发生了什么:
ch09/a-coroutines-variables/src/main.rs
State0::Start => {
// initialize stack (hoist variables)
self.stack.counter = Some(0);
// ---- Code you actually wrote ----
println!("Program starting");
// ---------------------------------
let fut1 = Box::new(http::Http::get("/600/HelloAsyncAwait"));
self.state = State0::Wait1(fut1);
// save stack
}
这里有一些重要的更改,我已经将它们标记出来。让我们逐一分析它们:
在 Start 状态中,我们做的第一件事是在顶部添加一个段,用于初始化堆栈。我们做的其中一件事是将相关代码段(在这种情况下,是第一次等待点之前的代码段)的所有变量声明提升到函数顶部。
在我们的示例中,我们还将变量初始化为它们的初始值,在这种情况下是 0。
我们还添加了一个注释,说明我们应该保存堆栈,但由于第一次等待点之前发生的只是 counter 的初始化,这里没有什么需要存储的。
让我们来看看第一次等待点之后发生了什么:
ch09/a-coroutines-variables/src/main.rs
State0::Wait1(ref mut f1) => {
match f1.poll(waker) {
PollState::Ready(txt) => {
// Restore stack
let mut counter = self.stack.counter.take().unwrap();
// ---- Code you actually wrote ----
println!("{txt}");
counter += 1;
// ---------------------------------
let fut2 = Box::new(http::Http::get("/400/HelloAsyncAwait"));
self.state = State0::Wait2(fut2);
// save stack
self.stack.counter = Some(counter);
}
PollState::NotReady => break PollState::NotReady,
}
}
这里的更改也很有趣。我已经标记了我们需要做的更改。
我们做的第一件事是通过获取 counter 的所有权来恢复堆栈(take() 将当前存储在 self.stack.counter 中的值替换为 None)。然后将其写入我们在代码段中使用的同名变量(counter)中。在这种情况下,获取所有权并将值放回去并不是问题,它模仿了我们在协程/等待示例中编写的代码。
接下来的更改只是将第一次等待点之后的所有代码段粘贴进来。在这种情况下,唯一的更改是将 counter 变量增加 1。
最后,我们将堆栈状态保存回来,以便在等待点之间保持其更新的状态。
注意
在第五章中,我们看到了需要在纤程中存储/恢复寄存器状态。由于第五章展示了有栈协程实现的示例,因此我们不必关心堆栈状态,因为所有需要的状态都存储在我们创建的堆栈中。
由于我们的协程是无栈的,我们不会为每个协程存储整个调用堆栈,但我们确实需要存储/恢复跨等待点使用的堆栈部分。无栈协程仍需要保存一些堆栈中的信息,正如我们在这里所做的那样。
当我们进入 State0::Wait2 状态时,我们以相同的方式开始:
ch09/a-coroutines-variables/src/main.rs
State0::Wait2(ref mut f2) => {
match f2.poll(waker) {
PollState::Ready(txt) => {
// Restore stack
let mut counter = self.stack.counter.take().unwrap();
// ---- Code you actually wrote ----
println!("{txt}");
counter += 1;
println!("Received {} responses.", counter);
// ---------------------------------
self.state = State0::Resolved;
// Save stack (all variables set to None already)
break PollState::Ready(String::new());
}
PollState::NotReady => break PollState::NotReady,
}
}
由于我们的程序中没有更多等待点,剩下的代码会进入这个段落,并且由于我们此时已经完成了对 counter 的操作,可以简单地通过让它超出作用域来释放它。如果变量持有任何资源,这些资源也将在此释放。
至此,我们为协程赋予了跨等待点保存变量的能力。让我们通过运行 cargo run 来尝试一下。
你应该会看到以下输出(我删除了未更改的部分):
…
HelloAsyncAwait
Received 2 responses.
main: All tasks are finished
好的,我们的程序工作正常并且如预期运行。太好了!
现在,让我们来看一个需要在等待点之间存储引用的示例,因为这是使协程/等待函数表现得像“正常”函数的重要方面。
改进示例 2 – 引用
让我们为该示例的下一个版本进行一些设置:
- 创建一个名为
b-coroutines-references的新文件夹,并将a-coroutines-variables中的所有内容复制到该文件夹中。 - 可以通过更改
Cargo.toml中[package]部分的name属性来更改项目名称,使其与文件夹名称一致,但这并不是必须的。
注意
你可以在本书的 GitHub 仓库中找到此示例,路径为 ch09/b-coroutines-references。
这次,我们将学习如何通过以下协程/等待示例程序,在我们的协程中存储对变量的引用:
use std::fmt::Write;
coroutine fn async_main() {
let mut buffer = String::from("\nBUFFER:\n----\n");
let writer = &mut buffer;
println!("Program starting");
let txt = http::Http::get("/600/HelloAsyncAwait").wait;
writeln!(writer, "{txt}").unwrap();
let txt = http::Http::get("/400/HelloAsyncAwait").wait;
writeln!(writer, "{txt}").unwrap();
println!("{}", buffer);
}
在这个示例中,我们创建了一个名为 buffer 的 String 类型变量,并用一些文本进行初始化,然后获取其可变引用 &mut buffer 并存储在 writer 变量中。每次接收到响应时,我们通过 writer 中的可变引用将响应写入 buffer,并在程序结束时打印 buffer。
让我们看看如何让它工作。
首先,我们导入 fmt::Write 特性,以便使用 writeln! 宏将数据写入缓冲区。
在 main.rs 的顶部添加以下代码:
ch09/b-coroutines-references/src/main.rs
use std::fmt::Write;
接下来,我们需要更改 Stack0 结构体,以便表示在更新后的示例中我们必须在等待点之间存储的内容:
ch09/b-coroutines-references/src/main.rs
#[derive(Default)]
struct Stack0 {
buffer: Option<String>,
writer: Option<*mut String>,
}
这里需要注意的一点是,writer 不能是 Option<&mut String>,因为我们知道它会引用同一结构体中的 buffer 字段。在 Rust 中,字段引用自身(&self)的结构体称为自引用结构体,Rust 无法表示这种情况,因为自引用的生命周期是无法表达的。
解决方案是将 &mut 自引用转换为指针,并确保自己正确管理生命周期。
唯一需要更改的其他内容是 Future::poll 的实现:
ch09/b-coroutines-references/src/main.rs
State0::Start => {
// initialize stack (hoist variables)
self.stack.buffer = Some(String::from("\nBUFFER:\n----\n"));
self.stack.writer = Some(self.stack.buffer.as_mut().unwrap());
// ---- Code you actually wrote ----
println!("Program starting");
// ---------------------------------
let fut1 = Box::new(http::Http::get("/600/HelloAsyncAwait"));
self.state = State0::Wait1(fut1);
// save stack
}
这些更改看起来有点奇怪。我们更改的第一行相当直接。我们将 buffer 变量初始化为一个新的 String,就像我们在协程/等待程序的顶部那样。
然而,下一行看起来有些危险。我们将对 buffer 的 &mut 引用转换为 *mut 指针。
重要提示
是的,我知道我们可以选择另一种方式来实现这一点,因为在需要的地方我们可以随时获取对 buffer 的引用,而不是将它存储在变量中,但这是因为我们的示例非常简单。想象一下,当我们使用一个需要借用异步函数本地数据的库时,我们不得不像在这里这样手动管理生命周期,但场景要复杂得多。
self.stack.buffer.as_mut().unwrap() 返回对 buffer 字段的 &mut 引用。由于 self.stack.writer 是 Option<*mut String> 类型,引用将被强制转换为指针(意味着 Rust 根据上下文隐式地进行这种转换)。
注意
我们在这里使用 *mut String 是因为我们刻意不想使用字符串切片(&str),这通常是我们在 Rust 中引用 String 类型时得到(且想要)的结果。
接下来看看第一次等待点之后发生了什么:
ch09/b-coroutines-references/src/main.rs
State0::Wait1(ref mut f1) => {
match f1.poll(waker) {
PollState::Ready(txt) => {
// Restore stack
let writer = unsafe { &mut *self.stack.writer.take().unwrap() };
// ---- Code you actually wrote ----
writeln!(writer, "{txt}").unwrap();
// ---------------------------------
let fut2 = Box::new(http::Http::get("/400/HelloAsyncAwait"));
self.state = State0::Wait2(fut2);
// save stack
self.stack.writer = Some(writer);
}
PollState::NotReady => break PollState::NotReady,
}
}
我们所做的第一个更改是关于如何恢复堆栈。我们需要恢复 writer 变量,使其持有一个 &mut String 类型的引用,指向我们的 buffer。为此,我们必须编写一些不安全的代码来解引用我们的指针,并让我们获取对 buffer 的 &mut 引用。
注意
将引用转换为指针是安全的,不安全部分是解引用指针。
接下来,我们添加了写入响应的代码行。我们可以像在协程/等待函数中那样保持不变。
最后,我们将堆栈状态保存回来,因为我们需要两个变量在等待点之间继续生存。
注意
我们不需要获取存储在 writer 字段中的指针的所有权就可以使用它,因为我们可以简单地复制它,但为了保持一致性,我们像在第一个示例中那样获取它的所有权。这也有意义,因为如果不需要在下一个等待点保存指针,我们可以通过不存储它来让它超出作用域。
最后一部分是在我们到达 Wait2 并且 future 返回 PollState::Ready 时:
State0::Wait2(ref mut f2) => {
match f2.poll(waker) {
PollState::Ready(txt) => {
// Restore stack
let buffer = self.stack.buffer.as_ref().unwrap();
let writer = unsafe { &mut *self.stack.writer.take().unwrap() };
// ---- Code you actually wrote ----
writeln!(writer, "{txt}").unwrap();
println!("{}", buffer);
// ---------------------------------
self.state = State0::Resolved;
// Save stack / free resources
let _ = self.stack.buffer.take();
break PollState::Ready(String::new());
}
PollState::NotReady => break PollState::NotReady,
}
}
在这个代码段中,我们恢复了两个变量,因为我们通过 writer 变量写入了最后的响应,然后将存储在 buffer 中的所有内容打印到终端。
我想指出的是,println!("{}", buffer); 这一行在原始协程/等待示例中接收一个引用,即使看起来我们传递的是一个拥有的 String。因此,恢复 buffer 为 &String 类型是合理的,而不是拥有的版本。转移所有权还会使我们在 writer 变量中的指针失效。
最后,我们会丢弃不再需要的数据。由于我们在恢复堆栈时获取了其所有权,因此 self.stack.writer 字段已经被设置为 None,但是我们也需要获取 self.stack.buffer 中存储的 String 类型的所有权,以便在该作用域结束时将其丢弃。如果不这样做,我们会一直持有分配给 String 的内存,直到整个协程被丢弃(这可能会是很久以后)。
现在,我们已经完成了所有更改。如果之前我们所做的重写已经在 corofy 中实现,那么理论上我们的协程/等待实现可以支持更复杂的用例。
让我们通过运行 cargo run 来看看程序执行时会发生什么:
Program starting
FIRST POLL - START OPERATION
main: 1 pending tasks. Sleep until notified.
FIRST POLL - START OPERATION
main: 1 pending tasks. Sleep until notified.
BUFFER:
----
HTTP/1.1 200 OK
content-length: 15
connection: close
content-type: text/plain; charset=utf-8
date: Thu, 30 Nov 2023 22:48:11 GMT
HelloAsyncAwait
HTTP/1.1 200 OK
content-length: 15
connection: close
content-type: text/plain; charset=utf-8
date: Thu, 30 Nov 2023 22:48:11 GMT
HelloAsyncAwait
main: All tasks are finished
呼,太好了。所有那些危险的不安全代码运行得很好,不是吗?做得好!在结束之前,让我们做一个小小的改进。
改进示例 3 – 这……不好……
假装你还没读到这个章节的标题,并享受我们之前的示例能够编译并显示正确结果的事实。
我认为我们的协程实现已经非常好了,现在我们可以来看看一些优化。我特别想对执行器做一个优化。
在深入之前,让我们进行一些设置:
- 创建一个名为
c-coroutines-problem的新文件夹,并将b-coroutines-references中的所有内容复制到该文件夹中。 - 可以通过更改
Cargo.toml中[package]部分的name属性来更改项目名称,使其与文件夹名称一致,但这不是必须的。
提示
你可以在本书的 GitHub 仓库中找到此示例,路径为 ch09/c-coroutines-problem。
一切就绪后,回到我们的优化。事实上,现实生活中我们的运行时环境处理的工作负载的新见解表明,大多数 future 在第一次 poll 时会返回 Ready。因此,理论上,我们只需在 block_on 中对接收到的 future 进行一次 poll,并且大多数情况下它会立即解析。
让我们导航到 src/runtime/executor.rs,看看如何通过添加几行代码来利用这一点。
如果你导航到 Executor::block_on 函数,会看到我们首先对 future 进行 spawn,然后再 poll。生成 future 意味着我们在堆中为它分配空间,并将指向其位置的指针存储在一个 HashMap 变量中。
由于 future 很可能在第一次 poll 时就返回 Ready,这是不必要的工作,可以避免。让我们在 block_on 函数的开始处添加这一小优化来利用这一点:
pub fn block_on<F>(&mut self, future: F)
where
F: Future<Output = String> + 'static,
{
// ===== OPTIMIZATION, ASSUME READY
let waker = self.get_waker(usize::MAX);
let mut future = future;
match future.poll(&waker) {
PollState::NotReady => (),
PollState::Ready(_) => return,
}
// ===== END
spawn(future);
loop {
…
现在,我们直接对 future 进行 poll,如果 future 在第一次 poll 时解析,我们直接返回,因为我们已经完成任务。这样,只有当我们需要等待时才会对 future 进行 spawn。
是的,这假设我们的 ID 永远不会达到 usize::MAX,但让我们假装这只是一个概念证明。如果 future 被生成并再次 poll,我们的 Waker 会被丢弃并替换为新的,因此这不应该是个问题。
让我们尝试运行程序看看会得到什么结果:
Program starting
FIRST POLL - START OPERATION
main: 1 pending tasks. Sleep until notified.
FIRST POLL - START OPERATION
main: 1 pending tasks. Sleep until notified.
/400/HelloAsyn
free(): double free detected in tcache 2
Aborted
等等,什么?!?
这可不是什么好事!嗯,可能是 Linux 内核的一个 bug,所以让我们在 Windows 上试一下:
…
error: process didn't exit successfully: `target\release\c-coroutines-
problem.exe` (exit code: 0xc0000374, STATUS_HEAP_CORRUPTION)
这听起来更糟糕!!发生了什么?
让我们仔细看看,当我们进行这一小优化时,我们的异步系统到底发生了什么。
发现自引用结构体
发生的情况是,我们创建了一个自引用结构体,将其初始化为指向自身的指针,然后移动了它。让我们更仔细地看看:
首先,我们在 block_on 中接收了一个 future 对象作为参数。这没什么问题,因为此时 future 还不是自引用的,所以我们可以随意移动它(这也是为什么在使用 async/await 时,在 poll 之前移动 future 完全没有问题)。
然后,我们对 future 进行了第一次 poll。我们所做的优化导致了一个关键的变化。第一次 poll 时,future 位于栈上(在 block_on 函数的栈帧内部)。
当我们第一次对 future 进行 poll 时,我们将变量初始化为其初始状态。此时,我们的 writer 变量获取了指向 buffer 变量的指针(存储在协程的一部分中),并在此时使其成为自引用。
第一次 poll future 时,它返回了 NotReady。
由于它返回了 NotReady,我们对 future 进行了 spawn,这将其移动到了执行器中 tasks 集合中的 HashMap<usize, Box<dyn Future<Output = String>>> 类型中。此时,future 被放入 Box,从而被移动到了堆上。
下次我们对 future 进行 poll 时,我们通过解引用 writer 变量持有的指针来恢复堆栈。然而,这里有一个大问题:指针现在指向的是第一次 poll 时 future 所在的栈上的旧位置。
这显然不会有好的结果,实际上也确实如此。
你已经亲眼看到了自引用结构体的问题,这个问题如何应用于 futures,以及为什么我们需要一些方法来防止这种情况的发生。
自引用结构体是指一个结构体获取对 self 的引用并将其存储在字段中。这里的术语“引用”有点不够精确,因为在 Rust 中没有办法获取对 self 的引用并将该引用存储在 self 中。要在安全的 Rust 中实现这一点,你必须将引用转换为指针(记住引用只是编程语言中特殊意义的指针)。
注意
当我们在本章中创建可视化图时,我们会忽略填充,即使我们知道结构体之间可能有一些填充空间,就像我们在第 4 章讨论的那样。
当这个值被移动到内存中的另一个位置时,指针不会更新,指向的是“旧”位置。
如果我们查看从栈的一个位置移动到另一个位置的过程,它看起来是这样的:
在上图中,我们可以看到左侧的内存地址以及与之相对应的栈的表示。由于值移动时指针没有更新,现在它指向的是旧位置,这会引发严重的问题。
注意
很难检测到这些问题,并且创建一个简单的示例来说明这种移动会导致严重问题是非常困难的。原因在于,即使我们移动了所有内容,旧的值并不会立即被清零或覆盖。通常,它们仍然存在,因此解引用前面的指针可能仍会产生正确的值。问题只会在你更改新位置的 x 值,并期望 y 指向它时出现。在这种情况下,解引用 y 仍然会产生有效值,但这个值是错误的。
经过优化的构建通常会优化掉不必要的移动,这使得检测 bug 更加困难,因为程序的大部分看起来都能正常运行,即使其中包含严重的 bug。
什么是移动?
对于许多来自 C#、JavaScript 等垃圾回收语言的程序员来说,Rust 中的“移动”是一个不熟悉的概念,而对于 C 和 C++ 程序员来说,它也与他们习惯的方式不同。在 Rust 中,移动的定义与所有权系统紧密相关。
移动意味着转移所有权。在 Rust 中,移动是传递值的默认方式,并且每当你更改对象的所有权时都会发生。如果你移动的对象只包含复制类型(实现了 Copy 特性的类型),那么移动就像将数据复制到栈上的新位置一样简单。
对于非复制类型,移动会像第一个示例中那样复制它所包含的所有复制类型,但它还会复制指向资源(如堆分配)的指针。从移动对象中移动走的对象对我们来说不可访问(例如,如果你尝试使用被移动的对象,编译会失败并告知你对象已被移动),因此在任何时间点上,只有一个所有者拥有该分配的所有权。
与克隆相比,移动不会重新创建任何资源并进行克隆。
还有一个重要的事情是,编译器确保 drop 永远不会在被移动的对象上调用,因此唯一可以释放资源的就是新对象,即那些接管了所有权的对象。
图 9.2 提供了 move、clone 和 copy 之间区别的简化可视化概述(在这个可视化中,我们排除了结构体的任何内部填充)。这里我们假设我们有一个包含两个字段的结构体——一个是复制类型 a,它是 i64 类型,另一个是非复制类型 b,它是 Vec<u8> 类型:
移动在很多方面类似于对结构体中位于栈上的所有内容进行深拷贝。当你有一个指向 self 的指针时(比如在自引用结构体中),这会导致问题,因为 self 在移动后会开始于一个新的内存地址,但指向 self 的指针不会随之调整以反映这种变化。
在大多数时候,当你编写 Rust 程序时,你可能不会过多地考虑移动,因为它是语言的一部分,你并不需要显式地使用它,但知道它是什么以及它的作用仍然很重要。
现在,我们已经很好地理解了这个问题是什么,让我们更深入地了解 Rust 如何通过其类型系统来解决这一问题,以防止我们移动那些依赖于内存中稳定位置才能正确运行的结构体。
Rust 中的 Pinning
以下图表展示了一个稍微复杂的自引用结构体,以便帮助我们理解:
从非常高的层面来看,Pinning 使得我们可以依赖具有稳定内存地址的数据,因为它禁止了任何可能移动该数据的操作:
Pinning 的概念相对简单。复杂的部分在于它如何在语言中实现以及如何使用。
Pinning 的理论
Pinning 是 Rust 标准库的一部分,由两个部分组成:类型 Pin 和标记特征 Unpin。Pinning 仅是一个语言构造。没有一种特殊的内存位置或区域可以将值移动到该位置后使其被固定。也没有用于请求操作系统确保值在内存中保持不变的系统调用。Pinning 只是类型系统的一部分,旨在防止我们移动某个值。
Pin 并没有消除对 unsafe 的需求——它只是为 unsafe 的使用者提供了一种保证,即只要用户使用的是安全的 Rust 代码,值在内存中的位置是稳定的。这使得我们可以编写安全的自引用类型。它确保了所有可能导致问题的操作都必须使用 unsafe。
回到我们的协程示例,如果我们要移动结构体,我们必须编写 unsafe 的 Rust 代码。这就是 Rust 保证其安全性的方式。如果你知道你创建的 future 永远不会引用 self,你可以选择使用 unsafe 来移动它,但如果你做错了,责任就会落在你身上。
在深入了解 Pinning 之前,我们需要定义几个在后续中将会用到的术语。
定义
以下是我们必须理解的定义:
-
Pin<T>是整个机制的核心类型。你可以在 Rust 标准库的std::pin模块中找到它。Pin包装了实现Deref特征的类型,这在实际操作中意味着它可以包装引用和智能指针。 -
Unpin 是一个标记特征。如果一个类型实现了
Unpin,那么 pinning 对该类型将没有任何效果。你没有看错——没有任何效果。类型仍然会被Pin包装,但你可以轻松地将它取出来。 有趣的是,几乎所有类型默认实现了Unpin,而如果你想手动标记某个类型为!Unpin,你必须在你的类型中添加一个名为PhantomPinned的标记特征。让类型T实现!Unpin是使Pin<&mut T>起作用的唯一方法。 -
Pinning 一个
!Unpin类型将保证该值在内存中的位置保持不变,直到它被释放为止,只要你保持在安全的 Rust 代码中。 -
Pin Projections 是作用于被固定类型的辅助方法。由于它们只对
self的被固定实例有效,因此语法通常会显得有些奇怪。例如,它们通常看起来像fn foo(self: Pin<&mut self>)。 -
结构化 Pinning 与 Pin Projections 相关,如果你有
Pin<&mut T>,而T有一个可以自由移动的字段a和一个不能移动的字段b,你可以执行以下操作:- 为
a编写一个 Pin Projection,签名为fn a(self: Pin<&mut self>) -> &A。在这种情况下,我们说a的 pinning 是非结构化的。 - 为
b编写一个 projection,签名为fn b(self: Pin<&mut self>) -> Pin<&mut B>,在这种情况下,我们说b的 pinning 是结构化的,因为在结构体T被固定时,b也被固定。
- 为
了解了最重要的定义之后,让我们来看一下固定值的两种方式。
固定到堆上
注意
这里展示的小代码片段可以在本书的 GitHub 仓库中的 ch09/d-pin 文件夹中找到。不同的示例被实现为不同的方法,你可以在主函数中对它们进行注释/取消注释。
让我们编写一个小示例来说明固定值的不同方式:
use std::{marker::PhantomPinned, pin::Pin};
#[derive(Default)]
struct Foo {
a: MaybeSelfRef,
b: String,
}
我们希望能够使用 MaybeSelfRef::default() 创建一个实例,该实例可以按我们希望的方式随意移动,但在某个时刻将其初始化为引用自身的状态,此时移动它将会导致问题。
这非常类似于前面示例中所见的那些在第一次 poll 之前不是自引用的 futures。让我们为 MaybeSelfRef 编写实现块,并查看代码:
impl MaybeSelfRef {
fn init(self: Pin<&mut Self>) {
unsafe {
let Self { a, b, .. } = self.get_unchecked_mut();
*b = Some(a);
}
}
fn b(self: Pin<&mut Self>) -> Option<&mut usize> {
unsafe { self.get_unchecked_mut().b.map(|b| &mut *b) }
}
}
如你所见,MaybeSelfRef 仅在调用 init 之后才成为自引用类型。
我们还定义了一个方法,它将存储在 b 中的指针转换为 Option<&mut usize>,即对 a 的可变引用。
需要注意的一点是,我们的两个函数都需要使用 unsafe。在没有 Pin 的情况下,唯一需要 unsafe 的方法是 b,因为我们在那里解引用了指针。获取固定值的可变引用始终需要 unsafe,因为此时没有任何机制能防止我们移动该被固定的值。
固定到堆上通常通过固定一个 Box 实现。Box 甚至提供了一个方便的方法,让我们获取 Pin<Box<...>>。让我们看一个简短的例子:
fn main() {
let mut x = Box::pin(MaybeSelfRef::default());
x.as_mut().init();
println!("{}", x.as_ref().a);
*x.as_mut().b().unwrap() = 2;
println!("{}", x.as_ref().a);
}
在这里,我们将 MaybeSelfRef 固定到堆上并初始化它。然后,我们打印出 a 的值,通过自引用在 b 中修改数据,并将其设置为 2。如果查看输出,你会发现一切如预期:
Finished dev [unoptimized + debuginfo] target(s) in 0.56s
Running `target\debug\x-pin-experiments.exe`
0
2
被固定的值永远不会移动,作为 MaybeSelfRef 的用户,我们不必编写任何 unsafe 代码。Rust 可以保证在安全的 Rust 中,我们永远不会获得 MaybeSelfRef 的可变引用,因为 Box 拥有它。
堆上固定是安全的,这并不奇怪,因为与栈不同,堆分配在整个程序运行期间都是稳定的,无论我们在哪里创建它。
重要
这通常是 Rust 中固定值的首选方式。栈上固定适用于那些你没有堆可用或不能接受额外分配开销的情况。
让我们也来看看栈上固定。
固定到栈上
将值固定到栈上可能比较困难。在第 5 章中,我们了解了栈是如何工作的,并知道随着值被压入和弹出,栈会增长和缩小。因此,如果我们要将值固定到栈上,就必须在栈的较高位置进行固定。这意味着如果我们在函数调用中将某个值固定到栈上,就不能期望在返回该函数时该值仍然保持固定。这是不可能的。
将值固定到栈上之所以困难,是因为我们通过获取 &mut T 来进行固定,而我们必须保证在该值被销毁之前不会移动它。如果我们不小心,很容易犯错。Rust 在这里无法帮助我们,因此我们需要自己确保这一保证。这就是为什么栈上固定是 unsafe 的原因。
让我们看一下使用栈固定的相同示例:
// ch09/d-pin/src/main.rs
fn stack_pinning_manual() {
let mut x = MaybeSelfRef::default();
let mut x = unsafe { Pin::new_unchecked(&mut x) };
x.as_mut().init();
println!("{}", x.as_ref().a);
*x.as_mut().b().unwrap() = 2;
println!("{}", x.as_ref().a);
}
显而易见的区别在于,将值固定到栈上是 unsafe 的,因此现在我们既需要 MaybeSelfRef 的使用者也需要实现者编写 unsafe 代码。
如果我们使用 cargo run 运行该示例,输出将与第一个示例相同:
Finished dev [unoptimized + debuginfo] target(s) in 0.58s
Running `target\debug\x-pin-experiments.exe`
0
2
栈固定需要 unsafe 的原因在于很容易不小心破坏 Pin 本应提供的保证。让我们来看一个示例:
// ch09/d-pin/src/main.rs
use std::mem::swap;
fn stack_pinning_manual_problem() {
let mut x = MaybeSelfRef::default();
let mut y = MaybeSelfRef::default();
{
let mut x = unsafe { Pin::new_unchecked(&mut x) };
x.as_mut().init();
*x.as_mut().b().unwrap() = 2;
}
swap(&mut x, &mut y);
println!(
"x: {{
+----->a: {:p},
| b: {:?},
| }}
|
| y: {{
| a: {:p},
+-----|b: {:?},
}}",
&x.a,
x.b,
&y.a,
y.b,
);
}
在这个示例中,我们创建了两个 MaybeSelfRef 实例,分别叫 x 和 y。然后,我们创建了一个作用域,在该作用域中将 x 固定,并通过解引用 b 中的自引用将 x.a 的值设置为 2,和之前一样。
现在,当我们退出作用域时,x 不再是固定的,这意味着我们可以在不需要 unsafe 的情况下获取其可变引用。
由于这是安全的 Rust,我们可以自由操作,因此我们交换了 x 和 y。
输出中打印了两个结构体中字段 a 的指针地址,以及字段 b 中存储的指针的值。
查看输出时,我们应该立即看到问题:
Finished dev [unoptimized + debuginfo] target(s) in 0.58s
Running `target\debug\x-pin-experiments.exe`
x: {
+----->a: 0xe45fcff558,
| b: None,
| }
|
| y: {
| a: 0xe45fcff570,
+-----|b: Some(0xe45fcff558),
}
虽然指针的值在每次运行时可能会有所不同,但显而易见的是,y 不再持有一个指向自身的指针。
现在,它指向了 x 的某个地方。这非常糟糕,会导致 Rust 本应防止的内存安全问题。
注意
正因为如此,标准库中有一个 pin! 宏,帮助我们实现安全的栈固定。这个宏在底层使用了 unsafe,但使得我们无法再访问被固定的值。
既然我们已经看到了栈固定的所有陷阱,我的明确建议是除非有必要,否则避免使用栈固定。如果必须使用它,那么使用 pin! 宏,以避免我们在这里描述的问题。
提示
在本书的 GitHub 仓库中,你可以在 ch09/d-pin/src/main.rs 文件中找到一个名为 stack_pinning_macro() 的函数。该函数展示了使用 Rust 的 pin! 宏的前述示例。
Pin投影和结构化固定
在结束固定的主题之前,我们快速解释一下什么是 Pin 投影和结构化固定。它们听起来都很复杂,但实际上非常简单。下图展示了这些术语之间的关系:
结构化固定意味着如果一个结构体被固定,那么它的字段也会被固定。我们通过 Pin 投影(Pin Projection) 来实现这一点,如以下代码示例所示。
如果我们继续之前的例子,创建一个名为 Foo 的结构体,包含 MaybeSelfRef 类型的字段 a 和 String 类型的字段 b,我们可以编写两个投影方法,返回固定版本的 a 和普通可变引用的 b:
#[derive(Default)]
struct Foo {
a: MaybeSelfRef,
b: String,
}
impl Foo {
fn a(self: Pin<&mut Self>) -> Pin<&mut MaybeSelfRef> {
unsafe {
self.map_unchecked_mut(|s| &mut s.a)
}
}
fn b(self: Pin<&mut Self>) -> &mut String {
unsafe {
&mut self.get_unchecked_mut().b
}
}
}
请注意,这些方法只能在 Foo 被固定时调用。你无法对 Foo 的普通实例调用这些方法。
Pin 投影有一些细微之处需要注意,不过官方文档中对它们有非常详细的说明(doc.rust-lang.org/stable/std/…),所以我建议你去那里了解更多关于编写投影时需要注意的事项。
注意: 由于手动创建 Pin 投影容易出错,有一个非常流行的用于生成 Pin 投影的库,叫做 pin_project(docs.rs/pin-project…)。如果你需要自己编写 Pin 投影,值得去了解一下这个库。
至此,我们基本上覆盖了 Rust 异步编程中所有的高级主题。然而,在进入本书的最后一章之前,让我们看看固定如何防止我们在上一个协程示例的迭代中犯下的大错误。
改进示例 4:固定(Pinning)来救场
幸运的是,我们需要做的更改非常小,但在继续之前,让我们先创建一个新文件夹,并将上一个示例中的所有内容复制到新文件夹中:
- 复制整个
c-coroutines-problem文件夹,并将新的副本命名为e-coroutines-pin。 - 打开
Cargo.toml,将包的名称重命名为e-coroutines-pin。
提示: 你可以在本书的 GitHub 仓库中找到我们将在这里讨论的示例代码,位于 ch09/e-coroutines-pin 文件夹中。
现在,我们已经设置了一个新文件夹,让我们开始进行必要的更改。最合理的起点是更改 future.rs 中的 Future 定义。
修改 future.rs
首先,我们需要在文件顶部引入 Pin 类型:
use std::pin::Pin;
唯一需要更改的地方是在 Future trait 中对 poll 方法的定义:
fn poll(self: Pin<&mut Self>, waker: &Waker) -> PollState<Self::Output>;
这样改动几乎完成了主要工作,但由于 poll 方法中的 self 现在是 Pin<&mut Self>,这对调用 poll 的每个地方都有影响,我们需要相应地进行修正。
让我们从 http.rs 文件开始。
修改 http.rs
首先,我们需要在文件顶部引入 Pin 类型。文件开头应如下所示:
use crate::{future::PollState, runtime::{self, reactor, Waker}, Future};
use mio::Interest;
use std::{io::{ErrorKind, Read, Write}, pin::Pin};
接下来,我们需要在 HttpGetFuture 的 Future 实现中进行一些更改。首先修改 poll 方法的参数:
fn poll(mut self: Pin<&mut Self>, waker: &Waker) -> PollState<Self::Output>
由于 self 现在是 Pin<&mut Self>,因此为了让借用检查器满意,我们需要对代码中的一些小地方进行改动。让我们从代码的顶部开始:
let id = self.id;
if self.stream.is_none() {
println!("FIRST POLL - START OPERATION");
self.write_request();
let stream = (&mut self).stream.as_mut().unwrap();
runtime::reactor().register(stream, Interest::READABLE, id);
runtime::reactor().set_waker(waker, self.id);
}
将 id 赋值给一个局部变量的原因是借用检查器会在我们尝试同时将 &mut self 和 &self 作为参数传递给 register/deregister 函数时给我们带来一些小麻烦,因此我们将 id 分配给一个变量,问题就解决了。
接下来,还有两行需要更改,就是将内部缓冲区转换为 String 类型,以及在与反应器解除注册时:
let s = String::from_utf8_lossy(&self.buffer).to_string();
runtime::reactor().deregister(self.stream.as_mut().unwrap(), id);
break PollState::Ready(s);
重要提示: 请注意,这个 Future 是 Unpin 的。HttpGetFuture 没有任何特性使其无法安全地移动,实际上,对于大多数这样的 Future 来说,情况也是如此。只有那些由 async/await 创建的 Future 才具有自引用的特性。因此这里不需要任何 unsafe 代码。
接下来,让我们修改 main.rs,因为我们在这里也有一些重要的更改要做。
修改 main.rs
让我们从顶部开始,确保导入正确的库:
mod future;
mod http;
mod runtime;
use future::{Future, PollState};
use runtime::Waker;
use std::{fmt::Write, marker::PhantomPinned, pin::Pin};
这次我们需要 PhantomPinned 标记和 Pin。
接下来需要修改的是 State0 枚举。我们在状态之间保存的 Future 现在都是固定的(Pinned):
Wait1(Pin<Box<dyn Future<Output = String>>>),
Wait2(Pin<Box<dyn Future<Output = String>>>),
接下来是一个重要的更改。我们需要使协程无法移动(!Unpin),一旦它们被固定,就不能再移动。我们可以通过向 Coroutine0 结构体添加一个标记特征来做到这一点:
struct Coroutine0 {
stack: Stack0,
state: State0,
_pin: PhantomPinned,
}
我们还需要在 new 方法中添加 PhantomPinned 标记:
impl Coroutine0 {
fn new() -> Self {
Self {
state: State0::Start,
stack: Stack0::default(),
_pin: PhantomPinned,
}
}
}
最后需要更改的是 poll 方法。让我们先从函数签名开始:
fn poll(self: Pin<&mut Self>, waker: &Waker) -> PollState<Self::Output>
我找到的最简单的方法是,在函数的最顶部定义一个名为 this 的新变量,替代函数体中所有使用的 self。
由于这种更改非常简单,我不一一列举每一行代码,而是在函数体中将 self 改为 this:
let this = unsafe { self.get_unchecked_mut() };
loop {
match this.state {
State0::Start => {
// 初始化堆栈(提升变量声明 - 还没有堆栈)
this.stack.buffer = Some(String::from("\nBUFFER:\n----\n"));
this.stack.writer = Some(this.stack.buffer.as_mut().unwrap());
// ---- 你实际编写的代码 ----
println!("Program starting");
...
这里重要的一行是 let this = unsafe { self.get_unchecked_mut() };。由于我们之前添加了标记特征使被固定的值是 !Unpin,因此访问固定值是 unsafe 的。
获取固定值是 unsafe 的,因为 Rust 无法保证我们不会移动这个固定值。这样做的好处是,如果以后遇到类似问题,我们可以搜索使用 unsafe 的位置,问题肯定出在那里。
接下来,我们需要将保存在等待状态中的 Future 固定下来。我们可以通过调用 Box::pin 而不是 Box::new 来完成:
let fut1 = Box::pin(http::Http::get("/600/HelloAsyncAwait"));
let fut2 = Box::pin(http::Http::get("/400/HelloAsyncAwait"));
main.rs 中最后需要更改的地方是在轮询子 Future 的位置,因为我们现在需要通过 Pin 类型来获取可变引用:
match f1.as_mut().poll(waker)
match f2.as_mut().poll(waker)
请注意,我们在这里不需要使用 unsafe,因为这些 Future 是 !Unpin 的。
最后,我们需要更改一些 executor.rs 中的代码。让我们前往那里作为我们的最后一步。
修改 executor.rs
首先,我们要确保依赖项是正确的。这里唯一需要更改的是从标准库中添加 Pin:
ch09/e-coroutines-pin/src/runtime/executor.rs
...
thread::{self, Thread}, pin::Pin,
};
接下来我们要修改的是 Task 类型别名,使其现在引用 Pin<Box<…>>:
type Task = Pin<Box<dyn Future<Output = String>>>;
接下来,我们还要修改 spawn 函数中的一行代码,需要将 Futures 固定到堆上:
e.tasks.borrow_mut().insert(id, Box::pin(future));
如果我们现在尝试运行示例,代码将无法编译,并给出以下错误:
error[E0599]: no method named `poll` found for struct `Pin<Box<dyn future::Future<Output = String>>>` in the current scope
--> src\runtime\executor.rs:89:30
它不允许我们直接对 Future 进行轮询,因为 poll 只能对 Pin<&mut Self> 类型调用,而不再是 &mut self。
因此,我们必须在尝试轮询之前决定是将值固定到堆上还是栈上。在我们的例子中,我们的整个执行器是通过对 Futures 进行堆分配来工作的,所以唯一合理的方法就是将它们固定到堆上。
让我们完全去掉之前的优化,并更改一行代码,使执行器重新正常工作:
ch09/e-coroutines-pin/src/runtime/executor.rs
match future.as_mut().poll(&waker) {
如果现在通过运行 cargo run 再次尝试运行程序,你应该能看到预期的输出,而不再需要担心协程生成的 Future 被移动(输出略有缩减):
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running `target\debug\e-coroutines-pin.exe`
Program starting
FIRST POLL - START OPERATION
main: 1 pending tasks. Sleep until notified.
FIRST POLL - START OPERATION
main: 1 pending tasks. Sleep until notified.
BUFFER:
----
HTTP/1.1 200 OK
content-length: 15
[=== ABBREVIATED ===]
date: Sun, 03 Dec 2023 23:18:12 GMT
HelloAsyncAwait
main: All tasks are finished
现在你拥有了可以安全跨等待点存储数据和引用的自引用协程。恭喜你!
尽管这些更改占用了相当多的篇幅,但它们本身其实相当简单。大部分更改只是因为 Pin 的 API 与我们之前使用的引用 API 不同。
好消息是,这些更改让我们非常容易将整个运行时迁移到由 async/await 创建的 Futures,而不是由 coroutine/wait 创建的我们自己的 Futures。
总结
这真是一段令人兴奋的旅程,对吧?如果你读到了本章的结尾,说明你做得非常棒,我有一个好消息要告诉你:你已经几乎了解了 Rust 的 Futures 的所有工作原理以及它们的特殊之处。所有复杂的话题都已经涵盖了。
在接下来的最后一章中,我们将从手工实现的协程切换到正式的 async/await。与到目前为止所经历的一切相比,这个过程将显得轻松许多。
在继续之前,让我们停下来回顾一下本章学到的内容。
首先,我们扩展了协程实现,以便能够在等待点之间存储变量。这一点非常重要,如果我们的 coroutine/wait 语法希望在可读性和实用性上与普通的同步代码竞争,那么这种能力必不可少。
接下来,我们学习了如何存储和恢复包含引用的变量,这与存储数据同样重要。
然后,我们亲眼看到了某些情况,除了我们在本章中实现了异步系统之外,你永远不会在 Rust 中看到这些情况(这确实是个很大的工作量,仅仅为了证明一个观点)。我们看到了如何移动持有自引用的协程会导致严重的内存安全问题,以及为什么我们需要一些手段来防止这些问题的发生。
这就引出了固定(Pinning)和自引用结构体的话题,如果你之前不了解这些,现在你就了解了。此外,你还应该至少了解什么是 Pin 投影,以及什么是结构性固定。
然后,我们对比了将值固定到栈与固定到堆的区别。你甚至还看到了当将某些东西固定到栈时,如何轻易地破坏 Pin 保证,以及为什么在做这件事时要格外小心。
你还了解了一些广泛使用的工具,这些工具可以帮助处理 Pin 投影和栈固定,使得它们都变得更加安全和易于使用。
接下来,我们亲身体验了如何利用固定来防止我们在协程实现中遇到的问题。
如果看看我们到目前为止构建的内容,这些已经相当令人印象深刻了。我们拥有以下内容:
- 我们自己创建的协程实现
coroutine/wait语法和帮助我们处理协程样板代码的预处理器- 能够安全地在等待点之间存储数据和引用的协程
- 一个高效的运行时,它能够存储、调度并轮询任务直到完成
- 将新任务生成到运行时的能力,从而一个任务可以生成成百上千的新任务并并发执行
- 一个基于 epoll/kqueue/IOCP 的反应器,能够高效地等待并响应操作系统报告的新事件
我认为这非常酷。
这本书还没完全结束。在下一章中,你将看到如何只需少量更改就能让我们的运行时运行由 async/await 创建的 Futures,而不是我们自己的协程实现。这让我们能够利用 Rust 异步编程的所有优势。我们还会花一些时间讨论当今 Rust 异步编程的现状、你可能遇到的不同运行时,以及我们可能在未来期待什么。
所有的艰难工作现在都已经完成了。干得好!