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

OTP 消息章法

GenServer 把收信、回信和系统消息整理成大家都认识的规则。

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

把手写循环改成 GenServer 后,怎样划分 call 与 cast,并阻止慢任务把 mailbox 塞满?

先看现场

发送者很快,服务却越来越慢

调用者不断 cast,服务却在回调里执行慢 I/O。发送没有等待,mailbox 越积越长,后来的同步查询也开始超时。

能够观察到
  • mailbox 长度随时间的变化
  • call 的延迟与 timeout 次数
  • 输入速率、完成速率与 busy 拒绝次数
为什么学这一站

它要解决什么

每次重写启动、系统消息和回复,容易漏掉细节。OTP behaviour 提供通用章法。API、状态归属和过载策略仍由我们决定。

走完这一站

你会做到

  • 能分清给使用者调用的 API,和进程内部收到消息后执行的 callback

  • 能根据“是否必须知道结果”和“忙不过来怎么办”选择 call 或 cast

  • 能让需要长期工作的 GenServer 或 gen_server 由监督树照看

出发前
  • 完成消息与 mailbox 章节,亲手写过一次消息循环
  • 知道 request、reply、reference 和 timeout 各自做什么
同一件事,两种写法

先看做什么,再看怎么写

API 隐藏消息形状,callback 处理约定。两种语言返回相同语义的 tuple。

当前代码
Elixir
# GenServer 负责保存并按顺序更新这张 map
defmodule KV do
  use GenServer

  # 这些函数是调用者看到的公开 API
  def start_link(opts), do: GenServer.start_link(__MODULE__, %{}, opts)
  def get(server, key), do: GenServer.call(server, {:get, key})
  def put(server, key, value), do: GenServer.call(server, {:put, key, value})

  # callback 负责处理真正的消息与状态
  @impl true
  def init(state), do: {:ok, state}

  @impl true
  def handle_call({:get, key}, _from, state),
    do: {:reply, Map.fetch(state, key), state}

  def handle_call({:put, key, value}, _from, state),
    do: {:reply, :ok, Map.put(state, key, value)}
end
设计选择

为什么这样写

为什么把手写 loop 换成 GenServer/gen_server?

这次选择

OTP 统一启动、同步调用、异步消息和系统消息的规则,让工具与监督者都能认识它。

代价与边界

框架不会自动给容量上限,也不会让慢 callback 变快。

换一把尺子

从 Java、Python、JavaScript 看过来

Java

熟悉的起点
interface 加 service object。
BEAM 在意什么
behaviour 约定 callback;GenServer 还带一套进程协议。
别带错直觉
一个 GenServer 串行处理 callback,不是自动线程池。

Python

熟悉的起点
ABC/Protocol 加长期运行的 task。
BEAM 在意什么
API 与 callback 被刻意分开,状态由一个进程拥有。
别带错直觉
cast 只是不等 reply,不代表没有积压。

JavaScript

熟悉的起点
class、event emitter 或 async handler。
BEAM 在意什么
call/cast 与系统消息都进入同一进程协议。
别带错直觉
慢 callback 会挡住后面的消息。
LAB
动手

发得快,不等于做得完

约 15–25 分钟

用 cast 快速发送更新,再观察 mailbox 和同步查询延迟。

  1. 01

    新增使用 cast 的 put_async/3

  2. 02

    连续送出一大批更新,并分别记录发送结束与处理结束的时间

  3. 03

    同时发起一个同步 get,记录它的等待时间和 message_queue_len

复制到终端,按回车
# 读取 pid 的 mailbox 长度,观察 cast 是否正在积压
:erlang.process_info(pid, :message_queue_len)
你会看到
  • 发送循环很快结束,不代表服务端已经处理完
  • 同步 get 可能排在许多 cast 后面,等待时间明显变长
故意弄坏

在 handle_cast 中加入慢 I/O,再加快发送。观察 mailbox 是否持续增长。

这次能看清

异步 API 可以让调用者不等待,但等待并没有消失,而是可能转移到了服务端的 mailbox。

这次还不能说明

本地实验不能给出真实服务上限。硬件、消息大小和外部 I/O 都会影响结果。

先认词

这段代码里的关键词

01

behaviour

一组 callback 合约。实现模块完成指定 callback,框架负责消息循环、系统消息和调试。

02

call

需要 reply 的同步请求。调用者会等待,但这还不等于完整背压。

03

cast

不等 reply 的异步消息。发送过快时,消息会堆进 mailbox。

从代码里认出章法

这里用了哪些设计模式

01

Template Method

由 behaviour 固定生命周期与回调骨架,业务只填必要步骤。

02

Facade

用清楚的客户端 API 隐藏原始消息形状。

03

Bounded Buffer

限制待处理工作数量,满载时明确返回 busy。

想一想

下面哪种情况最适合考虑使用 cast?

轮到你

写一个有界任务服务

一种语言写 API,另一种写 worker。最多运行 N 个任务;有界队列满时返回 busy。

提示 1先迈一步

让一个进程保管队列和容量,但每个真正的任务交给独立进程执行。

提示 2再缩小一点

用 monitor 同时发现 worker 完成和异常退出,别忘了归还容量。

提示 3离答案很近了

不要悄悄接收无限任务;满了就把情况告诉调用者。

提示 4从终点往回想

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

过关条件
  • 能看见并发数和等待队列各自的上限

  • worker 即使异常退出,容量计数最后也会恢复正确

  • 调用者能清楚知道任务被接收还是因为繁忙被拒绝

带走

记住三句话

  1. 1

    behaviour 提供可靠的消息循环章法,但具体业务约定仍要自己想清楚。

  2. 2

    好用的客户端 API 会把内部消息形状藏起来,让调用者专心表达目的。

  3. 3

    cast 能让发送者先走,却可能把压力留在 mailbox;容量和过载处理必须明确设计。

再读一点

去看原版资料

Elixir GenServerErlang gen_server
本站结束实验做过,答案也想过,就把这一站收好。