从 Actor 模型说起:并发不是共享内存

大多数语言的并发模型是「共享内存 + 锁」:多个线程读写同一块数据,靠锁保证不出乱子。Elixir(以及它脚下的 Erlang VM)走的是完全不同的路线——Actor 模型:每个并发单元(叫进程)拥有自己独立的内存,不和别人共享;它们之间唯一的通信方式是发消息。

这个选择带来两个直接后果。第一,没有共享内存就没有数据竞争,也就没有锁;第二,进程之间天然隔离,一个进程崩溃不会弄脏别人的数据。这两点叠加,让 Erlang/Elixir 敢于把并发度做到极高——一台普通服务器跑几十万个并发进程是家常便饭。

Elixir 的「进程」不是操作系统线程,而是运行在 BEAM 虚拟机上的轻量调度单元:创建开销极小、切换由虚拟机调度器完成。写 Elixir 时你不必纠结「线程池开多大」,进程数量本身就是一种合理抽象。理解 Actor 模型,是理解 Elixir 一切并发特性的地基。

在共享内存的世界里,并发是「如何安全地共享」;在 Actor 的世界里,并发是「如何优雅地隔离」。
本文核心知识点
  • Actor 模型进程独立、消息通信、无共享状态。
  • 进程BEAM 上的轻量并发单元,可大规模创建。
  • 信箱每条消息进入进程私有的队列。
  • Task把异步任务封装成可等待的结果。
  • 监督树让崩溃被监测、被重启,而不是传染。
Elixir 并发与容错实战配图:深蓝至蓝绿渐变背景,多个半透明圆形进程节点错落分布,每个节点带信箱槽口,节点之间发光弧线互连并有彩色光点流动,顶部中枢节点向下分出层级线条构成监督树
圆形节点各带信箱、弧线传消息、顶层中枢向下监督——Actor 世界里的并发图景。

进程:轻量级并发单元

在 Elixir 里,进程用 spawn 创建:

pid = spawn(fn ->
  IO.puts("hello from #{inspect(self())}")
end)

spawn 返回一个进程标识 pid(process id),self() 返回当前进程自己的 pid。每个进程都有自己的信箱(mailbox)和独立堆栈。创建进程的代价极低,BEAM 的调度器按可用的 CPU 核心把海量进程均匀摊开。

和线程最大的不同:进程不共享任何状态,没有「加锁保护共享变量」这回事。你要的数据要么在进程自己的堆里,要么通过消息从别的进程拿过来。这种「一切靠消息」的纪律,从根上消灭了死锁和数据竞争这两大类并发 bug。

进程还会自然「退出」:函数执行完进程就结束,或者主动 exit。崩溃也是一种退出——抛异常会让进程带着失败原因退出,而不会影响其他进程。下一节的监督机制,正是建立在这个「进程可退出、退出可被观察」的基础之上。

消息传递与信箱:send 与 receive

进程之间通信用 send 和 receive。send 把消息放进目标进程的信箱(一个先进先出的队列),receive 从自己信箱里按模式匹配取消息:

defmodule Echo do
  def loop do
    receive do
      {:echo, sender, text} ->
        send(sender, {:reply, text})
        loop()
      {:stop, _sender} ->
        :ok
    end
  end
end

pid = spawn(Echo, :loop, [])
send(pid, {:echo, self(), "hi"})
receive do
  {:reply, text} -> IO.puts(text)
end

receive 会阻塞当前进程,直到信箱里出现匹配的消息;没有匹配时消息保留在信箱里,等待下一次 receive。这种「模式匹配 + 选择性接收」让进程能按消息类型分派逻辑,写出来的并发代码像在写状态机。

信箱是 Actor 模型里优雅的一部分:发送方永远不需要等接收方就绪,消息先进信箱排队。但也要注意,如果进程处理不过来,信箱会越积越长,内存随之增长——生产里要给关键进程的消息量做监控。

spawn 与 Task:异步任务的两级抽象

手写 spawn + receive 适合「长期存活的进程」,而一次性异步任务用 Task 更顺手。Task 把「起一个进程干活、把结果拿回来」封装成简单 API:

task = Task.async(fn ->
    Process.sleep(100)
    42
  end)

result = Task.await(task)   # 阻塞等待结果
IO.puts(result)             # 42

多个 Task 可以并行启动再一起等待:

tasks = for i <- 1..5 do
  Task.async(fn -> slow_calc(i) end)
end
results = Task.await_many(tasks, 5_000)   # 带超时,单位毫秒
IO.inspect(results)

Task 底层还是进程,但替你处理了「发消息求结果」的样板:await 带超时参数,超时抛异常而不是无限等。Task 还支持 Task.start(不关心结果)、Task.yield(轮询)等变体。日常并发里 80% 的场景,Task 就够了;需要长期状态才回到底层进程。

流式并发:Stream 与管道

Elixir 的 Stream 提供惰性的可枚举序列,配合 Task.async_stream 可以在保持管道写法的同时并行处理每个元素:

1..100
|> Task.async_stream(fn n -> fetch_remote(n) end,
     max_concurrency: 10, timeout: 5_000)
|> Enum.to_list()
|> Enum.filter(fn {:ok, v} -> v > 50 end)

async_stream 按元素起任务并行处理,max_concurrency 控制并发上限(保护下游),每个结果按输入顺序返回。这相当于把「worker 池 + 扇入」揉进了一个管道操作符里。

Stream 的惰性也很实用:Stream.map 只是声明「要对每个元素做什么」,真正执行要到被消费时。组合 async_stream 与 Stream.take,可以在不跑完全部数据的情况下提前拿到前 N 个结果,适合流式数据处理。

注意结果的形状:async_stream 的每个元素是 {:ok, value} 或 {:error, reason},处理时先解包再使用,错误不会被静默吞掉。