第 02 站展开探险地图
00装好工具前置准备01起跑线前置准备02进程与信箱R1Elixir 数据流水线可选复习R2读懂 Erlang可选复习03消息与超时04OTP 消息章法05会重启的监督树06给并发设上限07BEAM 节点失联X1两种语言对暗号双语扩展X2双语搭档双语扩展08可靠任务调度器
首页/BEAM 主线/第 02 站
02
地基零基础BEAMOTP

进程与信箱

各自保管数据,需要合作时互相发消息。

3小关卡
约 3 小时 · 建议分 3 次可以分几次
QUESTION · 本站只追这一问

多个发送者同时更新计数时,谁拥有状态,怎样发现 mailbox 里一直没有被处理的消息?

先看现场

计数器还活着,读取请求却不回

计数进程仍在运行,发送者也不断投递消息,可读取结果不再变化。处理循环漏掉了一种消息形状,信箱里的未匹配消息越积越多。

能够观察到
  • 计数进程的 PID 是否仍然存活
  • mailbox 长度是否持续增长
  • 停止变化的读取结果,以及信箱中未匹配消息的实际形状
为什么学这一站

它要解决什么

BEAM 用许多小进程同时工作。每个进程保管自己的数据,通过消息合作。一个进程出错时,影响通常可以留在局部。

走完这一站

你会做到

  • 能分清 BEAM 小进程、操作系统进程和线程不是同一种东西

  • 能说清每个进程怎样保管自己的数据,又怎样从 mailbox 收消息

  • 能根据消息循环,猜出下一轮状态和模式匹配的结果

出发前
  • 完成起跑线,并成功运行过一小段 Erlang 或 Elixir 代码
  • 见过 tuple、list 和 map;暂时记不牢写法也没关系
同一件事,两种写法

先看做什么,再看怎么写

先跑一封最短信,认清 PID、发送与接收;再看计数进程怎样把新状态带进下一轮,而不是原地改旧值。

当前代码
Elixir
# 先记住当前 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 永远只有一个线程。
LAB
动手

给计数器寄三封信

约 15–25 分钟

依次发送“加 1”“加 2”“加 3”,再读取总数。除了答案 6,还要找出状态由谁保管。

  1. 01

    向计数器连续发送 {:add, 1}、{:add, 2} 和 {:add, 3}

  2. 02

    发送带着自己 PID 的 {:read, self()},让计数器知道回信地址

  3. 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 永远不会越积越多。

先认词

这段代码里的关键词

01

轻量进程

由 BEAM 安排的独立工作单元。它不是完整的操作系统进程,因此能创建很多个。

02

mailbox

进程自己的收件箱。消息在这里等待,进程再按模式寻找当前能处理的内容。

03

reduction

BEAM 估算工作量的单位。一个进程做了一段工作后,调度器会让其他进程获得机会。

从代码里认出章法

这里用了哪些设计模式

01

Actor Model

让一个进程拥有状态,只通过消息与外界合作。

02

Single Writer

由唯一拥有者串行处理更新,避开共享可变状态。

03

Message Filter

用明确的消息模式决定处理范围,并观察未匹配消息。

想一想

进程的收件箱里有一封当前无法匹配的消息,通常会怎样?

轮到你

画三座驿站的传信图

画出 sensor、collector 和 dashboard,给消息命名,再标出发送者与接收者。

提示 1先迈一步

不要只把消息叫 data,要写清它带来的是温度、查询还是回复。

提示 2再缩小一点

如果一封信需要回信,别忘了带上回信地址或 reference。

提示 3离答案很近了

想一想:展示板暂时不工作时,哪些信可能越积越多?

提示 4从终点往回想

先挑一条“过关信号”,为它写一个最小测试。如果电脑看不出结果,就把这句话改成一个真正能观察到的现象。

过关条件
  • 每封消息都能看出是谁发送、谁接收

  • 每一份状态都有明确的保管者

  • 图上至少标出一个 mailbox 可能变长的位置

带走

记住三句话

  1. 1

    每个 BEAM 进程管好自己的状态,再通过消息和伙伴合作。

  2. 2

    消息循环常常把下一轮要记住的状态放在递归参数里。

  3. 3

    暂时无法匹配的消息会留下来,因此 mailbox 有多长值得我们留意。

再读一点

去看原版资料

Erlang ProcessesElixir Processes
本站结束实验做过,答案也想过,就把这一站收好。