把章节变成可单步观察的过程
分类:工程设计
先说结论
异步结果回来时,“对象引用还不为空”不足以证明结果仍然属于当前对象。生命周期状态和 generation token 要共同回答:对象是否还活着、是否仍是发起请求时的那一代。
一个晚到的头像请求
对象槽位:7,generation=3
请求 A:加载玩家 100 的头像
对象被回收,槽位 7 generation 变为 4
请求 B:同一槽位现在显示玩家 205
请求 A 晚到,携带 generation=3
虽然槽位 index 仍是 7,但 generation 不匹配,请求 A 必须丢弃。否则玩家 100 的头像会覆盖玩家 205,这类错误看起来像“偶发 UI 串图”,根因却是身份代次没有验证。
生活化模型:一张取餐号牌
把一个对象想成餐厅里的一张取餐号牌。
号牌会经历:
尚未发出
↓
已经发出,正在备餐
↓
餐品完成
↓
顾客取走,号牌作废
如果号牌 A 已经作废,后来又发出了外观相同的号牌,新号牌也不等于旧号牌。
旧厨单晚到时,不能因为“桌面上现在也有一张 A”就把旧餐交给新顾客。除了名字,还需要知道:
这是不是同一次生命周期?
这就是对象生命周期与异步竞态的核心。
为什么“对象还存在”不够
同步代码通常按顺序发生:
创建 → 使用 → 销毁
异步代码会把“请求发出”和“结果回来”分开:
创建对象
启动异步加载
销毁对象
重新创建一个同用途对象
旧异步加载完成
回调执行时,对象引用可能仍然非空,甚至界面上已经出现一个“同名新对象”,但旧结果依然不属于它。
因此不能只判断:
对象是否非空?
还应判断:
对象处于什么状态?
回调属于哪一代生命周期?
本次操作是否已经取消?
结果对应的前置条件是否仍成立?
用显式状态表达生命周期
生命周期状态机把允许发生的变化写清楚。
一个教学模型可以是:
Created
↓ Start
Active
↓ BeginStop
Stopping
↓ CompleteStop
Disposed
Active ── Fault ──> Faulted
Stopping ─ Fault ─> Faulted
状态名称只是教学示例。生产状态必须来自对象真实职责,不能为了“看起来完整”随意增加默认分支。
状态机提供什么
状态机能明确:
哪些操作在当前状态合法。
一次状态变化由谁触发。
失败发生在哪个阶段。
清理是否已经开始或完成。
晚到回调为什么不能再写入。
它也让非法调用暴露出来。例如对 Disposed 对象再次 Start,应该报告状态错误,而不是静默当作成功。
状态机不能单独解决什么
如果一个对象结束后,又复用了同一个实例或同一个业务标识,那么:
旧回调看到的当前状态可能也是 Active。
此时仅检查状态仍然不够,还需要区分生命周期代次。
Generation Token:为每一代身份编号
Generation Token 可以理解成号牌背后的批次号。
每次开始新的生命周期或会替代旧结果的操作时:
generation 增加。
异步操作捕获启动时的 generation。
回调到达时与当前 generation 比较。
只有相等时,结果才有资格修改当前状态。
流程如下:
当前 generation = G
↓
启动异步操作,捕获 G
↓
对象重置或开始新一代,generation 改变
↓
旧回调携带 G 返回
↓
G != 当前 generation
↓
判定为过期结果,不写入当前对象
这里的 G 是符号,不是固定生产值。
Token 解决的是归属,不是取消
Generation Token 能阻止旧结果污染新状态,但它不会自动停止后台工作。
旧操作可能仍然消耗:
网络带宽。
文件读取。
计算时间。
内存。
下游服务容量。
因此通常还要把“结果是否有资格提交”和“工作是否应该停止”分开设计:
generation:验证结果归属。
CancellationToken:请求协作式取消。
两者不是互相替代。
异步回调晚到的完整事实链
对象进入 Active
↓
启动异步操作,记录 generation 与取消令牌
↓
对象开始停止
↓
发出取消请求,并改变 generation
↓
异步操作可能立即取消,也可能已经完成或暂时无法响应取消
↓
回调最终到达
↓
先识别完成、取消或失败
↓
再验证 generation 与状态
↓
只有归属有效的成功结果可以写入
↓
取消与失败仍以明确结果对外可见
“发出取消请求”不等于“回调绝不会再来”。取消通常是协作式协议,操作是否以及何时响应,取决于被调用方。
最小 C# 示例
下面是一个教学用的异步资源拥有者。它展示状态、generation、取消以及错误可见性,不提供重试或默认值替代。
public enum OwnerState
{
Created,
Active,
Stopping,
Disposed,
Faulted
}
public enum LoadStatus
{
Applied,
Cancelled,
Superseded,
Faulted
}
public sealed record LoadOutcome(
LoadStatus Status,
Exception? Error = null);
public sealed class AsyncOwner<T> : IAsyncDisposable
{
private long _generation;
private CancellationTokenSource? _lifetimeCts;
public OwnerState State { get; private set; } = OwnerState.Created;
public T? Value { get; private set; }
public void Start()
{
if (State != OwnerState.Created)
throw new InvalidOperationException($"Cannot start from {State}.");
_generation = checked(_generation + 1);
_lifetimeCts = new CancellationTokenSource();
State = OwnerState.Active;
}
public async Task<LoadOutcome> LoadAsync(
Func<CancellationToken, Task<T>> load)
{
if (State != OwnerState.Active || _lifetimeCts is null)
throw new InvalidOperationException($"Cannot load from {State}.");
long operationGeneration = _generation;
CancellationToken token = _lifetimeCts.Token;
try
{
T loaded = await load(token);
if (operationGeneration != _generation || State != OwnerState.Active)
return new LoadOutcome(LoadStatus.Superseded);
Value = loaded;
return new LoadOutcome(LoadStatus.Applied);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
return new LoadOutcome(LoadStatus.Cancelled);
}
catch (Exception error)
{
// 原始异常随结果返回,调用方仍然能观察和记录失败。
if (operationGeneration == _generation)
State = OwnerState.Faulted;
return new LoadOutcome(LoadStatus.Faulted, error);
}
}
public ValueTask DisposeAsync()
{
if (State == OwnerState.Disposed)
return ValueTask.CompletedTask;
State = OwnerState.Stopping;
// 先让当前代失效,再请求后台操作协作式取消。
_generation = checked(_generation + 1);
_lifetimeCts?.Cancel();
_lifetimeCts?.Dispose();
_lifetimeCts = null;
Value = default;
State = OwnerState.Disposed;
return ValueTask.CompletedTask;
}
}
这个示例中的 default 只用于清除已释放对象持有的引用,不会被当作加载成功结果返回。
错误为什么仍然可见
示例明确区分:
Applied:结果成功写入当前代。
Cancelled:当前生命周期请求了取消。
Superseded:结果成功返回,但已不属于当前代。
Faulted:操作失败,并携带原始 Exception。
没有把异常转换为“成功 + 空值”,也没有在失败后自动使用旧数据。
调用方必须检查 LoadOutcome。如果调用方选择记录或展示失败,那是上层明确策略;底层不应默默吞掉它。
进阶:取消不是错误替代器
取消与失败是不同事实:
取消:调用方不再需要这项工作,且操作接受了取消请求。
失败:操作尝试完成,但发生错误。
过期:操作可能成功,但结果不再属于当前生命周期。
不能把任意异常都改写为取消,也不能把过期结果伪装成失败。
同样,捕获 OperationCanceledException 时应确认它对应本次请求的取消条件。否则可能把下游自己的超时或取消原因错误归类。
可视化的逐步状态
可以用时间轴观察竞态:
时间 ─────────────────────────────────────────>
对象代次 G1 G2
对象状态 Active Stop Active
操作 A ├──────────────● 晚到
操作 B ├────●
取消请求 ↑
结果归属 A 捕获 G1 B 捕获 G2
提交判断 G1 != G2 G2 == G2
其中 G1、G2 是教学符号。
交互演示可允许控制:
操作 A 的完成时刻。
对象停止和重启的时刻。
操作是否响应取消。
操作最终成功、取消或失败。
状态面板应同时显示:
当前对象状态。
当前 generation。
每个操作捕获的 generation。
取消是否已请求。
原始错误是否存在。
结果是否被应用,以及拒绝应用的原因。
正常路径
对象进入 Active。
异步操作捕获当前 generation。
操作成功完成。
generation 与状态仍匹配。
结果写入并返回 Applied。
正常路径也应经过归属验证,而不是只有发生异常时才检查。
边界路径
结果与取消几乎同时发生
取消请求与异步完成之间没有天然的全局先后保证。
系统应根据可观察状态给出一种明确结果:
若成功结果先被确认且归属仍有效,则可以提交。
若取消已被接受并抛出对应取消异常,则报告 Cancelled。
若代次已改变,即使操作成功也报告 Superseded。
具体同步原语必须保证状态检查和结果提交之间不会再次被并发修改。
多个同代操作竞争
Generation 只区分生命周期,不一定区分同一代里的多个请求。
如果“后发请求覆盖先发请求”是规则,还需要操作级 token 或请求序号。是否允许覆盖属于行为设计,不能由 generation 自动推导。
Dispose 被重复调用
释放操作通常需要幂等地保护自身资源,但“重复释放返回完成”不代表其他非法状态转换也应该静默成功。
失败路径
状态转换非法:抛出或返回明确状态错误。
异步操作失败:返回 Faulted,并保留原始异常。
取消未被下游响应:回调仍需经过 generation 检查。
旧操作晚到:返回 Superseded,不写入当前对象。
清理自身失败:应单独暴露清理错误,不能因对象已标记结束就吞掉。
generation 溢出:checked 运算暴露异常,而不是悄悄回绕。
这里没有“失败后默认成功”的路径。
代价与权衡
显式生命周期会增加:
状态枚举与转换代码。
每次异步操作的 token 捕获和比较。
取消令牌的创建与释放。
结果类型和错误传播。
并发访问时的同步成本。
它换来的是:
晚到回调不会悄悄污染新状态。
非法调用更容易定位。
取消、过期和失败可以分别观察。
清理边界更明确。
若有 N 个在途操作,需要至少保存与这些操作相关的状态,空间成本通常与在途操作数量有关。generation 比较本身是常数时间,但真正成本往往来自被取消的后台工作与并发协调。
最重要的收获
状态描述对象现在能做什么。
generation 描述结果属于哪一代。
取消请求停止不再需要的工作,但不保证回调不会晚到。
晚到结果必须先验证归属,再决定能否写入。
取消、过期与失败是三种不同事实。
原始错误必须保持可见,不能转换成默认成功或空值。