异步Rust——响应式编程

564 阅读30分钟

响应式编程是一种编程范式,其中代码对数据值或事件的变化做出反应。响应式编程使我们能够构建能够实时动态响应变化的系统。需要强调的是,本章是在异步编程的背景下编写的。由于整个书籍都已经专门讨论了响应式编程,我们无法覆盖该主题的每一个方面。相反,我们将重点关注异步方式来轮询并响应数据变化,通过构建一个基本的加热器系统,在该系统中,未来值会根据温度变化做出反应。接下来,我们将通过使用原子操作、互斥锁和队列来构建一个事件总线,使我们能够将事件发布到多个订阅者。

在本章结束时,你将熟悉足够的异步数据共享概念,能够构建线程安全的、可变的数据结构。这些数据结构可以被多个并发的异步任务安全地操作。你还将能够实现观察者模式。在本章结束时,你将掌握构建异步 Rust 解决方案的技能,以应对你在进一步阅读中会遇到的响应式设计模式。

我们从构建一个基本的响应式系统开始。

构建基本的响应式系统

在构建我们的基本响应式系统时,我们将实现观察者模式。在这种模式下,我们有主题(Subject)和订阅该主题更新的观察者(Observer)。当主题发布更新时,观察者通常会根据观察者的具体需求对该更新作出反应。对于我们的基本响应式系统,我们将构建一个简单的加热系统。当温度低于所设定的目标温度时,系统会打开加热器,如图6-1所示。

image.png

在这个系统中,温度和目标温度是主题(subjects)。加热器和显示器是观察者(observers)。当温度低于设定的目标温度时,我们的加热器会开启。当温度发生变化时,我们的显示器会将温度打印到终端。在一个真实的系统中,我们通常会将系统连接到温度传感器上。然而,由于我们使用这个示例来探索响应式编程,我们跳过了硬件工程的部分,直接通过编程模拟加热器的效果和热量损失对温度的影响。现在我们已经设计好了系统,可以开始定义我们的主题了。

定义我们的主题

系统中的观察者将是具有不断轮询的 Future,因为它们将不断轮询主题,以查看主题是否发生变化。在开始构建温度系统之前,我们需要以下依赖项:

[dependencies]
tokio = { version = "1.26.0", features = ["full"] }
clearscreen = "2.0.1"

我们使用 clearscreen 来更新系统的显示,使用 Tokio crate 提供异步运行时的简单接口。LazyLock(现在已成为标准库的一部分)允许我们懒加载变量,即只有在第一次访问时才会创建它们。使用这些依赖项后,我们需要以下导入来构建系统:

use std::sync::Arc;
use std::sync::atomic::{AtomicI16, AtomicBool};
use core::sync::atomic::Ordering;
use std::sync::LazyLock;
use std::future::Future;
use std::task::Poll;
use std::pin::Pin;
use std::task::Context;
use std::time::{Instant, Duration};

现在我们已经拥有了所有需要的东西,可以开始定义我们的主题了:

static TEMP: LazyLock<Arc<AtomicI16>> = LazyLock::new(|| {
    Arc::new(AtomicI16::new(2090)) 
});
static DESIRED_TEMP: LazyLock<Arc<AtomicI16>> = LazyLock::new(|| {
    Arc::new(AtomicI16::new(2100)) 
});
static HEAT_ON: LazyLock<Arc<AtomicBool>> = LazyLock::new(|| {
    Arc::new(AtomicBool::new(false)) 
});

这些主题具有以下职责:

  • 系统的当前温度。
  • 我们希望房间达到的目标温度。
  • 加热器是否应开启或关闭。如果 booltrue,我们指示加热器开启;如果 boolfalse,加热器将关闭。

注意:
如果你曾经搜索过响应式编程或响应式系统,你可能会看到有关消息和事件的内容。消息和事件确实是响应式编程的一部分,但我们需要记住,软件开发的重要部分之一就是避免过度工程化我们的系统。系统越复杂,维护和修改就越困难。我们的系统有基本的反馈需求:加热器根据一个数值来开启或关闭。如果我们深入研究锁和线程间发送消息的通道,最终会发现,它们归结为锁的原子操作和其他数据集合来处理数据。目前,由于系统的简单要求,使用原子操作就足够了。

通过使用观察者订阅主题,我们解耦了代码。例如,我们可以通过让新的观察者来观察主题,轻松增加观察者的数量,而不需要修改现有主题的任何代码。

现在我们已经准备好定义我们的主题,下一步是构建一个观察者来显示我们的主题,并控制 HEAT_ON 主题。

构建我们的显示观察者

现在我们已经定义了主题,可以定义我们的显示 future

pub struct DisplayFuture {
    pub temp_snapshot: i16,
}

impl DisplayFuture {
    pub fn new() -> Self {
        DisplayFuture {
            temp_snapshot: TEMP.load(Ordering::SeqCst)
        }
    }
}

当我们创建 future 时,我们加载温度主题的值并将其存储。这里使用 Ordering::SeqCst 来确保温度值在所有线程中一致。严格的顺序保证了没有其他线程以我们看不到的方式修改温度。

然后,我们可以使用存储的温度与轮询时的当前温度进行比较:

impl Future for DisplayFuture {
    type Output = ();

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let current_snapshot = TEMP.load(Ordering::SeqCst); 
        let desired_temp = DESIRED_TEMP.load(Ordering::SeqCst);
        let heat_on = HEAT_ON.load(Ordering::SeqCst);

        if current_snapshot == self.temp_snapshot { 
            cx.waker().wake_by_ref();
            return Poll::Pending;
        }
        if current_snapshot < desired_temp && heat_on == false { 
            HEAT_ON.store(true, Ordering::SeqCst);
        }
        else if current_snapshot > desired_temp && heat_on == true { 
            HEAT_ON.store(false, Ordering::SeqCst);
        }
        clearscreen::clear().unwrap(); 
        println!("Temperature: {}\nDesired Temp: {}\nHeater On: {}", 
            current_snapshot as f32 / 100.0,
            desired_temp as f32 / 100.0,
            heat_on);
        self.temp_snapshot = current_snapshot; 
        cx.waker().wake_by_ref();
        return Poll::Pending;
    }
}

这段代码做了以下几件事:

  1. 获取系统的快照。
  2. 检查 future 保存的温度快照与当前温度之间是否有差异。如果没有差异,则没有必要重新渲染显示或做出任何加热决策,因此我们只返回 Pending,结束轮询。
  3. 检查当前温度是否低于目标温度。如果是,我们将 HEAT_ON 标志设置为 true
  4. 如果温度高于目标温度,我们将 HEAT_ON 标志设置为 false
  5. 清除终端以进行更新。
  6. 打印当前的快照状态。
  7. 更新 future 引用的快照。

最初,我们获取整个系统的快照。这种方法可以进行辩论。有人认为我们应该在每一步都加载原子值。这样,每次我们做出更改状态或显示状态的决策时,都能获得状态的真实情况。这是一个合理的观点,但在这类决策中总是会有权衡。

对于我们的系统,显示是唯一会改变 HEAT_ON 标志状态的观察者,而 future 中的逻辑基于温度做出决策。然而,温度受其他两个因素的影响,这些因素可能会在快照和打印之间影响温度,如图 6-2 所示。

image.png

在我们的系统中,如果温度显示稍微偏差一秒钟,这并不是世界末日。有人可能会认为,重要的是拍摄一个快照,从这个快照做出决策,并打印出这个快照,以便看到做出决策时使用的确切数据。这也会为我们提供清晰的调试信息。我们还可以拍摄快照,根据该快照改变 HEAT_ON 标志的状态,然后加载每个原子变量以打印到控制台,这样显示在打印的那一瞬间会始终是准确的。记录决策时的快照,并在打印时加载原子值,也是一个可选方案。

对于我们的简单系统,我们已经接近到对细节过于苛求的地步,我们将坚持打印快照,这样我们可以看到系统如何适应并做出决策。然而,构建响应式系统时,考虑这些权衡是很重要的。观察者正在处理的数据可能已经过时。

对于我们的模拟,我们可以通过将运行时限制为单线程来消除使用过时数据的风险。这将确保我们的快照不会过时,因为在处理显示 future 时,另一个 future 无法改变温度。我们也可以不限制运行时为单线程,而是将温度包装在互斥锁中,这也能确保在快照和打印之间温度不会改变。

然而,我们的系统是对温度做出反应的。温度并不是我们系统虚构出来的构造。热量损失和加热器实时地影响着温度,如果我们想出一些技巧来避免在其他进程改变我们主题的状态时不让温度在系统中变化,那我们只是在自欺欺人。

尽管我们的系统足够简单,不必担心过时的数据,但我们可以使用比较与交换(compare-and-exchange)功能,如标准库文档中的代码示例所示:

use std::sync::atomic::{AtomicI64, Ordering};

let some_var = AtomicI64::new(5);

assert_eq!(
    some_var.compare_exchange(
        5,
        10,
        Ordering::Acquire,
        Ordering::Relaxed
    ),
    Ok(5)
);
assert_eq!(some_var.load(Ordering::Relaxed), 10);

assert_eq!(
    some_var.compare_exchange(
        6,
        12,
        Ordering::SeqCst,
        Ordering::Acquire
    ),
    Err(10)
);
assert_eq!(some_var.load(Ordering::Relaxed), 10);

在这里,我们可以理解为什么原子操作被称为原子操作,因为它们的事务是原子的。这意味着,在对原子值进行操作时,不会有其他事务发生在这个原子值上。在 compare_exchange 函数中,我们断言原子值在更新之前是某个特定值。如果值不是我们预期的,我们将返回一个错误,显示原子的实际值。我们可以使用 compare_exchange 函数提示观察者根据返回的值做出另一个决策,并根据更新后的信息尝试对原子值进行另一次更新。

现在我们已经涵盖了足够多的内容,突出显示了响应式编程中的数据并发问题以及提供解决方案的领域。接下来,我们可以继续构建我们的响应式系统,加入加热器和热量损失的观察者。

构建我们的加热器和热量损失观察者

为了使加热器观察者发挥作用,我们需要读取 HEAT_ON 布尔值,而不关心温度。然而,加热器涉及到时间因素。遗憾的是,在撰写本文时,我们生活在一个加热器并非即时生效的世界中;它们需要时间来加热房间。因此,我们的加热器 future 使用时间快照,而不是温度快照,给加热器 future 赋予如下形式:

pub struct HeaterFuture {
    pub time_snapshot: Instant,
}

impl HeaterFuture {
    pub fn new() -> Self {
        HeaterFuture {
            time_snapshot: Instant::now()
        }
    }
}

现在我们有了时间快照,我们可以通过 poll 函数引用它,并在特定时间段后增加温度:

impl Future for HeaterFuture {
    type Output = ();

    fn poll(
        mut self: Pin<&mut Self>,
        cx: &mut Context<'_>
    ) -> Poll<Self::Output> {
        if HEAT_ON.load(Ordering::SeqCst) == false { 
            self.time_snapshot = Instant::now();
            cx.waker().wake_by_ref();
            return Poll::Pending;
        }
        let current_snapshot = Instant::now();
        if current_snapshot.duration_since(self.time_snapshot) < Duration::from_secs(3) { 
            cx.waker().wake_by_ref();
            return Poll::Pending;
        }
        TEMP.fetch_add(3, Ordering::SeqCst); 
        self.time_snapshot = Instant::now();
        cx.waker().wake_by_ref();
        return Poll::Pending;
    }
}

在我们的加热器 future 中,我们执行以下步骤:

  1. 如果 HEAT_ON 标志关闭,则尽可能快速地退出,因为此时什么都不会发生。我们希望尽快将 future 从执行器中释放,以避免阻塞其他 future
  2. 如果持续时间未超过 3 秒,我们也退出,因为加热器尚未生效。
  3. 最后,时间已经过去,并且 HEAT_ON 标志为 true,因此我们将温度增加 3。

我们在每次退出机会中更新 self.time_snapshot,当 HEAT_ON 标志为 false 但时间尚未过去时。如果我们不更新 time_snapshot,我们的加热器 future 可能会在 HEAT_ON 标志为 false 时一直被轮询,直到 3 秒过后才会生效。但一旦 HEAT_ON 标志切换为 true,对温度的影响将是即时的。对于我们的加热器 future,我们需要在每次轮询之间重置状态。

对于我们的热量损失 future,我们有如下定义:

pub struct HeatLossFuture {
    pub time_snapshot: Instant,
}

impl HeatLossFuture {
    pub fn new() -> Self {
        HeatLossFuture {
            time_snapshot: Instant::now()
        }
    }
}

对于热量损失 future,构造方法与加热器 future 相同,因为我们在每次轮询时都在引用时间的流逝。然而,在此 poll 中,我们仅在热量损失发生后重置快照,因为热量损失在这个模拟中只是一个常数。我们建议你自己尝试构建这个 future,如果你尝试自己构建 future,它应当采用以下形式:

impl Future for HeatLossFuture {
    type Output = ();

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let current_snapshot = Instant::now();
        if current_snapshot.duration_since(self.time_snapshot) > Duration::from_secs(3) {
            TEMP.fetch_sub(1, Ordering::SeqCst);
            self.time_snapshot = Instant::now();
        }
        cx.waker().wake_by_ref();
        return Poll::Pending;
    }
}

现在我们已经定义了所有的 future,它们将在程序运行时持续轮询。使用以下代码运行所有的 future,将得到一个持续更新温度并记录加热器是否开启的显示:

#[tokio::main]
async fn main() {
    let display = tokio::spawn(async {
        DisplayFuture::new().await;
    });
    let heat_loss = tokio::spawn(async {
        HeatLossFuture::new().await;
    });
    let heater = tokio::spawn(async {
        HeaterFuture::new().await;
    });
    display.await.unwrap();
    heat_loss.await.unwrap();
    heater.await.unwrap();
}

在达到目标温度后,你应该会看到温度在目标温度上下轻微振荡。

警告

振荡在经典系统理论中是标准现象。如果我们为显示器添加时间快照,并延迟切换 HEAT_ON 标志,振荡将会增大。振荡需要特别注意。如果观察者的反应发生了延迟,而另一个观察者也延迟了对初始观察者结果的反应,那么你可能会得到一个非常混乱的系统,这种系统非常难以理解或预测。这是 COVID-19 大流行期间及之后供应链中断的一个重要原因。Donella H. Meadows 的《系统思考》(Thinking in Systems,Chelsea Green Publishing,2008)指出,需求的延迟反应会在供应链中产生振荡。长供应链有多个环节在振荡。如果这些振荡变得过于不同步,就会形成一个复杂的混乱系统,难以解决。这部分解释了为什么在大流行后,供应链的恢复花费了很长时间。幸运的是,计算机系统是相当即时的。但值得记住的是,延迟链的危险及其反应。

现在我们的系统已经正常工作,我们可以继续通过回调从用户获取输入。

通过回调获取用户输入

为了从终端获取用户输入,我们将使用 device_query crate,版本如下:

device_query = "1.1.3"

通过这个,我们使用以下特性和结构体:

use device_query::{DeviceEvents, DeviceState};
use std::io::{self, Write};
use std::sync::Mutex;

device_query crate 使用回调,这是异步编程的一种形式。回调用于将一个函数传递给另一个函数。传递的函数随后会被调用。我们可以通过以下代码编写自己的基本回调函数:

fn perform_operation_with_callback<F>(callback: F)
where
    F: Fn(i32),
{
    let result = 42;
    callback(result);
}

fn main() {
    let my_callback = |result: i32| {
        println!("The result is: {}", result);
    };
    perform_operation_with_callback(my_callback);
}

我们刚才做的还是阻塞的。我们可以通过使用一个事件循环线程来使回调对主线程非阻塞,这个线程是一个常驻的循环。这个循环接受传入的事件,这些事件是回调(如图 6-3 所示)。

image.png

例如,Node.js 服务器通常有一个线程池,事件循环将事件传递给线程池。如果我们的回调具有返回到事件源的通道,那么当合适的时候,数据可以发送回事件的源头。

对于我们的输入,我们必须跟踪设备状态和输入,使用以下代码:

static INPUT: LazyLock<Arc<Mutex<String>>> = LazyLock::new(|| {
    Arc::new(Mutex::new(String::new()))
});
static DEVICE_STATE: LazyLock<Arc<DeviceState>> = LazyLock::new(|| {
    Arc::new(DeviceState::new())
});

我们必须考虑我们的代码结构。目前,当 DisplayFuture 检查温度时,显示会更新,如果温度发生变化,显示也会更新。然而,当我们有用户输入时,这种方式就不再适用。如果我们仔细想想,如果用户输入只有在温度变化时才显示更新,那肯定不是一个好的应用。这会导致用户感到沮丧,按下同一个键多次,却只能在温度更新时看到它们的多次按键被执行。我们的系统需要在用户按下键时立刻更新显示。考虑到这一点,我们需要一个可以在多个地方调用的渲染函数。这个函数的形式如下:

pub fn render(temp: i16, desired_temp: i16, heat_on: bool, input: String) {
    clearscreen::clear().unwrap();
    let stdout = io::stdout();
    let mut handle = stdout.lock();
    println!("Temperature: {}\nDesired Temp: {}\nHeater On: {}",
        temp as f32 / 100.0,
        desired_temp as f32 / 100.0,
        heat_on);
    print!("Input: {}", input);
    handle.flush().unwrap();
}

这个函数与我们的显示类似,但我们还打印了输入。这意味着,DisplayFuturepoll 函数会调用渲染函数,如下所示:

#[tokio::main]
async fn main() {
    let _guard = DEVICE_STATE.on_key_down(|key| {
        let mut input = INPUT.lock().unwrap();
        input.push_str(&key.to_string());
        std::mem::drop(input);
        render(
            TEMP.load(Ordering::SeqCst),
            DESIRED_TEMP.load(Ordering::SeqCst),
            HEAT_ON.load(Ordering::SeqCst),
            INPUT.lock().unwrap().clone()
        );
    });
    let display = tokio::spawn(async {
        DisplayFuture::new().await;
    });
    let heat_loss = tokio::spawn(async {
        HeatLossFuture::new().await;
    });
    let heater = tokio::spawn(async {
        HeaterFuture::new().await;
    });
    display.await.unwrap();
    heat_loss.await.unwrap();
    heater.await.unwrap();
}

请注意 _guard,它是回调的保护者。device_query crate 中的回调保护者在添加回调时返回。如果我们丢弃这个保护者,事件监听器就会被移除。幸运的是,我们的主线程会被阻塞,直到我们退出程序,因为我们的显示、热量损失和加热器任务会持续轮询,直到我们强制退出程序。

on_key_down 函数创建了一个线程并运行一个事件循环。这个事件循环有用于鼠标和键盘移动的回调。一旦我们从键盘按键中获得事件,我们就将其添加到输入状态中,并重新渲染显示。我们不会过多讨论将键映射到显示效果的细节,因为这超出了本章的目标。现在运行程序,你应该能够看到每次按键时输入被更新,跟踪你按下的键。

回调非常简单且易于实现。回调的执行流程也具有可预测性。然而,你可能会陷入嵌套回调的陷阱,这种情况可能演变成所谓的回调地狱(callback hell)。这会导致代码难以维护和理解。

现在,你已经有了一个基本的系统,可以接收用户输入。如果你想进一步探索这个系统,可以修改输入代码来处理目标温度的变化。请注意,我们的系统仅对基本数据类型做出反应。如果我们的系统需要复杂的数据类型来表示事件怎么办?此外,我们的系统可能需要知道事件的顺序并对所有事件做出反应,以便正确运行。

并非所有的响应式系统仅仅是在当前时间响应一个整数值。例如,如果我们在构建一个股票交易系统,我们不仅仅想知道股票的当前价格,还想知道股票的历史数据,而不是等到轮询时才得到当前价格。我们也不能保证在异步环境中轮询会在什么时候发生,因此,当我们轮询股票价格事件时,我们希望能够访问自上次轮询以来发生的所有事件,以便决定哪些事件是重要的。为了做到这一点,我们需要一个事件总线,能够让我们进行订阅。

通过事件总线启用广播

事件总线是一个系统,它使得更大系统的各个部分能够发送包含特定信息的消息。与具有简单发布/订阅关系的广播通道不同,事件总线可以停靠在多个站点,只有特定的人会下车。这意味着我们可以有多个订阅者来接收来自单一源的更新,但这些订阅者可以要求仅接收特定类型的消息,而不是每一条广播消息。我们可以有一个主题发布事件到事件总线,多个观察者随后可以按照事件发布的顺序消费该事件。在本节中,我们将构建自己的事件总线,以便探索其底层机制。不过,像 Tokio 这样的 crate 中已经提供了广播通道。

注意:
广播通道类似于广播电台。当电台发出一条消息时,多个听众可以在同一频道上收听相同的消息。在编程中的广播通道,多个监听者可以订阅并接收相同的消息。广播通道与常规通道不同。在常规通道中,一部分程序发送消息,另一部分程序接收该消息。而在广播通道中,一部分程序发送消息,且相同的消息被多个程序部分接收。

如果没有特定需求,直接使用现成的广播通道要比自己构建更为可取。

在我们构建事件总线之前,我们需要以下依赖项:

tokio = { version = "1.26.0", features = ["full"] }
futures = "0.3.28"

我们还需要这些导入:

use std::sync::{Arc, Mutex, atomic::{AtomicU32, Ordering}};
use tokio::sync::Mutex as AsyncMutex;
use std::collections::{VecDeque, HashMap};
use std::marker::Send;

现在我们已经拥有了构建事件总线结构体所需的一切。

构建我们的事件总线结构体

由于异步编程需要通过线程传递结构体,以便异步任务能被轮询,我们必须克隆每个发布的事件,并将这些克隆事件分发到每个订阅者进行消费。消费者还需要能够访问事件的回溯,如果某些原因导致消费者被延迟处理事件。消费者还需要能够取消订阅事件。考虑到所有这些因素,我们的事件总线结构体如下所示:

pub struct EventBus<T: Clone + Send> {
    chamber: AsyncMutex<HashMap<u32, VecDeque<T>>>,
    count: AtomicU32,
    dead_ids: Mutex<Vec<u32>>,
}

我们表示事件的类型 T 需要实现 Clone 特性,以便可以克隆并分发给每个订阅者,同时需要实现 Send 特性,以便能够在不同线程之间传递。chamber 字段是一个 AsyncMutex,允许具有特定 ID 的订阅者访问他们的事件队列。count 字段用于分配 ID,而 dead_ids 用于跟踪已取消订阅的消费者。

注意,chamber 的互斥锁是异步的,而 dead_ids 的互斥锁不是异步的。chamber 使用异步互斥锁是因为我们可能有大量订阅者在循环并轮询 chamber 来访问他们各自的队列。我们不希望执行器被异步任务阻塞,等待互斥锁,这会显著降低系统性能。然而,对于 dead_ids,我们不会在其上进行循环和轮询。它只会在消费者想要取消订阅时被访问。使用阻塞互斥锁还使得我们在处理句柄丢弃时能够轻松实现取消订阅过程。我们将在构建句柄时详细介绍这一点。

对于我们的事件总线结构体,我们现在可以实现以下函数:

impl<T: Clone + Send> EventBus<T> {

    pub fn new() -> Self {
        Self {
            chamber: AsyncMutex::new(HashMap::new()),
            count: AtomicU32::new(0),
            dead_ids: Mutex::new(Vec::new()),
        }
    }

    pub async fn subscribe(&self) -> EventHandle<T> {
        . . .
    }

    pub fn unsubscribe(&self, id: u32) {
        self.dead_ids.lock().unwrap().push(id);
    }

    pub async fn poll(&self, id: u32) -> Option<T> {
        . . .
    }

    pub async fn send(&self, event: T) {
        . . .
    }
}

我们所有的函数都只有 &self 引用,没有可变引用。这是因为我们利用了原子操作和互斥锁的内部可变性,所有的可变引用都位于互斥锁内部,绕过了 Rust 的规则——一次只能有一个可变引用。原子操作也不需要可变引用,因为我们可以执行原子操作。这意味着我们的事件总线结构体可以被包装在 Arc 中,并可以多次克隆以在多个线程之间传递,从而使这些线程能够安全地对事件总线执行多个可变操作。对于我们的 unsubscribe 函数,我们只是将 ID 推送到 dead_ids 字段中。我们将在“通过异步任务与我们的事件总线交互”部分中讨论这一点的原因。

消费者要执行的第一个操作是调用总线的 subscribe 函数,它的定义如下:

pub async fn subscribe(&self) -> EventHandle<T> {
    let mut chamber = self.chamber.lock().await;
    let id = self.count.fetch_add(1, Ordering::SeqCst);
    chamber.insert(id, VecDeque::new());
    EventHandle {
        id,
        event_bus: Arc::new(self),
    }
}

在这段代码中,我们返回了一个 EventHandle 结构体,我们将在下一个子部分中定义该句柄。我们通过 fetch_add 方法将 count 增加 1,并使用新的 count 作为 ID,接着在该 ID 下插入一个新的队列。然后,我们返回对 self 的引用,即包装在 Arc 中的事件总线,并结合 ID 放入句柄结构体中,以便消费者与事件总线进行交互。

警告:
虽然通过增加 count 并将其作为新的 ID 是分配 ID 的一种简单方式,但高吞吐量和长期运行的系统可能最终会用尽数字。如果这个风险是一个严重的考虑因素,可以添加另一个字段,用于回收 dead_ids 字段中已清除的 ID。在分配新 ID 时,可以从回收的 ID 中提取。然后,只有在回收的 ID 中没有可用 ID 时,才会增加 count

现在,消费者已经订阅了总线,它可以使用以下总线函数进行轮询:

pub async fn poll(&self, id: u32) -> Option<T> {
    let mut chamber = self.chamber.lock().await;
    let queue = chamber.get_mut(&id).unwrap();
    queue.pop_front()
}

我们直接解包 get_mut 方法的返回值来获取与 ID 相关的队列,因为我们将通过句柄进行交互,且只有在我们订阅总线时才能获取该句柄。因此,我们知道该 ID 一定在 chamber 中。由于每个 ID 都有自己的队列,每个订阅者可以在自己的时间消费所有发布的事件。这种简单的实现可以修改为使 poll 函数返回整个队列,替换现有队列为空队列。这种新方法减少了对总线的轮询调用,因为消费者可以循环遍历从总线的 poll 函数调用中提取的队列。由于我们将自己的结构体作为事件放入事件总线,我们还可以创建一个时间戳特性,并声明时间戳是放入事件总线的事件所必需的。时间戳将使我们能够在轮询时丢弃过期的事件,只返回最近的事件。

现在我们已经定义了一个基本的 poll 函数,我们可以为总线构建 send 函数:

pub async fn send(&self, event: T) {
    let mut chamber = self.chamber.lock().await;
    for (_, value) in chamber.iter_mut() {
        value.push_back(event.clone());
    }
}

我们已经具备了事件总线功能所需的所有内部数据结构。接下来,我们需要构建自己的句柄。

构建我们的事件总线句柄

我们的句柄需要有一个 ID 和对总线的引用,以便句柄能够轮询总线。我们的句柄定义如下:

pub struct EventHandle<'a, T: Clone + Send> {
    pub id: u32,
    event_bus: Arc<&'a EventBus<T>>,
}

在生命周期标注下,我们可以看到句柄的生命周期不能超过总线的生命周期。需要注意的是,Arc 会计算引用的数量,只有在没有任何指向总线的 Arc 时,才会释放总线。因此,我们可以保证总线的生命周期与系统中最后一个句柄的生命周期一致,从而确保我们的句柄是线程安全的。

我们还需要处理句柄的销毁。如果句柄被移除内存后,就无法访问与该句柄 ID 相关的队列,因为句柄存储了该 ID。然而,事件仍会持续发送到该 ID 的队列中。如果开发者在使用我们的队列时,句柄被销毁而没有显式调用取消订阅函数,那么事件总线会填满多个没有订阅者的队列。这种情况会浪费内存,甚至可能增长到计算机内存耗尽的程度,具体取决于某些参数。这种情况叫做内存泄漏,是一个真实的风险。图 6-4 是一张展示咖啡机并非因咖啡泄漏而故障,而是因内存泄漏而故障的照片。

为了防止内存泄漏,我们必须为句柄实现 Drop 特性,当句柄被销毁时,它会取消订阅事件总线:

impl<'a, T: Clone + Send> Drop for EventHandle<'a, T> {
    fn drop(&mut self) {
        self.event_bus.unsubscribe(self.id);
    }
}

通过实现 Drop 特性,当句柄被丢弃时,我们会自动调用 unsubscribe 函数,从事件总线中取消该句柄的订阅,防止内存泄漏的发生。

image.png

我们的句柄现在已经完成,能够安全地从事件总线消费事件,而不需要担心内存泄漏。接下来,我们将使用句柄构建与事件总线交互的任务。

通过异步任务与我们的事件总线交互

在本章中,我们的观察者一直在实现 Future 特性,并将主题的状态与观察者的状态进行比较。现在,我们直接将事件流式传输到我们的 ID,我们可以通过使用异步函数轻松地实现一个消费者异步任务:

async fn consume_event_bus(event_bus: Arc<EventBus<f32>>) {
    let handle = event_bus.subscribe().await;
    loop {
        let event = handle.poll().await;
        match event {
            Some(event) => {
                println!("id: {} value: {}", handle.id, event);
                if event == 3.0 {
                    break;
                }
            },
            None => {}
        }
    }
}

在这个例子中,我们流式传输一个浮动值,如果发送 3.0,则会终止循环。这只是一个用于教学的示例,但实现逻辑来影响 HEAT_ON 的原子布尔值会非常简单。如果我们不想过于积极地轮询事件总线,还可以在 None 分支中实现一个 Tokio 异步睡眠函数。

警告:
事件创建的速率有时可能会大于事件处理的速率。这会导致事件的积压,称为背压(backpressure)。背压可以通过多种方法解决,超出了本书的讨论范围。诸如缓冲、流量控制、速率限制、批处理和负载均衡等概念可以帮助减少背压的积累。我们在第 11 章中讨论了如何测试通道的背压。

我们还需要一个后台任务,用于在经过一定时间后批量清理已取消的 ID。这个垃圾回收任务也可以通过异步函数定义:

async fn garbage_collector(event_bus: Arc<EventBus<f32>>) {
    loop {
        let mut chamber = event_bus.chamber.lock().await;
        let dead_ids = event_bus.dead_ids.lock().unwrap().clone();
        event_bus.dead_ids.lock().unwrap().clear();
        for id in dead_ids.iter() {
            chamber.remove(id);
        }
        std::mem::drop(chamber);
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
    }
}

我们在批量删除后直接丢弃 chamber。这样做是为了避免在不使用它时阻塞其他任务尝试访问 chamber

注意:
在数据库系统中,并不在删除请求发出时立即删除记录是常见的做法,这叫做“墓碑标记”(tombstoning)。相反,数据库会标记记录,指示 GET 请求应将其视为已删除记录。然后,垃圾回收过程定期清理这些墓碑记录。在每次删除请求时清理和重新分配存储是一个昂贵的选择,因为你希望继续处理异步数据库请求。

我们现在拥有了一切所需的内容来与事件总线交互。接下来,我们创建我们的事件总线和对它的引用:

let event_bus = Arc::new(EventBus::<f32>::new());
let bus_one = event_bus.clone();
let bus_two = event_bus.clone();
let gb_bus_ref = event_bus.clone();

现在,即使 event_bus 被直接丢弃,其他引用仍会通过 Arc 保持 EventBus<f32> 的生命周期,直到所有四个引用都被丢弃。接下来,我们启动我们的消费者和垃圾回收任务:

let _gb = tokio::task::spawn(async {
    garbage_collector(gb_bus_ref).await
});
let one = tokio::task::spawn(async {
    consume_event_bus(bus_one).await
});
let two = tokio::task::spawn(async {
    consume_event_bus(bus_two).await
});

在这个示例中,我们有可能在两个任务订阅之前就发送事件,因此我们等待一秒钟,然后广播三个事件:

std::thread::sleep(std::time::Duration::from_secs(1));
event_bus.send(1.0).await;
event_bus.send(2.0).await;
event_bus.send(3.0).await;

第三个事件是 3.0,意味着消费者任务会从总线取消订阅。我们可以打印 chamber 的状态,等待垃圾回收器清除已取消的 ID,然后再次打印状态:

let _ = one.await;
let _ = two.await;
println!("{:?}", event_bus.chamber.lock().await);
std::thread::sleep(std::time::Duration::from_secs(3));
println!("{:?}", event_bus.chamber.lock().await);

运行这段代码,会得到以下输出:

id: 0 value: 1
id: 1 value: 1
id: 0 value: 2
id: 1 value: 2
id: 0 value: 3
id: 1 value: 3
{1: [], 0: []}
{}

两个订阅者都接收了事件,并且在取消订阅时垃圾回收工作正常。

事件总线是响应式编程的核心。我们可以动态地添加和移除订阅者。我们可以控制事件的分发和消费方式,且实现只需连接到事件总线的代码非常简单。

总结

尽管本书的范围并不包括对响应式编程的全面介绍,但我们已经覆盖了其基本的异步特性,如轮询主题和通过我们自己编写的事件总线异步分发数据。现在,您应该能够提出异步实现的响应式编程方案。

响应式编程不仅仅局限于一个程序中的不同线程和通道。响应式编程的概念也可以应用于多个计算机和进程,这被称为响应式系统。例如,我们的消息总线可以将消息发送到集群中的多个服务器。事件驱动系统在扩展架构时也非常有用。我们必须记住,响应式编程的解决方案往往有更多的移动部件。只有当实际系统在性能上开始出现问题时,我们才会转向事件驱动系统。直接使用响应式编程可能会导致复杂的解决方案,难以维护,因此要小心。

您可能已经注意到,我们在实现异步代码时依赖了 Tokio。在第 7 章中,我们将介绍如何定制 Tokio,以解决具有约束和细微差别的问题。将整章内容专门用于 Tokio 可能会引起争议,但实际上,Tokio 是 Rust 生态系统中最广泛使用的异步运行时。