GenServer:标准化的心脏
上一章结尾,我们用二十行代码手搓了一个「抱着状态的进程」。它能跑,但它是三无产品:无回执、无超时、无人管崩溃。四十年来,每个 Erlang 工程师都曾把同样的补丁打在同样的 loop 上——直到这些补丁被官方收编成一个标准件:GenServer(通用服务器)。它是 OTP 的头号明星,你以后写的 Elixir 服务,一半以上的进程都是它。
手搓版的四个缺口
先给上一章的 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/0、value/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 用得好不好,就看重活有没有被外包出去。
把 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 全部魔法的谜底,提前写在这里。
- 把 Counter 打进 iex 跑通,然后用
:sys.get_state(Counter)偷看状态——提前尝一口第 11 章。 - 给 Counter 加一个
reset/0(cast)和incr_by/1(call,返回新值),分清哪些代码跑在哪个进程。 - 写一个 RateLimiter GenServer:state 是 Map(key → 次数),
allow?/1用 call 判断某 key 一分钟内是否超过 10 次,handle_info 每分钟清零。 - 向同事解释:为什么「默认 call、慎用 cast」?背压两个字务必出现。
心脏装好了,但还没人监护它:Counter 崩了就永远没了。下一章,给它配主治医生——监督树,以及 let it crash 的下半句。