Rust 异步服务里,Actor 模型不是框架,是一种边界
很多 Rust 异步代码写到最后会变成
Arc<Mutex<State>>到处传。能跑,但越来越难判断谁在改状态。
共享锁状态是最直觉的写法:
use std::{collections::HashMap, sync::Arc};
use tokio::sync::Mutex;
type SharedUsers = Arc<Mutex<HashMap<u64, String>>>;小项目没问题。问题是业务一复杂,读任务、写任务、定时任务、WebSocket 连接、HTTP handler 都来拿这把锁。
最后状态确实安全了,但行为变乱了:
谁能改状态?
什么时候改?
锁里能不能 await?
一个操作改到一半失败怎么办?Actor 模型的价值不是“更高级”,而是把状态修改收口到一个地方。
这篇不讲 Actix,也不引入框架。只用 Tokio channel 写一个最小 Actor,看它怎么帮 Rust 异步服务减少共享锁。
本文代码环境:
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }共享锁的问题不是锁,是边界不清楚
一个用户状态服务可以这样写:
async fn set_user(users: SharedUsers, id: u64, name: String) {
let mut users = users.lock().await;
users.insert(id, name);
}这段代码本身没错。
但随着函数变多,状态修改会散落在各处。每个函数都能拿锁,每个函数都可能改状态,业务规则就不再有中心。
Actor 换了一个方向:状态不共享,只让一个任务拥有它。其他地方想操作状态,只能发消息。
HTTP handler ─┐
WebSocket ───┼── Command channel ──> Actor 持有 State
定时任务 ───┘状态只有一个 owner,修改只有一个入口。
这就是 Actor 模型最适合 Rust 的地方。
先定义消息,而不是先定义锁
Actor 的边界从消息开始。
use tokio::sync::oneshot;
enum UserCommand {
SetName {
id: u64,
name: String,
},
GetName {
id: u64,
reply: oneshot::Sender<Option<String>>,
},
}这段 enum 比 HashMap 更重要。
它明确告诉你:外部世界只能做两件事,设置名字,查询名字。以后加删除、批量导入、订阅变更,也都从这里进。
Actor 的消息就是状态边界的公开 API。
状态留在 Actor 里面
Actor 本体很普通:
use std::collections::HashMap;
use tokio::sync::mpsc;
struct UserActor {
users: HashMap<u64, String>,
rx: mpsc::Receiver<UserCommand>,
}运行循环只做一件事:收消息,改自己的状态。
impl UserActor {
async fn run(mut self) {
while let Some(cmd) = self.rx.recv().await {
match cmd {
UserCommand::SetName { id, name } => {
self.users.insert(id, name);
}
UserCommand::GetName { id, reply } => {
let _ = reply.send(self.users.get(&id).cloned());
}
}
}
}
}这里没有 Mutex。
因为 users 只活在 Actor 任务里,没有被共享出去。Rust 的所有权系统直接帮你把“谁能改状态”这件事锁死了。
给外部一个 Handle,而不是暴露 Sender
可以直接把 mpsc::Sender<UserCommand> 传出去,但我更喜欢包一层 handle:
#[derive(Clone)]
struct UserHandle {
tx: mpsc::Sender<UserCommand>,
}handle 负责把 channel 细节藏起来:
impl UserHandle {
async fn set_name(&self, id: u64, name: String) -> Result<(), &'static str> {
self.tx
.send(UserCommand::SetName { id, name })
.await
.map_err(|_| "actor stopped")
}
}查询需要 oneshot 回包:
impl UserHandle {
async fn get_name(&self, id: u64) -> Result<Option<String>, &'static str> {
let (reply, rx) = oneshot::channel();
self.tx
.send(UserCommand::GetName { id, reply })
.await
.map_err(|_| "actor stopped")?;
rx.await.map_err(|_| "actor dropped reply")
}
}这样 handler 看到的是业务方法,不是 channel 操作。
这层封装很值。以后你想加 timeout、tracing、背压、错误分类,都可以放在 handle 里。
启动 Actor
创建函数也很直接:
fn spawn_user_actor() -> UserHandle {
let (tx, rx) = mpsc::channel(128);
let actor = UserActor {
users: HashMap::new(),
rx,
};
tokio::spawn(actor.run());
UserHandle { tx }
}channel 容量就是 Actor 的等待区。
容量太小,突发请求容易失败;容量太大,延迟会被藏起来。这个选择和 背压 里的队列容量是同一个问题。
Actor 不是绕开背压,Actor 只是让背压有一个清楚的位置。
Actor 里不要做无限等待的外部 I/O
Actor 持有状态,所以它的事件循环不能被长时间卡住。
不建议这样:
// 不推荐:Actor 事件循环被外部 RPC 卡住
async fn enrich_user_from_remote(id: u64) -> String {
call_remote_service(id).await
}更稳的做法是把外部 I/O 放到 Actor 外面,完成后再发消息回来。
async fn fetch_then_update(handle: UserHandle, id: u64) -> Result<(), &'static str> {
let name = call_remote_service(id).await?;
handle.set_name(id, name).await
}模拟远程调用:
async fn call_remote_service(id: u64) -> Result<String, &'static str> {
Ok(format!("user-{id}"))
}这条规则不是绝对的。有些 Actor 本来就是 I/O worker。但如果 Actor 的职责是管理状态,就尽量别让它在持有状态的循环里等慢下游。
Actor 适合什么,不适合什么
| 场景 | 是否适合 |
|---|---|
| 房间状态、会话状态 | 适合 |
| 热更新状态机 | 适合 |
| 单资源调度器 | 适合 |
| 大量独立小对象 | 谨慎,Actor 数量会膨胀 |
| 纯无状态 CRUD | 不需要 |
| CPU 密集任务 | 不适合,应该用线程池 |
Actor 不是万能模式。
它适合“状态有明确 owner,所有修改应该串行化”的场景。不适合拿来包一切函数。
当你发现某个
Arc<Mutex<State>>被 5 个以上模块拿着改,就该考虑 Actor。
结论
Actor 模型在 Rust 里不神秘。
可以直接记这几条:
- 状态只放在 Actor 任务里
- 外部只能通过消息操作状态
- 消息 enum 就是状态边界 API
- 用 handle 封装
mpsc::Sender - 查询用 oneshot 返回
- channel 容量就是背压点
- 管状态的 Actor 不要被慢 I/O 卡住
Actor 的核心价值,是让状态修改只有一个入口。
Rust 已经帮你把内存安全守住了,但业务状态的一致性还要靠设计。Actor 不是为了显得架构高级,而是为了让“谁能改状态”这件事变得清楚。