Skip to main content

第 6 章 通用服务进程(Generic server processes)学习笔记

本章内容为原创学习笔记与概念整理,不是原书逐段翻译。

上一章从 spawnsendreceive 入手,说明了服务进程如何维护状态、处理消息并响应调用方。本章的核心问题是:既然服务进程模式会反复出现,哪些部分应该抽出来复用,哪些部分应该留给业务模块实现?

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/1increment/2value/1
  • 服务端回调:init/1handle_cast/2handle_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/3GenServer.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/3GenServer.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.callGenServer.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

  1. start_link/0 启动服务,初始状态是空 map。
  2. put(server, key, value) 使用 cast
  3. get(server, key) 使用 call,返回 {:ok, value}:error
  4. delete(server, key) 使用 cast
  5. keys(server) 使用 call 返回所有 key。
  6. 用 struct 记录 entrieshitsmisses

思考扩展:

  • get/2 是应该返回 nil,还是更明确的 :error
  • put/3 是否需要验证 key 类型?
  • 如果写入非常频繁,单个服务进程是否会成为瓶颈?
  • 如果服务崩溃,数据是否需要恢复?

本章复盘

  • GenServer 把服务进程的通用循环抽象成标准框架。
  • 客户端 API 负责隐藏消息格式,回调函数负责处理服务端逻辑。
  • call 用于同步请求,cast 用于异步请求,普通消息进入 handle_info/2
  • init/1 建立初始状态,回调返回值决定回复、继续或停止。
  • start_link 是进入 OTP 监督树的常见启动方式。
  • 单个 GenServer 内部仍然串行处理消息,慢回调和无界状态都会造成风险。
  • 不要为了“拥有状态”就使用 GenServer;要看是否真的需要进程、生命周期和故障边界。
  • 后续章节会把多个服务进程组合成更完整的并发系统。