OTP 与进程:GenServer、Supervisor 与“让它崩”哲学

要点

开山篇给过 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 官方指南为准。本文未引用后续尚未发布的版本特性。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇