从 Actor 模型说起:并发不是共享内存
大多数语言的并发模型是「共享内存 + 锁」:多个线程读写同一块数据,靠锁保证不出乱子。Elixir(以及它脚下的 Erlang VM)走的是完全不同的路线——Actor 模型:每个并发单元(叫进程)拥有自己独立的内存,不和别人共享;它们之间唯一的通信方式是发消息。
这个选择带来两个直接后果。第一,没有共享内存就没有数据竞争,也就没有锁;第二,进程之间天然隔离,一个进程崩溃不会弄脏别人的数据。这两点叠加,让 Erlang/Elixir 敢于把并发度做到极高——一台普通服务器跑几十万个并发进程是家常便饭。
Elixir 的「进程」不是操作系统线程,而是运行在 BEAM 虚拟机上的轻量调度单元:创建开销极小、切换由虚拟机调度器完成。写 Elixir 时你不必纠结「线程池开多大」,进程数量本身就是一种合理抽象。理解 Actor 模型,是理解 Elixir 一切并发特性的地基。
在共享内存的世界里,并发是「如何安全地共享」;在 Actor 的世界里,并发是「如何优雅地隔离」。
- Actor 模型进程独立、消息通信、无共享状态。
- 进程BEAM 上的轻量并发单元,可大规模创建。
- 信箱每条消息进入进程私有的队列。
- Task把异步任务封装成可等待的结果。
- 监督树让崩溃被监测、被重启,而不是传染。
进程:轻量级并发单元
在 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},处理时先解包再使用,错误不会被静默吞掉。
监督树与容错:让崩溃成为设计
Elixir 容错的核心思想反直觉:不试图避免崩溃,而是把崩溃当作正常路径的一部分来管理。机制是监督树——一个监督者进程专门盯着一批子进程,子进程崩了,监督者按策略决定:重启它、重启整组,还是自己也放弃。
children = [
{Cache, []},
{QueueWorker, []}
]
Supervisor.start_link(children, strategy: :one_for_one)
:one_for_one 策略下,某个子进程崩溃只重启它自己;:one_for_all 则一个崩、全组重启;:rest_for_one 重启崩溃者及它后面的兄弟。OTP 里 GenServer、Task、Agent 都能挂进监督树。
这套设计把「恢复逻辑」从业务代码里剥离出来:业务函数崩溃就崩溃,监督者负责拉起新进程、业务状态从初始化重新开始。代码不用写满 try/rescue 去兜底每个异常,正确性反而更好——因为你不再依赖「半崩溃状态下的继续运行」。
「让崩溃成为设计」还要求崩溃信息可观测:写日志、用 Logger 记录退出原因,配合 Erlang 的 :observer 或监控工具实时看进程树。容错不是「假装没崩」,而是「崩得清楚、恢复得干净」。
实战:一个并发消息管道
把前面所有概念组装成一个「并发消息管道」:一批消息进来,并行加工,统一收集结果,任何一个加工器崩溃都不影响整体。
defmodule Pipeline do
def run(messages) do
messages
|> Task.async_stream(&process/1, max_concurrency: 20)
|> Enum.map(fn
{:ok, v} -> v
{:error, e} -> {:failed, e}
end)
end
defp process(msg) do
if rem(msg, 7) == 0 do
raise "bad luck: #{msg}"
end
msg * 2
end
end
Pipeline.run(1..10) # 结果为 {:failed, _} 与正常值的混合
这里 Task.async_stream 负责并发与错误捕获:某个元素抛异常,对应结果是 {:error, _},其他元素照常处理。输出是一个「失败与成功并存」的列表,由调用方决定怎么处理失败项——错误被显式建模,而不是中断整条管道。
如果需要「一个失败就整体重启」,把加工函数放进 GenServer、挂到监督树下即可。至此你看到的是一条完整的路径:进程与消息 → Task 与 Stream → 监督树容错 → 把并发写成像数据管道一样清晰。这也是 Elixir 在即时通讯、实时系统里备受青睐的原因。
并发模型决定你能安全地写多快;容错模型决定系统坏了之后还能不能回来。Elixir 两样都给你了。