你用 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)          │  ← 高性能数据服务
  │       极致延迟,内存效率高           │
  └─────────────────────────────────────┘

用对的工具做对的事

系列预告

这篇文章是总览,后续我们会深入每个具体主题:

主题核心问题
2Actor 模型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

本系列:

  1. 同源不同路:两种 Actor 哲学的碰撞(本篇)
  2. Actor 模型:GenServer vs Actix Actor
  3. 进程与并发:spawn 的两种人生
  4. 消息传递:从 send 到 Handler
  5. 容错机制:Let it crash vs 不让你 crash
  6. 模式匹配:熟悉的语法,不同的能力
  7. 状态管理:从 Agent/ETS 到 Rust 的选择