第 5 章 并发原语(Concurrency primitives)学习笔记
本章内容为原创学习笔记与概念整理,不是原书逐段翻译。
本章从函数式 Elixir 转向 BEAM 并发模型。前几章强调不可变数据、模式匹配、递归和抽象边界;这些能力在并发编程中会继续发挥作用。Elixir 的并发不是先共享一块内存再加锁保护,而是把系统拆成很多轻量进程,每个进程拥有自己的执行流和状态,通过消息传递协作。
学习这一章时,可以把重点放在三个问题上:
- BEAM 进程和操作系统进程、线程有什么不同?
- 一个进程如何创建、接收消息、维护状态并继续运行?
- 哪些运行时特征会影响并发程序的稳定性?
5.1 BEAM 中的并发模型
Elixir 运行在 Erlang VM,也就是 BEAM 上。BEAM 的并发模型围绕“进程”展开,但这里的进程不是操作系统层面的重量级进程,而是由虚拟机管理的轻量执行单元。
BEAM 进程有几个关键特征:
- 轻量:创建成本远低于操作系统进程,适合用大量进程表达大量独立活动。
- 隔离:每个进程有自己的栈、堆和邮箱,不共享可变内存。
- 抢占式调度:虚拟机会在进程之间分配执行机会,避免单个普通进程长期霸占调度器。
- 消息传递:进程之间通过发送消息协作,消息进入目标进程的邮箱,由目标进程按自己的节奏处理。
- 失败隔离:一个进程崩溃通常不会直接破坏其他进程的内存状态。
这套模型天然适合服务端系统。一个 HTTP 连接、一个后台任务、一个客户端会话、一个缓存分片,都可以被建模为进程。系统的复杂度从“多个线程竞争同一份状态”转变为“多个隔离进程用消息协议沟通”。
与共享内存并发的差异
在很多语言里,并发状态常通过锁、互斥量或原子变量保护。这样做的风险是:状态越共享,调用链越长,越难判断谁能修改什么,以及修改顺序是否安全。
BEAM 鼓励另一种思路:
- 状态属于某个进程。
- 其他进程不能直接修改它。
- 想要读取或改变状态,需要发送消息。
- 状态拥有者串行处理消息,因此同一份状态不会被多个执行流同时写入。
这并不意味着 BEAM 程序没有并发问题。邮箱堆积、消息协议设计不清、超时处理缺失、进程生命周期混乱,仍然会造成生产问题。区别在于,问题通常出现在进程边界和消息流上,而不是低层内存竞争上。
5.2 使用进程
创建进程最直接的方式是 spawn/1。它接收一个匿名函数,并让该函数在新进程中执行:
spawn(fn ->
IO.puts("run in another process")
end)
spawn/1 会立即返回新进程的 PID。新进程执行完函数后就会结束。可以用 self/0 获取当前进程的 PID:
parent = self()
spawn(fn ->
send(parent, {:done, self()})
end)
receive do
{:done, pid} ->
IO.puts("process #{inspect(pid)} finished")
end
这段代码包含 BEAM 并发的基本形状:
- 父进程保存自己的 PID。
- 子进程通过
send/2把消息发给父进程。 - 父进程通过
receive从邮箱中选择匹配的消息。
邮箱和 receive
每个进程都有邮箱。消息发送是异步的:send/2 把消息放入目标进程邮箱后就返回,发送方不会等待目标进程处理。
接收消息时,receive 会按模式匹配选择消息:
receive do
{:ok, value} ->
{:handled, value}
{:error, reason} ->
{:failed, reason}
end
如果邮箱中没有任何消息能匹配当前 receive 的分支,当前进程会等待。实际代码中常配合 after 设置超时:
receive do
{:reply, value} ->
{:ok, value}
after
1_000 ->
{:error, :timeout}
end
超时不是补丁,而是消息协议的一部分。只要存在跨进程请求,就应该思考“对方不回复、崩溃、很慢、或者消息丢在错误邮箱里”时调用方该怎么结束等待。
消息协议
消息只是 Elixir 数据,可以是 tuple、map、atom、列表或其他值。为了让系统可维护,建议为进程定义清晰的消息协议。例如,一个计数器进程可以支持三类消息:
{:increment, amount}
{:get, caller}
:stop
这比随意发送裸值更可靠。tuple 的第一个元素通常作为消息标签,后续元素承载参数。若请求方需要回复,可以把自己的 PID 一起发过去。
5.3 有状态服务进程
在 Elixir 中,进程维护状态的常见方式是递归循环。状态不是被原地修改,而是作为函数参数传入下一轮循环。
下面是一个最小计数器:
defmodule CounterServer do
def start(initial_value \\ 0) do
spawn(fn -> loop(initial_value) end)
end
def increment(server, amount \\ 1) do
send(server, {:increment, amount})
:ok
end
def value(server) do
send(server, {:get, self()})
receive do
{:counter_value, value} -> value
after
1_000 -> {:error, :timeout}
end
end
def stop(server) do
send(server, :stop)
:ok
end
defp loop(value) do
receive do
{:increment, amount} when is_integer(amount) ->
loop(value + amount)
{:get, caller} ->
send(caller, {:counter_value, value})
loop(value)
:stop ->
:ok
_unknown ->
loop(value)
end
end
end
这个例子体现了几个重要习惯:
- 客户端不要直接拼消息,优先调用
CounterServer.increment/2、CounterServer.value/1这样的 API。 - 服务进程的状态只存在于
loop/1的参数中。 - 每处理完一条需要继续运行的消息,就用新状态再次调用
loop/1。 - 不认识的消息要么忽略,要么记录,要么显式失败;不要让邮箱协议变得含糊。
这种写法是理解 OTP GenServer 的基础。GenServer 并没有取消这些概念,而是把“循环、状态、同步调用、异步调用、超时、调试信息”等通用部分标准化。
可变状态的重新理解
从进程外部看,服务进程的状态像是可变的:发一条 {:increment, 1} 消息,之后读到的值变了。但从进程内部看,每一次状态变化都是一次新的递归调用,旧值没有被原地改写。
这正是 Elixir 并发的一个实用平衡点:语言层面保持不可变数据,系统层面通过长期运行的进程表达随时间演化的状态。
注册进程
如果每个调用方都需要手动保存 PID,程序会很快变得难维护。可以把进程注册到一个名字下:
pid = CounterServer.start()
Process.register(pid, :counter)
CounterServer.increment(:counter, 3)
CounterServer.value(:counter)
注册名适合简单实验和局部唯一的服务。生产系统中要注意命名冲突、进程重启后的重新注册、分布式节点之间的名字可见性等问题。后续学习 OTP supervision、registry、via tuple 时,会看到更系统的做法。
5.4 运行时注意事项
BEAM 让并发变得轻量,但并不等于可以忽略运行时成本。下面几类问题尤其值得早期养成敏感度。
单个进程内部仍然是顺序执行
一个进程一次只处理一条消息。如果某个服务进程承担太多职责,它就会成为瓶颈。常见信号包括:
- 邮箱长度持续增长。
- 请求超时频繁出现。
- 单个进程处理逻辑越来越复杂。
- 明明系统有很多 CPU 核心,但吞吐受一个集中进程限制。
解决方向通常是拆分职责、分片状态、减少同步请求,或把耗时工作交给独立工作进程。
邮箱不是无限资源
发送消息很容易,但处理消息需要时间。如果生产速度长期大于消费速度,邮箱会持续膨胀。邮箱膨胀会增加内存压力,也可能让重要消息排在大量旧消息后面。
设计消息协议时,可以考虑:
- 是否需要背压,而不是无限制发送。
- 是否可以合并重复消息。
- 是否需要丢弃过期消息。
- 是否应该把慢任务拆到独立进程。
- 是否要监控关键进程的邮箱长度。
同步调用要有超时
“发请求并等待回复”是很自然的模式,但等待不能没有边界。被调用进程可能崩溃、忙碌、协议不匹配,或者回复发到了错误的 PID。
自己用 send 和 receive 写同步请求时,至少要回答三个问题:
- 请求消息里有没有包含调用方 PID 或唯一引用?
- 回复消息是否能和其他请求区分开?
- 等待多久后调用方应该放弃?
唯一引用可以用 make_ref/0 生成:
ref = make_ref()
send(server, {:get, self(), ref})
receive do
{:counter_value, ^ref, value} -> {:ok, value}
after
1_000 -> {:error, :timeout}
end
^ref 使用 pin 操作符,要求回复中的引用必须等于当前请求生成的引用。这能降低多个请求交错时误收消息的风险。
进程不是对象
Elixir 进程可以保存状态,也能响应消息,但它不是面向对象意义上的对象。不要把每个数据实体都机械地变成一个进程。是否需要进程,取决于它是否代表独立的活动、生命周期、故障边界或并发瓶颈。
适合进程的场景:
- 长期运行的服务。
- 独立的外部连接。
- 需要隔离失败的任务。
- 可并行处理的工作单元。
- 需要串行保护的一份状态。
不一定适合进程的场景:
- 普通纯数据结构。
- 没有独立生命周期的临时值。
- 只为模拟对象方法调用而创建的包装层。
- 大量极短且不需要隔离的简单函数调用。
练习:实现一个键值服务进程
可以用下面的练习检验是否理解了本章核心:
- 创建
KeyValueServer.start/0,启动一个保存空 map 的进程。 - 实现
put(server, key, value),异步写入键值。 - 实现
get(server, key),同步读取并带 1 秒超时。 - 实现
delete(server, key),删除键。 - 让同步回复使用
make_ref/0,避免不同请求串线。 - 思考当 key 不存在时返回
nil、:error还是{:error, :not_found}更适合。
一个可行的消息协议如下:
{:put, key, value}
{:get, caller, ref, key}
{:delete, key}
:stop
完成后再尝试扩展:
- 记录每个 key 的写入次数。
- 增加
keys/1查询。 - 给
put/3增加输入校验。 - 在未知消息到达时打印调试日志。
本章复盘
- BEAM 进程是轻量、隔离、由虚拟机调度的并发单元。
- Elixir 并发通过消息传递协作,避免共享可变内存。
spawn/1、send/2、receive、self/0是理解底层并发的基础工具。- 服务进程通过递归循环持有状态,每轮循环用新参数表示新状态。
- 对外暴露模块 API,比让调用方直接拼消息更容易维护。
- 同步请求需要超时,复杂请求最好配合唯一引用。
- 单个进程内部仍然顺序处理消息,过度集中会形成瓶颈。
- 邮箱增长、无界等待、协议混乱,是手写并发代码最常见的风险。
- 后续学习
GenServer时,可以把它理解为对本章服务进程模式的标准化封装。