在本书的这一阶段,你应该已经熟悉了异步编程。当你在代码中看到 await 语法时,能够理解其背后是如何通过 futures(未来值)、任务(tasks)、线程(threads)和队列(queues)协同工作的。然而,异步编程的基础构建块又是什么呢?如果我们能够让代码在无需使用异步运行时的情况下实现暂停和恢复,又会怎样?更进一步,如果我们能够利用这种暂停与恢复的机制,通过普通的测试方法来测试代码,会怎样?这些测试可以探索代码在不同轮询顺序和配置下的表现。此外,这种暂停与恢复的机制还能作为同步代码与异步代码之间的接口。而这正是协程(coroutines)大显身手的地方。
在本章结束时,你应该能够定义协程,并解释如何使用它们。你还应该能够将协程集成到自己的程序中,以在处理需要大量内存的任务时保持较低的内存消耗。通过协程,你将能够在没有异步运行时的情况下模拟异步功能,并实现一个基本的执行器(executor)。这使得你可以在主线程中获得异步功能,而无需依赖异步运行时。你还将能够对协程的轮询时机和顺序进行精细控制。
注意:
在撰写本文时,我们正在使用 Rust 的每晚版本(nightly Rust)中的协程语法。语法可能会发生变化,或者协程语法可能已经被引入到 Rust 的稳定版本中。尽管语法变化可能会带来一些不便,但本章讨论的是协程的基础原理。这些基础原理不会因语法变化而受影响,也不会影响协程的整体实现及其使用方法。
引入协程
在深入探讨协程之前,你需要先了解什么是协程以及为什么需要使用它。
什么是协程?
协程是一种特殊类型的程序,它可以在执行过程中暂停,并在稍后的某个时间点从暂停的位置恢复执行。这与普通的子程序(如函数)不同,普通子程序通常会运行到完成并返回一个值或抛出一个错误。图 5-1 展示了两者之间的对比。
让我们将协程与子程序进行比较。子程序在启动后,从开始执行到结束,任何特定的子程序实例只会返回一次。而协程则不同,它可以以多种方式退出。它可以像子程序一样完成执行,但也可以通过调用另一个协程(称为“让步”)来退出,然后再返回到相同的点。因此,协程通过在暂停时保存状态来跟踪它的状态。
协程并非Rust独有,许多不同语言中都有协程的实现。它们都具有相同的基本特性,允许执行暂停和恢复:
- 非阻塞
当协程被暂停时,它们不会阻塞执行线程。 - 有状态
协程可以在暂停时保存它们的状态,然后在恢复时从该状态继续执行。无需从头开始。 - 协作式
协程可以在控制的方式下暂停并在稍后的阶段恢复执行。
现在让我们思考一下协程与线程的相似之处。表面上看,它们似乎非常相似——执行任务并随后暂停/恢复。区别在于调度。线程是抢占式调度的:任务会被外部调度器中断,目的是稍后恢复该任务。相比之下,协程是协作式的:它可以暂停或让出给另一个协程,而不需要调度器或操作系统的介入。
使用协程听起来很不错,那么为什么还要使用async/await呢?就像编程中的任何事物一样,这里也有权衡。让我们先来看看一些优点。协程去除了像互斥锁这样的同步原语,因为协程是在同一个线程中运行的。这可以使得代码更容易理解和编写,这是一个不容忽视的考虑。在线程之间来回切换的开销比在一个线程中切换协程要小得多。这对于需要长时间等待的任务尤其有用。想象一下,你需要跟踪100个文件的变化。如果让操作系统调度100个线程来循环检查每个文件,将会非常麻烦。上下文切换是计算密集型的。相反,更高效且更容易的方法是让100个协程检查它们正在监控的文件是否发生变化,然后将变化的文件发送到线程池进行处理。
使用单线程中的协程的主要缺点是你没有充分利用计算机的能力。在一个线程中运行程序意味着你没有将任务分配到多个核心上。现在你已经了解了协程是什么,接下来让我们探讨一下为什么我们应该使用它们。
为什么使用协程?
从高层次来看,协程使我们能够暂停一个操作,将控制权返回给执行协程的线程,然后在需要时恢复该操作。这听起来很像 async。一个 async 任务可以通过轮询将控制权交给另一个任务执行。通过多线程,我们可以将数据发送到线程,并通过通道和数据结构来检查线程的状态,这些结构是通过同步原语包装的。另一方面,协程使我们能够暂停一个操作,并通过唤醒器恢复执行,而无需使用 async 运行时或线程。
这可能看起来有些抽象,但我们可以通过一个简单的文件写入示例来说明使用协程的优势。假设我们收到大量整数,并需要将它们写入文件。可能这些数字来自另一个程序,我们不能等到所有数字都接收到再开始写入,因为这会占用太多内存。
在这个练习中,我们需要以下依赖项:
[dependencies]
rand = "0.8.5"
在编写示例代码之前,我们需要导入以下内容:
#![feature(coroutines)]
#![feature(coroutine_trait)]
use std::fs::{OpenOptions, File};
use std::io::{Write, self};
use std::time::Instant;
use rand::Rng;
use std::ops::{Coroutine, CoroutineState};
use std::pin::Pin;
我们还需要在 Cargo.toml 中添加 rand crate。对于一个简单的写入练习,这些导入可能看起来有些过多,但当我们走完这个示例时,你将看到它们是如何被利用的。这些宏是用来启用实验性功能的。
对于我们的简单文件写入示例,函数如下:
fn append_number_to_file(n: i32) -> io::Result<()> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open("numbers.txt")?;
writeln!(file, "{}", n)?;
Ok(())
}
这个函数打开文件并进行写入。现在我们要测试它并测量性能,因此我们生成20万个随机整数,并循环遍历它们进行写入。同时,我们使用以下代码来记录操作时间:
fn main() -> io::Result<()> {
let mut rng = rand::thread_rng();
let numbers: Vec<i32> = (0..200000).map(|_| rng.gen()).collect();
let start = Instant::now();
for &number in &numbers {
if let Err(e) = append_number_to_file(number) {
eprintln!("Failed to write to file: {}", e);
}
}
let duration = start.elapsed();
println!("Time elapsed in file operations is: {:?}", duration);
Ok(())
}
截至编写时,该测试完成用了4.39秒。虽然不算很快,但这是因为我们每次都在打开文件,检查权限和更新文件元数据都会带来额外的开销。
现在我们可以使用协程来处理整数的写入。首先,我们定义一个结构体来保存文件描述符:
struct WriteCoroutine {
pub file_handle: File,
}
impl WriteCoroutine {
fn new(path: &str) -> io::Result<Self> {
let file_handle = OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
Ok(Self { file_handle })
}
}
然后我们实现 Coroutine 特征:
impl Coroutine<i32> for WriteCoroutine {
type Yield = ();
type Return = ();
fn resume(mut self: Pin<&mut Self>, arg: i32)
-> CoroutineState<Self::Yield, Self::Return> {
writeln!(self.file_handle, "{}", arg).unwrap();
CoroutineState::Yielded(())
}
}
我们的协程,如通过 type Yield = (); 和 type Return = (); 所示,结构设计为既不生成中间值也不返回最终结果。我们可以稍后解释这一点,但重要的是我们返回了 CoroutineState::Yielded。这类似于在 future 中返回 Pending,但返回的是 Yield 类型。我们还可以返回 CoroutineState::Complete,这类似于在 future 中的 Ready。
在我们的测试中,我们可以创建协程并循环遍历数字,使用以下代码调用 resume 函数:
let mut coroutine = WriteCoroutine::new(
"numbers.txt"
)?;
for &number in &numbers {
Pin::new(&mut coroutine).resume(number);
}
更新后的测试大约需要 622.6 毫秒。这大约比之前快了六倍。确实,我们可以直接创建文件描述符并在循环中引用它以获得相同的效果,但这证明了暂停协程的状态并在需要时恢复它是有益的。我们成功地将写入逻辑隔离开,但我们并没有需要线程或 async 运行时来实现这个加速。协程可以作为线程和 async 任务中的构建块,用于暂停和恢复计算。
备注
你可能注意到,实现 Future 特征和 Coroutine 特征有一些相似之处。它们有两种可能的状态,根据两种可能的结果你可以恢复或完成,并且可以暂停和恢复协程与 async 任务。可以说,async 任务在广义上是协程,区别在于它们被发送到不同的线程,并由执行器轮询。
协程有很多用途。它们可以用来处理网络请求、大数据处理或用户界面应用。与使用回调相比,协程提供了处理 async 任务的更简单方法。使用协程,我们可以在一个线程中实现 async 功能,而无需队列或定义的执行器。
使用协程生成
你可能在其他语言中遇到过生成器的概念,例如 Python。生成器是协程的一个子集,有时称为“弱协程”。之所以这么称呼,是因为它们总是将控制权交还给调用它们的进程,而不是交给另一个协程。
生成器允许我们懒惰地执行操作。我们可以在需要时执行操作,也就是说,懒惰操作只有在需要时才会生成输出值。这可能是执行计算、通过网络建立连接或加载数据。当处理可能低效或不可行的大数据集时,懒惰评估尤其有用。像 range 这样的迭代器也起着类似的作用,允许你懒惰地生成值序列。
值得注意的是,Rust 语言正在不断发展,其中一个发展方向是异步生成器。这是能够在异步上下文中生成值的特殊类型的生成器。目前这方面的工作还在进行中,详细信息可以参见 Rust RFC Book 网站。
让我们将理论付诸实践,编写我们的第一个简单生成器。
在 Rust 中实现一个简单的生成器
假设我们需要从一个大型数据结构中提取信息,这些数据存储在一个数据文件中。该数据文件非常大,理想情况下我们不想一次性将其加载到内存中。为了演示流式处理,我们使用一个只有五行的小数据文件。记住,这是一个教学示例;在实际应用中,使用生成器读取一个五行的数据文件会被认为是做得过头了!你可以自己制作这个文件。我们已经在项目中保存了一个包含五行数据、每行一个数字的文件,名为 data.txt。
我们需要之前示例中的协程功能,并导入那些组件:
#![feature(coroutines)]
#![feature(coroutine_trait)]
use std::fs::File;
use std::io::{self, BufRead, BufReader};
use std::ops::{Coroutine, CoroutineState};
use std::pin::Pin;
接下来,我们创建我们的 ReadCoroutine 结构体:
struct ReadCoroutine {
lines: io::Lines<BufReader<File>>,
}
impl ReadCoroutine {
fn new(path: &str) -> io::Result<Self> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let lines = reader.lines();
Ok(Self { lines })
}
}
然后我们为这个结构体实现 Coroutine 特征。我们的输入文件包含数字 1 到 5,因此我们将会生成一个 i32 类型的值:
impl Coroutine<()> for ReadCoroutine {
type Yield = i32;
type Return = ();
fn resume(mut self: Pin<&mut Self>, _arg: ())
-> CoroutineState<Self::Yield, Self::Return> {
match self.lines.next() {
Some(Ok(line)) => {
if let Ok(number) = line.parse::<i32>() {
CoroutineState::Yielded(number)
} else {
CoroutineState::Complete(())
}
}
Some(Err(_)) | None => CoroutineState::Complete(()),
}
}
}
协程中包含一个 Yield 语句,允许我们从生成器中“让步”一个值。Coroutine 特征只有一个必需的方法,即 resume。这个方法使我们能够恢复执行,从上次执行的点继续。在我们的例子中,resume 方法从文件中读取行,将它们解析为整数,并将它们逐个生成,直到没有更多的行可以生成,此时协程完成。
现在我们将在测试文件上调用我们的函数:
fn main() -> io::Result<()> {
let mut coroutine = ReadCoroutine::new("./data.txt")?;
loop {
match Pin::new(&mut coroutine).resume(()) {
CoroutineState::Yielded(number) => println!("{:?}", number),
CoroutineState::Complete(()) => break,
}
}
Ok(())
}
你应该会看到以下输出:
1
2
3
4
5
警告
Coroutine 特征之前被称为 Generator 特征,GeneratorState 也在 2023 年底发生了名称更改。如果你已经安装了较旧版本的 nightly Rust,你需要更新它,才能使以下代码正常工作。
现在,我们将继续探讨如何将两个协程一起使用,让它们相互“让步”。在这一部分,你将开始看到使用协程的强大之处。
堆叠我们的协程
在本节中,我们将使用文件传输示例来演示如何顺序地使用两个协程。这种方法非常有用,因为我们可能希望传输一个文件,其大小如此之大,以至于无法将所有数据一次性加载到内存中。但通过逐步传输数据,我们可以在不耗尽内存的情况下完成所有数据的传输。为了解决这个问题,一个协程读取文件并生成数据,而另一个协程接收这些数据并将其写入文件。
我们将重新使用之前的 ReadCoroutine 并添加 WriteCoroutine,它是本章第一部分中的一个例子。在那个例子中,我们将20万个随机数字写入一个名为 numbers.txt 的文件。现在,我们将重新使用这个文件作为我们要传输的文件。我们将读取 numbers.txt 并将数据写入 output.txt 文件。
我们稍微修改 WriteCoroutine,使其接收路径参数,而不是硬编码路径:
struct WriteCoroutine {
pub file_handle: File,
}
impl WriteCoroutine {
fn new(path: &str) -> io::Result<Self> {
let file_handle = OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
Ok(Self { file_handle })
}
}
现在我们创建一个管理器,它拥有一个读取协程和一个写入协程:
struct CoroutineManager{
reader: ReadCoroutine,
writer: WriteCoroutine
}
我们需要创建一个函数来启动我们的文件传输。首先,我们将创建一个新函数来实例化协程管理器,设置读取和写入文件路径。然后,我们创建一个新的 run 函数。我们需要将读取器和写入器在内存中固定(pin),以便它们可以在程序的整个生命周期内使用。
接下来,我们将创建一个循环,整合读取和写入功能。当读取器返回 Yielded(即有输出)时,写入器将接收这个输出并写入文件。如果没有更多的数据需要读取,则退出循环。以下是完整的代码:
impl CoroutineManager {
fn new(read_path: &str, write_path: &str) -> io::Result<Self> {
let reader = ReadCoroutine::new(read_path)?;
let writer = WriteCoroutine::new(write_path)?;
Ok(Self {
reader,
writer,
})
}
fn run(&mut self) {
let mut read_pin = Pin::new(&mut self.reader);
let mut write_pin = Pin::new(&mut self.writer);
loop {
match read_pin.as_mut().resume(()) {
CoroutineState::Yielded(number) => {
write_pin.as_mut().resume(number);
}
CoroutineState::Complete(()) => break,
}
}
}
}
我们可以在 main 函数中使用这个管理器:
fn main() {
let mut manager = CoroutineManager::new(
"numbers.txt", "output.txt"
).unwrap();
manager.run();
}
运行该代码后,你可以打开新的 output.txt 文件,检查文件是否包含正确的内容。
我们在这里所做的基本上是创建了一个文件传输过程。一个协程逐行读取文件并将其值生成出来,另一个协程接收这些值并写入文件。在这两个协程中,文件句柄在整个执行过程中保持打开,这意味着我们不需要反复处理缓慢的 I/O 操作。通过这种懒加载和写入方式,我们可以将程序排队执行,处理多个文件传输任务,逐个完成。从更广泛的角度看,这种方法的好处非常明显。我们可以用它将100个大文件(每个文件几GB)从一个位置传输到另一个位置,甚至通过网络传输。
从协程中调用协程
在前面的示例中,我们使用了一个协程来生成一个值,然后由写入协程接收并处理。这个过程是由一个管理器来处理的。在理想情况下,我们希望完全去除管理器,允许协程直接相互调用并交换数据。这种类型的协程被称为对称协程,在其他语言中有类似的实现。然而,这个特性在 Rust 中并不是标准功能(目前尚未实现),因此,为了实现类似的功能,我们需要避免使用 Yielded 和 Complete 语法。
我们将创建一个名为 SymmetricCoroutine 的特征。它包含一个函数 resume_with_input,该函数接收输入并提供输出:
trait SymmetricCoroutine {
type Input;
type Output;
fn resume_with_input(
self: Pin<&mut Self>, input: Self::Input
) -> Self::Output;
}
现在,我们可以为我们的 ReadCoroutine 实现这个特征。它输出 i32 类型的值。注意,我们不再使用 Yielded,但仍然使用行解析器。这将输出我们需要的值:
impl SymmetricCoroutine for ReadCoroutine {
type Input = ();
type Output = Option<i32>;
fn resume_with_input(
mut self: Pin<&mut Self>, _input: ()
) -> Self::Output {
if let Some(Ok(line)) = self.lines.next() {
line.parse::<i32>().ok()
} else {
None
}
}
}
对于 WriteCoroutine,我们也实现这个特征:
impl SymmetricCoroutine for WriteCoroutine {
type Input = i32;
type Output = ();
fn resume_with_input(
mut self: Pin<&mut Self>, input: i32
) -> Self::Output {
writeln!(self.file_handle, "{}", input).unwrap();
}
}
最后,我们将所有内容组合在 main 函数中:
fn main() -> io::Result<()> {
let mut reader = ReadCoroutine::new("numbers.txt")?;
let mut writer = WriteCoroutine::new("output.txt")?;
loop {
let number = Pin::new(&mut reader).resume_with_input(());
if let Some(num) = number {
Pin::new(&mut writer).resume_with_input(num);
} else {
break;
}
}
Ok(())
}
main 函数明确地指示了协程如何一起工作。这涉及到手动调度,因此技术上它并不符合真正对称协程的标准。我们正在模仿对称协程的一些功能,作为一个教学示例。真正的对称协程应该将控制权从读取协程传递到写入协程,而不需要返回到 main 函数;然而,这被 Rust 的借用规则所限制,因为两个协程都需要互相引用。尽管如此,这仍然是一个有用的示例,展示了如何通过编写自己的协程来提供更多功能。
接下来,我们将探讨异步行为,以及如何通过简单的协程来模拟这些功能。
使用协程模拟异步行为
对于本练习,我们需要以下导入:
#![feature(coroutines, coroutine_trait)]
use std::{
collections::VecDeque,
future::Future,
ops::{Coroutine, CoroutineState},
pin::Pin,
task::{Context, Poll},
time::Instant,
};
在本章的引言中,我们讨论了协程与异步编程的相似性,因为协程的执行会暂停,并在满足特定条件时恢复执行。可以强烈争辩说,所有的异步编程都是协程的一个子集。异步运行时本质上是在多个线程间调度协程。
我们可以通过一个简单的例子来演示如何暂停执行。首先,我们设置一个协程,该协程会睡眠 1 秒:
struct SleepCoroutine {
pub start: Instant,
pub duration: std::time::Duration,
}
impl SleepCoroutine {
fn new(duration: std::time::Duration) -> Self {
Self {
start: Instant::now(),
duration,
}
}
}
impl Coroutine<()> for SleepCoroutine {
type Yield = ();
type Return = ();
fn resume(
self: Pin<&mut Self>, _: ()
) -> CoroutineState<Self::Yield, Self::Return> {
if self.start.elapsed() >= self.duration {
CoroutineState::Complete(())
} else {
CoroutineState::Yielded(())
}
}
}
我们将创建三个 SleepCoroutine 实例,它们将同时运行,每个实例睡眠 1 秒。
我们创建一个计数器,并使用它来循环遍历协程队列,执行 yield 或 complete。最后,我们计算整个操作的时间:
fn main() {
let mut sleep_coroutines = VecDeque::new();
sleep_coroutines.push_back(
SleepCoroutine::new(std::time::Duration::from_secs(1))
);
sleep_coroutines.push_back(
SleepCoroutine::new(std::time::Duration::from_secs(1))
);
sleep_coroutines.push_back(
SleepCoroutine::new(std::time::Duration::from_secs(1))
);
let mut counter = 0;
let start = Instant::now();
while counter < sleep_coroutines.len() {
let mut coroutine = sleep_coroutines.pop_front().unwrap();
match Pin::new(&mut coroutine).resume(()) {
CoroutineState::Yielded(_) => {
sleep_coroutines.push_back(coroutine);
},
CoroutineState::Complete(_) => {
counter += 1;
},
}
}
println!("Took {:?}", start.elapsed());
}
这将花费 1 秒钟来完成,尽管我们执行了 3 个协程,每个协程需要 1 秒钟。我们可能期望它需要 3 秒钟才能完成。然而,所花费的时间大大减少,恰恰是因为它们是协程:它们能够暂停执行,并在稍后的时间恢复。我们并没有使用 Tokio 或任何其他异步运行时,所有操作都在一个线程中运行。它们只是简单地暂停并恢复。
从某种程度上说,我们已经为这个用例编写了一个特定的执行器。我们甚至可以使用执行器语法来使这一点更加清晰。让我们创建一个使用 VecDeque 的 Executor 结构体:
struct Executor {
coroutines: VecDeque<Pin<Box<
dyn Coroutine<(), Yield = (), Return = ()>
>>>,
}
现在我们为 Executor 添加基本功能:
impl Executor {
fn new() -> Self {
Self {
coroutines: VecDeque::new(),
}
}
}
我们定义一个 add 函数,它重用了之前的代码,在其中协程可以被重新加入队列:
fn add(&mut self, coroutine: Pin<Box<
dyn Coroutine<(), Yield = (), Return = ()>>>)
{
self.coroutines.push_back(coroutine);
}
最后,我们将协程状态代码包装到一个名为 poll 的函数中:
fn poll(&mut self) {
println!("Polling {} coroutines", self.coroutines.len());
let mut coroutine = self.coroutines.pop_front().unwrap();
match coroutine.as_mut().resume(()) {
CoroutineState::Yielded(_) => {
self.coroutines.push_back(coroutine);
},
CoroutineState::Complete(_) => {},
}
}
现在,main 函数可以创建执行器,添加协程,然后轮询它们,直到它们都完成:
fn main() {
let mut executor = Executor::new();
for _ in 0..3 {
let coroutine = SleepCoroutine::new(
std::time::Duration::from_secs(1)
);
executor.add(Box::pin(coroutine));
}
let start = Instant::now();
while !executor.coroutines.is_empty() {
executor.poll();
}
println!("Took {:?}", start.elapsed());
}
就是这样!我们创建了我们的第一个执行器。我们将在第 11 章中进一步构建它。现在,我们已经通过协程和执行器实现了异步功能,让我们通过为我们的 SleepCoroutine 实现 Future 特征,真正明确异步和协程之间的关系:
impl Future for SleepCoroutine {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>)
-> Poll<Self::Output> {
match Pin::new(&mut self).resume(()) {
CoroutineState::Complete(_) => Poll::Ready(()),
CoroutineState::Yielded(_) => {
cx.waker().wake_by_ref();
Poll::Pending
},
}
}
}
这个例子展示了协程如何暂停和恢复执行,类似于 async/await 的工作方式。区别在于,我们在一个线程中使用协程。这里的主要缺点是你需要编写协程,并且如果你愿意的话,还需要编写自己的执行器。这意味着协程可能与所解决的问题高度耦合。
除了这个主要缺点外,我们还失去了使用线程池的好处。定义你自己的协程在异步运行时可能过于复杂时是合理的。我们也可以在需要更多控制的情况下使用协程。例如,当异步任务被发送到运行时时,我们实际上对任务何时被轮询相较于其他异步任务并没有太多控制。接下来,我们将探讨如何控制协程。
控制协程
在本书中,我们一直在内部控制异步任务的执行流程。例如,当我们实现 Future 特征时,我们可以选择何时返回 Pending 或 Ready,这取决于 poll 函数的内部逻辑。我们的异步函数也是如此;我们可以使用 await 语法选择异步任务何时将控制权交还给执行器,并使用 return 语句选择异步任务何时返回 Ready。
我们可以通过外部同步原语(如原子值和互斥锁)来控制这些异步任务,让异步任务根据这些原子值和互斥锁的变化做出反应。然而,反应外部信号的逻辑必须在将异步任务发送到运行时之前编写到异步任务中。对于简单的情况,这可能是可以接受的,但它也暴露了异步任务在系统变化时可能变得脆弱的问题。这样做还使得异步任务在其他上下文中变得更加难以使用。异步任务可能还需要知道其他异步任务的状态,然后才会做出反应,这可能会导致潜在的问题,如死锁。
注意
死锁可能发生在任务 A 需要从任务 B 获取某些资源才能继续,而任务 B 又需要从任务 A 获取某些资源才能继续,导致进展停滞。我们将在第 11 章中讨论死锁、活锁及其他潜在的陷阱,并介绍如何进行测试。
此时,协程的外部控制便利性显得尤为重要。为了演示外部控制如何简化我们的程序,我们将编写一个简单的程序,该程序循环遍历一个包含值和存活/死亡状态的协程向量。当协程被调用时,生成一个随机数作为值,并将这个值“让步”出去。我们可以积累所有这些值,并制定简单的规则来决定何时终止协程。为了生成随机数,我们需要以下依赖项:
[dependencies]
rand = "0.8.5"
接下来,我们需要以下导入:
#![feature(coroutines, coroutine_trait)]
use std::{
ops::{Coroutine, CoroutineState},
pin::Pin,
time::Duration,
};
use rand::Rng;
现在,我们可以构建一个随机数协程:
struct RandCoRoutine {
pub value: u8,
pub live: bool,
}
impl RandCoRoutine {
fn new() -> Self {
let mut coroutine = Self {
value: 0,
live: true,
};
coroutine.generate();
coroutine
}
fn generate(&mut self) {
let mut rng = rand::thread_rng();
self.value = rng.gen_range(0..=10);
}
}
考虑到外部代码将控制我们的协程,我们使用一个简单的生成器实现:
impl Coroutine<()> for RandCoRoutine {
type Yield = u8;
type Return = ();
fn resume(mut self: Pin<&mut Self>, _: ())
-> CoroutineState<Self::Yield, Self::Return> {
self.generate();
CoroutineState::Yielded(self.value)
}
}
我们可以在代码库中的任何地方使用这个生成器,因为它按照预期工作。运行我们的协程不需要外部依赖,而且我们的测试也很简单。在 main 函数中,我们创建一个这些协程的向量,调用它们直到所有协程都“死亡”:
let mut coroutines = Vec::new();
for _ in 0..10 {
coroutines.push(RandCoRoutine::new());
}
let mut total: u32 = 0;
loop {
let mut all_dead = true;
for mut coroutine in coroutines.iter_mut() {
if coroutine.live {
// ...
}
}
if all_dead {
break
}
}
println!("Total: {}", total);
如果循环中的协程还活着,我们可以假设所有协程还未“死亡”,并将 all_dead 标志设置为 false。然后我们调用协程的 resume 函数,提取结果,并制定简单的规则决定是否杀死该协程:
all_dead = false;
match Pin::new(&mut coroutine).resume(()) {
CoroutineState::Yielded(result) => {
total += result as u32;
},
CoroutineState::Complete(_) => {
panic!("Coroutine should not complete");
},
}
if coroutine.value < 9 {
coroutine.live = false;
}
如果我们在循环中降低杀死协程的阈值,最终的总和会更高,因为阈值更难达成。我们在主线程中运行,因此可以访问该线程中的所有内容。例如,我们可以跟踪所有“死亡”的协程,并在数量过多时重新启动它们。我们也可以使用死亡数量来改变何时杀死协程的规则。
现在,我们仍然可以在异步任务中实现这个玩具示例。例如,future 可以持有并轮询另一个 future,如下所示:
struct NestingFuture {
inner: Pin<Box<dyn Future<Output = ()> + Send>>,
}
impl Future for NestingFuture {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>)
-> Poll<Self::Output> {
match self.inner.as_mut().poll(cx) {
Poll::Ready(_) => Poll::Ready(()),
Poll::Pending => Poll::Pending,
}
}
}
没有什么能阻止 NestingFuture 拥有一个其他 future 的向量,每当它们被轮询时更新自己的值字段,并始终返回 Pending。NestingFuture 然后可以提取该值字段,并根据规则决定最近被轮询的 future 是否应该被杀死。然而,NestingFuture 会在异步运行时的线程中运行,这意味着它在主线程中的数据访问是有限的。
考虑到对协程的控制便利性,我们需要记住,这并不是“非此即彼”的问题。不是协程与异步的对立。通过以下代码,我们可以证明我们可以将协程发送到线程上:
let (sender, receiver) = std::sync::mpsc::channel::<RandCoRoutine>();
let _thread = std::thread::spawn(move || {
loop {
let mut coroutine = match receiver.recv() {
Ok(coroutine) => coroutine,
Err(_) => break,
};
match Pin::new(&mut coroutine).resume(()) {
CoroutineState::Yielded(result) => {
println!("Coroutine yielded: {}", result);
},
CoroutineState::Complete(_) => {
panic!("Coroutine should not complete");
},
}
}
});
std::thread::sleep(Duration::from_secs(1));
sender.send(RandCoRoutine::new()).unwrap();
sender.send(RandCoRoutine::new()).unwrap();
std::thread::sleep(Duration::from_secs(1));
由于协程是线程安全的,并且能够轻松映射协程的结果,我们已经完成了对协程理解的探索。我们可以得出结论,协程是可以暂停和恢复的计算单元。此外,这些协程还实现了 Future 特征,可以调用 resume 函数,并将该函数的结果映射到 poll 函数的结果,如图 5-2 所示。
图 5-2 还显示了我们可以在协程函数和未来(Future)函数之间插入可选的适配器代码。这样,我们可以将协程视为基本的计算构建块。这些协程可以在同步代码中暂停和恢复,因此在标准测试环境中非常容易测试,因为你不需要异步运行时就能测试这些协程。你还可以选择何时调用协程的 resume 函数,因此测试协程之间交互的不同顺序也变得简单。
一旦你对协程及其工作方式感到满意,你可以将一个或多个协程封装在一个实现了 Future 特征的结构体中。这个结构体本质上是一个适配器,使得协程可以与异步运行时进行交互。这为我们提供了最终的灵活性和控制力,既能在测试和实现我们的计算过程时有更大的控制,又能清晰地划分这些计算步骤与异步运行时之间的边界,因为异步运行时本质上是一个具有队列的线程池。任何熟悉单元测试的人都知道,我们不应该为了测试函数或结构体的计算逻辑而与线程池进行交互。考虑到这一点,让我们总结一下协程如何与异步世界中的测试结合。
测试协程
对于我们的测试示例,我们不想用复杂的逻辑使本章内容过于冗长,因此我们将测试两个协程,这两个协程获取相同的互斥锁并将互斥锁中的值加一。通过这个示例,我们可以测试当锁被获取以及交互后的锁的最终结果时会发生什么。
注意
虽然使用协程进行测试既简单又强大,但如果不使用协程,测试也并不完全不可行。第11章专门讲解了测试,并且在那一章中你将不会看到任何协程。
我们从一个结构体开始,该结构体拥有一个互斥锁的句柄和一个阈值,当协程达到阈值时就完成:
use std::ops::{Coroutine, CoroutineState};
use std::pin::Pin;
use std::sync::{Arc, Mutex};
pub struct MutexCoRoutine {
pub handle: Arc<Mutex<u8>>,
pub threshold: u8,
}
接下来,我们实现获取锁并将值增加 1 的逻辑:
impl Coroutine<()> for MutexCoRoutine {
type Yield = ();
type Return = ();
fn resume(mut self: Pin<&mut Self>, _: ())
-> CoroutineState<Self::Yield, Self::Return> {
match self.handle.try_lock() {
Ok(mut handle) => {
*handle += 1;
},
Err(_) => {
return CoroutineState::Yielded(());
},
}
self.threshold -= 1;
if self.threshold == 0 {
return CoroutineState::Complete(())
}
return CoroutineState::Yielded(())
}
}
我们尝试获取锁,如果获取不到,我们不想阻塞,所以我们会返回一个 yield。如果成功获取到锁,我们将值加 1,阈值减 1,然后根据阈值是否达到返回 Yielded 或 Complete。
警告
在异步函数中阻塞代码可能导致整个异步运行时停滞,因为它会阻止其他任务继续执行。在 Rust 中,这个概念通常被称为“函数颜色”,即函数是同步(阻塞)还是异步(非阻塞)的。错误混用这些类型可能导致问题。
例如,如果 Mutex 的 try_lock 方法会阻塞,那么在异步上下文中这将是有问题的。虽然 try_lock 本身是非阻塞的,但你需要注意,其他的锁机制(如 lock)会阻塞,因此在异步函数中应当避免使用这些,或小心处理。
接下来,我们可以在同一个文件中测试我们的协程,使用以下模板:
#[cfg(test)]
mod tests {
use super::*;
use std::future::Future;
use std::task::{Context, Poll};
use std::time::Duration;
// 同步测试接口
fn check_yield(coroutine: &mut MutexCoRoutine) -> bool {
. . .
}
// 异步运行时接口
impl Future for MutexCoRoutine {
. . .
}
#[test]
fn basic_test() {
. . .
}
#[tokio::test]
async fn async_test() {
. . .
}
}
我们将直接检查代码的工作情况,然后查看代码在异步运行时中的表现。我们有两个接口。我们不想修改代码来满足测试要求,而是使用一个简单的接口,它根据协程返回的类型返回一个布尔值;这是函数定义:
fn check_yield(coroutine: &mut MutexCoRoutine) -> bool {
match Pin::new(coroutine).resume(()) {
CoroutineState::Yielded(_) => {
true
},
CoroutineState::Complete(_) => {
false
},
}
}
对于异步接口,我们只是将协程的输出映射到等效的异步输出:
impl Future for MutexCoRoutine {
type Output = ();
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>)
-> Poll<Self::Output> {
match Pin::new(&mut self).resume(()) {
CoroutineState::Complete(_) => Poll::Ready(()),
CoroutineState::Yielded(_) => {
cx.waker().wake_by_ref();
Poll::Pending
},
}
}
}
现在,我们可以在 basic_test 函数中构建我们的第一个基础测试。我们首先定义互斥锁和协程:
let handle = Arc::new(Mutex::new(0));
let mut first_coroutine = MutexCoRoutine {
handle: handle.clone(),
threshold: 2,
};
let mut second_coroutine = MutexCoRoutine {
handle: handle.clone(),
threshold: 2,
};
我们首先想要自己获取锁,然后调用这两个协程,检查它们是否返回 yield 并且互斥锁的值仍然是零,因为我们已经获取了锁:
let lock = handle.lock().unwrap();
for _ in 0..3 {
assert_eq!(check_yield(&mut first_coroutine), true);
assert_eq!(check_yield(&mut second_coroutine), true);
}
assert_eq!(*lock, 0);
std::mem::drop(lock);
注意
你可能已经注意到,我们在最初测试两个 yield 后才释放锁。如果我们不这么做,后续的测试将失败,因为我们的协程将永远无法获取锁。
我们执行这个循环以证明当协程未能获取锁时,阈值不会被改变。如果阈值被改变,经过两次迭代,协程将返回 Complete,下次调用该协程时将导致错误。虽然没有循环也能通过测试,但在开始时加入循环可以避免对测试中断原因的困惑。
在释放锁之后,我们调用每个协程两次,以确保它们返回我们期望的值,并检查每次调用后的互斥锁,确保状态按预期变化:
assert_eq!(check_yield(&mut first_coroutine), true);
assert_eq!(*handle.lock().unwrap(), 1);
assert_eq!(check_yield(&mut second_coroutine), true);
assert_eq!(*handle.lock().unwrap(), 2);
assert_eq!(check_yield(&mut first_coroutine), false);
assert_eq!(*handle.lock().unwrap(), 3);
assert_eq!(check_yield(&mut second_coroutine), false);
assert_eq!(*handle.lock().unwrap(), 4);
我们的第一个测试就完成了。
在我们的异步测试中,我们以完全相同的方式创建了互斥锁和协程。然而,我们现在的目标是测试我们的行为最终结果是否在异步运行时中相同,并且我们的异步接口是否按预期工作。因为我们使用的是 Tokio 测试功能,所以我们可以直接生成任务,等待它们完成,并检查锁的状态:
let handle_one = tokio::spawn(async move {
first_coroutine.await;
});
let handle_two = tokio::spawn(async move {
second_coroutine.await;
});
handle_one.await.unwrap();
handle_two.await.unwrap();
assert_eq!(*handle.lock().unwrap(), 4);
如果我们运行命令 cargo test,我们将看到两个测试都能通过。就这样!我们一步一步地检查了两个协程和一个互斥锁之间的交互,检查了每次迭代之间的状态。我们的协程在同步代码中工作得很好。通过一个简单的适配器,我们还可以看到我们的协程在异步运行时中按预期的方式工作!我们可以看到,我们在每次协程与异步测试交互时,并不能检查互斥锁的状态。异步执行器在做自己的事情。
总结
在本章中,我们通过实现 Coroutine 特征构建了自己的协程,并使用 Yield 和 Complete 来暂停和恢复协程。我们实现了一个管道,其中一个协程读取文件并生成值,另一个协程使用这些值并将它们写入文件。最后,我们构建了自己的执行器,展示了协程是如何真正暂停和恢复的。
在阅读本章的过程中,你可能会注意到协程中的 Yield/Complete 与异步编程中的 Pending/Ready 之间的相似性。在我们看来,最好的理解方式是:async/await 是协程的一种子类型。它是一个跨线程操作并使用队列的协程。你可以在协程和异步编程中暂停一个活动并稍后恢复。
协程使我们能够构建结构化的代码,因为它们可以充当异步和同步代码之间的接缝。通过协程,我们可以构建同步代码模块,并通过标准测试进行评估。我们可以构建作为协程的适配器,以便我们的同步代码可以与需要异步功能的代码连接,而这些异步功能被表示为协程。然后,我们可以单元测试我们的协程,查看它们在不同顺序和组合下的行为。我们可以将这些协程注入到 Future 特征的实现中,将我们的代码集成到异步运行时中,因为我们可以在未来的 poll 函数中调用我们的协程。在这里,我们只需要通过接口将这些异步代码隔离开来。一个异步函数可以调用你的代码,然后将输出传递到第三方异步代码中,反之亦然。
一种好的隔离代码的方法是通过响应式编程,在这种方式中,我们的代码单元可以通过订阅广播通道来消费数据。我们将在第6章中进一步探讨这个概念。