演员是通过消息传递独立运行的代码单元,它们之间通过消息交换进行通信。演员可以拥有自己的状态,并对其进行引用和操作。由于我们有与异步兼容的非阻塞通道,我们的异步运行时可以同时处理多个演员,只有当它们接收到通道中的消息时,才能继续处理。
演员的隔离使得异步测试变得简单,并且可以轻松实现异步系统。在本章结束时,你将能够构建一个包含路由演员的演员系统。你构建的这个演员系统可以在程序的任何地方轻松调用,而不需要传递系统的引用。你还将能够构建一个监督者心跳系统,跟踪其他演员,并在这些演员未能在超时阈值之前向监督者发送心跳时强制重新启动它们。为了开始这段旅程,你需要理解如何构建基本的演员。
构建一个基本的演员
我们可以构建的最基本的演员是一个异步函数,它在无限循环中监听消息:
use tokio::sync::{
mpsc::channel,
mpsc::{Receiver, Sender},
oneshot
};
struct Message {
value: i64
}
async fn basic_actor(mut rx: Receiver<Message>) {
let mut state = 0;
while let Some(msg) = rx.recv().await {
state += msg.value;
println!("Received: {}", msg.value);
println!("State: {}", state);
}
}
这个演员通过使用多生产者单消费者通道(mpsc)监听传入的消息,更新状态并打印出来。我们可以像下面这样测试我们的演员:
#[tokio::main]
async fn main() {
let (tx, rx) = channel::<Message>(100);
let _actor_handle = tokio::spawn(
basic_actor(rx)
);
for i in 0..10 {
let msg = Message { value: i };
tx.send(msg).await.unwrap();
}
}
但如果我们想接收一个响应怎么办?现在,我们只是把消息发送到空中,然后在终端中查看输出。我们可以通过将 oneshot::Sender 包装在发送给演员的消息中来实现响应。接收演员可以使用这个 oneshot::Sender 来发送响应。我们可以通过以下代码来定义一个响应演员:
struct RespMessage {
value: i32,
responder: oneshot::Sender<i64>
}
async fn resp_actor(mut rx: Receiver<RespMessage>) {
let mut state = 0;
while let Some(msg) = rx.recv().await {
state += msg.value;
if msg.responder.send(state).is_err() {
eprintln!("Failed to send response");
}
}
}
如果我们想向响应演员发送消息,我们需要构造一个 oneshot 通道,用它来构造一个消息,发送消息,然后等待响应。以下代码展示了如何实现这一过程:
let (tx, rx) = channel::<RespMessage>(100);
let _resp_actor_handle = tokio::spawn(async {
resp_actor(rx).await;
});
for i in 0..10 {
let (resp_tx, resp_rx) = oneshot::channel::<i64>();
let msg = RespMessage {
value: i,
responder: resp_tx
};
tx.send(msg).await.unwrap();
println!("Response: {}", resp_rx.await.unwrap());
}
这里我们使用 oneshot 通道,因为我们只需要发送一次响应,之后客户端代码可以继续进行其他操作。对于我们的用例,使用 oneshot 通道是最好的选择,因为它在内存和同步方面进行了优化,适合于发送一次消息并关闭的场景。
考虑到我们通过通道发送结构体给演员,你可以看到我们的功能可能会变得更加复杂。例如,发送一个枚举,封装多个消息,可能会根据发送的消息类型指示演员执行一系列动作。演员还可以创建新的演员或向其他演员发送消息。
从我们展示的例子来看,我们本来可以使用互斥锁来处理状态的变更。使用互斥锁会很简单,但它与演员模型相比又如何呢?
与互斥锁(Mutex)相比使用演员模型
为了这个练习,我们需要以下额外的导入:
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::mpsc::error::TryRecvError;
要重新创建我们上一节中使用互斥锁的演员实现,我们有一个函数,形式如下:
async fn actor_replacement(state: Arc<Mutex<i64>>, value: i64) -> i64 {
let mut state = state.lock().await;
*state += value;
return *state
}
虽然这段代码很简单,但是它的性能如何呢?我们可以设计一个简单的测试:
let state = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
let now = tokio::time::Instant::now();
for i in 0..100000000 {
let state_ref = state.clone();
let future = async move {
let handle = tokio::spawn(async move {
actor_replacement(state_ref, i).await
});
let _ = handle.await.unwrap();
};
handles.push(tokio::spawn(future));
}
for handle in handles {
let _ = handle.await.unwrap();
}
println!("Elapsed: {:?}", now.elapsed());
我们一次性生成了很多任务,试图获取互斥锁,然后等待它们。如果我们一次生成一个任务,我们将无法看到互斥锁的并发性对结果的真实影响,而只是看到单个事务的速度。我们生成大量任务是因为我们想看到两种方法之间的统计学差异。
这些测试运行时间较长,但结果不容忽视。在写这篇文章时,使用一台高配的 M2 MacBook,在 --release 模式下运行所有互斥锁任务的完成时间为 155 秒。
为了使用我们在上一节中实现的演员进行相同的测试,我们需要以下代码:
let (tx, rx) = channel::<RespMessage>(100000000);
let _resp_actor_handle = tokio::spawn(async {
resp_actor(rx).await;
});
let mut handles = Vec::new();
let now = tokio::time::Instant::now();
for i in 0..100000000 {
let tx_ref = tx.clone();
let future = async move {
let (resp_tx, resp_rx) = oneshot::channel::<i64>();
let msg = RespMessage {
value: i,
responder: resp_tx
};
tx_ref.send(msg).await.unwrap();
let _ = resp_rx.await.unwrap();
};
handles.push(tokio::spawn(future));
}
for handle in handles {
let _ = handle.await.unwrap();
}
println!("Elapsed: {:?}", now.elapsed());
运行这个测试的时间是 103 秒。请注意,我们在 --release 模式下运行了测试,以查看编译器优化对系统的影响。演员模型比互斥锁快了 52 秒。原因之一是获取互斥锁的开销。当我们向通道中放入消息时,我们需要检查通道是否已满或已关闭。获取互斥锁时,检查更为复杂。通常这些检查包括判断锁是否已被其他任务持有。如果是,尝试获取锁的任务需要注册兴趣并等待通知。
注意
通常,在并发环境中,通过通道传递消息的扩展性比互斥锁要好,因为发送方不必等待其他任务完成当前操作。它们可能需要等待将消息放入通道的队列中,但等待消息放入队列的时间比等待互斥锁操作完成、释放锁并让等待中的任务获取锁要快。因此,通道通常会带来更高的吞吐量。
为了进一步说明这一点,我们来看一个更复杂的事务场景,而不仅仅是简单地将值加一。也许在提交最终结果之前,我们需要进行一些检查和计算。作为高效的工程师,我们可能希望在该过程进行时做其他事情。因为我们正在发送消息并等待响应,所以我们的演员代码已经具备了这种便利,如下所示:
let future = async move {
let (resp_tx, resp_rx) = oneshot::channel::<i32>();
let msg = RespMessage {
value: i,
responder: resp_tx
};
tx_ref.send(msg).await.unwrap();
// do something else
let _ = resp_rx.await.unwrap();
};
然而,我们的互斥锁实现只会将控制权交还给调度器。如果我们想在等待复杂事务完成时进展我们的互斥锁任务,我们需要启动另一个异步任务,如下所示:
async fn actor_replacement(state: Arc<Mutex<i32>>, value: i32) -> i32 {
let update_handle = tokio::spawn(async move {
let mut state = state.lock().await;
*state += value;
return *state
});
// do something else
update_handle.await.unwrap()
}
但是,启动这些额外的异步任务会大大增加我们的测试时间,使其达到 174 秒,比演员模型的 103 秒多了 73 秒。这并不令人惊讶,因为我们将一个异步任务发送到运行时,并获得一个句柄,只是为了让我们稍后在任务中进展等待事务结果。
通过我们的测试结果,我们可以看出为什么我们更愿意使用演员模型。演员模型编写起来更复杂。你需要通过通道传递消息,并包装一个 oneshot 通道供演员响应,以获取结果。这比获取锁要复杂。但是,演员的灵活性——可以自由选择何时等待消息结果——是免费的。而互斥锁,如果需要这种灵活性,则会有很大的性能惩罚。
我们还可以说,演员模型更容易理解。如果我们考虑这个问题,演员包含它们的状态。如果你想查看与该状态的所有交互,你只需要查看演员代码。然而,使用互斥锁的代码库中,我们不知道所有与状态的交互在哪里发生。互斥锁的分布式交互也增加了系统高度耦合的风险,使得重构变得困难。
现在我们已经让演员工作,我们需要能够在系统中利用它们。实现演员模型的最简单方法是使用路由器模式。
实现路由器模式
在我们的路由中,我们构建一个路由器演员,它接收消息。这些消息可以通过枚举进行包装,帮助路由器定位到正确的演员。作为示例,我们将实现一个基本的键值存储。我们必须强调,尽管我们在 Rust 中构建了这个键值存储,但你不应该在生产环境中使用这个教学示例。像 RocksDB 和 Redis 这样的成熟解决方案,已经投入了大量的工作和专业知识,使它们的键值存储既强大又可扩展。
对于我们的键值存储,我们需要设置、获取和删除键。我们可以使用图 8-1 中定义的消息布局来表示所有这些操作。
在我们开始编码之前,我们需要以下导入:
use tokio::sync::{
mpsc::channel,
mpsc::{Receiver, Sender},
oneshot,
};
use std::sync::OnceLock;
接下来,我们需要定义我们的消息布局,如图 8-1 所示:
struct SetKeyValueMessage {
key: String,
value: Vec<u8>,
response: oneshot::Sender<()>,
}
struct GetKeyValueMessage {
key: String,
response: oneshot::Sender<Option<Vec<u8>>>,
}
struct DeleteKeyValueMessage {
key: String,
response: oneshot::Sender<()>,
}
enum KeyValueMessage {
Get(GetKeyValueMessage),
Delete(DeleteKeyValueMessage),
Set(SetKeyValueMessage),
}
enum RoutingMessage {
KeyValue(KeyValueMessage),
}
现在,我们有了一条可以路由到键值演员的消息,这条消息通过数据来信号化正确的操作。对于我们的键值演员,我们接受 KeyValueMessage,匹配变体,并执行相应的操作,如下所示:
async fn key_value_actor(mut receiver: Receiver<KeyValueMessage>) {
let mut map = std::collections::HashMap::new();
while let Some(message) = receiver.recv().await {
match message {
KeyValueMessage::Get(GetKeyValueMessage { key, response }) => {
let _ = response.send(map.get(&key).cloned());
}
KeyValueMessage::Delete(DeleteKeyValueMessage { key, response }) => {
map.remove(&key);
let _ = response.send(());
}
KeyValueMessage::Set(SetKeyValueMessage { key, value, response }) => {
map.insert(key, value);
let _ = response.send(());
}
}
}
}
有了键值消息的处理后,我们需要将我们的键值演员与路由器演员连接起来:
async fn router(mut receiver: Receiver<RoutingMessage>) {
let (key_value_sender, key_value_receiver) = channel(32);
tokio::spawn(key_value_actor(key_value_receiver));
while let Some(message) = receiver.recv().await {
match message {
RoutingMessage::KeyValue(message) => {
let _ = key_value_sender.send(message).await;
}
}
}
}
我们在路由器演员中创建了键值演员。演员可以创建其他演员。将键值演员的创建放在路由器演员中,可以确保系统设置不会出错,并减少我们在程序中设置演员系统时的代码冗余。我们的路由器是接口,因此所有的消息都将通过路由器来传递给其他演员。
现在我们已经定义了路由器,我们需要关注路由器的通道。所有发送到我们的演员系统的消息都将通过这个通道。我们选择了数字 32,意味着该通道最多可以同时容纳 32 条消息。这个缓冲区大小提供了一些灵活性。
如果我们需要追踪该通道的发送者的引用,这个系统就不太实用了。如果一个开发人员想要向我们的演员系统发送消息,而他们在四个函数调用的深度中,那么如果他们必须追溯到主函数,逐一打开每个函数的参数来传递通道的发送者,会让这个开发者感到非常沮丧。以后进行更改时也会非常麻烦。为了避免这种沮丧,我们将发送者定义为一个全局静态变量:
static ROUTER_SENDER: OnceLock<Sender<RoutingMessage>> = OnceLock::new();
当我们创建路由器的主通道时,我们将设置发送者。你可能会想,是否在路由器演员函数内部构建主通道并设置 ROUTER_SENDER 会更符合惯例。然而,如果函数尝试在通道设置之前发送消息,可能会遇到一些并发问题。请记住,异步运行时可以跨多个线程运行,因此,异步任务可能会在路由器演员设置通道时试图调用通道。因此,最好在主函数开始时先设置通道,然后即使路由器演员不是第一个被轮询的任务,它仍然可以访问在它被轮询之前发送到通道的消息。
注意全局静态变量
我们使用全局变量(
ROUTER_SENDER)和OnceLock来简化示例,并避免让章节充斥着额外的设置代码。尽管这种方法使代码保持简单明了,但在异步 Rust 代码中使用全局状态时,必须注意以下潜在缺点:
- 脆弱性
全局状态可能导致难以追踪的错误,尤其是在较大或更复杂的应用程序中。如果全局状态意外被修改,可能会导致意外的副作用。- 测试难度
依赖全局状态的代码更难进行测试。测试可能会变得依赖于它们的执行顺序,或者相互干扰。- 资源管理
在使用全局状态时,管理资源的生命周期(如发送者通道)会变得更加复杂。为了避免这些问题,你可以在主函数的开始时创建通道,并将
Sender传递给你的演员,之后演员可以将Sender传递给其他演员,因为我们可以克隆Sender结构体,如下所示:#[tokio::main] async fn main() -> Result<(), std::io::Error> { let (sender, receiver) = channel(32); tokio::spawn(router(receiver, sender.clone())); . . . }在本章中我们省略了这个方法,因为我们已经有很多移动部分需要跟踪。
我们的路由器演员现在已经准备好接收消息并将它们路由到键值存储。接下来,我们需要一些函数来发送键值消息。我们可以从 set 函数开始,该函数的定义如下:
pub async fn set(key: String, value: Vec<u8>) -> Result<(), std::io::Error> {
let (tx, rx) = oneshot::channel();
ROUTER_SENDER.get().unwrap().send(
RoutingMessage::KeyValue(KeyValueMessage::Set(
SetKeyValueMessage {
key,
value,
response: tx,
}
))).await.unwrap();
rx.await.unwrap();
Ok(())
}
这段代码中有不少 unwrap,但如果系统因为通道错误而失败,那我们可能面临更严重的问题。这些 unwrap 仅仅是为了避免章节中的代码膨胀。我们将在“创建演员监督”中讨论如何处理错误。我们可以看到,路由消息是自解释的。我们知道它是一个路由消息,并且消息被路由到键值演员。然后,我们知道我们在调用哪个方法,并传递了哪些数据。路由消息枚举提供了足够的信息来告诉我们函数的目标路由。
现在我们的 set 函数已经定义好,你应该可以自己实现 get 函数了。试试看。
希望你的 get 函数与以下内容相似:
pub async fn get(key: String) -> Result<Option<Vec<u8>>, std::io::Error> {
let (tx, rx) = oneshot::channel();
ROUTER_SENDER.get().unwrap().send(
RoutingMessage::KeyValue(KeyValueMessage::Get(
GetKeyValueMessage {
key,
response: tx,
}
))).await.unwrap();
Ok(rx.await.unwrap())
}
我们的 delete 函数与 get 非常相似,唯一的区别是路由不同,而且 delete 函数不会返回任何内容:
pub async fn delete(key: String) -> Result<(), std::io::Error> {
let (tx, rx) = oneshot::channel();
ROUTER_SENDER.get().unwrap().send(
RoutingMessage::KeyValue(KeyValueMessage::Delete(
DeleteKeyValueMessage {
key,
response: tx,
}
))).await.unwrap();
rx.await.unwrap();
Ok(())
}
现在我们的系统准备好了。我们可以通过以下的 main 函数来测试我们的路由器和键值存储:
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let (sender, receiver) = channel(32);
ROUTER_SENDER.set(sender).unwrap();
tokio::spawn(router(receiver));
let _ = set("hello".to_owned(), b"world".to_vec()).await?;
let value = get("hello".to_owned()).await?;
println!("value: {:?}", String::from_utf8(value.unwrap()));
let _ = delete("hello".to_owned()).await?;
let value = get("hello".to_owned()).await?;
println!("value: {:?}", value);
Ok(())
}
运行这段代码后,我们会得到以下输出:
value: Ok("world")
value: None
我们的键值存储系统现在已经正常工作并且可以运行了。然而,当我们的系统关闭或崩溃时会发生什么呢?我们需要一个演员来跟踪系统的状态,并在系统重启时恢复它。
为演员实现状态恢复
目前,我们的系统有一个键值存储演员。然而,我们的系统可能会被停止并重新启动,或者某个演员可能会崩溃。如果发生这种情况,我们可能会丢失所有数据,这是不理想的。为了减少数据丢失的风险,我们将创建另一个演员,将我们的数据写入文件。我们新系统的大致框架如图 8-2 所示。
目前,我们的系统有一个键值存储演员。然而,系统可能会停止并重新启动,或者某个演员可能会崩溃。如果发生这种情况,我们可能会丢失所有数据,这是不可接受的。为了减少数据丢失的风险,我们将创建另一个演员,将数据写入文件。我们新系统的大致框架如图 8-2 所示。
从图 8-2 中,我们可以看到以下步骤:
- 调用我们的演员系统。
- 路由器将消息发送到键值存储演员。
- 键值存储演员克隆操作并将该操作发送给写入演员。
- 写入演员在其自己的映射中执行该操作,并将映射写入数据文件。
- 键值存储演员在自己的映射上执行操作,并将结果返回给调用演员系统的代码。
当我们的演员系统启动时,我们将有以下顺序:
- 路由器演员启动,创建键值存储演员。
- 键值存储演员创建写入演员。
- 当写入演员启动时,它从文件中读取数据,填充自身,并将数据发送给键值存储演员。
注意
我们已经给写入演员提供了对数据文件的独占访问。这将避免并发问题,因为写入演员一次只能处理一个事务,其他资源将不会修改文件。写入演员的文件独占访问还可以提高性能,因为写入演员可以在整个生命周期内保持数据文件句柄打开,而不是每次写入时都重新打开文件。这大大减少了操作系统对文件权限和可用性检查的调用次数。
对于这个系统,我们需要更新键值演员的初始化代码。我们还需要构建写入演员,并为写入演员添加一个新消息,该消息可以从键值消息中构建。
在开始编写新代码之前,我们需要以下导入:
use serde_json;
use tokio::fs::File;
use tokio::io::{
self,
AsyncReadExt,
AsyncWriteExt,
AsyncSeekExt
};
use std::collections::HashMap;
对于我们的写入消息,我们需要让写入演员进行设置和删除操作。我们还需要写入演员返回从文件中读取的完整状态,定义如下:
enum WriterLogMessage {
Set(String, Vec<u8>),
Delete(String),
Get(oneshot::Sender<HashMap<String, Vec<u8>>>),
}
我们需要从键值消息构造此消息,而不消耗它:
impl WriterLogMessage {
fn from_key_value_message(message: &KeyValueMessage)
-> Option<WriterLogMessage> {
match message {
KeyValueMessage::Get(_) => None,
KeyValueMessage::Delete(message) => Some(
WriterLogMessage::Delete(message.key.clone())
),
KeyValueMessage::Set(message) => Some(
WriterLogMessage::Set(
message.key.clone(),
message.value.clone()
)
),
}
}
}
现在我们的消息定义已经完成。我们只需要最后一个功能:加载状态。在启动时,我们需要两个演员加载状态,因此我们的文件加载通过以下单独的函数定义:
async fn read_data_from_file(file_path: &str)
-> io::Result<HashMap<String, Vec<u8>>> {
let mut file = File::open(file_path).await?;
let mut contents = String::new();
file.read_to_string(&mut contents).await?;
let data: HashMap<String, Vec<u8>> = serde_json::from_str(&contents)?;
Ok(data)
}
虽然这有效,但我们需要确保加载状态时具有容错能力。恢复演员关闭前的状态很不错,但如果演员无法加载丢失或损坏的状态文件而无法运行,那我们的系统就不好用了。因此,我们将加载操作封装在一个函数中,如果加载状态时出现问题,将返回一个空的哈希映射:
async fn load_map(file_path: &str) -> HashMap<String, Vec<u8>> {
match read_data_from_file(file_path).await {
Ok(data) => {
println!("Data loaded from file: {:?}", data);
return data;
},
Err(e) => {
println!("Failed to read from file: {:?}", e);
println!("Starting with an empty hashmap.");
return HashMap::new();
}
}
}
我们打印这些日志,以便在未获得预期结果时检查系统日志。
现在我们可以构建我们的写入演员。我们的写入演员需要从文件中加载数据,然后监听传入的消息:
async fn writer_actor(mut receiver: Receiver<WriterLogMessage>)
-> io::Result<()> {
let mut map = load_map("./data.json").await;
let mut file = File::create("./data.json").await.unwrap();
while let Some(message) = receiver.recv().await {
match message {
. . .
}
let contents = serde_json::to_string(&map).unwrap();
file.set_len(0).await?;
file.seek(std::io::SeekFrom::Start(0)).await?;
file.write_all(contents.as_bytes()).await?;
file.flush().await?;
}
Ok(())
}
注意
你可以看到,在每个消息周期之间,我们会清空文件并写入整个映射。这并不是一种高效的写入文件的方式。然而,本章的重点是演员以及如何使用它们。关于将事务写入文件的权衡是一个涉及各种文件类型、批量写入和垃圾回收来清理数据的大主题。如果你对此感兴趣,Alex Petrov 的《Database Internals》(O'Reilly, 2019)对将事务写入文件进行了全面的介绍。
在写入演员中匹配消息时,我们根据消息的类型插入、删除或克隆映射,然后返回整个映射:
match message {
WriterLogMessage::Set(key, value) => {
map.insert(key, value);
}
WriterLogMessage::Delete(key) => {
map.remove(&key);
},
WriterLogMessage::Get(response) => {
let _ = response.send(map.clone());
}
}
尽管我们的路由器演员保持不变,我们的键值存储演员需要在做任何其他操作之前创建写入演员:
let (writer_key_value_sender, writer_key_value_receiver) = channel(32);
tokio::spawn(writer_actor(writer_key_value_receiver));
然后,键值存储演员需要从写入演员获取映射的状态:
let (get_sender, get_receiver) = oneshot::channel();
let _ = writer_key_value_sender.send(WriterLogMessage::Get(
get_sender
)).await;
let mut map = get_receiver.await.unwrap();
最后,键值存储演员可以构造一个写入消息,并将该消息发送给写入演员,然后再处理事务:
while let Some(message) = receiver.recv().await {
if let Some(write_message) = WriterLogMessage::from_key_value_message(
&message) {
let _ = writer_key_value_sender.send(write_message).await;
}
match message {
. . .
}
}
这样,我们的系统就支持从文件中写入和加载数据,同时所有的键值事务都在内存中处理。如果你在 main 函数中调试你的代码,注释掉部分代码并检查 data.json 文件,你会看到它的正常工作。然而,如果你的系统运行在服务器上,你可能不会手动监控文件,查看发生了什么情况。现在我们的演员系统变得更加复杂,写入演员可能已经崩溃并且没有运行,而我们却对此一无所知,因为我们的键值存储演员仍然可能在运行。这时,监督机制就显得非常重要,我们需要跟踪演员的状态。
创建演员监督机制
目前我们有两个演员:写入演员和键值存储演员。在本节中,我们将构建一个监督者演员,用来跟踪系统中的每个演员。这时我们会感激我们已经实现了路由器模式。创建一个监督者演员,然后将监督者演员通道的发送者传递给每个演员会非常麻烦。相反,我们可以通过路由器将更新消息发送给监督者演员,因为每个演员都可以直接访问 ROUTER_SENDER。监督者还可以通过路由器向正确的演员发送重置请求,如图 8-3 所示。
在图 8-3 中,你可以看到,如果我们没有从键值演员或写入演员那里收到更新,我们可以重置键值演员。因为我们可以让键值演员在创建写入演员时持有写入演员的句柄,所以如果键值演员死掉,写入演员也会死亡。当键值演员再次创建时,写入演员也会随之创建。
要实现这个心跳监控机制,我们需要稍微重构一下我们的演员模型,但这将展示如何通过在复杂度上做一点妥协,使我们能够追踪和管理长期运行的演员。不过,在编写代码之前,我们需要引入以下库来处理演员的时间检查:
use tokio::time::{self, Duration, Instant};
我们还需要支持演员的重置和心跳注册。因此,我们必须扩展我们的 RoutingMessage 枚举:
enum RoutingMessage {
KeyValue(KeyValueMessage),
Heartbeat(ActorType),
Reset(ActorType),
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum ActorType {
KeyValue,
Writer
}
在这里,我们可以请求任何演员的重置,或注册我们希望在 ActorType 枚举中声明的演员的心跳。
我们的第一次重构可以是我们的键值存储演员。首先,我们为写入演员定义一个句柄:
let (writer_key_value_sender, writer_key_value_receiver) = channel(32);
let _writer_handle = tokio::spawn(
writer_actor(writer_key_value_receiver)
);
我们仍然向写入演员发送获取消息来填充映射,但我们将消息处理代码提升到一个无限循环中,以便我们可以实现超时:
let timeout_duration = Duration::from_millis(200);
let router_sender = ROUTER_SENDER.get().unwrap().clone();
loop {
match time::timeout(timeout_duration, receiver.recv()).await {
Ok(Some(message)) => {
if let Some(write_message) = WriterLogMessage::from_key_value_message(&message) {
let _ = writer_key_value_sender.send(write_message).await;
}
match message {
// 其他消息处理逻辑
}
},
Ok(None) => break,
Err(_) => {
router_sender.send(
RoutingMessage::Heartbeat(ActorType::KeyValue)
).await.unwrap();
}
};
}
在循环结束时,我们发送一个心跳消息给路由器,告知我们的键值存储仍然存活。我们还设有超时,因此如果超过200毫秒没有消息,我们仍然会执行一个循环,因为我们不希望由于没有接收到消息,导致监控者认为我们的演员已死或卡住。
对于写入演员,我们也需要类似的处理方式。我们建议你自己尝试编写代码。希望你的尝试与以下代码类似:
let timeout_duration = Duration::from_millis(200);
let router_sender = ROUTER_SENDER.get().unwrap().clone();
loop {
match time::timeout(timeout_duration, receiver.recv()).await {
Ok(Some(message)) => {
match message {
// 其他消息处理逻辑
}
let contents = serde_json::to_string(&map).unwrap();
file.set_len(0).await?;
file.seek(std::io::SeekFrom::Start(0)).await?;
file.write_all(contents.as_bytes()).await?;
file.flush().await?;
},
Ok(None) => break,
Err(_) => {
router_sender.send(
RoutingMessage::Heartbeat(ActorType::Writer)
).await.unwrap();
}
};
}
现在,我们的演员支持向路由器发送心跳,供监控器跟踪。接下来,我们需要构建我们的监控者演员。我们的监控者演员与其他演员的方式相似。它有一个包含超时的无限循环,因为没有心跳消息不应阻止监控者检查它正在跟踪的演员的状态。实际上,缺少心跳消息可能意味着系统需要检查。然而,不同的是,监控者演员不是在无限循环周期结束时发送消息,而是通过其自己的状态循环,检查任何没有汇报的演员。如果演员超时,监控者演员会发送重置请求给路由器。这个过程的概要代码如下:
async fn heartbeat_actor(mut receiver: Receiver<ActorType>) {
let mut map = HashMap::new();
let timeout_duration = Duration::from_millis(200);
loop {
match time::timeout(timeout_duration, receiver.recv()).await {
Ok(Some(actor_name)) => map.insert(actor_name, Instant::now()),
Ok(None) => break,
Err(_) => {
continue;
}
};
let half_second_ago = Instant::now() - Duration::from_millis(500);
for (key, &value) in map.iter() {
// 对演员的超时检查
}
}
}
我们决定将超时截止时间定为半秒。截止时间越小,演员在故障后的重启越快。然而,这也增加了工作量,因为演员等待消息的超时也必须更小,以满足监控者的要求。
当我们循环遍历状态键检查演员时,如果超时截止时间被超过,我们发送一个重置请求:
if value < half_second_ago {
match key {
ActorType::KeyValue | ActorType::Writer => {
ROUTER_SENDER.get().unwrap().send(
RoutingMessage::Reset(ActorType::KeyValue)
).await.unwrap();
map.remove(&ActorType::KeyValue);
map.remove(&ActorType::Writer);
break;
}
}
}
你可能注意到,即使写入演员失败,我们也会重置键值演员。这是因为键值演员会重启写入演员。我们还从映射中移除了这些键,因为当键值演员重新启动时,它会发送一个心跳消息,导致这些键被重新检查。然而,写入键可能仍然过时,导致第二次不必要的触发。我们可以在这些演员重新注册后开始检查它们。
我们的路由器演员现在必须支持所有这些更改。首先,我们需要将键值通道和句柄设置为可变:
let (mut key_value_sender, mut key_value_receiver) = channel(32);
let mut key_value_handle = tokio::spawn(
key_value_actor(key_value_receiver)
);
这是因为如果键值演员被重置,我们需要重新分配一个新的句柄和通道。然后,我们启动心跳演员来监督其他演员:
let (heartbeat_sender, heartbeat_receiver) = channel(32);
tokio::spawn(heartbeat_actor(heartbeat_receiver));
现在我们的演员系统正在运行,我们的路由器演员可以处理传入的消息:
while let Some(message) = receiver.recv().await {
match message {
RoutingMessage::KeyValue(message) => {
let _ = key_value_sender.send(message).await;
},
RoutingMessage::Heartbeat(message) => {
let _ = heartbeat_sender.send(message).await;
},
RoutingMessage::Reset(message) => {
// 重置逻辑
}
}
}
对于重置,我们需要执行几个步骤。首先,我们创建一个新的通道。我们终止键值演员,重新分配发送器和接收器到新通道,然后启动一个新的键值演员:
match message {
ActorType::KeyValue | ActorType::Writer => {
let (new_key_value_sender, new_key_value_receiver) = channel(32);
key_value_handle.abort();
key_value_sender = new_key_value_sender;
key_value_receiver = new_key_value_receiver;
key_value_handle = tokio::spawn(
key_value_actor(key_value_receiver)
);
time::sleep(Duration::from_millis(100)).await;
},
}
你可以看到,我们加了一个小的 sleep,确保任务已启动并在异步运行时运行。你可能担心,在这个过渡过程中,向键值演员发送的请求可能会出错。然而,所有请求都经过路由器演员。如果这些消息正在发送到路由器,它们将排队在路由器的通道中。这样,你可以看到演员系统是非常容错的。
由于这段代码涉及很多移动部分,我们将所有内容一起运行,使用主函数:
警告
在运行以下代码之前,请确保你的 data.json 文件包含一对空的大括号,如下所示:
{}
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let (sender, receiver) = channel(32);
ROUTER_SENDER.set(sender).unwrap();
tokio::spawn(router(receiver));
let _ = set("hello".to_string(), b"world".to_vec()).await?;
let value = get("hello".to_string()).await?;
println!("value: {:?}", value);
let value = get("hello".to_string()).await?;
println!("value: {:?}", value);
ROUTER_SENDER.get().unwrap().send(
RoutingMessage::Reset(ActorType::KeyValue)
).await.unwrap();
let value = get("hello".to_string()).await?;
println!("value: {:?}", value);
let _ = set("test".to_string(), b"world".to_vec()).await?;
std::thread::sleep(std::time::Duration::from_secs(1));
Ok(())
}
运行 main 函数时,我们会看到如下输出:
Data loaded from file: {}
value: Some([119, 111, 114, 108, 100])
value: Some([119, 111, 114, 108, 100])
Data loaded from file: {"hello": [119, 111, 114, 108, 100]}
value: Some([119, 111, 114, 108, 100])
我们可以看到,数据在系统启动时被写入演员加载。我们的 get 函数在设置 hello 值后工作正常。然后我们手动强制了一个重置。这里我们可以看到数据再次被加载,意味着写入演员被重启。我们知道之前的写入演员已经死亡,因为写入演员会获取文件句柄并持有它。如果在重启之前再次尝试访问文件,将会发生错误,因为文件描述符已被占用。
如果你希望晚上能安然入睡,可以在写入演员的循环开始前添加一个时间戳,并在每次循环迭代的开始时打印出该时间戳,这样打印出的时间戳就不再依赖任何传入的消息。这样打印的输出会像下面这样:
Data loaded from file: {}
writer instance: Instant { tv_sec: 1627237, tv_nsec: 669830291 }
value: Some([119, 111, 114, 108, 100])
writer instance: Instant { tv_sec: 1627237, tv_nsec: 669830291 }
value: Some([119, 111, 114, 108, 100])
Starting key_value_actor
writer instance: Instant { tv_sec: 1627237, tv_nsec: 669830291 }
Data loaded from file: {"hello": [119, 111, 114, 108, 100]}
writer instance: Instant { tv_sec: 1627237, tv_nsec: 773026500 }
value: Some([119, 111, 114, 108, 100])
writer instance: Instant { tv_sec: 1627237, tv_nsec: 773026500 }
writer instance: Instant { tv_sec: 1627237, tv_nsec: 773026500 }
writer instance: Instant { tv_sec: 1627237, tv_nsec: 773026500 }
writer instance: Instant { tv_sec: 1627237, tv_nsec: 773026500 }
writer instance: Instant { tv_sec: 1627237, tv_nsec: 773026500 }
writer instance: Instant { tv_sec: 1627237, tv_nsec: 773026500 }
我们可以看到,重置前后实例不同,并且在重置后没有留下旧的写入实例的踪迹。我们可以放心地知道,重置是有效的,系统中没有一个孤独的演员在没有目的的情况下运行(在我们的系统中是这样——我们不能为好莱坞的情况担保)。
总结
在本章中,我们构建了一个接受键值事务的系统,使用写入演员进行备份,并通过心跳机制进行监控。尽管这个系统涉及很多组件,但通过使用路由器模式,实施过程得到了简化。路由器模式的效率不如直接调用演员,因为消息必须经过一个演员才能到达目标。然而,路由器模式是一个很好的起点。在确定解决问题所需的演员时,可以依赖路由器模式。一旦解决方案初步形成,您可以逐步过渡到演员之间直接相互调用,而不是通过路由器演员。
虽然我们专注于使用演员构建整个系统,但我们必须记住,演员是在异步运行时环境中运行的。由于演员是隔离的,并且由于它们只通过消息进行通信,因此它们很容易进行测试。我们可以采用混合方式使用演员。这意味着我们可以通过演员为正常的异步系统增加额外的功能。演员通道可以在任何地方访问。就像从路由器演员迁移到演员之间直接调用一样,当新的异步代码的整体形式逐渐形成时,您也可以慢慢将新代码从演员迁移到标准的异步代码。您还可以使用演员在处理遗留代码时,将其功能拆分出来,从而将依赖项隔离,以便将遗留代码纳入测试环境。
总的来说,由于演员的隔离特性,它们是一个非常有用的工具,可以在各种环境中实现。在您仍处于探索阶段时,演员还可以充当代码的过渡区域。在紧迫的截止日期下,我们曾多次在微服务集群中使用演员来解决如缓存和缓冲聊天机器人消息等问题。
在第九章中,我们将继续探索如何通过设计模式来接近和构建解决方案。