要点
开山篇给过 spawn/send/receive 的一瞥,本篇回答”然后呢”:裸原语为什么走不远,GenServer 与 Supervisor 各接管什么,以及 let it crash 到底指什么、不指什么。前置知识:模式匹配的多子句分发(语法要点篇)。截至本文(2025 年 5 月)稳定线为 Elixir 1.18.x,运行时对应 Erlang/OTP 27 线,本篇所有 API 都是 OTP 体系里稳定十余年的基本面。
裸原语的天花板:你要自己写掉的东西
开山篇的 spawn/receive 例子能跑,但离”服务”差三层。用裸进程写一个带状态的计数器,下面这些全都得手写:
| 要处理的事 | 裸进程的做法 | GenServer 给的 |
|---|---|---|
| 请求-响应 | 手工拼”带引用的消息 + receive 等回复” | handle_call 一行,回复自动路由回调用方 |
| 超时与兜底 | 每个 receive 都写 after,漏写永久阻塞 | 框架统一管理,消息按类型进不同回调 |
| 异常退出 | 进程死了状态没了,没人知道 | 配 Supervisor 自动重启,重启策略可声明 |
| 代码结构 | 消息协议散落在 send/receive 两侧 | 客户端 API 与回调分离,协议就是函数签名 |
GenServer 不是新概念,是 OTP 把上面这套 boilerplate 收编成的一个 behaviour(行为模块):你实现回调,框架负责消息循环。这就是”用 Elixir 写服务”和”用 Elixir 玩并发”的分界线。
GenServer:四个回调撑起一个服务
一个可直接粘进 iex 跑的计数器:
defmodule Counter do
use GenServer
1. 客户端 API:外部只跟这三个函数打交道,不知道背后是进程
def start_link(opts \\ []),
do: GenServer.start_link(__MODULE__, 0, name: Keyword.get(opts, :name, __MODULE__))
def bump, do: GenServer.cast(__MODULE__, :bump) # 异步:发完就走,不等结果
def get, do: GenServer.call(__MODULE__, :get) # 同步:阻塞到拿到回复
1. 服务端回调:状态(这里是计数)只存在于本进程的堆里
@impl true
def init(initial), do: {:ok, initial}
@impl true
def handle_call(:get, _from, count), do: {:reply, count, count}
@impl true
def handle_cast(:bump, count), do: {:noreply, count + 1}
end
{:ok, _pid} = Counter.start_link()
Counter.bump()
Counter.bump()
Counter.get() # => 2
回调分工一张表:
| 回调 | 什么时候进来 | 返回值含义 |
|---|---|---|
| init/1 | 进程启动时,初始化状态 | {:ok, state};返回 {:stop, reason} 可拒绝启动 |
| handle_call/3 | 有人调 GenServer.call(同步) | {:reply, 回复, 新状态} |
| handle_cast/2 | 有人调 GenServer.cast(异步) | {:noreply, 新状态} |
| handle_info/2 | 其余一切消息:裸 send、Process.send_after 的定时消息 | {:noreply, 新状态} |
最常被忽略的是 handle_info:它是所有”不是 call/cast”的消息出口。定时器(Process.send_after)、第三方库发的消息都落在这里。
反模式一:把慢活写进 handle_call
GenServer 一次只处理一条消息,这是它的串行化保证,也是它的软肋。如果 handle_call 里做 HTTP 请求、大文件读写这类秒级慢活,整个进程在这期间不处理任何消息,后续调用方全部排队——排队的不是回调,是持有信箱的整个进程。现象是”响应越来越慢直到超时”,信箱堆积(Process.info(pid, :message_queue_len) 能看到长度上涨)。
正确姿势是把慢活挪出去,进程只做轻活:
# 慢活丢给 Task,结果以普通消息回到本进程,落在 handle_info
Task.Supervisor.start_link(name: App.TaskSup) # 通常放在监督树里
def fetch_later(url), do: Task.Supervisor.start_child(App.TaskSup, fn ->
result = slow_http_get(url)
GenServer.cast(__MODULE__, {:fetched, result}) # 干完用 cast 汇报
end)
判据很简单:handle_call/handle_cast 里的工作超过毫秒级,就该考虑挪出去。要不要挪、挪给 Task 还是独立进程,取决于失败隔离的粒度——这正是下一节的内容。
Supervisor:重启策略是声明出来的
Supervisor 自己不干活,只盯着子进程:谁死了,按策略拉谁、怎么拉。策略三选一:
| 策略 | 行为 | 适用 |
|---|---|---|
| one_for_one | 谁死重启谁 | 子进程相互独立(默认首选) |
| one_for_all | 一个死,全部重启 | 子进程间有启动依赖,单独活着没意义 |
| rest_for_one | 一个死,它和它之后启动的都重启 | 启动顺序即依赖链 |
children = [
App.Cache, # 只列模块名时,Elixir 找 start_link/1 生成子进程规格
{DynamicSupervisor, name: App.TaskRoot},
]
Supervisor.start_link(children, strategy: :one_for_one)
两个防失控参数要知道:默认 5 秒内最多重启 3 次,超过就放弃整个监督树(连带上层一起崩)——这是故意的,防止”重启风暴”无限循环烧 CPU。重启强度调大之前先想清楚为什么进程会反复死。另外,启动时才确定数量的子进程(比如每用户一个会话进程)不放静态 children 列表,交给 DynamicSupervisor 的 start_child 动态加。
let it crash 的边界:什么该崩,什么不该
这句话常被误读成”不写错误处理”。准确的版本是:预期内的失败用返回值表达,预期外的失败让它崩。用户提交了格式错误的参数,返回 {:error, :bad_input},这是数据流的一部分;进程内部状态被破坏、外部服务返回了不可能的形状,这是 bug,防御性地 catch 住只会让进程带着坏状态继续服务——比崩溃更糟。
# 反模式:catch 一切,让进程带病续跑
def handle_call(:work, _from, state) do
result = risky() # 若这里因状态损坏而炸
{:reply, result, state}
rescue
e -> {:reply, {:error, "failed"}, state} # 崩点被吞,坏 state 留在进程里
end
1. 正确:预期外异常不接,让 Supervisor 换一个全新 init 的进程顶上
def handle_call(:work, _from, state), do: {:reply, risky(), state}
配合件是 link 与 monitor:link 双向死(默认 GenServer.start_link 就带),monitor 单向观察(Process.monitor(pid) 收 :DOWN 消息),”知道别人死了但自己不陪死”的场景用后者。需要进程”看到”自己的退出信号再决定去留时,用 Process.flag(:trap_exit, true) 把 link 退出转成消息——清理型收尾用。
对照站内写过的 PHP 协程路线(Swoole 协程运行时):协程共享 worker 进程内存,一个未捕获的协程错误可能污染整个 worker,所以 Swoole 生态的重点实践是”捕获一切、隔离请求”;BEAM 反着来——进程间本来就隔离,崩溃成本是”新起一个微进程 + 重新 init”,比修复脏状态便宜。这不是谁先进的问题,是虚拟机给出的容错原语不同,工程习惯必须跟着原语走。
反模式二:万物皆 GenServer
学完 GenServer 容易手里拿着锤子看什么都是钉子。三种情况不需要它:纯计算(给输入算输出,无状态)直接调函数,起进程白付消息传递的开销;只读的共享数据(配置、路由表)优先考虑 :ets 表——读写并发下比”所有请求挤过一个 GenServer”吞吐高一个量级,代价是要自己处理生命周期(一个可见进程当 owner);进程间广播(”通知所有订阅者”)用 Phoenix.PubSub 或 Registry 的 :duplicate 模式,逐个 cast 是下策。判断口诀:需要独占的可变状态才上 GenServer,其余场景都有更轻的原语。顺带一句,给注册的进程起”每用户唯一”的名字,用 name: {:via, Registry, {App.Registry, user_id}} 挂到 Registry 上,1.18 起还补了 Registry.lock/3 用于跨进程的具名锁。
下一步
这套模型在 Web 层的样子:Phoenix 里每个 HTTP 请求、每个 LiveView 会话都是监督树下的普通 BEAM 进程。怎么把它用成一个真实的实时看板(聚合进程 + PubSub 广播 + 流式渲染),见《用 Phoenix LiveView 做一个实时看板》;系列定位回顾在开山篇。想系统读官方材料,elixir-lang.org 的 GenServer 与 Supervisor 指南是唯一权威入口,本篇的回调语义以它为准。
版本核实(以官方文档为准):Elixir 稳定线为 1.18.x(1.18.0 发布于 2024-12-19,本篇涉及的 1.18 新增为 Registry.lock/3;1.18.3 发布于 2025-03-06),运行时对应 Erlang/OTP 27 线;GenServer 四回调、Supervisor 三策略与”默认 5 秒内最多 3 次重启”、Task.Supervisor 与 DynamicSupervisor 均为 OTP 体系多年稳定的 API,语义以 elixir-lang.org 官方指南为准。本文未引用后续尚未发布的版本特性。