Skip to main content

第 10 章 超越 GenServer(Beyond GenServer)学习笔记

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

前面几章围绕 GenServer 构建并发服务、监督树和容错边界。学到这里,很容易形成一种错觉:只要需要进程或状态,就应该写一个 GenServer。本章要拆掉这个惯性。

GenServer 很重要,但它不是所有并发问题的最佳答案。Elixir/OTP 还提供了更适合短任务、简单状态和高并发共享读取的工具,例如 TaskAgent 和 ETS。选择工具时,重点不是哪个“更高级”,而是哪个更贴合问题的生命周期、状态模型和性能需求。

10.1 不要把 GenServer 当默认答案

GenServer 适合长期运行、拥有状态、需要接收多种消息、需要被监督的服务进程。它的优势是通用,但通用也意味着你要自己设计 API、状态、同步/异步边界和回调行为。

如果问题更简单,直接使用 GenServer 可能会增加不必要的样板代码。例如:

  • 只是启动一个短时间计算任务。
  • 只是异步执行一段函数。
  • 只是保存一个小状态并提供简单更新。
  • 只是需要一个多个进程都能快速读取的表。

这些场景可以考虑其他构件。

一个实用判断:

  • 有长期服务身份和复杂消息协议:优先考虑 GenServer
  • 有一次性或短生命周期异步工作:优先考虑 Task
  • 有简单状态容器且并发压力不大:可以考虑 Agent
  • 有大量进程需要共享读取或写入表数据:考虑 ETS。

10.2 Task:表达异步工作

Task 用来启动一段异步工作。它比裸 spawn 更结构化,也更容易等待结果、接入监督树或处理失败。

最常见的形式是:

task =
Task.async(fn ->
expensive_calculation()
end)

result = Task.await(task, 5_000)

这里调用方启动任务后可以继续做别的事,之后再用 await 等待结果。await 有超时,避免调用方无限等待。

适合 Task 的场景

Task 适合这些工作:

  • 可以独立运行的一次性计算。
  • 并行执行多个外部请求。
  • 把慢操作从当前进程中移走。
  • 启动后台任务并由监督者管理。

例如,可以并发请求多个服务:

tasks =
urls
|> Enum.map(fn url ->
Task.async(fn -> fetch_url(url) end)
end)

responses =
tasks
|> Enum.map(&Task.await(&1, 5_000))

这种写法表达的是“这些工作可以并行,最后我需要所有结果”。

不等待的 Task

有时调用方不需要结果,只想让一段工作在后台执行。这时可以使用受监督的任务,而不是随意 spawn。关键问题是:任务失败后谁知道?是否要重启?是否要记录?

如果后台任务是一次性工作,失败后通常不应该无限重启。与监督树结合时,要为任务选择合适的重启策略,避免临时任务变成重启风暴。

Task 不是长期服务

Task 不适合维护长期状态,也不适合承载复杂消息协议。它是“做一件事”的抽象,而不是“长期服务”的抽象。

如果你发现任务需要反复接收消息、保存状态、响应多个客户端,那它很可能应该变成 GenServer 或其他长期进程。

10.3 Agent:简单状态容器

Agent 是对状态进程的轻量封装。它让你用函数读取和更新内部状态,而不必自己写完整的 GenServer 回调。

一个简单例子:

{:ok, pid} = Agent.start_link(fn -> %{} end)

Agent.update(pid, fn state ->
Map.put(state, :answer, 42)
end)

Agent.get(pid, fn state ->
Map.fetch(state, :answer)
end)

Agent 的状态仍然由一个进程拥有,因此同一时间仍然只能串行处理操作。它简化了代码,但没有消除单进程瓶颈。

适合 Agent 的场景

Agent 适合:

  • 小范围状态。
  • 简单 get/update 操作。
  • 原型或辅助组件。
  • 状态逻辑不复杂、消息类型很少的场景。

例如测试中保存临时状态、开发工具中维护一个小表、教学示例里展示进程状态,都可以用 Agent

Agent 的边界

如果状态更新逻辑越来越复杂,Agent 可能会让职责变得不清楚。因为很多业务逻辑会散落在调用方传入的匿名函数中:

Agent.update(pid, fn state ->
# 复杂业务规则藏在这里
end)

当多个调用点都传入不同更新函数时,状态规则不再集中,维护难度会上升。这时更适合切换到 GenServer,把业务操作命名成明确回调和客户端 API。

判断信号:

  • 更新逻辑需要校验和分支。
  • 需要区分多种命令。
  • 需要返回复杂错误。
  • 需要记录日志或指标。
  • 需要与其他进程交互。

出现这些信号时,Agent 的简洁可能会变成隐藏复杂度。

10.4 ETS:共享表和高并发读取

ETS 是 Erlang Term Storage。它提供运行时内存表,可以被多个进程访问。与 GenServer 持有状态不同,ETS 表不是通过单个服务进程串行处理每次读写,因此适合某些高并发共享数据场景。

可以把 ETS 理解为 BEAM 内部的内存表。它常用于:

  • 缓存。
  • 注册表。
  • 计数器或统计数据。
  • 高并发读多写少的数据。
  • 需要跨进程共享但不值得放进外部数据库的临时数据。

基本操作

一个简单 ETS 表可以这样创建和使用:

table = :ets.new(:cache, [:set, :public])

:ets.insert(table, {:answer, 42})
:ets.lookup(table, :answer)
:ets.delete(table, :answer)

ETS 是 Erlang 提供的能力,因此 API 在 :ets 模块中,返回值风格也更 Erlang。使用时要习惯 tuple、atom 和列表结果。

表类型和访问权限

常见表类型包括:

  • :set:每个 key 一条记录。
  • :ordered_set:按 key 有序。
  • :bag:同一个 key 可对应多条不同记录。
  • :duplicate_bag:允许同一个 key 下重复记录。

访问权限包括:

  • :private:只有拥有者进程可读写。
  • :protected:拥有者写,其他进程可读。
  • :public:多个进程都可读写。

权限选择很重要。public 很方便,但会让写入规则分散到系统各处。许多系统会用 protected:一个拥有者进程负责写入,其他进程直接读取,从而平衡并发读取和写入控制。

ETS 和 GenServer 的组合

ETS 不一定替代 GenServer,更常见的是组合使用。

例如:

  • GenServer 负责表生命周期、写入规则和清理。
  • ETS 负责高并发读取。

这样调用方读取时不必经过 GenServer,减少瓶颈;写入仍由服务进程控制,避免规则散落。

概念结构:

writer process / GenServer
|
v
ETS table <--- many readers

这类设计特别适合读多写少的缓存或配置快照。

ETS 的风险

ETS 是强大的底层工具,也更容易绕开抽象边界。常见风险包括:

  • 任意进程都写入 public 表,导致数据规则失控。
  • 表拥有者进程退出后,表随之消失。
  • 数据只在内存中,重启后丢失。
  • 把 ETS 当数据库使用,忽略持久化和恢复。
  • 表数据增长没有上限。

ETS 适合运行时共享状态,不是持久化数据库。只要数据重要,就要有恢复策略。

10.5 选择工具的维度

可以从五个维度选择并发构件。

生命周期

任务是一次性的,服务是长期的,ETS 表通常跟随拥有者或应用生命周期。生命周期越长,越需要明确监督、命名和清理策略。

状态复杂度

简单状态可以用 Agent,复杂业务状态更适合 GenServer。如果状态被许多进程共享读取,ETS 可能更合适。

并发压力

单个 GenServerAgent 都是串行瓶颈。高并发读场景可以考虑 ETS,或者按 key 分片多个进程。

失败语义

任务失败通常影响发起者或当前工作。服务失败需要监督者决定恢复。ETS 表丢失可能影响大量读者,因此要明确拥有者和重建方式。

API 清晰度

无论底层用什么,都应该用模块 API 包起来。调用方不应该到处直接操作 ETS 表或随意传 Agent 更新函数。清晰 API 能保留未来替换实现的空间。

练习:为待办系统选择存储方案

基于前面的待办系统,尝试设计一个读多写少的查询缓存:

  1. 列表服务器仍然负责写入待办条目。
  2. 每次写入后,把按日期整理的查询结果更新到 ETS。
  3. 查询某天条目时,优先直接读 ETS。
  4. ETS 表由一个专门进程创建和拥有。
  5. 思考拥有者进程重启后如何重建表数据。

再比较三种实现:

  • 全部状态放在 GenServer
  • 简单缓存放在 Agent
  • 查询索引放在 ETS。

分别说明它们在代码复杂度、读取性能、写入控制和故障恢复上的取舍。

本章复盘

  • GenServer 是通用服务进程抽象,但不是所有并发问题的默认答案。
  • Task 适合一次性异步工作和并行计算,不适合长期状态服务。
  • Agent 适合简单状态容器,复杂业务规则增长后应考虑 GenServer
  • ETS 提供跨进程共享内存表,常用于缓存、注册和高并发读取。
  • ETS 的访问权限和表拥有者决定了数据边界与故障行为。
  • GenServer 与 ETS 可以组合:服务进程控制写入,ETS 承担高并发读取。
  • 选择工具时应同时考虑生命周期、状态复杂度、并发压力、失败语义和 API 清晰度。