本章介绍了在 Rust 中使用异步编程的重要组成部分,并概述了任务(tasks)、未来(futures)、async 和 await。我们涵盖了上下文、pin、轮询和闭包等概念,这些是充分利用 Rust 异步编程的关键概念。我们选择了本章中的示例来展示学习要点,虽然它们不一定在效率上是最优的。最后,本章通过构建一个异步审计日志记录器的示例将所有概念整合在一起。
在本章结束时,您将能够定义任务和未来,并理解未来的更技术性组件,包括上下文和 pin。
理解任务
在异步编程中,任务代表一个异步操作。基于任务的异步模式(TAP)提供了一个对异步代码的抽象。您编写代码时,它是一个顺序的语句集合,您可以将这些代码读取为每个语句在下一个语句开始之前完成。例如,让我们考虑制作一杯咖啡和烤吐司的过程,其中包括以下步骤:
- 放面包进烤面包机
- 涂抹黄油
- 烧水
- 倒入牛奶
- 加入速溶咖啡颗粒(虽然这不是最好的方式,但简化了示例)
- 倒入烧开的水
我们当然可以应用异步编程来加速这个过程,但首先我们需要将所有步骤分解为两个大步骤:制作咖啡和制作吐司,如下所示:
制作咖啡
- 烧水
- 倒入牛奶
- 加入速溶咖啡
- 倒入烧开的水
制作吐司
- 放面包进烤面包机
- 涂抹黄油
即使我们每个人只有一双手,我们也可以同时进行这两个步骤。我们可以先烧水,在水烧开的同时把面包放进烤面包机。我们等待水烧开和吐司烤制时会有一些空闲时间,因此,如果我们希望提高效率,且愿意承担可能因为水过早烧开而导致先倒水后加咖啡和牛奶的风险,我们可以将步骤进一步拆解,如下所示:
准备咖啡杯
- 倒牛奶
- 加速溶咖啡
制作咖啡 - 烧水
- 倒入烧开的水
制作吐司 - 放面包进烤面包机
- 涂抹黄油
在等待水烧开和面包烤制时,我们可以同时进行倒牛奶和加速溶咖啡,从而减少空闲时间。首先,我们可以看到,步骤并不是目标特定的。当我们走进厨房时,我们会考虑做吐司和做咖啡,这是两个独立的目标。但我们已为这两个目标定义了三个步骤。步骤就是指我们可以并发执行的操作,这些操作是异步的,并且不需要同步进行,以达成我们的目标。
需要注意的是,在假设和我们愿意容忍的情况之间会有权衡。例如,如果水尚未烧开,就将水倒入杯子里可能是完全不可接受的。这在没有延迟的情况下是一个风险。然而,我们可以做出一个合理的假设,认为水的烧开过程会有一些延迟。
现在,您已经理解了什么是步骤,我们可以回到我们的示例,使用像 Tokio 这样的高级库,让我们专注于步骤的概念以及它们如何与任务相关。别担心——我们将在后面的章节中使用其他库,探讨更底层的概念。首先,我们需要导入以下内容:
use std::time::Duration;
use tokio::time::sleep;
use std::thread;
use std::time::Instant;
我们使用 Tokio 的 sleep 来表示那些可以等待的步骤,例如烧水和烤面包。由于 Tokio sleep 函数是非阻塞的,我们可以在等待水烧开或面包烤制时切换到另一个步骤。我们使用 thread::sleep 来模拟需要我们双手操作的步骤,因为在倒牛奶、倒水或涂抹黄油时,我们不能同时做其他事情。一般来说,编写这些步骤的程序将会是 CPU 密集型的。
我们接着定义准备咖啡杯的步骤:
async fn prep_coffee_mug() {
println!("Pouring milk...");
thread::sleep(Duration::from_secs(3));
println!("Milk poured.");
println!("Putting instant coffee...");
thread::sleep(Duration::from_secs(3));
println!("Instant coffee put.");
}
接下来定义“制作咖啡”的步骤:
async fn make_coffee() {
println!("boiling kettle...");
sleep(Duration::from_secs(10)).await;
println!("kettle boiled.");
println!("pouring boiled water...");
thread::sleep(Duration::from_secs(3));
println!("boiled water poured.");
}
最后定义制作吐司的步骤:
async fn make_toast() {
println!("putting bread in toaster...");
sleep(Duration::from_secs(10)).await;
println!("bread toasted.");
println!("buttering toasted bread...");
thread::sleep(Duration::from_secs(5));
println!("toasted bread buttered.");
}
您可能已经注意到,await 被用于那些表示可以等待的、非密集型的步骤。我们使用 await 关键字来挂起步骤的执行,直到结果准备好。当遇到 await 时,异步运行时可以切换到另一个异步任务。
注意
您可以在 Rust RFC 书籍网站上阅读更多关于 async 和 await 语法的内容。
现在我们已经定义了所有步骤,可以通过以下代码异步运行它们:
#[tokio::main]
async fn main() {
let start_time = Instant::now();
let coffee_mug_step = prep_coffee_mug();
let coffee_step = make_coffee();
let toast_step = make_toast();
tokio::join!(coffee_mug_step, coffee_step, toast_step);
let elapsed_time = start_time.elapsed();
println!("It took: {} seconds", elapsed_time.as_secs());
}
在这里,我们定义了我们的步骤,这些步骤被称为未来(futures)。我们将在下一节中详细介绍未来。现在,可以将未来理解为可能已经完成或可能还未完成的占位符。我们等待步骤完成,然后打印出所花费的时间。如果我们运行程序,将得到以下输出:
Pouring milk...
Milk poured.
Putting instant coffee...
Instant coffee put.
boiling kettle...
putting bread in toaster...
kettle boiled.
pouring boiled water...
boiled water poured.
bread toasted.
buttering toasted bread...
toasted bread buttered.
It took: 24 seconds
这个输出比较长,但它是重要的。我们可以看到,它看起来有些奇怪。如果我们要提高效率,应该不是先倒牛奶和加咖啡,而是先把水烧开,把面包放进烤面包机,然后再去倒牛奶。我们可以看到,准备咖啡杯的步骤是首先传递给了 tokio::join 宏。如果我们一次又一次运行程序,准备咖啡杯的步骤将始终是第一个被执行的未来。现在,如果我们回到准备咖啡杯的函数中,我们可以在其余过程之前添加一个非阻塞的休眠函数:
async fn prep_coffee_mug() {
sleep(Duration::from_millis(100)).await;
. . .
}
这将给我们以下输出:
boiling kettle...
putting bread in toaster...
Pouring milk...
Milk poured.
Putting instant coffee...
Instant coffee put.
bread toasted.
buttering toasted bread...
toasted bread buttered.
kettle boiled.
pouring boiled water...
boiled water poured.
It took: 18 seconds
好了,现在顺序就合理了:我们先烧水,再放面包进烤面包机,然后倒牛奶,因此我们节省了 6 秒的时间。然而,因果关系是反直觉的。额外添加的休眠函数减少了我们的整体时间。这是因为这个额外的休眠函数允许异步运行时切换上下文到其他任务,并在它们的 await 行执行时继续执行这些任务。通过这种方式,我们在未来的执行中插入了一个人为的延迟,促使其他任务开始执行,这种方式被非正式地称为协作式多任务处理。
当我们将未来传递给 tokio::join 宏时,所有的异步表达式都是在同一个任务中并发执行的。join 宏并不会创建任务,它仅仅是使多个未来能够在同一个任务中并发执行。例如,我们可以用以下代码生成一个任务:
let person_one = tokio::task::spawn(async {
prep_coffee_mug().await;
make_coffee().await;
make_toast().await;
});
任务中的每个未来都会阻塞该任务的进一步执行,直到该未来完成。因此,假设我们使用以下注释来确保运行时只有一个工作线程:
#[tokio::main(flavor = "multi_thread", worker_threads = 1)]
我们创建了两个任务,每个任务代表一个人,这将导致 36 秒的运行时间:
let person_one = tokio::task::spawn(async {
let coffee_mug_step = prep_coffee_mug();
let coffee_step = make_coffee();
let toast_step = make_toast();
tokio::join!(coffee_mug_step, coffee_step, toast_step);
}).await;
let person_two = tokio::task::spawn(async {
let coffee_mug_step = prep_coffee_mug();
let coffee_step = make_coffee();
let toast_step = make_toast();
tokio::join!(coffee_mug_step, coffee_step, toast_step);
}).await;
我们可以通过使用 join 来重新定义任务,而不是阻塞未来:
let person_one = tokio::task::spawn(async {
let coffee_mug_step = prep_coffee_mug();
let coffee_step = make_coffee();
let toast_step = make_toast();
tokio::join!(coffee_mug_step, coffee_step, toast_step);
});
let person_two = tokio::task::spawn(async {
let coffee_mug_step = prep_coffee_mug();
let coffee_step = make_coffee();
let toast_step = make_toast();
tokio::join!(coffee_mug_step, coffee_step, toast_step);
});
let _ = tokio::join!(person_one, person_two);
连接两个任务表示两个不同的人将导致 28 秒的运行时间。连接三个任务将导致 42 秒的运行时间。考虑到每个任务的总阻塞时间是 14 秒,时间增加是可以理解的。从时间线性的增加我们可以推断出,尽管三个任务被发送到异步运行时并放入队列中,但执行器在遇到 await 时将任务设置为空闲,并在轮询空闲任务时继续处理队列中的下一个任务。
异步运行时可以有多个工作线程和队列,我们将在第三章中探讨如何编写我们自己的运行时。根据我们在本节中所涵盖的内容,我们可以给出以下任务定义:
任务是一个异步计算或操作,由执行器管理并推动其完成。它代表一个未来的执行,并可能涉及多个未来的组合或链式执行。
现在,让我们讨论未来(future)。
Futures
异步编程的一个关键特性是未来(future)的概念。我们之前提到,未来是一个占位符对象,表示一个尚未完成的异步操作的结果。未来使得你能够启动一个任务并在后台执行任务的同时继续进行其他操作。
为了真正理解未来是如何工作的,我们需要讲解它的生命周期。当一个未来被创建时,它是空闲的,还没有被执行。一旦未来开始执行,它可以产生一个值、完成(resolve),或者因为未来仍在等待结果而进入挂起状态(pending)。当未来再次被轮询时,轮询结果可以是 Pending 或 Ready。未来会继续被轮询,直到它被解决或被取消。
为了演示未来是如何工作的,我们将构建一个简单的计数器未来。它将计数到 5,然后准备就绪。首先,我们需要导入以下内容:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::Duration;
use tokio::task::JoinHandle;
你应该能够理解这段代码的大部分内容。我们将在构建基本的未来后讨论 Context 和 Pin。由于我们的未来是一个计数器,结构体将如下所示:
struct CounterFuture {
count: u32,
}
然后我们实现 Future 特性:
impl Future for CounterFuture {
type Output = u32;
fn poll(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>
) -> Poll<Self::Output> {
self.count += 1;
println!("polling with result: {}", self.count);
std::thread::sleep(Duration::from_secs(1));
if self.count < 5 {
cx.waker().wake_by_ref();
Poll::Pending
} else {
Poll::Ready(self.count)
}
}
}
此时我们不关注 Pin 或 Context,而是关注 poll 函数的整体逻辑。每次未来被轮询时,计数会增加 1。如果计数为 3,我们就表示未来已经准备好。我们引入了 std::thread::sleep 函数,仅仅是为了夸大所需时间,方便在运行代码时更易于跟踪这个示例。为了运行我们的未来,我们只需要以下代码:
#[tokio::main]
async fn main() {
let counter_one = CounterFuture { count: 0 };
let counter_two = CounterFuture { count: 0 };
let handle_one: JoinHandle<u32> = tokio::task::spawn(async move {
counter_one.await
});
let handle_two: JoinHandle<u32> = tokio::task::spawn(async move {
counter_two.await
});
tokio::join!(handle_one, handle_two);
}
在不同任务中运行两个我们的未来将得到以下输出:
polling with result: 1
polling with result: 1
polling with result: 2
polling with result: 2
polling with result: 3
polling with result: 3
polling with result: 4
polling with result: 4
polling with result: 5
polling with result: 5
其中一个未来被从队列中取出,进行轮询并设置为空闲状态,而另一个未来则从任务队列中取出并进行轮询。这些未来是交替进行轮询的。你可能已经注意到,我们的 poll 函数并不是异步的。这是因为异步的 poll 函数会导致循环依赖,因为你会在轮询一个未来的过程中再发送一个未来进行轮询。通过这一点,我们可以看到,未来是异步计算的基石。
poll 函数接受一个自身的可变引用。然而,这个可变引用是被 Pin 包裹的,我们需要进一步讨论 Pin。
Futures 中的 Pin
在 Rust 中,编译器经常在内存中移动值。例如,如果我们将一个变量传递给一个函数,内存地址可能会被移动。
不仅仅是移动值会导致内存地址的变化。集合类型也可能改变内存地址。例如,当一个向量达到容量时,它会在内存中重新分配,从而改变内存地址。
大多数常见的基本类型(如数字、字符串、布尔值、结构体和枚举)都实现了 Unpin 特性,这使得它们可以在内存中移动。如果你不确定你的数据类型是否实现了 Unpin 特性,可以运行 doc 命令并检查数据类型实现的特性。例如,图 2-1 显示了标准文档中 i32 类型的自动特性实现。
为什么我们关注固定(Pinning)和解固定(Unpinning)?我们知道,未来(futures)是会被移动的,因为在代码中生成任务时,我们通常会使用 async move。然而,移动可能是危险的。为了演示这一点,我们可以构建一个基本的结构体,它引用了自身:
use std::ptr;
struct SelfReferential {
data: String,
self_pointer: *const String,
}
*const String 是一个指向字符串的原始指针。这个指针直接引用数据的内存地址。原始指针不提供任何安全保证。因此,如果指针指向的数据发生移动,引用并不会更新。我们使用原始指针来展示为什么固定(pinning)是必要的。为了演示这一点,我们需要定义结构体的构造函数,并打印结构体引用的数据,如下所示:
impl SelfReferential {
fn new(data: String) -> SelfReferential {
let mut sr = SelfReferential {
data,
self_pointer: ptr::null(),
};
sr.self_pointer = &sr.data as *const String;
sr
}
fn print(&self) {
unsafe {
println!("{}", *self.self_pointer);
}
}
}
接下来,我们通过创建两个 SelfReferential 结构体实例,交换这些实例的内存位置,并打印原始指针指向的数据,以暴露结构体移动的危险:
fn main() {
let first = SelfReferential::new("first".to_string());
let moved_first = first; // 移动结构体
moved_first.print();
}
如果你尝试运行这段代码,你将得到一个错误,可能是一个段错误(segmentation fault)。段错误是由于访问了程序不拥有的内存导致的错误。我们可以看到,移动一个引用自身的结构体是危险的。固定(pinning)可以确保未来(future)保持在一个固定的内存地址。这一点很重要,因为未来(future)可以被暂停或恢复,这可能会改变其内存地址。
我们已经涵盖了我们定义的基本未来(future)中的几乎所有组件,剩下的唯一组件就是上下文(context)。
Futures 中的 Context
Context 仅用于提供对 waker 的访问,以唤醒任务。waker 是一个句柄,用于通知执行器任务何时准备好运行。
尽管这是 Context 当前的主要功能,但需要注意的是,这一功能可能会在未来有所发展。Context 的设计留有扩展的空间,例如随着 Rust 异步生态系统的成长,可能会引入额外的责任或能力。
让我们看一下简化版的 poll 函数,以便我们专注于唤醒未来的路径:
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
. . .
if self.count < 5 {
cx.waker().wake_by_ref();
Poll::Pending
} else {
Poll::Ready(self.count)
}
}
waker 被包装在 Context 中,只有在 poll 的结果是 Pending 时才会被使用。waker 实际上是在唤醒未来,以便它可以执行。如果未来已经完成,则不需要执行任何操作。如果我们移除 waker 并再次运行程序,输出将是:
polling with result: 1
polling with result: 1
我们的程序没有完成,程序挂起了。这是因为我们的任务仍然处于空闲状态,但没有办法再次唤醒它们以进行轮询并执行到完成。未来需要 Waker::wake() 函数,以便在未来应重新轮询时调用。这个过程的步骤如下:
- 调用未来的
poll函数,结果是未来需要等待一个异步操作完成,才能返回一个值。 - 未来通过调用引用
waker的方法,注册对操作完成的通知。 - 执行器注意到未来操作的兴趣,并将
waker存储在队列中。 - 在某个稍后的时间,操作完成,执行器收到通知。执行器从队列中取出
waker并调用wake_by_ref,唤醒未来。 wake_by_ref函数向相关任务发出信号,任务应该被安排执行。这种方式可以根据运行时的不同有所变化。- 当未来被执行时,执行器将再次调用未来的
poll方法,未来将确定操作是否已完成,如果已完成,则返回一个值。
我们可以看到,未来与 async/await 函数一起使用,但我们也可以思考它们还可以如何使用。例如,我们可以在执行线程上使用超时:当一定时间过去时,线程结束,这样就不会导致程序无限挂起。这在某些函数可能需要较长时间才能完成时非常有用,我们希望能够继续或早期错误。记住,线程为执行任务提供底层功能。我们从 tokio::time 中导入 timeout,并设置一个慢任务。在这个示例中,我们通过让任务睡眠 10 秒来夸大效果:
use std::time::Duration;
use tokio::time::timeout;
async fn slow_task() -> &'static str {
tokio::time::sleep(Duration::from_secs(10)).await;
"Slow Task Completed"
}
现在我们设置超时,在此例中,超时设置为 3 秒。如果未来没有在这 3 秒内完成,线程将结束。我们匹配结果并打印出 "Task timed out":
#[tokio::main]
async fn main() {
let duration = Duration::from_secs(3);
let result = timeout(duration, slow_task()).await;
match result {
Ok(value) => println!("Task completed successfully: {}", value),
Err(_) => println!("Task timed out"),
}
}
取消安全性
当我们对未来(future)应用超时时间时,如前面的示例所示,如果未来(在此案例中为
slow_task)未在指定的时间内完成,它可能会被取消。这就引入了取消安全性(cancel safety)这一概念。取消安全性确保当一个未来被取消时,它所使用的任何状态或资源都会被正确处理。如果任务在执行过程中被取消,它不应该使系统处于不良状态,比如持有锁、留下打开的文件或部分修改数据。
在 Rust 的异步生态系统中,大多数操作默认都是取消安全的;它们可以被安全地中断,不会导致问题。然而,仍然建议了解任务如何与外部资源或状态进行交互,并确保这些交互是取消安全的。
在我们的示例中,如果由于超时而取消了
slow_task(),任务本身将被简单地停止,超时将返回一个错误,表明任务未按时完成。由于tokio::time::sleep是一个取消安全的操作,因此不会发生资源泄漏或不一致的状态。然而,如果任务涉及更复杂的操作,例如网络通信或文件 I/O,那么可能需要额外小心,确保取消操作得到适当处理。
对于 CPU 密集型的工作,我们还可以将工作卸载到一个独立的线程池中,未来(future)将在工作完成时解析。现在,我们已经涵盖了未来(futures)的上下文。
直接轮询并不是最高效的方式,因为我们的执行器将忙于轮询那些尚未准备好的未来。为了说明我们如何防止忙碌轮询,我们将讨论如何远程唤醒未来(futures)。
远程唤醒 Futures
假设我们使用异步 Rust 进行网络调用,网络调用的路由和响应的接收发生在我们的 Rust 程序之外。考虑到这一点,持续轮询我们的网络 future 直到操作系统给出信号,表示数据已经在我们正在监听的端口上接收,是没有意义的。我们可以通过外部引用 future 的 waker,并在需要时唤醒 future,从而避免持续的轮询。
为了演示这一点,我们可以使用通道模拟外部调用。首先,我们需要导入以下内容:
use std::pin::Pin;
use std::task::{Context, Poll, Waker};
use std::sync::{Arc, Mutex};
use std::future::Future;
use tokio::sync::mpsc;
use tokio::task;
通过这些导入,我们现在可以定义我们的 future,结构体形式如下:
struct MyFuture {
state: Arc<Mutex<MyFutureState>>,
}
struct MyFutureState {
data: Option<Vec<u8>>,
waker: Option<Waker>,
}
在这里,我们可以看到 MyFuture 的状态可以从另一个线程访问。MyFuture 的状态包含了 waker 和 data。为了让我们的主函数更加简洁,我们为 MyFuture 定义了一个构造函数:
impl MyFuture {
fn new() -> (Self, Arc<Mutex<MyFutureState>>) {
let state = Arc::new(Mutex::new(MyFutureState {
data: None,
waker: None,
}));
(
MyFuture {
state: state.clone(),
},
state,
)
}
}
在构造函数中,我们构建了 future,同时返回一个对状态的引用,以便我们可以在 future 外部访问 waker。最后,我们为我们的 future 实现了 Future 特性:
impl Future for MyFuture {
type Output = String;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>)
-> Poll<Self::Output> {
println!("Polling the future");
let mut state = self.state.lock().unwrap();
if state.data.is_some() {
let data = state.data.take().unwrap();
Poll::Ready(String::from_utf8(data).unwrap())
} else {
state.waker = Some(cx.waker().clone());
Poll::Pending
}
}
}
在这个实现中,每次我们轮询 future 时,都会打印信息来跟踪我们轮询了多少次 future。然后我们访问状态,检查是否有数据。如果没有数据,我们将 waker 存入状态,这样我们可以在 future 外部唤醒它。如果状态中有数据,我们知道任务已经准备好,就返回 Ready。
我们的 future 现在已经准备好进行测试。在主函数中,我们创建 future,通信通道,并启动我们的 future,代码如下:
let (my_future, state) = MyFuture::new();
let (tx, mut rx) = mpsc::channel::<()>(1);
let task_handle = task::spawn(async {
my_future.await
});
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
println!("spawning trigger task");
我们可以看到,我们让程序休眠 3 秒。这段休眠给了我们时间检查是否在轮询多次。如果我们的方法按预期工作,我们应该只会在休眠期间得到一次轮询。接下来,我们启动我们的触发任务,代码如下:
let trigger_task = task::spawn(async move {
rx.recv().await;
let mut state = state.lock().unwrap();
state.data = Some(b"Hello from the outside".to_vec());
loop {
if let Some(waker) = state.waker.take() {
waker.wake();
break;
}
}
});
tx.send(()).await.unwrap();
我们可以看到,一旦触发任务接收到通道中的消息,它就获取到 future 的状态并填充数据。接着我们检查是否存在 waker。一旦拿到 waker,我们就唤醒 future。
最后,我们通过以下代码等待两个异步任务:
let outome = task_handle.await.unwrap();
println!("Task completed with outcome: {}", outome);
trigger_task.await.unwrap();
如果我们运行代码,输出将是:
Polling the future
spawning trigger task
Polling the future
Task completed with outcome: Hello from the outside
我们可以看到,轮询只会在初始化时执行一次,然后在我们用数据唤醒 future 时执行第二次。异步运行时为我们提供了高效的方式来监听操作系统事件,这样就不需要盲目地轮询 future。例如,Tokio 拥有一个事件循环来监听操作系统事件,然后处理它们,以便事件可以唤醒正确的任务。然而,在本书中,我们希望保持代码示例的简单性,因此我们将在 poll 函数中直接调用 waker。这样做是为了减少在专注于异步编程其他领域时不必要的代码。
现在我们已经讲解了如何从外部事件唤醒 future,接下来我们将讨论如何在多个 future 之间共享数据。
在 Futures 之间共享数据
虽然在 futures 之间共享数据可能会使事情变得复杂,但它确实是有用的。我们可能希望在 futures 之间共享数据,原因包括:
- 聚合结果
- 依赖计算
- 缓存结果
- 同步
- 共享状态
- 任务协调和监督
- 资源管理
- 错误传播
虽然在 futures 之间共享数据是有用的,但在进行数据共享时,我们需要注意一些事项。我们可以通过一个简单的示例来突出这些注意点。首先,我们将依赖标准的 Mutex,并进行以下导入:
use std::sync::{Arc, Mutex};
use tokio::task::JoinHandle;
use core::task::Poll;
use tokio::time::Duration;
use std::task::Context;
use std::pin::Pin;
use std::future::Future;
我们使用标准库中的 Mutex 而不是 Tokio 版本的 Mutex,因为我们不希望在 poll 函数中使用异步功能。
对于我们的示例,我们将使用一个基本的结构体,它有一个计数器。一个异步任务将用于增加计数,另一个任务将用于减少计数。如果两个任务访问共享数据的次数相同,最终结果将为零。因此,我们需要构建一个基本的枚举来定义任务的类型,代码如下:
#[derive(Debug)]
enum CounterType {
Increment,
Decrement
}
接下来,我们定义共享数据的结构体,如下所示:
struct SharedData {
counter: i32,
}
impl SharedData {
fn increment(&mut self) {
self.counter += 1;
}
fn decrement(&mut self) {
self.counter -= 1;
}
}
现在我们定义了共享数据结构体后,可以使用以下代码定义我们的计数器 future:
struct CounterFuture {
counter_type: CounterType,
data_reference: Arc<Mutex<SharedData>>,
count: u32
}
在这里,我们定义了 future 将在共享数据上执行的操作类型。我们还可以访问共享数据,并使用计数器来在共享数据的执行次数达到预定值时停止 future。
poll 函数的签名如下所示:
impl Future for CounterFuture {
type Output = u32;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>)
-> Poll<Self::Output> {
. . .
}
}
在我们的 poll 函数中,首先通过以下代码访问共享数据:
std::thread::sleep(Duration::from_secs(1));
let mut guard = match self.data_reference.try_lock() {
Ok(guard) => guard,
Err(error) => {
println!(
"error for {:?}: {}",
self.counter_type, error
);
cx.waker().wake_by_ref();
return Poll::Pending
}
};
我们使用 sleep 来夸大时间差异,以便在运行程序时更容易跟踪程序的流程。然后我们使用 try_lock,这是因为我们使用的是标准库中的 Mutex。如果使用 Tokio 版本的 Mutex 会更好,但请记住我们的 poll 函数不能是异步的。这里就有一个问题。如果我们使用标准的 lock 函数来获取 Mutex,我们可能会阻塞线程,直到锁被获取。请记住,在我们的运行时中,可能有一个线程在处理多个任务。如果我们将整个线程阻塞,直到获取 Mutex,那就违背了异步运行时的初衷。因此,try_lock 函数会尝试获取锁,并立即返回一个结果,指示是否成功获取了锁。如果没有获取到锁,我们将打印出错误信息(仅用于教育目的),然后返回 Poll::Pending。这意味着 future 将继续被轮询,直到锁被获取,从而避免不必要地阻塞异步运行时。
如果成功获取了锁,我们就可以继续执行 poll 函数,操作共享数据,代码如下:
let value = &mut *guard;
match self.counter_type {
CounterType::Increment => {
value.increment();
println!("after increment: {}", value.counter);
},
CounterType::Decrement => {
value.decrement();
println!("after decrement: {}", value.counter);
}
}
现在共享数据已经被修改,我们可以根据计数器的值返回相应的结果,代码如下:
std::mem::drop(guard);
self.count += 1;
if self.count < 3 {
cx.waker().wake_by_ref();
return Poll::Pending
} else {
return Poll::Ready(self.count)
}
通过上述代码,我们实现了在异步任务中共享和操作数据的基本过程。在这个过程中,我们通过 try_lock 来确保不会阻塞线程,并且在适当的时候唤醒 future。
我们可以看到,在处理返回结果之前,我们先丢弃了 guard。这样做可以增加 guard 可用的时间,从而使其他 future 能够使用它,并且使我们能够更新 self.count。
运行我们 future 的两个不同变体可以通过以下代码实现:
#[tokio::main]
async fn main() {
let shared_data = Arc::new(Mutex::new(SharedData{counter: 0}));
let counter_one = CounterFuture {
counter_type: CounterType::Increment,
data_reference: shared_data.clone(),
count: 0
};
let counter_two = CounterFuture {
counter_type: CounterType::Decrement,
data_reference: shared_data.clone(),
count: 0
};
let handle_one: JoinHandle<u32> = tokio::task::spawn(async move {
counter_one.await
});
let handle_two: JoinHandle<u32> = tokio::task::spawn(async move {
counter_two.await
});
tokio::join!(handle_one, handle_two);
}
我们需要多次运行程序才能打印出错误,但当获取锁时发生错误时,我们得到了以下输出:
after decrement: -1
after increment: 0
error for Increment: try_lock failed because the operation would block
after decrement: -1
after increment: 0
after decrement: -1
after increment: 0
最终结果仍然是零,因此错误并没有影响整体结果。只是 future 被重新轮询了一次。虽然这个过程很有趣,但我们也可以使用第三方库(如 Tokio)提供的更高层次的抽象来模拟完全相同的行为,从而实现更简洁的实现。
高级 Futures 之间的数据共享
我们在上一节中构建的 future 可以替换为以下的异步函数:
async fn count(count: u32, data: Arc<tokio::sync::Mutex<SharedData>>,
counter_type: CounterType) -> u32 {
for _ in 0..count {
let mut data = data.lock().await;
match counter_type {
CounterType::Increment => {
data.increment();
println!("after increment: {}", data.counter);
},
CounterType::Decrement => {
data.decrement();
println!("after decrement: {}", data.counter);
}
}
std::mem::drop(data);
std::thread::sleep(Duration::from_secs(1));
}
return count;
}
在这里,我们仅仅是通过异步方式获取锁,并通过睡眠来允许第二个 future 对共享数据进行操作。这可以通过以下代码轻松运行:
let shared_data = Arc::new(tokio::sync::Mutex::new(SharedData{counter: 0}));
let shared_two = shared_data.clone();
let handle_one: JoinHandle<u32> = tokio::task::spawn(async move {
count(3, shared_data, CounterType::Increment).await
});
let handle_two: JoinHandle<u32> = tokio::task::spawn(async move {
count(3, shared_two, CounterType::Decrement).await
});
tokio::join!(handle_one, handle_two);
如果我们运行这段代码,我们将得到与上一节中的 futures 相同的输出和行为。然而,它显然更简单、更容易编写。两种方法各有利弊。例如,如果我们只是想编写具有我们已编码行为的 futures,使用异步函数显然更合适。然而,如果我们需要更细粒度地控制 future 的轮询方式,或者如果我们无法访问异步实现,但我们有一个尝试执行的阻塞函数,那么自己编写 poll 函数可能更合适。
Rust 中 Futures 的不同之处
其他编程语言也实现了异步编程的 futures,其中一些语言依赖于回调模型。回调模型使用一个函数,该函数在另一个函数完成时触发。这个回调函数通常作为参数传递给该函数。这个模型在 Rust 中不起作用,因为回调模型依赖动态调度,这意味着在运行时决定实际要调用的函数,而不是编译时决定。这产生了额外的开销,因为程序必须在运行时确定调用哪个函数。这违反了零成本抽象的原则,并导致性能下降。
Rust 采用了一种替代方法,旨在通过使用
Future特性来优化运行时性能,Future使用轮询(poll)。运行时负责管理何时调用轮询。它不需要调度回调函数,也不需要担心确定调用哪个函数,而是通过轮询来检查未来(future)是否完成。这种方式更加高效,因为 futures 可以被表示为一个状态机,并且所有状态都保存在单一的堆内存分配中。状态机捕捉了执行异步函数所需的局部变量。这意味着每个任务都有一个内存分配,不需要担心内存分配的大小不合适。这个决策充分体现了 Rust 编程语言的特点,开发者花时间确保实现的正确性。很多时候,我们并不是孤立地使用
async/await,而是在任务完成时做其他事情。我们可以通过像and_then或or_else这样的特定组合器来指定这些操作,这些组合器是 Tokio 提供的。
Futures 是如何被处理的?
让我们通过高层次地概述未来(future)是如何被处理的过程:
创建一个 future
一个 future 可以通过多种方式创建。常见的方法之一是通过在函数前加上 async 关键字来定义一个异步函数。然而,正如我们之前看到的,你也可以通过自己实现 Future 特性手动创建一个 future。当我们调用一个异步函数时,它返回一个 future。此时,future 还没有执行任何计算,await 也没有被调用。
启动一个任务
我们通过对 future 使用 await 来启动一个任务,这意味着我们注册到一个执行器(executor)。然后,执行器负责将任务执行完毕。为了做到这一点,它维护了一个任务队列。
轮询任务
执行器通过调用 poll 方法来处理任务中的 futures。这是 Future 特性的一个功能,即使你编写自己的 future,也需要实现它。future 要么已经准备好(ready),要么仍然处于等待状态(pending)。
安排下一次执行
如果 future 还没有准备好(即不为 ready),执行器将任务放回队列,等待未来的某个时刻再次执行。
完成 future
最终,所有任务中的 futures 都会完成,poll 将返回 ready。我们应该注意,结果可能是 Result 或 Error。此时,执行器可以释放任何不再需要的资源,并将结果传递下去。
关于异步运行时的说明
需要注意的是,异步运行时的实现有不同的变种,Tokio 的异步运行时要复杂得多,并将在第 7 章中详细介绍。
我们已经涵盖了为什么我们需要固定 futures 以防止未定义行为、futures 中的上下文以及 futures 之间的数据共享。为了巩固我们所学的内容,接下来我们可以进入第 3 章,在实际项目中实现我们在任务和 futures 部分所讨论的内容。
将所有内容结合起来
我们已经讨论了任务和未来(futures),以及它们如何与异步编程相关联。现在,我们将编写一个实现了本章内容的系统。对于我们的这个问题,我们可以设想有一个服务器或守护进程,它接收请求或消息。接收到的数据需要记录到文件中,以便我们在需要时检查发生了什么。这个问题的关键是我们无法预测何时会发生日志记录。例如,如果我们只是将数据写入文件,单一的写入操作可以是阻塞的。然而,来自不同程序的多个请求可能会导致显著的开销。因此,将写入任务发送给异步运行时,并在可能时将日志写入文件是合理的。需要注意的是,这个示例是用于教育目的的。虽然异步写入文件对本地应用程序可能很有用,但如果你的服务器设计用于处理大量流量,则应该考虑使用数据库选项。
在下面的示例中,我们为一个记录交互的应用程序创建了一个审计日志。这是许多使用敏感数据的产品(例如医疗行业)中的重要部分。我们希望记录用户的操作,但我们不希望这个记录操作阻塞程序的执行,因为我们仍然希望提供快速的用户体验。为了使这个练习工作,你将需要以下依赖:
[dependencies]
tokio = { version = "1..39.0", features = ["full"] }
futures-util = "0.3"
使用这些依赖,我们需要导入以下内容:
use std::fs::{File, OpenOptions};
use std::io::prelude::*;
use std::sync::{Arc, Mutex};
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::task::JoinHandle;
use futures_util::future::join_all;
到这时,基本上所有内容都应该能理解,并且你应该能够推测它们的用途。我们将在整个程序中引用 handle,所以我们不妨现在就定义它的类型:
type AsyncFileHandle = Arc<Mutex<File>>;
type FileJoinHandle = JoinHandle<Result<bool, String>>;
考虑到我们不希望两个任务同时写入文件,确保每次只有一个任务拥有文件的可变访问权限是有意义的。
我们可能需要写入多个文件。例如,我们可能希望将所有的登录信息写入一个文件,将错误消息写入另一个文件。如果系统中有病人的信息,可能会希望为每个病人创建一个日志文件(因为你可能会按病人逐个检查日志文件),并且你希望防止未授权人员查看他们无权查看的病人记录。考虑到需要多个文件进行日志记录,我们可以创建一个函数,用于创建文件或获取现有文件的句柄,如下所示:
fn get_handle(file_path: &dyn ToString) -> AsyncFileHandle {
match OpenOptions::new().append(true).open(file_path.to_string()) {
Ok(opened_file) => {
Arc::new(Mutex::new(opened_file))
},
Err(_) => {
Arc::new(Mutex::new(File::create(file_path.to_string()).unwrap()))
}
}
}
现在我们有了文件句柄,接下来需要编写一个未来(future),用于写入日志。我们的 future 结构体如下所示:
struct AsyncWriteFuture {
pub handle: AsyncFileHandle,
pub entry: String
}
现在,我们可以为 AsyncWriteFuture 结构体实现 Future 特性并定义 poll 函数。我们将使用本章中介绍的相同方法。因此,你可以尝试自己编写 Future 的实现和 poll 函数。希望你的实现看起来像这样:
impl Future for AsyncWriteFuture {
type Output = Result<bool, String>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let mut guard = match self.handle.try_lock() {
Ok(guard) => guard,
Err(error) => {
println!("error for {} : {}", self.entry, error);
cx.waker().wake_by_ref();
return Poll::Pending;
}
};
let lined_entry = format!("{}\n", self.entry);
match guard.write_all(lined_entry.as_bytes()) {
Ok(_) => println!("written for: {}", self.entry),
Err(e) => println!("{}", e)
};
Poll::Ready(Ok(true))
}
}
Self::Output 的类型并不非常重要。我们决定使用一个 true 值来表示写入成功,但一个空的 bool 或其他类型也可以。上述代码的主要关注点是我们尝试获取文件句柄的锁。如果没有成功获取锁,我们返回 Pending。如果成功获取锁,我们就将条目写入文件。
当涉及到写入日志时,对于其他开发人员来说,构造我们的 future 并将任务提交到异步运行时并不是特别直观。他们只想写入日志文件。因此,我们需要编写一个自己的 write_log 函数,它接受文件的句柄和要写入日志的行。我们可以在这个函数内部启动一个 Tokio 任务并返回任务的句柄。这是一个很好的机会,你可以尝试自己编写这个函数。
如果你尝试自己编写 write_log 函数,它应该采用与以下代码类似的方法:
fn write_log(file_handle: AsyncFileHandle, line: String) -> FileJoinHandle {
let future = AsyncWriteFuture{
handle: file_handle,
entry: line
};
tokio::task::spawn(async move {
future.await
})
}
需要注意的是,即使这个函数在函数定义前没有加上 async,它仍然表现得像一个异步函数。我们可以调用它并获取句柄,然后在程序的后续部分选择是否等待它,如下所示:
let handle = write_log(file_handle, name.to_string());
或者,我们可以直接等待它:
let result = write_log(file_handle, name.to_string()).await;
现在,我们可以通过以下主函数运行我们的异步日志记录函数:
#[tokio::main]
async fn main() {
let login_handle = get_handle(&"login.txt");
let logout_handle = get_handle(&"logout.txt");
let names = ["one", "two", "three", "four", "five", "six"];
let mut handles = Vec::new();
for name in names {
let file_handle = login_handle.clone();
let file_handle_two = logout_handle.clone();
let handle = write_log(file_handle, name.to_string());
let handle_two = write_log(file_handle_two, name.to_string());
handles.push(handle);
handles.push(handle_two);
}
let _ = join_all(handles).await;
}
如果你查看输出,你会看到类似于以下的内容。为了简洁起见,我们没有包含完整的输出。可以看到,由于 try_lock(),数字“六”没有被写入文件,但数字“五”成功写入:
...
error for six : try_lock failed because the operation would block
written for: five
error for six : try_lock failed because the operation would block
...
为了确保这一切按异步方式工作,让我们看看 login.txt 文件。你的文件可能顺序不同,但我的文件内容如下:
one
four
three
five
two
six
你可以看到,进入循环之前按顺序排列的数字已经被异步地写入并打乱了顺序。
确保异步操作的顺序
这是一个需要注意的重要观察点。获取锁的顺序是不确定的,因此我们不能假设日志的写入顺序。锁并不是导致这种无序的唯一原因。任何异步操作的响应延迟也会导致结果无序,因为当我们在等待一个结果时,我们处理了另一个结果。因此,在选择异步解决方案时,我们不能依赖结果按特定顺序处理。
如果顺序至关重要,那么保持单一的 future 并使用像队列这样的数据集合会减慢所有步骤的完成速度,但会确保步骤按照你需要的顺序处理。在这种情况下,如果我们需要按顺序写入文件,我们可以将队列包装在
Mutex中,并让一个 future 在每次轮询时负责检查队列。然后,另一个 future 可以向队列添加内容。增加访问队列的 futures 数量会妥协顺序的假设。虽然将访问队列的 futures 数量限制为每侧一个可以减少速度,但如果存在 I/O 延迟,我们仍然会受益。这是因为日志输入的等待不会阻塞我们的线程。
就这样!我们构建了一个异步日志记录函数,将其封装在一个函数中,使其易于与其他部分交互。希望这个工作示例能够巩固我们在本章中讨论的概念。
总结
在本章中,我们开始了对 Rust 中异步编程的探索,突出了任务在其中的重要角色。这些异步工作的单元,基于 futures,不仅仅是技术构造;它们是高效并发的基石。例如,考虑准备咖啡和吐司的日常任务。通过将其分解为异步块,我们亲身体验到,在代码中进行多任务处理,和我们日常生活中的多任务处理一样,既实用又节省时间。
然而,异步编程并不是确定性的,这意味着异步任务的执行顺序不是固定的。虽然这种不确定性最初可能令人畏惧,但它也为优化提供了机会。协作式多任务并不仅仅是一个技巧;它是一种策略,旨在最大化资源利用,这是我们在加速异步操作时所应用的。
我们还讨论了任务之间的数据共享,这虽然是一个有用的工具,但也可能带来双刃剑的效果。很容易认为访问共享数据是设计解决方案的好工具,但如果没有仔细控制,如我们在 Mutex 示例中展示的那样,它可能会导致无法预见的延迟和复杂性。这里有一个宝贵的教训:共享状态必须得到管理,不仅仅是为了保持顺序,更是为了维护代码流的清晰性。
最后,我们对 Future 特性的探讨不仅仅是学术练习;它为我们提供了理解和控制任务执行复杂性的视角。它提醒我们,权力伴随着责任——控制任务轮询的能力也伴随着理解每个 await 表达式影响的责任。在未来的工作中,请记住,实施和利用异步操作不仅仅是将任务启动起来,更是要理解每个异步表达式的内在动态。在第三章中,我们将进一步通过构建自己的异步队列,深入理解这些动态,并获得在 Rust 中定义和控制异步工作流所需的洞察力。