第 04 站展开探险地图
OTP 消息章法
GenServer 把收信、回信和系统消息整理成大家都认识的规则。
把手写循环改成 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。
# 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 会挡住后面的消息。
发得快,不等于做得完
用 cast 快速发送更新,再观察 mailbox 和同步查询延迟。
- 01
新增使用 cast 的
put_async/3 - 02
连续送出一大批更新,并分别记录发送结束与处理结束的时间
- 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 都会影响结果。
这段代码里的关键词
behaviour
一组 callback 合约。实现模块完成指定 callback,框架负责消息循环、系统消息和调试。
call
需要 reply 的同步请求。调用者会等待,但这还不等于完整背压。
cast
不等 reply 的异步消息。发送过快时,消息会堆进 mailbox。
这里用了哪些设计模式
Template Method
由 behaviour 固定生命周期与回调骨架,业务只填必要步骤。
Facade
用清楚的客户端 API 隐藏原始消息形状。
Bounded Buffer
限制待处理工作数量,满载时明确返回 busy。
下面哪种情况最适合考虑使用 cast?
写一个有界任务服务
一种语言写 API,另一种写 worker。最多运行 N 个任务;有界队列满时返回 busy。
提示 1先迈一步
让一个进程保管队列和容量,但每个真正的任务交给独立进程执行。
提示 2再缩小一点
用 monitor 同时发现 worker 完成和异常退出,别忘了归还容量。
提示 3离答案很近了
不要悄悄接收无限任务;满了就把情况告诉调用者。
提示 4从终点往回想
先挑一条“过关信号”,为它写一个最小测试。如果电脑看不出结果,就把这句话改成一个真正能观察到的现象。
能看见并发数和等待队列各自的上限
worker 即使异常退出,容量计数最后也会恢复正确
调用者能清楚知道任务被接收还是因为繁忙被拒绝
记住三句话
- 1
behaviour 提供可靠的消息循环章法,但具体业务约定仍要自己想清楚。
- 2
好用的客户端 API 会把内部消息形状藏起来,让调用者专心表达目的。
- 3
cast 能让发送者先走,却可能把压力留在 mailbox;容量和过载处理必须明确设计。