第 02 站展开探险地图
进程与信箱
各自保管数据,需要合作时互相发消息。
多个发送者同时更新计数时,谁拥有状态,怎样发现 mailbox 里一直没有被处理的消息?
计数器还活着,读取请求却不回
计数进程仍在运行,发送者也不断投递消息,可读取结果不再变化。处理循环漏掉了一种消息形状,信箱里的未匹配消息越积越多。
- 计数进程的 PID 是否仍然存活
- mailbox 长度是否持续增长
- 停止变化的读取结果,以及信箱中未匹配消息的实际形状
它要解决什么
BEAM 用许多小进程同时工作。每个进程保管自己的数据,通过消息合作。一个进程出错时,影响通常可以留在局部。
你会做到
能分清 BEAM 小进程、操作系统进程和线程不是同一种东西
能说清每个进程怎样保管自己的数据,又怎样从 mailbox 收消息
能根据消息循环,猜出下一轮状态和模式匹配的结果
- 完成起跑线,并成功运行过一小段 Erlang 或 Elixir 代码
- 见过 tuple、list 和 map;暂时记不牢写法也没关系
先看做什么,再看怎么写
先跑一封最短信,认清 PID、发送与接收;再看计数进程怎样把新状态带进下一轮,而不是原地改旧值。
# 先记住当前 IEx 进程,worker 才知道回信地址
parent = self()
# spawn 启动一个独立的小进程,并立即交回它的 PID
worker =
spawn(fn ->
# 子进程把自己的 PID 装进消息,发回 parent
send(parent, {:hello, self()})
end)
# parent 从自己的 mailbox 等待;^worker 核对回信者
receive do
{:hello, ^worker} -> :worker_replied
end
# total 是计数进程当前保管的状态
counter = fn loop, total ->
receive do
# 用新总数进入下一轮,旧值不会被原地修改
{:add, n} -> loop.(loop, total + n)
{:read, caller} ->
send(caller, {:total, total})
loop.(loop, total)
end
end
# 启动计数器进程,初始值为 0
pid = spawn(fn -> counter.(counter, 0) end)为什么这样写
为什么不用一个共享对象,让所有人直接改?
让一个进程拥有状态,其他人只发消息。这样写入顺序与故障边界更清楚。
消息与进程有成本。没有状态、生命周期或隔离需求的纯计算,普通函数更合适;进程也不天然更快。
从 Java、Python、JavaScript 看过来
Java
- 熟悉的起点
- 线程、虚拟线程与共享对象。
- BEAM 在意什么
- BEAM 进程默认隔离内存,以 mailbox 传 term。
- 别带错直觉
- 现代 Java 也有虚拟线程;关键差别仍是状态归属与故障语义。
Python
- 熟悉的起点
- threading、multiprocessing 或 asyncio task。
- BEAM 在意什么
- BEAM 进程由 VM 抢占调度,并有独立 mailbox。
- 别带错直觉
- Python 有不同运行时与 free-threaded 构建,不要把 GIL 写成永久定律。
JavaScript
- 熟悉的起点
- event loop、Promise 与 Worker。
- BEAM 在意什么
- 大量 BEAM 进程各自等待消息,不共享一个应用级回调队列。
- 别带错直觉
- JS Worker 也能隔离;不要说 JavaScript 永远只有一个线程。
给计数器寄三封信
依次发送“加 1”“加 2”“加 3”,再读取总数。除了答案 6,还要找出状态由谁保管。
- 01
向计数器连续发送
{:add, 1}、{:add, 2}和{:add, 3} - 02
发送带着自己 PID 的
{:read, self()},让计数器知道回信地址 - 03
用
Process.info(pid, :message_queue_len)看看它的收件箱里还有几封信
# 连续发送三条加法消息;每次 send 都立即返回
send(pid, {:add, 1})
send(pid, {:add, 2})
send(pid, {:add, 3})
# 带上自己的 PID 请求当前总数
send(pid, {:read, self()})
# 等计数器回信;超过一秒就返回 timeout
receive do
{:total, total} -> total
after
1_000 -> :timeout
end
# 确认当前还有多少消息没有处理
Process.info(pid, :message_queue_len)- 最后会收到
{:total, 6} - 读取完成后,
message_queue_len通常回到 0 - 其他进程只能发消息,不能伸手直接改掉计数器保管的数字
删掉 {:read, caller} 再发送读取消息。进程不会报错,这条消息会留在 mailbox。
这个小进程可以按收到消息的顺序更新计数,并由自己保管状态。
这还不能保证所有消息设计都不会互相抢先,也不能保证每个 mailbox 永远不会越积越多。
这段代码里的关键词
轻量进程
由 BEAM 安排的独立工作单元。它不是完整的操作系统进程,因此能创建很多个。
mailbox
进程自己的收件箱。消息在这里等待,进程再按模式寻找当前能处理的内容。
reduction
BEAM 估算工作量的单位。一个进程做了一段工作后,调度器会让其他进程获得机会。
这里用了哪些设计模式
Actor Model
让一个进程拥有状态,只通过消息与外界合作。
Single Writer
由唯一拥有者串行处理更新,避开共享可变状态。
Message Filter
用明确的消息模式决定处理范围,并观察未匹配消息。
进程的收件箱里有一封当前无法匹配的消息,通常会怎样?
画三座驿站的传信图
画出 sensor、collector 和 dashboard,给消息命名,再标出发送者与接收者。
提示 1先迈一步
不要只把消息叫 data,要写清它带来的是温度、查询还是回复。
提示 2再缩小一点
如果一封信需要回信,别忘了带上回信地址或 reference。
提示 3离答案很近了
想一想:展示板暂时不工作时,哪些信可能越积越多?
提示 4从终点往回想
先挑一条“过关信号”,为它写一个最小测试。如果电脑看不出结果,就把这句话改成一个真正能观察到的现象。
每封消息都能看出是谁发送、谁接收
每一份状态都有明确的保管者
图上至少标出一个 mailbox 可能变长的位置
记住三句话
- 1
每个 BEAM 进程管好自己的状态,再通过消息和伙伴合作。
- 2
消息循环常常把下一轮要记住的状态放在递归参数里。
- 3
暂时无法匹配的消息会留下来,因此 mailbox 有多长值得我们留意。