Skip to main content

第 7 章 构建并发系统(Building a concurrent system)学习笔记

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

前两章分别讲了底层进程原语和 GenServer。本章的重心从“写一个服务进程”转向“让多个进程协作成一个系统”。这一步很关键,因为真实应用通常不是一个孤立进程,而是一组职责不同、生命周期不同、性能特征不同的进程。

可以把本章看成一次小型系统设计练习:用 Elixir 组织一个待办列表服务,让它能管理多份列表、按需创建进程、保存数据,并分析进程之间的依赖关系。

7.1 从脚本走向 Mix 项目

前几章的小例子可以放在单个文件或 IEx 中运行。但当模块数量增加、测试出现、运行入口变复杂时,就需要使用 Mix 项目。

Mix 在 Elixir 项目中承担几类职责:

  • 创建标准目录结构。
  • 编译多文件模块。
  • 运行测试。
  • 管理依赖。
  • 启动应用和运行任务。
  • 为后续 OTP 应用结构打基础。

常见项目结构大致如下:

todo/
mix.exs
lib/
todo/
cache.ex
database.ex
server.ex
todo.ex
test/
todo_test.exs
test_helper.exs

一个好习惯是让目录结构反映模块边界。比如 Todo.Server 放待办列表本身的服务逻辑,Todo.Cache 管理多个列表进程,Todo.Database 负责持久化。这样文件名、模块名和职责能互相呼应。

组织模块时先分职责

并发系统最容易犯的错误,是先急着创建进程,再回头想职责。更稳的顺序是:

  1. 识别领域对象和操作。
  2. 找出哪些状态需要长期保存。
  3. 判断哪些状态需要串行访问。
  4. 决定哪些部分需要独立进程。
  5. 再用 GenServer 或其他 OTP 构件表达这些边界。

在待办列表示例中,可以有三类明显职责:

  • 单个待办列表:保存条目并响应增删查改。
  • 列表缓存:按名称找到或创建对应的待办列表进程。
  • 数据库/持久化:把列表内容保存到磁盘或其他存储中。

这三类职责不应该塞进同一个 GenServer。如果所有操作都经过一个集中进程,系统很快会遇到串行瓶颈。

7.2 管理多份待办列表

上一章的 GenServer 示例通常只有一个服务实例。本章开始面对更常见的场景:系统里可能有很多待办列表,每份列表有独立状态和独立生命周期。

一种设计是:

  • 每份待办列表由一个 Todo.Server 进程管理。
  • Todo.Cache 负责根据列表名找到对应服务器。
  • 如果列表服务器不存在,缓存进程创建它并返回 PID。

这个结构把“列表内容状态”和“列表进程索引状态”分开。单个列表的操作只阻塞对应列表进程,不会影响其他列表。

列表服务器

列表服务器负责一份具体列表。它可以暴露这样的客户端 API:

defmodule Todo.Server do
use GenServer

def start_link(name) do
GenServer.start_link(__MODULE__, name)
end

def add_entry(server, entry) do
GenServer.cast(server, {:add_entry, entry})
end

def entries(server, date) do
GenServer.call(server, {:entries, date})
end
end

服务内部状态可以是一个自定义列表结构,也可以是包含列表名和条目集合的 struct。关键在于调用方不应该直接知道状态形状,而是通过模块 API 操作。

缓存服务器

缓存服务器保存“列表名到 PID”的映射:

%{
"work" => #PID<0.123.0>,
"home" => #PID<0.124.0>
}

客户端可以请求:

Todo.Cache.server_process("work")

如果 "work" 已存在,缓存直接返回对应 PID;如果不存在,就启动新的 Todo.Server 并缓存它。

这个模式非常常见:一个注册/缓存进程管理很多工作进程。它让调用方不需要关心进程创建细节,只需要用业务标识拿到服务。

进程依赖

缓存进程创建列表进程后,两者就形成依赖。需要思考:

  • 如果列表进程崩溃,缓存里的 PID 是否会变成无效值?
  • 如果缓存进程崩溃,已经创建的列表进程是否还存在?
  • 如果多个调用方同时请求同一个列表,会不会创建重复进程?
  • 谁负责重启这些进程?

这些问题是进入 OTP supervision 前的重要铺垫。没有监督树时,你可以启动进程,但系统并不知道进程失败后应该怎么恢复。后续章节会用 supervision tree 把这些生命周期规则显式化。

7.3 写测试时关注进程边界

并发系统测试不只是断言函数返回值,还要验证消息和进程生命周期是否符合预期。

针对待办列表系统,可以覆盖这些行为:

  • 第一次请求某个列表名时会创建列表服务器。
  • 第二次请求同名列表时返回同一个服务器。
  • 不同列表名返回不同服务器。
  • 向某个列表添加条目不会污染其他列表。
  • 查询不存在的日期返回空结果。

测试 GenServer 时要避免过度依赖内部状态。更好的测试方式是通过公开 API 观察行为:

server = Todo.Cache.server_process("work")

Todo.Server.add_entry(server, %{date: ~D[2026-07-04], title: "Write notes"})

assert Todo.Server.entries(server, ~D[2026-07-04]) == [
%{date: ~D[2026-07-04], title: "Write notes"}
]

如果测试经常需要直接读进程字典、内部 map 或私有函数,通常说明模块边界需要调整,或者公开 API 不够表达业务需求。

7.4 持久化边界

一旦系统需要在进程重启后保留数据,就必须引入持久化。持久化可以很简单,比如把 term 编码后写入文件;也可以更正式,比如使用数据库。

无论存储形式如何,都要先分清两个问题:

  • 什么时候读?
  • 什么时候写?

一种简单策略是:

  • 列表服务器启动时,根据列表名从存储中加载已有数据。
  • 每次列表变化后,把新状态写回存储。

这很好理解,但也带来性能问题。如果每次添加条目都同步写磁盘,列表服务器就会被 I/O 拖慢。由于 GenServer 串行处理消息,慢写入会阻塞后续查询和更新。

持久化进程

可以把持久化独立成数据库进程:

Todo.Database.store(key, data)
Todo.Database.get(key)

这让列表服务器不直接处理文件细节,而是把存储委托给专门模块。但要注意:如果数据库进程只有一个,它也可能成为全局瓶颈。

常见改进包括:

  • 按 key 分片到多个工作进程。
  • 对写入做批处理。
  • 使用异步写入并接受短时间内的数据丢失风险。
  • 采用真正适合并发访问的外部数据库。
  • 把热路径中的读取缓存到内存。

持久化策略没有唯一正确答案,取决于一致性、吞吐、恢复、数据量和故障模型。

7.5 分析瓶颈

并发系统不是进程越多越快。性能瓶颈往往来自某个必须串行处理的中心点。

在待办列表系统中,可能的瓶颈包括:

  • 所有列表查找都经过一个缓存进程。
  • 所有磁盘访问都经过一个数据库进程。
  • 某个热门列表由单个列表服务器处理。
  • 同步调用等待慢 I/O。
  • 消息堆积导致邮箱变长。

分析时可以画出消息流:

client
|
v
Todo.Cache
|
v
Todo.Server("work")
|
v
Todo.Database

然后问:

  • 哪个节点会收到最多消息?
  • 哪个节点处理最慢?
  • 哪个节点崩溃会影响最大?
  • 哪些请求可以并行,哪些必须串行?
  • 是否有不必要的同步等待?

这种分析比盲目加进程更重要。Elixir 给了很便宜的进程,但架构仍然需要清晰的数据流和所有权。

7.6 进程所有权与状态所有权

构建并发系统时,可以把每份状态都问一遍:“谁拥有它?”

在待办列表系统中:

  • 列表条目由对应的 Todo.Server 拥有。
  • 名称到 PID 的映射由 Todo.Cache 拥有。
  • 磁盘文件或持久化记录由 Todo.Database 管理。

所有权清楚后,消息协议就更容易设计。其他进程不能绕过所有者直接修改状态,而是向所有者发请求。这样可以把并发问题局部化。

不清楚的所有权会导致这些症状:

  • 多个进程都认为自己可以更新同一份数据。
  • 某个进程保存了另一个进程状态的过期副本。
  • 崩溃恢复后,不知道以哪份状态为准。
  • 缓存、数据库和内存状态之间出现难以解释的不一致。

7.7 小型系统设计清单

为一个并发功能设计进程结构时,可以用下面的清单快速过一遍:

  • 系统里有哪些长期存在的状态?
  • 哪些状态需要串行保护?
  • 哪些操作可以并行?
  • 哪些操作可能很慢?
  • 哪些进程需要被命名或注册?
  • 哪些进程可以动态创建?
  • 进程崩溃后,调用方会看到什么?
  • 状态是否需要恢复?
  • 同步调用的超时时间是多少?
  • 有没有中心化瓶颈?

这份清单不要求一次答完所有问题,但能迫使设计从“能跑”走向“能解释、能恢复、能扩展”。

练习:为待办系统增加归档功能

可以在本章系统基础上继续练习:

  1. 为每个待办列表增加 archive_done/1 操作。
  2. 已完成条目移动到归档状态,不再出现在普通查询中。
  3. 增加 archived_entries/2 查询某天归档条目。
  4. 思考归档数据和活跃数据是否应该由同一个进程管理。
  5. 如果归档写入很慢,是否需要独立持久化队列?

这个练习的重点不是代码技巧,而是状态边界:归档是列表内部状态的一部分,还是一个单独的历史记录组件?不同答案会导向不同进程结构。

本章复盘

  • Mix 项目让多模块、多测试和后续 OTP 应用组织更自然。
  • 并发系统设计应先分清职责,再决定哪些职责需要进程。
  • 单个待办列表可以由一个服务进程管理,多份列表可由缓存进程定位和创建。
  • 进程之间存在依赖关系,崩溃和重启策略需要后续监督机制处理。
  • 持久化是独立边界;同步 I/O 容易拖慢服务进程。
  • 单个缓存进程、数据库进程或热门列表进程都可能成为瓶颈。
  • 进程所有权和状态所有权越清晰,消息协议越容易维护。
  • 构建并发系统的目标不是“更多进程”,而是让职责、状态、生命周期和故障影响都变得清楚。