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

消息与超时

先自己收信和回信,再揭开 GenServer 帮我们做了什么。

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

并发请求的回复会迟到、乱序,服务进程也可能退出时,怎样保证每个调用者拿到自己的结果?

先看现场

第二个请求收到了第一个请求的迟到回复

第一个请求超时后,迟到回复仍留在调用者信箱里。第二个请求没有独立编号,刚好匹配了旧回复;服务退出又被误报成普通超时。

能够观察到
  • 请求、reference 与回复的完整日志
  • 超时后调用者 mailbox 中仍然存在的消息
  • monitor 的 DOWN 与 timeout 先后发生的时间线
为什么学这一站

它要解决什么

先写一次消息循环,才能看清 GenServer 做了什么。请求带编号,回复带同一编号。timeout 只结束等待,不会撤回消息。

走完这一站

你会做到

  • 能给每个 request 加上不会混淆的 reference,并让 reply 带着同一编号回来

  • 能说清 link 和 monitor 都在关注进程退出,但方向和反应不同

  • 能认出三种常见情况:互相等待、迟到回复、mailbox 越积越多

出发前
  • 能用 Elixir 或 Erlang 的模式匹配认出一条消息
  • 知道每个 BEAM 进程都有自己的 mailbox
同一件事,两种写法

先看做什么,再看怎么写

request reference 配对回复,monitor reference 报告退出;timeout 仍不会撤回已发送的消息。

当前代码
Elixir
# 发出请求前,分别准备“观察进程”和“配对回复”的 reference
def call(server, request, timeout \\ 1_000) do
  monitor_ref = Process.monitor(server)
  request_ref = make_ref()
  # 同时告诉服务端回信地址和请求编号
  send(server, {:call, self(), request_ref, request})

  receive do
    # 正常回复要匹配 request reference
    {:reply, ^request_ref, response} ->
      # 不再观察,并清掉可能已到达的 DOWN
      Process.demonitor(monitor_ref, [:flush])
      {:ok, response}

    # monitor 单向报告服务进程退出
    {:DOWN, ^monitor_ref, :process, ^server, reason} ->
      {:error, {:server_down, reason}}
  after
    # timeout 只停止等待,不会撤回 request
    timeout ->
      Process.demonitor(monitor_ref, [:flush])
      {:error, :timeout}
  end
end
设计选择

为什么这样写

为什么 reply 要同时带 request reference?

这次选择

同一个调用者可能收到迟到、乱序或无关消息。reference 让匹配说清“这是哪一次请求的回答”。

代价与边界

timeout 不是取消。还需决定迟到回复、服务退出和 monitor 清理。

换一把尺子

从 Java、Python、JavaScript 看过来

Java

熟悉的起点
Future、CompletableFuture 与 executor。
BEAM 在意什么
reply 是普通 mailbox 消息,需要 reference 配对;monitor 另行报告退出。
别带错直觉
等待超时通常不等于远端工作已经取消。

Python

熟悉的起点
await、Future 与 Queue。
BEAM 在意什么
receive 可以按模式选择消息,未匹配消息仍留下。
别带错直觉
选择性接收会反复扫描 mailbox,不能无限堆积。

JavaScript

熟悉的起点
Promise resolve/reject。
BEAM 在意什么
request/reply 是自己约定的消息协议,不是 VM 自动生成的 Promise。
别带错直觉
timeout 后的 reply 仍可能到达。
LAB
动手

让回信迟到

约 15–25 分钟

服务端 1.5 秒后回复,客户端只等 0.5 秒。看看 timeout 后回复去了哪里。

  1. 01

    写一个收到 request 后稍等 1.5 秒再 reply 的小服务

  2. 02

    发起一次只愿意等待 500 毫秒的 call

  3. 03

    两秒后运行 Process.info(self(), :messages),看看自己的 mailbox

复制到终端,按回车
# 查看当前 IEx 进程 mailbox 中仍未处理的消息
Process.info(self(), :messages)
你会看到
  • call 会先返回 {:error, :timeout}
  • 服务端仍可能完成工作,迟到的 reply 也可能出现在调用者的 mailbox
故意弄坏

去掉 reference,再发送两个速度不同的请求。观察回复是否错配。

这次能看清

停止等待不等于停止服务端工作;reference 能正确配对回复。

这次还不能说明

一次实验只能展示几种到达顺序,还不能告诉我们真正服务中该把 timeout 设成多少。

先认词

这段代码里的关键词

01

reference

BEAM 生成的一张几乎不会重复的小票,常用来确认“这封 reply 属于哪一个 request”。

02

link

两个进程间的双向退出联系。默认情况下,异常退出信号会沿 link 传播。

03

monitor

单向观察关系。被观察者退出时,观察者收到 DOWN,但不会自动退出。

从代码里认出章法

这里用了哪些设计模式

01

Correlation Identifier

给每次请求唯一 reference,只接收带同一 reference 的回复。

02

Request-Reply

明确请求者、接收者与回复消息的协议。

03

Timeout

限制等待时间,同时承认迟到消息仍需识别和处理。

想一想

调用者等到 timeout 以后,已经发给服务端的消息通常会怎样?

轮到你

写一个 KV 小服务

实现 put、get 和 delete。同步请求带 reference,并区分 timeout 与服务端退出。

提示 1先迈一步

先把所有来信和回信的形状写在纸上,再写 loop。

提示 2再缩小一点

等待 reply 时,也要在同一个 receive 中留意 DOWN 消息。

提示 3离答案很近了

正常收到 reply 后,想一想何时用 demonitor 停止已经不需要的观察。

提示 4从终点往回想

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

过关条件
  • 多个请求即使回复顺序不同,也不会互相认错

  • 调用者能分清“等太久”和“服务已经退出”

  • 测试中确实制造并检查了一封迟到的 reply

带走

记住三句话

  1. 1

    timeout 只表示停止等待,不会自动取消已经开始的工作。

  2. 2

    reference 像回执编号,让 request 和 reply 不会认错彼此。

  3. 3

    link 建立双向的退出联系;monitor 只负责观察并把消息送回来,由观察者决定下一步。

再读一点

去看原版资料

ProcessErlang concurrent programming
本站结束实验做过,答案也想过,就把这一站收好。