跳到主要内容

第 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 能保留未来替换实现的空间。

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

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

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

再比较三种实现:

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

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

本章复盘​

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