35 角色与进程
用角色和进程封装状态,通过邮箱与消息协作,避免调用方直接共享内部可变数据。
学习目标
- 能解释消息如何经过邮箱进入角色,并指出状态变化的唯一拥有者
- 能修改一段消息处理代码,补上重复、超时和角色失败时的拒绝与恢复路径
- 能回答:移除邮箱后,第一处可观察的差异在哪里,怎样从原始消息重放?
为什么这一单元不可压缩
想象两个柜台同时处理同一笔订单。顾客把要求交给柜台,柜台按顺序收件,只有一位工作人员能改订单,旁边的人负责发现超时和重新安排。任何人都可以直接翻改草稿时,最后留下的结果看似完整,却无法知道谁覆盖了谁。
软件里的并发请求也会遇到这个问题。若发送者直接改动处理者正在使用的数据,重复请求、等待和失败就会互相污染;测试偶尔通过,线上却很难回答“哪一次变化先发生”。
本章把这条路径拆成可观察的站点:一条请求怎样到达、一位拥有者怎样处理、失败怎样升级、响应怎样证明自己对应了哪条请求。
本章验收路线
本单元只改变一个条件:先观察消息链的正常路径,再移除邮箱。预期是首差停在“无法证明到达与排队”的位置,而不是让角色状态悄悄改变。先预测:如果消息没有邮箱,哪一个节点应拒绝继续?
复核记录至少保存:消息身份、当前节点、状态变化、下游接收者、拒绝原因、恢复动作和消息契约。最终响应存在,不等于整条路径可证明;必须能从原始消息重放同一结论。
知识点清单
| 知识点 | 本章的可视化 | 是否进入名词解释 |
|---|---|---|
| 消息与消息契约 | 消息链图、响应节点 | 是 |
| 邮箱与背压 | 邮箱节点、故障注入 | 是 |
| 角色状态与所有权 | 角色状态节点、单步实验 | 是 |
| 监督与失败升级 | 监督节点、故障回退 | 是 |
| 提示59:用角色实现并发性时不必共享状态 | 三步 Stepper 与实验台 | 是 |
五个边界如何互相约束
<Term def="描述意图、输入身份和处理目标的一份不可变请求。">消息</Term> 是发送者交给系统的事实,而不是对内部变量的直接引用。消息应带有唯一身份、目标对象和允许的副作用;重复消息可以被识别,非法消息可以在进入处理前被拒绝。
<Term def="保存待处理消息并定义到达顺序、容量和等待策略的通道。">邮箱</Term> 把到达和处理分开。它可以让角色一次只处理一条消息,也必须暴露容量、超时和拒绝条件;没有邮箱,就没有可复核的排队证据。
<Term def="拥有自己的状态并通过消息与外界协作的独立处理者。">角色</Term> 只允许自己的处理循环改变内部状态。发送者不拿状态引用,接收者也不把中间对象共享出去;每次改变都应能追溯到一条消息。
<Term def="规定谁可以改变状态、何时交接以及失败后谁负责恢复的约定。">所有权</Term> 是并发设计的责任边界。单一拥有者并不意味着没有并发,而是把并发请求变成可排队的意图,避免多个调用方同时写同一份可变数据。
<Term def="观察角色是否存活、是否超时以及是否需要重启或转移工作的外部监督机制。">监督</Term> 不负责偷偷修正状态。它只根据可观察的心跳、超时和失败消息作出升级决定,恢复时仍要从原始消息或持久化日志重放。
提示59:用角色实现并发性时不必共享状态
提示59的关键不是给每个请求都创建一个重量级线程,而是把“谁能写状态”变成清楚的设计选择。发送者只提交消息,角色按自己的顺序处理,监督记录失败;这样读者可以分别检查消息语义、排队行为、状态变化和恢复路径。
下面的 TypeScript 片段只展示边界。receive 不返回状态引用,handle 也只在角色内部执行;真实系统还要把超时、重试上限和幂等键纳入契约。
type Message = {
id: string;
kind: "reserve" | "release";
itemId: string;
};
type ActorState = { reserved: Set<string> };
function handle(state: ActorState, message: Message) {
if (message.kind === "reserve") state.reserved.add(message.itemId);
if (message.kind === "release") state.reserved.delete(message.itemId);
return state;
}关键不在函数名,而在责任边界:调用者不能直接拿到 ActorState;同一 id 的消息要么被识别为重复,要么被明确拒绝。发生失败时,监督只能依据契约决定停止、重启或转移,不能把最后一个响应当成完整证据。
从消息到响应的三步观察
每一步都保留同一条输入,只改变观察焦点。先说出预期首差,再看图示中的高亮站点;如果观察结果与预测不符,就保存分叉处并从原始消息重新开始。
1. 冻结消息与契约
写下消息身份、目标对象、允许的副作用和拒绝条件。发送者只交付意图,不共享角色内部状态。
常见误区与回退
可重置实验:故障如何停在第一处
猜一猜:点击“注入单故障”并把进度推进到邮箱后,角色状态应该改变吗?正确的观察是邮箱证据缺失,角色拒绝继续处理;点击重置后,再从同一条消息重新验证正常路径。
消息到响应实验台
通过播放、单步和拖动进度,观察内部状态如何只被拥有者改变。
第 1 / 5 步 · 消息:只表达意图,不直接触碰内部状态
只有观察到消息契约与角色状态都成立,响应才可被接受。
实验记录可以写成如下形式。它描述事实和回退,不把系统压缩成一个“并发可靠度”分数:
actor_run:
message_id: reserve-204
current_node: mailbox
state_change: none
downstream: actor-state
rejection: mailbox-unavailable
recovery: restore-mailbox-and-replay
contract: idempotent-message-v1把 current_node 从邮箱改成响应而不补充中间证据,是典型的“结果看起来成功”陷阱。重放时必须保留原始消息身份和同一个边界,才知道恢复是否真的修复了问题。
如何选择边界
| 场景 | 首选边界 | 必须验收 | 不要做什么 |
|---|---|---|---|
| 一个对象有明确写入者 | 角色 + 邮箱 | 所有权、容量、顺序 | 暴露可变状态引用 |
| 下游速度不同 | 有界邮箱 + 背压 | 超时、拒绝、重试上限 | 假设邮箱无限容量 |
| 角色可能失败 | 监督 | 心跳、升级、重放 | 复制最后一个成功响应 |
边界的大小由消息契约和状态不变量决定。并发请求可以同时到达,但状态改变仍应落在一个可观察的处理点;监督的职责是让“没有完成”成为明确状态,而不是掩盖它。
本章回顾
- 消息表达意图,发送者不共享角色内部状态。
- 邮箱把到达顺序、容量和背压变成可观察边界。
- 角色拥有状态,所有权让写入责任集中且可追踪。
- 监督处理超时与失败,恢复必须从原始消息重放。
- 先定位首差,再判断响应是否满足消息契约。
可验证练习
练习
问题 1:找出首个差异。 两条 reserve 消息同时到达,一个邮箱容量为 1;第一条处理超时,第二条被系统直接标记为完成。请指出不能宣布成功的节点,并写出两项应保存的证据。
问题 2:修改 Demo 代码。 让下面的处理函数拒绝重复消息,并说明角色失败后监督如何恢复。要求不把角色状态返回给调用者。
function receive(state: ActorState, message: Message) {
handle(state, message);
return { ok: true, state };
}问题 3:做边界选型。 三个下游处理速度不同,可能重复收到同一通知;你会让它们共享一个可变对象,还是发送带版本和幂等键的消息?写出一个拒绝条件。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 消息
一份只表达意图和输入身份的请求;它可以被排队、拒绝、重试或重放。
- 邮箱
保存待处理消息的通道;它决定顺序、容量和等待太久时怎么办。
- 角色
拥有自己状态的处理者;外部只能发消息,不能直接改它的内部数据。
- 所有权
规定谁有修改权、何时交接以及失败后谁负责恢复的约定。
- 监督
观察存活、超时和失败并触发升级或重启的机制,不替系统伪造成功。
出处与改写范围
本文为改编重写:目录、版本与提示坐标用于限定范围;正文、代码、双图示、交互实验和练习均为本课程独立设计,不复制原书正文、插图或答案。