你用 Elixir 写了三年 GenServer,信手拈来。转到 Rust Actix,同样是 Actor 模型,却和编译器吵了三天架。明明是"表亲"技术,为什么感觉像两个世界?
一个困惑的开始
作为 Elixir 开发者,你可能听过这样的说法:
“Rust 的 Actix 也是 Actor 模型,和 Elixir 的 GenServer 差不多。”
于是你兴致勃勃地打开 Actix 文档,写下第一个 Actor。然后——
error[E0382]: use of moved value: `msg`
--> src/main.rs:15:9
|
14 | let data = msg.data;
| -------- value moved here
15 | process(msg);
| ^^^ value used here after move
error[E0597]: `ctx` does not live long enough
--> src/main.rs:23:5
|
23 | ctx.address()
| ^^^ borrowed value does not live long enough
什么鬼? 在 Elixir 里,你从来没遇到过什么"所有权"和"生命周期"。
这不是你的问题。这是两种完全不同的哲学在同一个概念名字下的碰撞。
同一个祖先,两条路
Actor 模型诞生于 1973 年,由 Carl Hewitt 提出。核心思想很简单:
Actor = 邮箱 + 状态 + 行为
┌────────────────────────────────────┐
│ Actor │
│ ┌────────────────────────────┐ │
│ │ 邮箱 (Mailbox) │ │ ← 接收消息
│ │ [msg1, msg2, msg3] │ │
│ └──────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────┐ │
│ │ 状态 (State) │ │ ← 私有数据
│ └──────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────┐ │
│ │ 行为 (Behavior) │ │ ← 处理消息
│ └────────────────────────────┘ │
└────────────────────────────────────┘
Erlang/Elixir 和 Rust/Actix 都实现了这个模型。 但它们对"如何实现"有截然不同的回答。
Actor 模型 (1973)
│
┌──────────────┴──────────────┐
│ │
▼ ▼
Erlang (1986) Akka (2009)
"让它崩溃" JVM 上的 Actor
│ │
▼ ▼
Elixir (2011) Actix (2017)
"现代化的 Erlang" "Rust 上的 Actor"
两条线,两种哲学。
| 起源 | Elixir (BEAM) | Rust Actix |
|---|---|---|
| 诞生背景 | 电信系统,追求 99.9999999% 可用性 | 系统编程,追求零成本抽象 |
| 核心承诺 | “永不宕机,出错也能恢复” | “编译通过,就不会出错” |
| 设计目标 | 容错、热更新、分布式 | 性能、内存安全、零开销 |
这不是谁好谁坏的问题,而是解决不同问题的不同答案。
差异一:运行时模型——机场 vs 私人飞机
Elixir:机场调度系统
BEAM 虚拟机就像一个繁忙的机场:
BEAM VM(机场)
┌─────────────────────────────────────────┐
│ │
│ 调度器 1 调度器 2 调度器 N │ ← 跑道(CPU 核心)
│ │ │ │ │
│ ┌───┴───┐ ┌───┴───┐ ┌───┴───┐ │
│ │P1│P2│ │P3│P4│ │P5│P6│..│ │ ← 航班(进程)
│ └───────┘ └───────┘ └───────┘ │
│ │
│ 每个进程 ~300 字节,可以有数百万个 │
│ 抢占式调度,公平分配时间片 │
└─────────────────────────────────────────┘
特点:
- 轻量级进程:一个进程只有 ~300 字节,创建成本几乎为零
- 抢占式调度:BEAM 自动切换,不会有进程独占 CPU
- 海量并发:百万级进程轻松跑起来
- 进程隔离:一架航班出问题,不影响其他航班
Rust Actix:私人飞机
Actix 更像私人飞机——性能极致,但什么都要自己来:
Rust + Actix(私人飞机)
┌─────────────────────────────────────────┐
│ │
│ actix-rt(基于 Tokio 的单线程运行时) │
│ │ │ │ │
│ ┌───┴───┐ ┌───┴───┐ ┌───┴───┐ │
│ │Worker1│ │Worker2│ │Worker3│ │ ← 每个 Worker 一个单线程
│ │Actor A│ │Actor B│ │Actor C│ │ ← Actor 跑在 Worker 上
│ └───────┘ └───────┘ └───────┘ │
│ │
│ Actor 更重(~KB 级),数量受内存限制 │
│ 协作式调度,需要主动让出(await) │
└─────────────────────────────────────────┘
特点:
- 原生编译:直接生成机器码,没有 VM 开销
- 单线程 Worker:每个 Worker 是独立的单线程运行时,避免跨线程同步开销
- 协作式调度:Actor 需要主动
await才会让出控制权 - 性能极致:单个请求的延迟可以极低
这意味着什么?
场景:10 万个并发连接
Elixir 的做法:
每个连接 = 一个进程
10 万进程 ≈ 30MB 内存
BEAM 自动调度,你什么都不用管
Rust Actix 的做法:
每个连接 = 一个 Actor 或 async task
需要精心设计 Worker 数量和 Actor 粒度
性能更高,但复杂度也更高
机场的好处:容量大、调度自动、单点故障不影响全局。私人飞机的好处:速度快、灵活、没有机场的规则限制。
差异二:内存管理——酒店 vs 民宿房东
Elixir:住酒店
在 Elixir 里,内存管理就像住酒店:
酒店(BEAM GC):
┌──────────────────────────────────────┐
│ 房间 1(进程 1) 房间 2(进程 2) │
│ ┌──────────┐ ┌──────────┐ │
│ │ 垃圾 │ │ │ │
│ │ 需要清理 │ │ 正在使用 │ │
│ └──────────┘ └──────────┘ │
│ ↓ │
│ 清洁员来打扫 │
│ (只清理这个房间,不影响隔壁) │
└──────────────────────────────────────┘
每个进程有自己的堆和垃圾回收器。房间 1 在打扫时,房间 2 的客人完全不受影响。
你从来不用想:
- 这个变量什么时候释放?
- 这块内存会不会被别人用?
- GC 会不会卡住整个系统?
Rust:当民宿房东
在 Rust 里,你是民宿房东,要追踪每把钥匙:
民宿房东(所有权系统):
┌──────────────────────────────────────┐
│ │
│ 钥匙 A ──────► 房客甲 │
│ (所有权) (拥有者) │
│ │
│ 钥匙 A ─ ─ ─► 房客乙 │
│ (借用) (临时使用) │
│ │
│ 规则: │
│ 1. 一把钥匙只能有一个主人 │
│ 2. 借出去的钥匙要按时归还 │
│ 3. 有人借着的时候,主人不能换锁 │
└──────────────────────────────────────┘
这就是为什么你会遇到这种错误:
fn process_message(msg: Message) {
let data = msg.data; // msg.data 的所有权移动到 data
process(msg); // 错误!msg 已经不完整了
}
在 Elixir 里,消息是复制的,你随便用。在 Rust 里,你要明确"谁拥有这个数据"。
代价与收益
| 方面 | Elixir (GC) | Rust (所有权) |
|---|---|---|
| 心智负担 | 低,不用想内存 | 高,要理解所有权 |
| GC 停顿 | 有,但是进程级别 | 无,没有 GC |
| 内存效率 | 一般 | 极致 |
| 学习曲线 | 平缓 | 陡峭 |
酒店的好处:不用操心打扫,专注业务。房东的好处:完全掌控,没有意外账单(内存泄漏)。
差异三:类型系统——演唱会检票 vs 机场安检
Elixir:演唱会检票
Elixir 是动态类型,像演唱会入场:
def greet(person) do
"Hello, #{person.name}!"
end
greet(%{name: "Alice"}) # 正常
greet(%{name: "Bob", age: 30}) # 也正常,多余字段无所谓
greet("Charlie") # 运行时才报错!
只要"看起来像票"就让你进。至于票是不是真的——进去再说。
好处:灵活、快速迭代、代码简洁。
代价:错误在运行时才发现。
Rust:机场安检
Rust 是静态类型 + 生命周期,像机场安检:
fn greet(person: &Person) -> String {
format!("Hello, {}!", person.name)
}
struct Person {
name: String,
}
greet(&Person { name: "Alice".into() }); // OK
greet(&"Charlie"); // 编译错误!类型不对
每个参数的类型、每块内存的生命周期,在登机前全部检查完毕。
机场安检流程:
乘客 ─► 身份核验 ─► 行李扫描 ─► 安检门 ─► 登机
│ │ │
▼ ▼ ▼
类型检查 所有权检查 生命周期检查
任何一关没过,都不让你上飞机(编译失败)
好处:编译通过 = 一大类 bug 不存在。
代价:前期要和编译器"沟通"很多。
这意味着什么?
在 Elixir 里,你可以这样写:
def handle_call({:get, key}, _from, state) do
{:reply, Map.get(state, key), state}
end
简洁优雅。但如果 state 不是 Map 呢?运行时才知道。
在 Rust 里,你必须明确:
impl Handler<Get> for MyActor {
type Result = Option<String>;
fn handle(&mut self, msg: Get, _ctx: &mut Context<Self>) -> Self::Result {
self.state.get(&msg.key).cloned()
}
}
繁琐一些,但编译器保证 state 一定有 get 方法。
差异四:错误哲学——急诊室 vs 体检中心
这是两者最深层的哲学差异。
Elixir:“急诊室随时待命”
Erlang/Elixir 的哲学是 “Let it crash”——让它崩。
监督树
┌──────────────┐
│ Supervisor │ ← 急诊室主任
└──────┬───────┘
│
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Worker1 │ │ Worker2 │ │ Worker3 │ ← 医生们
└─────────┘ └─────────┘ └─────────┘
Worker2 崩溃了?
1. Supervisor 收到通知
2. 按策略重启 Worker2
3. 其他 Worker 完全不受影响
4. 整个系统继续运行
这不是"不处理错误",而是把错误处理上移到监督者层面。
defmodule MyApp.Supervisor do
use Supervisor
def start_link(init_arg) do
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
def init(_init_arg) do
children = [
{MyWorker, []},
{AnotherWorker, []}
]
Supervisor.init(children, strategy: :one_for_one)
end
end
核心思想:既然 bug 不可避免,不如设计系统来容忍 bug。
Rust:“体检中心预防为主”
Rust 的哲学是**“编译期消灭错误”**——在问题发生前就阻止它。
编译器检查流水线
代码 ─► 类型检查 ─► 所有权检查 ─► 生命周期检查 ─► 通过!
│ │ │
▼ ▼ ▼
类型错误 内存错误 悬垂引用
被拦截 被拦截 被拦截
如果能编译通过,这些问题就不存在
对于业务逻辑错误,Rust 用 Result 类型强制你处理:
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err("Division by zero".to_string())
} else {
Ok(a / b)
}
}
fn main() {
match divide(10, 0) {
Ok(result) => println!("Result: {}", result),
Err(e) => println!("Error: {}", e),
}
// 你必须处理 Err 的情况,否则编译不过
}
两种哲学的对比
| 方面 | Elixir (Let it crash) | Rust (编译期消灭) |
|---|---|---|
| 错误发现时机 | 运行时 | 编译期 |
| 错误处理策略 | 隔离 + 重启 | 预防 + 强制处理 |
| 适合场景 | 长期运行的服务、分布式系统 | 对正确性要求极高的系统 |
| 心智模型 | “出错很正常,系统能恢复就行” | “尽量不出错,出错要明确处理” |
急诊室思维:病人会来的,随时准备好就行。体检中心思维:最好的治疗是预防,别让人生病。
两者不是对立的。实际上,很多 Rust 项目也会实现类似监督树的模式。但默认的思维方式不同。
那么,什么时候选谁?
这不是"哪个更好"的问题,而是"哪个更适合"的问题。
选 Elixir/BEAM 当你需要:
✓ 海量并发连接(聊天、实时通知、游戏)
✓ 高可用、永不宕机的服务
✓ 分布式系统、节点间通信
✓ 快速迭代、灵活应对变化
✓ 热更新(不停机升级)
代表场景:WhatsApp(50 个工程师支撑 9 亿用户)、Discord 的部分服务、金融交易系统。
选 Rust/Actix 当你需要:
✓ 极致性能(每个请求的延迟)
✓ 资源受限环境(嵌入式、边缘计算)
✓ 对内存安全有硬性要求
✓ 需要和 C/C++ 代码互操作
✓ 追求可预测的性能(无 GC 停顿)
代表场景:高频交易的核心路径、游戏引擎、操作系统组件。
两者的交集
有趣的是,Discord 的实践展示了两者可以共存:
Discord 的架构(简化):
┌─────────────────────────────────────┐
│ Elixir (Gateway) │ ← 海量 WebSocket 连接
│ 百万级并发,消息路由 │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Rust (Read States) │ ← 高性能数据服务
│ 极致延迟,内存效率高 │
└─────────────────────────────────────┘
用对的工具做对的事。
系列预告
这篇文章是总览,后续我们会深入每个具体主题:
| 篇 | 主题 | 核心问题 |
|---|---|---|
| 2 | Actor 模型 | GenServer 和 Actix Actor 到底怎么对应? |
| 3 | 进程与并发 | Elixir 的 spawn 在 Rust 里怎么写? |
| 4 | 消息传递 | 为什么 Actix 要定义那么多类型? |
| 5 | 容错机制 | 没有监督树,Rust 怎么处理错误? |
| 6 | 模式匹配 | 看起来像,用起来为什么不一样? |
| 7 | 状态管理 | Agent 和 ETS 在 Rust 里用什么替代? |
总结
Elixir 和 Rust Actix 的核心差异:
┌─────────────────────────────────────────────────────────────┐
│ Elixir (BEAM) │
│ "让系统可靠,即使代码有 bug" │
│ │
│ • 运行时:虚拟机,百万进程,抢占式调度 │
│ • 内存:GC,进程隔离,不用操心 │
│ • 类型:动态,灵活,运行时检查 │
│ • 错误:Let it crash + 监督树恢复 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Rust (Actix) │
│ "让代码正确,bug 在编译期就消灭" │
│ │
│ • 运行时:原生编译,零开销抽象 │
│ • 内存:所有权系统,编译期决定生命周期 │
│ • 类型:静态,严格,编译期检查 │
│ • 错误:Result 类型 + 强制处理 │
└─────────────────────────────────────────────────────────────┘
同源不同路:它们都相信 Actor 模型的价值,但对"如何实现可靠的并发系统"给出了不同的答案。
Elixir 说:“错误会发生,设计系统来容忍它。”
Rust 说:“尽量不让错误发生,发生了也要明确处理。”
两种哲学,没有对错,只有适合不适合。
下一篇:Actor 模型:GenServer vs Actix Actor
本系列: