VOL.III · 心跳CH 08监护 08/13

GenServer:标准化的心脏

上一章结尾,我们用二十行代码手搓了一个「抱着状态的进程」。它能跑,但它是三无产品:无回执、无超时、无人管崩溃。四十年来,每个 Erlang 工程师都曾把同样的补丁打在同样的 loop 上——直到这些补丁被官方收编成一个标准件:GenServer(通用服务器)。它是 OTP 的头号明星,你以后写的 Elixir 服务,一半以上的进程都是它。

GenServercall/casthandle_infobehaviour

手搓版的四个缺口

先给上一章的 Counter.loop 做个缺陷清单,每一条都是生产事故的种子:

  • 没有回执——send 完不知道对方收没收到、算没算完;想要「同步调用」得自己发 {:get, self()} 再 receive 等回信,还得防串线(两个请求的回信怎么区分?);
  • 没有超时——对方死了,你的 receive 等到天荒地老;
  • 没有标准死法——怎么优雅停机?怎么在崩溃时留下遗言(日志)?
  • 没有观测接口——第 11 章要用的 :sys.get_state、追踪、挂起,统统需要进程「会说标准协议」。

OTP 的答案:把「一个长活的、持有状态的、收消息干活的进程」该有的骨架全部标准化,你只填业务空格。这种「框架定骨架、你填回调」的约定,Elixir 里叫 behaviour——和 Android 里「继承 Service 覆写 onStartCommand」是同一种合作方式,只是这里没有继承,靠的是 use GenServer(第 4 章说过:use = 请对方注入代码)。

最小可用的 GenServer

defmodule Counter do
  use GenServer

  ## ---- 客户端 API(跑在调用者进程里)----
  def start_link(initial) do
    GenServer.start_link(__MODULE__, initial, name: __MODULE__)
  end
  def incr,     do: GenServer.cast(__MODULE__, :incr)
  def value,    do: GenServer.call(__MODULE__, :value)

  ## ---- 服务端回调(跑在 Counter 进程里)----
  @impl true
  def init(initial), do: {:ok, initial}

  @impl true
  def handle_cast(:incr, count), do: {:noreply, count + 1}

  @impl true
  def handle_call(:value, _from, count), do: {:reply, count, count}
end
iex(1)> Counter.start_link(0)
{:ok, #PID<0.121.0>}
iex(2)> Counter.incr()
:ok
iex(3)> Counter.incr()
:ok
iex(4)> Counter.value()
2

对照上一章的手搓版读这份代码,每个缺口都被补上了。注意文件的上下两半跑在不同进程里:上半的 incr/0value/0 是客户端 API,谁调用就跑在谁的进程里,它们只是把消息包装好寄出去;下半的 init/handle_cast/handle_call 是服务端回调,永远跑在 Counter 自己的进程里,由 GenServer 的内置 loop 逐条调用。「一个模块、两个进程的视角」是读懂一切 GenServer 代码的钥匙——评审时最常见的错误,就是搞混某行代码到底跑在谁家。

回调的返回值是和框架说话的暗号:{:ok, state}(初始化完成)、{:noreply, new_state}(不回信,换新状态继续)、{:reply, 回信内容, new_state}(回信并换状态)。状态本身可以是任意值——整数、Map、嵌套结构,随你。而你上一章手写的 loop(count + 1) 递归?就藏在 GenServer 内部,一行没少,只是不再需要你写。

call 还是 cast:一个背压问题

两种寄信方式的机制差异一句话说清:call 同步——寄出后原地等回信,默认等 5 秒,超时就崩;cast 异步——寄出即返回 :ok,爱谁谁。选哪个?新手直觉是「不需要返回值就 cast,快」,但工程上的判断标准更深一层:

⚕ 查房记录

call 的「慢」恰恰是它的药效:调用方必须等上一单处理完才能发下一单,生产速度被自动钳制在消费速度上——这是免费的背压(backpressure)。cast 没有这个闸门:上一章 Demo 里邮箱水位暴涨的场景,主谋通常就是无节制的 cast。经验法则:默认用 call,cast 留给「真的可以丢」的通知类消息(埋点、日志、缓存失效广播)。另外 call 超时崩的是调用方——这同样是设计:与其无限等待把僵死悄悄传染下去,不如当场暴露。

handle_info:第三个信箱口

除了 call 和 cast,进程还会收到「裸消息」——不经过 GenServer 协议、直接 send 来的,以及系统发的:定时器到点、监控的进程死了、TCP 数据到了。这些统一落到 handle_info/2:

@impl true
def init(state) do
  :timer.send_interval(60_000, :flush)   # 每分钟给自己寄一封 :flush
  {:ok, state}
end

@impl true
def handle_info(:flush, buffer) do
  persist(buffer)
  {:noreply, []}
end

回调返回值家族还有两个冷门成员顺带备案:{:noreply, state, 30_000} 末位挂一个空闲超时——30 秒没有新消息,就给自己来一条 handle_info(:timeout, ...),「无人访问自动休眠/自杀」的会话进程靠它;把末位写成 :hibernate 则让进程立刻深度休眠压缩内存,适合长期闲置的大状态进程。

「GenServer + send_interval 给自己发消息」是 BEAM 世界的 cron,轻量到不需要任何依赖。上一章警示过的「死信堆积」也在这里收口:GenServer 收到没有子句处理的消息会打日志并丢弃(而不是永远压箱底),框架替你打好了兜底补丁。

init 里别干慢活:handle_continue

一个隐蔽但常见的启动病:init 回调是同步的——start_link 要等 init 返回才返回,而监督树是按顺序拉起孩子的(下一章),于是 init 里一个 3 秒的「预热缓存」,就把整棵树的启动卡住 3 秒;十个这样的孩子,应用启动半分钟。标准解法是把慢活推迟到「出生之后的第一件事」:

@impl true
def init(opts) do
  {:ok, %{cache: nil}, {:continue, :warm_up}}   # 立刻返回,再补一刀
end

@impl true
def handle_continue(:warm_up, state) do
  {:noreply, %{state | cache: load_cache()}}     # 保证先于一切消息执行
end

{:continue, ...} 让 init 瞬间返回(树继续启动),而 handle_continue 保证在处理任何外部消息之前执行——既不卡启动,又不怕「还没预热就来请求」的竞态。这是 GenServer 面试的高频题,更是生产代码的必备卫生习惯。

from 与延迟回复:call 也能不马上答

handle_call 签名里一直被我们下划线掉的 _from,是调用者的回执地址 {pid, ref}。把它存下来、返回 {:noreply, state},稍后再 GenServer.reply(from, result)——调用方照常同步等待,服务端却可以先接下一单:「点单的排队,出餐的另说」。外包给 Task 干慢活、干完再 reply,就是靠这一手把 call 的背压和吞吐同时拿到的。

名字:别把 PID 存来存去

示例里的 name: __MODULE__ 给进程注册了一个名字,之后所有人用名字 Counter 说话,不用传 PID。这不只是方便:进程崩溃重启后 PID 会变,名字不变——持有旧 PID 的人对着尸体喊话,持有名字的人自动找到新任。单例服务用模块名注册即可;「每个订单一个进程」这种动态大军,用 Registry 按业务 ID 查号——第 10 章讲。

单进程 = 串行:最重要的性能模型

必须用红笔写的一节。GenServer 进程一次只处理一条消息,处理第 N 条时,第 N+1 条在邮箱里排队。这是特性——状态的所有变更天然串行化,不存在竞态,不需要锁,你写的回调永远是单线程心智模型。这也是陷阱——

def handle_call({:report, id}, _from, state) do
  data = HTTP.get!("https://slow-api/#{id}")   # 3 秒
  {:reply, summarize(data), state}
end
# 100 个并发请求 → 第 100 个等 300 秒 → 前面 95 个早就 call 超时崩了

一个慢回调,整个队列陪葬。药方按场景抓:①慢活外包——回调里 spawn(下一章用 Task)去干,干完寄结果回来,队列立刻疏通;②读多写少——状态放 ETS 表让读方绕过进程直读(第 10 章);③真需要并行——多开进程分片。口诀:GenServer 保护状态,不搬重物。判断一个系统的 GenServer 用得好不好,就看重活有没有被外包出去。

▸ Android 类比

把 GenServer 想成「自带 HandlerThread 的单例 Service」:所有请求 post 到它私有的消息队列,串行处理,状态无锁安全——这正是你写 HandlerThread + Handler 保护共享状态的套路,只是这里 8 行代码就有全套,还附赠超时、日志、重启和观测接口。而「不要在 Handler 里做耗时操作,会 ANR」的肌肉记忆,原封不动适用:handle_call 里的慢活,就是 GenServer 的 ANR。

它天生会说「体检协议」

最后揭一个本书的伏笔。为什么第 11 章的 :sys.get_state(Counter) 能凭空掏出任何 GenServer 的状态?因为 GenServer 的内置 loop 除了处理你的业务消息,还实现了一套 :sys 系统协议:收到「交出状态」「开启追踪」「挂起/恢复」这类系统消息时按标准应答。你手搓的 loop 不会说这门语言,所以手搓版才叫三无产品。用 OTP 标准件写的系统,天生就是可观测的——这句话是 VOL.IV 全部魔法的谜底,提前写在这里。

✚ 动手自测
  1. 把 Counter 打进 iex 跑通,然后用 :sys.get_state(Counter) 偷看状态——提前尝一口第 11 章。
  2. 给 Counter 加一个 reset/0(cast)和 incr_by/1(call,返回新值),分清哪些代码跑在哪个进程。
  3. 写一个 RateLimiter GenServer:state 是 Map(key → 次数),allow?/1 用 call 判断某 key 一分钟内是否超过 10 次,handle_info 每分钟清零。
  4. 向同事解释:为什么「默认 call、慎用 cast」?背压两个字务必出现。

心脏装好了,但还没人监护它:Counter 崩了就永远没了。下一章,给它配主治医生——监督树,以及 let it crash 的下半句。