第 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 负责持久化。这样文件名、模块名和职责能互相呼应。
组织模块时先分职责
并发系统最容易犯的错误,是先急着创建进程,再回头想职责。更稳的顺序是:
- 识别领域对象和操作。
- 找出哪些状态需要长期保存。
- 判断哪些状态需要串行访问。
- 决定哪些部分需要独立进程。
- 再用
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 小型系统设计清单
为一个并发功能设计进程结构时,可以用下面的清单快速过一遍:
- 系统里有哪些长期存在的状态?
- 哪些状态需要串行保护?
- 哪些操作可以并行?
- 哪些操作可能很慢?
- 哪些进程需要被命名或注册?
- 哪些进程可以动态创建?
- 进程崩溃后,调用方会看到什么?
- 状态是否需要恢复?
- 同步调用的超时时间是多少?
- 有没有中心化瓶颈?
这份清单不要求一次答完所有问题,但能迫使设计从“能跑”走向“能解释、能恢复、能扩展”。
练习:为待办系统增加归档功能
可以在本章系统基础上继续练习:
- 为每个待办列表增加
archive_done/1操作。 - 已完成条目移动到归档状态,不再出现在普通查询中。
- 增加
archived_entries/2查询某天归档条目。 - 思考归档数据和活跃数据是否应该由同一个进程管理。
- 如果归档写入很慢,是否需要独立持久化队列?
这个练习的重点不是代码技巧,而是状态边界:归档是列表内部状态的一部分,还是一个单独的历史记录组件?不同答案会导向不同进程结构。
本章复盘
- Mix 项目让多模块、多测试和后续 OTP 应用组织更自然。
- 并发系统设计应先分清职责,再决定哪些职责需要进程。
- 单个待办列表可以由一个服务进程管理,多份列表可由缓存进程定位和创建。
- 进程之间存在依赖关系,崩溃和重启策略需要后续监督机制处理。
- 持久化是独立边界;同步 I/O 容易拖慢服务进程。
- 单个缓存进程、数据库进程或热门列表进程都可能成为瓶颈。
- 进程所有权和状态所有权越清晰,消息协议越容易维护。
- 构建并发系统的目标不是“更多进程”,而是让职责、状态、生命周期和故障影响都变得清楚。