在整本书中,我们介绍了各种异步编程概念以及如何通过多种方式实现异步代码来解决问题。然而,我们知道软件工程并不是在真空中进行的。当你在实际应用中将异步编程知识付诸实践时,你将无法在完美的环境中单独应用异步代码。你可能会将异步代码应用到一个原本不支持异步编程的现有代码库中。你可能会与第三方服务(例如服务器)进行交互,在这种情况下,你需要处理服务器响应的变化。在本章中,我们将介绍一些设计模式,这些模式可以帮助你在解决各种问题时实现异步代码。
到本章结束时,你将能够在一个原本不支持异步编程的现有代码库中实现异步代码。你还将能够实现瀑布式设计模式,从而构建具有可重用异步组件的路径。你将能够实现装饰器模式,而不是修改我们的异步任务代码来添加新特性,这样你只需在运行或构建程序时添加一个编译标志,就可以轻松地插入额外的功能(例如日志记录)。最后,你将能够通过实现重试模式和断路器模式,使整个异步系统能够适应错误。
首先,在实现设计模式之前,我们需要能够在系统中实现异步代码。因此,我们应该从构建一个独立的模块开始。
构建独立模块
假设我们有一个没有任何异步代码的 Rust 代码库,我们希望将一些异步 Rust 代码集成到这个现有代码库中。与其重写整个代码库以纳入异步 Rust,我们建议将交互的影响范围保持较小。大规模重写通常很难按时完成,而且随着重写的延迟,更多功能被加入到现有代码库中,这可能威胁到重写工作的完成。因此,我们可以从小处着手,将异步代码写入一个独立的模块中,然后提供同步的入口点。同步入口点使得我们的异步模块可以在现有代码库中的任何地方实现。同步入口点还使得其他开发人员能够使用我们的异步模块,而无需了解异步编程。这简化了集成过程,其他开发人员可以在自己的时间里熟悉异步编程。
那么,如何在提供同步入口点的同时,仍然能享受到异步编程的好处呢?图 9-1 展示了一个高层次的流程,用于将异步编程的好处提供给非异步代码库。
如图 9-1 所示,我们将一个异步任务发送到运行时,并将该任务的句柄放入一个映射中,返回一个对应于该句柄的键。使用该模块的开发人员调用一个普通的阻塞函数,并收到一个唯一的 ID。任务正在异步运行时中执行,开发人员可以继续编写更多同步代码。当开发人员需要结果时,他们通过 get_add 函数传递唯一的 ID,该函数会阻塞同步代码,直到结果返回。开发人员将唯一的 ID 视为异步句柄,但不需要直接与任何异步代码进行交互。在我们实现这种方法之前,我们需要以下依赖项:
tokio = { version = "1.33.0", features = ["full"] }
uuid = { version = "1.5.0", features = ["v4"] }
有了这些依赖项后,我们可以创建一个 async_mod.rs 文件,将其与 main.rs 放在一起。我们的 async_mod.rs 文件将包含我们的异步模块代码。在这个文件中,我们需要以下导入:
use std::sync::LazyLock;
use tokio::runtime::{Runtime, Builder};
use tokio::task::JoinHandle;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
pub type AddFutMap = LazyLock<Arc<Mutex<HashMap<String, JoinHandle<i32>>>>>;
对于我们的运行时,我们将使用以下代码:
static TOKIO_RUNTIME: LazyLock<Runtime> = LazyLock::new(|| {
Builder::new_multi_thread()
.enable_all()
.build()
.expect("Failed to create Tokio runtime")
});
我们定义了一个简单的 async_add 函数,里面有一个 sleep 来模拟一个异步任务:
async fn async_add(a: i32, b: i32) -> i32 {
println!("starting async_add");
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
println!("finished async_add");
a + b
}
这是我们将暴露给异步运行时的核心异步任务,但不会暴露给模块外部,这就是为什么运行时和 async_add 函数不是公开的原因。
现在我们已经定义了异步运行时和 async_add 任务,我们可以构建我们的处理器。如图 9-2 所示,我们的处理器本质上是一个路由器,用于让我们的入口点与运行时和映射进行交互。
我们的处理器需要是一个函数,能够接受要相加的数字或获取结果的唯一 ID:
fn add_handler(a: Option<i32>, b: Option<i32>, id: Option<String>)
-> Result<(Option<i32>, Option<String>), String> {
static MAP: AddFutMap = LazyLock::new(|| Arc::new(
Mutex::new(HashMap::new())
));
match (a, b, id) {
(Some(a), Some(b), None) => {
// 处理提供的数字 a 和 b
. . .
},
(None, None, Some(id)) => {
// 处理提供的 ID 来获取结果
. . .
},
_ => Err(
"either a or b need to be provided or a handle_id".to_string()
)
}
}
注意
对于我们的示例,Option<i32> 输入适用于 add_handler 函数,因为用户不会直接与其交互。然而,如果你计划让 add_handler 支持更多操作,例如减法或乘法,最好传递一个枚举到 add_handler 函数中:
enum Operation {
Add { a: i32, b: i32 },
Multiply { a: i32, b: i32 },
Subtract { a: i32, b: i32 },
}
fn perform_operation(op: Operation) -> i32 {
match op {
Operation::Add { a, b } => a + b,
Operation::Multiply { a, b } => a * b,
Operation::Subtract { a, b } => a - b,
}
}
我们的未来映射是懒惰求值的,正如我们在第三章中定义 spawn_task 函数中的队列作为懒惰求值那样。如果我们调用处理函数并更新 MAP,下次调用该函数时,我们将在处理函数内获取更新后的 MAP。尽管我们只会从主线程的同步代码中调用处理函数,但我们不能保证其他开发人员不会启动一个线程并调用这个函数。
如果你百分之百确定该处理器只会在主线程中调用,你可以去掉 Arc 和 Mutex,使 MAP 可变,并在函数的其余部分使用 unsafe 代码来访问 MAP。然而,正如你可能猜到的那样,这种做法是不安全的。你也可以使用 thread_local 来去掉 Arc 和 Mutex。只要开发人员在任务启动的同一个线程中获取结果,这种方式是安全的。开发人员不需要访问程序中的整个映射,只需要访问存储他们异步句柄的映射。
在处理函数的第一个匹配分支中,我们提供了要相加的数字,因此我们会生成一个任务,将它与 MAP 中的唯一 ID 绑定,并返回该唯一 ID:
let handle = TOKIO_RUNTIME.spawn(async_add(a, b));
let id = uuid::Uuid::new_v4().to_string();
MAP.lock().unwrap().insert(id.clone(), handle);
Ok((None, Some(id)))
接下来,我们可以定义处理唯一 ID 获取任务结果的分支。在这里,我们从 MAP 中获取任务句柄,将其传递给异步运行时,阻塞当前线程直到结果返回,并返回该结果:
let handle = match MAP.lock().unwrap().remove(&id) {
Some(handle) => handle,
None => return Err("No handle found".to_string())
};
let result: i32 = match TOKIO_RUNTIME.block_on(async {
handle.await
}){
Ok(result) => result,
Err(e) => return Err(e.to_string())
};
Ok((Some(result), None))
现在我们的处理器可以工作了。需要注意的是,我们的处理器不是公开的。这是因为该接口并不简洁,使用我们的模块的开发人员可能会传递错误的输入组合。我们可以从第一个公共接口开始:
pub fn send_add(a: i32, b: i32) -> Result<String, String> {
match add_handler(Some(a), Some(b), None) {
Ok((None, Some(id))) => Ok(id),
Ok(_) => Err("Something went wrong, please contact author".to_string()),
Err(e) => Err(e)
}
}
我们强制开发人员提供两个整数,这些整数将传递给我们的处理器。然后我们返回唯一 ID。如果返回任何不是错误的变体,那么我们的实现肯定存在严重问题。为了帮助使用我们模块的开发人员节省调试时间,我们告诉他们联系作者,因为这是我们的问题需要解决。
获取结果的接口与发送接口类似,只是反过来了,形式如下:
pub fn get_add(id: String) -> Result<i32, String> {
match add_handler(None, None, Some(id)) {
Ok((Some(result), None)) => Ok(result),
Ok(_) => Err("Something went wrong, please contact author".to_string()),
Err(e) => Err(e)
}
}
现在我们的异步模块已经完成,我们可以在 main.rs 中使用它:
mod async_mod;
fn main() {
println!("Hello, world!");
let id = async_mod::send_add(1, 2).unwrap();
println!("id: {}", id);
std::thread::sleep(std::time::Duration::from_secs(4));
println!("main sleep done");
let result = async_mod::get_add(id).unwrap();
println!("result: {}", result);
}
运行代码将输出类似以下的内容:
Hello, world!
starting async_add
id: e2a2f3e1-2a77-432c-b0b8-923483ae637f
finished async_add
main sleep done
result: 3
你的 ID 会不同,但顺序应该是一样的。在这里,我们可以看到我们的异步任务正在处理,主线程继续执行,并且我们能够获得结果。我们可以看到我们的异步代码是多么隔离。现在我们有了自由去实验。例如,如果计算需求增加,我们将能够切换到本地集和使用本地线程状态来缓存最近计算的值,就像第七章中提到的那样。然而,我们的接口完全与异步原语解耦,因此其他使用我们模块的开发人员不会注意到差异,因此他们对我们接口的实现不会中断。
现在我们已经展示了如何实现一个对代码库其他部分影响最小的异步模块,我们可以在代码库中实现其他设计模式。我们可以从瀑布式设计模式开始。
瀑布设计模式
瀑布设计模式(也称为责任链模式)是一个异步任务的链条,各个任务直接将值传递给下一个任务,如图 9-3 所示。
实现基本的瀑布设计模式非常简单。在 Rust 中,我们可以利用错误处理系统来编写安全且简洁的代码。我们可以通过以下三个异步任务来演示这一点:
type WaterFallResult = Result<String, Box<dyn std::error::Error>>;
async fn task1() -> WaterFallResult {
Ok("Task 1 completed".to_string())
}
async fn task2(input: String) -> WaterFallResult {
Ok(format!("{} then Task 2 completed", input))
}
async fn task3(input: String) -> WaterFallResult {
Ok(format!("{} and finally Task 3 completed", input))
}
由于它们都返回相同的错误类型,因此它们通过 ? 运算符链接在一起:
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let output1 = task1().await?;
let output2 = task2(output1).await?;
let result = task3(output2).await?;
println!("{}", result);
Ok(())
}
瀑布式方法简单且可预测。它还使我们能够将异步任务作为构建块进行重用。例如,我们的三个异步任务可以接受 i32 数据类型。我们可以围绕这些异步任务添加逻辑,如下所示:
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let output1 = task1().await?;
let output2: i32;
if output1 > 10 {
output2 = task2(output1).await?;
} else {
output2 = task3(output1).await?;
}
println!("{}", output2);
Ok(())
}
考虑到我们如何使用逻辑来指导瀑布流的流程,我们可以看到瀑布实现可能对构建稍有不同但使用相同核心组件的路径非常有用。我们还可以根据需要轻松地在这些组件之间插入度量(metrics)。尽管在组件之间插入度量/日志记录可能很有用,但我们也可以使用装饰器模式为任务添加功能。
装饰器模式
装饰器模式是对某种功能的包装,它要么增强该功能,要么在主执行之前或之后执行某些逻辑。装饰器的经典例子是测试中的装置:单元测试在测试之前设置某些数据存储的状态,然后在测试之后销毁该状态。测试之间状态的设置和销毁确保了测试的原子性,且一个测试的失败不会影响其他测试的结果。这个状态管理可以包装在我们正在测试的代码周围。日志记录也是装饰器模式的经典用途,因为我们可以轻松关闭日志记录,而不需要更改核心逻辑。装饰器也用于会话管理。
在我们查看如何在异步上下文中实现装饰器模式之前,让我们先看一下如何为一个结构体实现基本的装饰器。我们的装饰器将对一个字符串进行增强。我们将装饰的功能将返回一个字符串,如下所示:
trait Greeting {
fn greet(&self) -> String;
}
struct HelloWorld;
impl Greeting for HelloWorld {
fn greet(&self) -> String {
"Hello, World!".to_string()
}
}
然后我们定义一个装饰器结构体,它实现了我们的 trait,并包含一个内嵌组件,该组件同样实现了我们的 trait:
struct ExcitedGreeting<T> {
inner: T,
}
impl<T> ExcitedGreeting<T> {
fn greet(&self) -> String
where
T: Greeting,
{
let mut greeting = self.inner.greet();
greeting.push_str(" I'm so excited to be in Rust!");
greeting
}
}
在这里,我们从内嵌结构体调用 trait 方法,并对字符串进行增强,返回修改后的字符串。我们可以轻松地测试我们的装饰器模式:
fn main() {
let raw_one = HelloWorld;
let raw_two = HelloWorld;
let decorated = ExcitedGreeting { inner: raw_two };
println!("{}", raw_one.greet());
println!("{}", decorated.greet());
}
我们可以轻松地将功能包装在我们的结构体周围。由于我们为装饰器实现了相同的 trait,我们还可以将包装后的结构体传递给期望实现该 trait 的函数。因此,如果我们期望的是 trait 而不是结构体,我们就不需要在代码库中更改任何代码。
我们甚至可以根据编译特性来使装饰器模式的实现变得依赖。例如,我们可以在 Cargo.toml 中添加一个特性:
[features]
logging_decorator = []
然后,我们可以重写我们的主函数,根据特性标志来编译是否使用装饰器逻辑:
fn main() {
#[cfg(feature = "logging_decorator")]
let hello = ExcitedGreeting { inner: HelloWorld };
#[cfg(not(feature = "logging_decorator"))]
let hello = HelloWorld;
println!("{}", hello.greet());
}
要运行我们的装饰器,我们需要在终端运行以下命令:
cargo run --features "logging_decorator"
如果需要,我们可以将这个特性设置为默认特性,并且如果该特性依赖于任何其他依赖项,我们也可以将额外的依赖项添加到该特性中。
现在你已经了解了装饰器的基本概念,我们可以在 Future 中实现相同的功能。我们将不再使用结构体,而是使用一个内嵌的 Future。在构建我们的 Future 之前,我们需要以下导入:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
对于这个装饰器,我们将实现一个日志记录的 trait,示例中会在轮询内嵌的 Future 之前调用一个日志函数。我们的日志记录 trait 形式如下:
trait Logging {
fn log(&self);
}
然后我们定义我们的日志记录结构体,它包含一个内嵌的 Future:
struct LoggingFuture<F: Future + Logging> {
inner: F,
}
impl<F: Future + Logging> Future for LoggingFuture<F> {
type Output = F::Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let inner = unsafe { self.map_unchecked_mut(|s| &mut s.inner) };
inner.log();
inner.poll(cx)
}
}
尽管我们在 poll 方法中使用了 unsafe 代码,但我们的代码是安全的。我们必须使用 unsafe 块,因为 Rust 编译器无法检查 pin 的投影。我们并没有从 pin 中移动值。
提示
虽然 unsafe 块是安全的,但我们可以通过以下代码避免使用 unsafe 标记:
struct LoggingFuture<F: Future + Logging> {
inner: Pin<Box<F>>,
}
impl<F: Future + Logging> Future for LoggingFuture<F> {
type Output = F::Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
let inner = this.inner.as_mut();
inner.log();
inner.poll(cx)
}
}
现在我们需要为任何实现了 Future 的类型实现 Logging trait:
impl<F: Future> Logging for F {
fn log(&self) {
println!("Polling the future!");
}
}
这意味着无论装饰器持有哪个 Future,我们都可以调用 log 函数。我们可以通过结合其他 trait 来进行创造性扩展,以便传递给装饰器的 Future 能够提供关于其未来状态的特定信息,但在这个示例中,我们仅展示如何实现异步装饰器。我们现在可以定义一个简单的 Future,将其包装并调用:
async fn my_async_function() -> String {
"Result of async computation".to_string()
}
#[tokio::main]
async fn main() {
let logged_future = LoggingFuture { inner: my_async_function() };
let result = logged_future.await;
println!("{}", result);
}
运行代码后,我们将得到以下输出:
Polling the future!
Result of async computation
在这里,我们可以看到我们的日志记录装饰器有效工作。我们还可以使用相同的编译特性方法来为装饰器启用不同的功能。
由于装饰器被设计为能够以最小的摩擦插入,并且具有相同的类型签名,它们不应对程序逻辑产生太大影响。如果我们希望根据某些条件来改变程序的流程,我们可以考虑使用状态机模式。
状态机模式
状态机保持一个特定的状态,并且包含如何更改该状态的逻辑。其他进程可以引用该状态来决定如何行动;这就是状态机模式。一个简单的现实世界的状态机例子是交通信号灯。根据国家的不同,交通信号灯的形式可能有所不同,但它们至少有两个状态:红灯和绿灯。根据系统的不同,一系列输入和硬编码的逻辑可以改变每个交通信号灯的状态。需要注意的是,驾驶员直接观察交通信号灯的状态,并根据状态采取行动。我们可以有任意数量的驾驶员,但合同保持不变。信号灯专注于维护状态,并根据输入改变状态,而驾驶员仅观察并对该状态做出反应。
通过这个类比,我们可以理解状态机可以用于任务调度和工作队列管理、网络通信、创建工作流和管道、以及控制具有不同状态并响应异步输入和定时事件的机器或系统。
实际上,为了进一步阐明,状态机的概念并不限于像交通信号灯这样的特定示例。Rust 的 async/await 模型也依赖于未来(futures)作为状态机的概念。一个 future 代表一个可能尚未可用的值,随着它的进展,它会经历不同的状态(例如,Pending、Ready),直到它产生一个结果或错误。
对于我们的示例,我们可以构建一个基本的开关状态,它可以是开(on)或关(off)。枚举在管理状态时非常有效,因为我们可以使用匹配模式(match),而枚举变体还可以包含数据。我们的简单状态如下所示:
enum State {
On,
Off,
}
接下来,我们定义一个事件状态,状态机通过该事件来改变状态:
enum Event {
SwitchOn,
SwitchOff,
}
现在我们有了事件和状态。事件和状态之间的接口可以通过以下代码定义:
impl State {
async fn transition(self, event: Event) -> Self {
match (&self, event) {
(State::On, Event::SwitchOff) => {
println!("Transitioning to the Off state");
State::Off
},
(State::Off, Event::SwitchOn) => {
println!("Transitioning to the On state");
State::On
},
_ => {
println!(
"No transition possible, staying in the current state"
);
self
},
}
}
}
在这里,我们可以看到,如果开关的状态是开启,那么关闭开关的事件将把状态切换为关闭,反之亦然。我们可以测试我们的状态机:
#[tokio::main]
async fn main() {
let mut state = State::On;
state = state.transition(Event::SwitchOff).await;
state = state.transition(Event::SwitchOn).await;
state = state.transition(Event::SwitchOn).await;
match state {
State::On => println!("State machine is in the On state"),
_ => println!("State machine is not in the expected state"),
}
}
运行此代码将输出以下内容:
Transitioning to the Off state
Transitioning to the On state
No transition possible, staying in the current state
State machine is in the On state
在我们的示例中,异步代码并不是必需的,但这只是因为我们的示例比较简单。我们可以使用异步代码,例如,通过互斥锁访问状态,或通过异步通道监听事件。如同我们在交通信号灯示例中所看到的,我们的状态机解耦了与状态相关的逻辑,而异步任务则在运行时中被处理。例如,我们的状态机可以是一个结构体,包含一个计数器和一个表示开关的枚举。当启动其他任务时,这些任务可以通过通道向我们的状态机发送事件以增加计数。当计数超过某个阈值时,状态机可以将状态切换为关闭。如果新任务需要检查状态机以确保开关状态为开启后才能启动,我们就实现了一个简单的信号系统,该系统限制了当任务计数过高时新异步任务的进展。然而,如果需要,我们也可以通过使用 AtomicUsize 替代 AtomicBool 来替换开关,但我们的状态机示例为我们提供了实现更复杂逻辑的基础。
我们的状态机还可以根据其状态轮询不同的 futures。以下代码展示了如何根据开关状态轮询不同的 futures:
struct StateFuture<F: Future, X: Future> {
pub state: State,
pub on_future: F,
pub off_future: X,
}
现在,状态机拥有了状态和两个 futures 进行轮询,我们可以实现轮询逻辑:
impl<F: Future, X: Future> Future for StateFuture<F, X> {
type Output = State;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
match self.state {
State::On => {
let inner = unsafe {
self.map_unchecked_mut(|s| &mut s.on_future)
};
let _ = inner.poll(cx);
cx.waker().wake_by_ref();
Poll::Pending
},
State::Off => {
let inner = unsafe {
self.map_unchecked_mut(|s| &mut s.off_future)
};
let _ = inner.poll(cx);
cx.waker().wake_by_ref();
Poll::Pending
},
}
}
}
在这个示例中,future 会不断在后台轮询。这使得我们的状态机可以根据状态切换其连续操作。添加额外的功能,例如通过通道监听事件来在轮询 futures 之前潜在地改变状态,也是可以轻松实现的。
回到我们之前提到的,如果任务计数过高,状态机限制新任务进展的情况,那么异步任务如何处理关闭状态呢?这就是重试模式(retry pattern)发挥作用的地方。
重试模式
我们可能会遇到异步任务在尝试访问某些资源时被阻塞的情况。这可能是我们的状态机表示任务过多,或者服务器超载。我们不希望异步任务放弃,因此重试可能会得到我们想要的结果。然而,我们也不希望无休止地向目标发送请求。如果服务器、互斥锁或数据库超载,最后一件我们想做的事就是用一个接一个的请求来淹没超载的目标。
重试模式允许异步任务重试请求。然而,在每次重试中,都会有一个延迟,并且这个延迟会在每次尝试时加倍。这种退避机制将允许我们的目标减小请求的频率,从而赶上目标正在处理的任务。
为了探索重试模式,我们首先定义一个始终返回错误的 get_data 函数:
async fn get_data() -> Result<String, Box<dyn std::error::Error>> {
Err("Error".into())
}
然后我们定义一个实现重试函数的异步任务:
async fn do_something() -> Result<(), Box<dyn std::error::Error>> {
let mut miliseconds = 1000;
let total_count = 5;
let mut count = 0;
let result: String;
loop {
match get_data().await {
Ok(data) => {
result = data;
break;
},
Err(err) => {
println!("Error: {}", err);
count += 1;
if count == total_count {
return Err(err);
}
}
}
tokio::time::sleep(
tokio::time::Duration::from_millis(miliseconds)
).await;
miliseconds *= 2;
}
Ok(())
}
我们运行我们的重试模式:
#[tokio::main]
async fn main() {
let outcome = do_something().await;
println!("Outcome: {:?}", outcome);
}
输出结果如下:
Error: Error
Error: Error
Error: Error
Error: Error
Error: Error
Outcome: Err("Error")
我们的重试模式工作正常。重试模式更多的是作为一个工具,而不是整个应用程序的设计选择。当一个异步任务需要访问目标时,在应用程序中散布使用重试模式将为系统提供更多灵活性,特别是当系统由于减少对目标的压力而能够处理流量激增时。
然而,如果我们一直遇到错误怎么办?肯定地,如果超过了某个阈值,继续生成任务就没有意义了。例如,如果一个服务器完全崩溃了,我们必须有一个状态,在这个状态下,我们不再通过发送更多的请求浪费 CPU 资源。这时,断路器模式(Circuit Breaker Pattern)可以帮助我们。
断路器模式
断路器模式在错误数量超过阈值时,阻止新任务的生成。与定义一个简单的状态机(状态为开或关)不同,我们可以使用两个简单的原子值来复制相同的效果,如下所示:
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::future::Future;
use tokio::task::JoinHandle;
static OPEN: AtomicBool = AtomicBool::new(false);
static COUNT: AtomicUsize = AtomicUsize::new(0);
其基本原理相当简单。如果 OPEN 为 true,我们认为电路是“开”的,不能再生成新任务。如果发生错误,我们将 COUNT 增加 1,并且如果 COUNT 超过阈值,就将 OPEN 设置为 true。我们还需要编写自己的 spawn_task 函数,在生成任务之前检查 OPEN 的状态。spawn_task 函数如下所示:
fn spawn_task<F, T>(future: F) -> Result<JoinHandle<T>, String>
where
F: Future<Output = T> + Send + 'static,
T: Send + 'static,
{
let open = OPEN.load(Ordering::SeqCst);
if open == false {
return Ok(tokio::task::spawn(future))
}
Err("Circuit Open".to_string())
}
接下来,我们可以定义两个简单的异步任务——一个任务抛出错误,另一个任务则正常运行:
async fn error_task() {
println!("error task running");
let count = COUNT.fetch_add(1, Ordering::SeqCst);
if count == 2 {
println!("opening circuit");
OPEN.store(true, Ordering::SeqCst);
}
}
async fn passing_task() {
println!("passing task running");
}
通过这些任务,我们可以确定系统何时会出现故障。我们可以测试当系统达到三次错误时,是否会出现故障:
#[tokio::main]
async fn main() -> Result<(), String> {
let _ = spawn_task(passing_task())?.await;
let _ = spawn_task(error_task())?.await;
let _ = spawn_task(error_task())?.await;
let _ = spawn_task(error_task())?.await;
let _ = spawn_task(passing_task())?.await;
Ok(())
}
这将输出以下内容:
passing task running
error task running
error task running
error task running
opening circuit
Error: "Circuit Open"
在达到阈值后,我们不能再生成新任务。达到阈值时,我们可以根据实际需求进行创意设计。例如,我们可以跟踪所有任务,仅在某些任务的个人阈值被突破时才阻止它们。我们还可以通过优雅地关闭程序并触发警报系统来停止程序,通知开发人员和 IT 员工程序已关闭。还可以通过时间快照,在一定时间后关闭电路。所有这些变种都取决于你正在解决的问题以及所需的解决方案。通过这个断路器模式,我们已经涵盖了足够的设计模式,以帮助你将异步代码实现到代码库中。
总结
在本章中,我们介绍了一系列设计模式,以帮助你实现本书中所学的异步代码。将代码库作为一个整体来思考是关键。如果你正在将异步代码集成到一个没有异步代码的现有代码库中,那么独立模块是显而易见的第一步。本章中的所有设计模式都通过简单的代码示例进行了选择。小而简单的步骤是实现异步代码的最佳方式。这种方法使得测试更加容易,并且如果近期的实现不再需要或破坏了代码中的其他部分,你可以轻松地回滚。
虽然提前应用设计模式可能很有诱惑力,但过度工程化通常是设计模式的一大批评。按照自己的方式编写代码,当设计模式自然而然地出现时再考虑实现它。当你为了强行应用某个设计模式而出发时,会增加实现的风险,导致过度工程化。理解你的设计模式非常重要,它能帮助你知道何时以及在哪里实现它们。
在第十章中,我们将介绍使用标准库而不依赖外部库的异步网络方法,通过这些方法我们将构建自己的异步 TCP 服务器。