第 6 章 通用服务进程(Generic server processes)学习笔记
本章内容为原创学习笔记与概念整理,不是原书逐段翻译。
上一章从 spawn、send、receive 入手,说明了服务进程如何维护状态、处理消息并响应调用方。本章的核心问题是:既然服务进程模式会反复出现,哪些部分应该抽出来复用,哪些部分应该留给业务模块实现?
GenServer 就是 Elixir/OTP 对这种模式的标准答案。它把进程循环、消息接收、同步调用、异步调用、超时和调试等通用机制放在框架里,把状态初始化、请求处理和状态转换交给回调模块。
6.1 为什么需要通用服务进程
手写服务进程通常会重复这些步骤:
- 启动一个新进程。
- 初始化状态。
- 进入递归循环。
- 从邮箱接收消息。
- 区分同步请求、异步请求和普通消息。
- 根据请求更新状态。
- 在需要时向调用方回复。
- 处理超时、停止和未知消息。
如果每个服务都手写这些细节,代码会很快变得笨重。更麻烦的是,不同开发者会写出风格各异的循环和消息协议,使系统难以统一监控、调试和监督。
通用服务进程的目标不是隐藏并发,而是把重复部分固定下来,让业务代码只描述“收到某类请求时,状态如何变化”。这和 Web 框架把 socket、路由分发、响应编码抽象掉,只让你写 controller/action 的思路类似。
6.2 从手写循环抽象出协议
可以先想象一个简化版的通用服务器。它大致需要两层模块:
- 通用模块:负责启动进程、接收消息、调用回调、维护循环。
- 回调模块:负责实现业务状态和具体请求处理。
例如,一个手写计数器服务可以把业务逻辑整理成三个回调:
defmodule CounterCallback do
def init(initial_value), do: initial_value
def handle_call(:value, state) do
{:reply, state, state}
end
def handle_cast({:increment, amount}, state) do
{:noreply, state + amount}
end
end
通用模块不关心 :value 或 :increment 的业务含义,只关心回调返回值的形状。例如:
{:reply, response, new_state}表示要回复调用方,并用new_state继续循环。{:noreply, new_state}表示不回复调用方,只更新状态。
这种返回值协议非常重要。它把“业务决策”和“进程控制”连接起来,同时保持二者分离。
同步请求和异步请求
服务进程最常见的两类请求是:
- 同步请求:调用方等待结果。
- 异步请求:调用方只发出指令,不等待业务结果。
在 GenServer 中,同步请求对应 call,异步请求对应 cast。
同步请求适合查询、需要确认成功的操作,或者调用方必须拿到结果才能继续的场景。它的代价是调用方会阻塞等待,所以应该设置合理超时,并避免在服务进程中执行耗时工作。
异步请求适合“提交一件事让服务稍后处理”的场景,例如刷新缓存、写入指标、触发后台计算。它不会给调用方业务回复,因此调用方不能直接知道处理是否成功。
选择同步还是异步时,可以问自己:
- 调用方是否马上需要结果?
- 调用方是否需要知道失败原因?
- 服务处理时间是否可控?
- 请求积压时是否允许调用方继续前进?
6.3 使用 GenServer
真实项目中一般直接使用 OTP 提供的 GenServer。一个最小计数器可以写成这样:
defmodule CounterServer do
use GenServer
def start_link(initial_value \\ 0) do
GenServer.start_link(__MODULE__, initial_value)
end
def increment(server, amount \\ 1) do
GenServer.cast(server, {:increment, amount})
end
def value(server) do
GenServer.call(server, :value)
end
@impl GenServer
def init(initial_value) do
{:ok, initial_value}
end
@impl GenServer
def handle_cast({:increment, amount}, state) do
{:noreply, state + amount}
end
@impl GenServer
def handle_call(:value, _from, state) do
{:reply, state, state}
end
end
这段代码有两类函数:
- 客户端 API:
start_link/1、increment/2、value/1。 - 服务端回调:
init/1、handle_cast/2、handle_call/3。
建议把这两类函数明确分开。调用方应该只使用客户端 API,而不是直接调用回调函数。回调函数是 GenServer 框架在服务进程内部调用的。
use GenServer 做了什么
use GenServer 会把当前模块声明为一个 GenServer 回调模块,并引入默认实现。它不是魔法运行时,而是编译期宏展开。理解这一点有助于避免误解:真正持有状态、接收消息、调度回调的是 GenServer 进程,业务模块只是提供回调。
@impl GenServer 不是必需语法,但非常推荐。它告诉编译器和读者:这个函数是在实现 GenServer 行为定义的回调。如果函数名或参数个数写错,编译器能给出更好的提醒。
6.4 回调返回值
GenServer 通过回调返回值控制下一步行为。常见返回值包括:
{:ok, initial_state}
{:reply, reply, new_state}
{:noreply, new_state}
{:stop, reason, new_state}
{:stop, reason, reply, new_state}
init/1 通常返回 {:ok, state}。如果初始化失败,可以返回 {:stop, reason}。在有监督树时,初始化失败会影响子进程启动结果。
handle_call/3 处理同步请求,通常返回 {:reply, reply, new_state}。第三个参数 _from 表示调用方信息;大多数简单场景用不到,但需要延迟回复时会用到。
handle_cast/2 处理异步请求,通常返回 {:noreply, new_state}。因为 cast 没有调用方等待回复,所以不能返回 {:reply, ...}。
handle_info/2 处理普通消息。不是通过 GenServer.call/3 或 GenServer.cast/2 进入服务的消息,通常会到这里,例如定时器消息、监控消息或外部进程直接 send 的消息。
6.5 状态设计
GenServer 的状态可以是任意 Elixir 数据:数字、map、struct、tuple、列表等。简单状态可以直接用 map;复杂状态建议定义 struct,让字段更清晰。
defmodule CacheState do
defstruct entries: %{}, hits: 0, misses: 0
end
状态设计要关注三个问题:
- 这个状态是否真的需要由一个进程独占?
- 每条消息处理时是否能快速完成?
- 状态增长是否有边界?
不要把所有数据都塞进一个 GenServer。单个 GenServer 是串行瓶颈;它适合保护一份需要串行访问的状态,但不适合承载所有读写压力。缓存、注册表、连接池等场景常需要分片、ETS 或专门的 OTP 组件。
6.6 进程生命周期
GenServer.start/3 和 GenServer.start_link/3 都能启动服务进程。区别在于 start_link 会把调用方和新进程链接起来。OTP 监督树通常要求子进程用 start_link 启动,这样监督者能感知子进程崩溃并按策略处理。
命名启动可以让调用方用名字访问服务:
GenServer.start_link(__MODULE__, initial_state, name: __MODULE__)
然后客户端 API 可以写成:
def value do
GenServer.call(__MODULE__, :value)
end
这种写法适合全局唯一的本地服务。若系统中需要多个同类服务实例,就不要硬编码模块名作为唯一名字,而应考虑动态监督、注册表或 via tuple。
停止进程
服务进程可以通过回调返回 {:stop, reason, new_state} 停止,也可以被外部进程终止。正常系统中,进程停止往往和监督策略有关。不要只把“停止”理解为资源清理,还要考虑谁负责重启、重启后状态从哪里恢复。
如果服务持有外部资源,例如文件、socket、端口或临时目录,需要了解 terminate/2 的适用边界。并不是所有崩溃场景都会给你优雅清理机会,所以关键资源管理最好交给更可靠的结构,而不是只依赖回调。
6.7 常见设计习惯
保持客户端 API 小而明确
客户端 API 是模块对外的协议。它应该隐藏 GenServer.call 和 GenServer.cast 的消息细节:
def put(server, key, value) do
GenServer.cast(server, {:put, key, value})
end
def fetch(server, key) do
GenServer.call(server, {:fetch, key})
end
这样以后即使内部消息格式变化,调用方也不需要修改。
避免在回调里做慢操作
GenServer 一次只处理一条消息。如果回调里执行慢查询、长时间计算或阻塞 I/O,后续消息都会排队等待。遇到慢操作时可以考虑:
- 把任务交给独立进程或
Task。 - 使用异步工作队列。
- 拆分服务职责。
- 把只读高频数据移到 ETS。
- 为同步调用设置更清楚的超时和失败路径。
不要把 GenServer 当万能容器
初学者常把 GenServer 用成“带状态的对象”。这会导致每个小数据都变成一个进程,系统里充满没有必要的生命周期管理。判断是否使用 GenServer,可以看它是否需要:
- 独立生命周期。
- 串行化访问某份状态。
- 接收异步事件。
- 被监督树管理。
- 与其他进程形成明确故障边界。
如果只是纯数据转换,普通函数和数据结构通常更好。
练习:把键值服务改为 GenServer
可以把上一章的键值服务练习改写为 GenServer:
start_link/0启动服务,初始状态是空 map。put(server, key, value)使用cast。get(server, key)使用call,返回{:ok, value}或:error。delete(server, key)使用cast。keys(server)使用call返回所有 key。- 用 struct 记录
entries、hits、misses。
思考扩展:
get/2是应该返回nil,还是更明确的:error?put/3是否需要验证 key 类型?- 如果写入非常频繁,单个服务进程是否会成为瓶颈?
- 如果服务崩溃,数据是否需要恢复?
本章复盘
GenServer把服务进程的通用循环抽象成标准框架。- 客户端 API 负责隐藏消息格式,回调函数负责处理服务端逻辑。
call用于同步请求,cast用于异步请求,普通消息进入handle_info/2。init/1建立初始状态,回调返回值决定回复、继续或停止。start_link是进入 OTP 监督树的常见启动方式。- 单个
GenServer内部仍然串行处理消息,慢回调和无界状态都会造成风险。 - 不要为了“拥有状态”就使用
GenServer;要看是否真的需要进程、生命周期和故障边界。 - 后续章节会把多个服务进程组合成更完整的并发系统。