返回文章列表

Rust 异步服务里,Actor 模型不是框架,是一种边界

334·3 分钟阅读
Rust微服务

很多 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 不是为了显得架构高级,而是为了让“谁能改状态”这件事变得清楚。