第 10 章 超越 GenServer(Beyond GenServer)学习笔记
本章内容为原创学习笔记与概念整理,不是原书逐段翻译。
前面几章围绕 GenServer 构建并发服务、监督树和容错边界。学到这里,很容易形成一种错觉:只要需要进程或状态,就应该写一个 GenServer。本章要拆掉这个惯性。
GenServer 很重要,但它不是所有并发问题的最佳答案。Elixir/OTP 还提供了更适合短任务、简单状态和高并发共享读取的工具,例如 Task、Agent 和 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 可能更合适。
并发压力
单个 GenServer 和 Agent 都是串行瓶颈。高并发读场景可以考虑 ETS,或者按 key 分片多个进程。
失败语义
任务失败通常影响发起者或当前工作。服务失败需要监督者决定恢复。ETS 表丢失可能影响大量读者,因此要明确拥有者和重建方式。
API 清晰度
无论底层用什么,都应该用模块 API 包起来。调用方不应该到处直接操作 ETS 表或随意传 Agent 更新函数。清晰 API 能保留未来替换实现的空间。
练习:为待办系统选择存储方案
基于前面的待办系统,尝试设计一个读多写少的查询缓存:
- 列表服务器仍然负责写入待办条目。
- 每次写入后,把按日期整理的查询结果更新到 ETS。
- 查询某天条目时,优先直接读 ETS。
- ETS 表由一个专门进程创建和拥有。
- 思考拥有者进程重启后如何重建表数据。
再比较三种实现:
- 全部状态放在
GenServer。 - 简单缓存放在
Agent。 - 查询索引放在 ETS。
分别说明它们在代码复杂度、读取性能、写入控制和故障恢复上的取舍。
本章复盘
GenServer是通用服务进程抽象,但不是所有并发问题的默认答案。Task适合一次性异步工作和并行计算,不适合长期状态服务。Agent适合简单状态容器,复杂业务规则增长后应考虑GenServer。- ETS 提供跨进程共享内存表,常用于缓存、注册和高并发读取。
- ETS 的访问权限和表拥有者决定了数据边界与故障行为。
GenServer与 ETS 可以组合:服务进程控制写入,ETS 承担高并发读取。- 选择工具时应同时考虑生命周期、状态复杂度、并发压力、失败语义和 API 清晰度。