第 4 周 · Tether 源码课
EffectScope:让插件离场时不留尾巴
学完你能做到
- 解释为什么“注册即回收”是动态插件的前提
- 预测父子 scope 与多个 disposer 的回收顺序
- 区分提前摘除 registration 与 scope 的兜底回收
- 用 EffectScopeTests 验证 LIFO、listener 移除和 failure aggregation
课程进度
- 第 1 周
- 第 2 周
- 第 3 周
- 第 4 周
- 第 5 周
- 第 6 周
- 第 7 周
- 第 8 周
- 第 9 周
- 第 10 周
- 第 11 周
- 第 12 周
- 第 13 周
- 第 14 周
- 第 15 周
- 第 16 周
一句话先懂
EffectScope 是一份可等待的资源所有权清单:它先叫停工作,再清理孩子,最后逆序撤销自己注册的一切。
只要 listener、service、timer 和子 plugin 都从当前 Context 注册,卸载这个 scope 就能把它们完整带走。
“插件能够加载”很容易;“插件能够反复卸载、重载而不重复响应、不泄漏文件、不保留旧程序集”才难。EffectScope 解决的是后半句。
先看大图
一个类比:演出结束的退场清单
搭舞台时通常按这个顺序:先接总电源,再开调音台,最后让演员上台。散场时应反过来:演员先离场,调音台再关,最后断总电源。
EffectScope 就像舞台经理的退场清单:
Effect(disposer)每注册一项,就把“如何撤销”记进当前清单。Fork()创建一支子团队;总场地关闭时,先等子团队完整退场。Cancellation是“停止产生新工作”的广播,不是资源已经清干净的证明。DisposeAsync()完成才表示所有 cleanup 都已 settled。
类比边界:真实 cleanup 可能异步、失败、被并发请求,甚至在回调内再次请求 disposal。舞台类比只表达所有权和逆序;准确的等待、失败聚合与重入语义必须看测试。
为什么一定是“注册即回收”
下面两段代码表面上都能监听事件:
// scope-bound:推荐
pluginContext.On("tick", HandleTick);
// 绕过 Context:plugin scope 不知道它存在
pluginContext.Events.On("tick", HandleTick);
第一行经 Context.On 订阅,并把 subscription disposer 同步绑进当前 scope。第二行直接碰共享 event bus;如果调用者忘记保存并释放返回值,插件卸载后旧 delegate 仍可能被调用,也会继续引用旧 plugin 对象。
这就是纪律的含义:不是“最好记得 Dispose”,而是 API 在发布资源的同一刻就登记 cleanup。
源码放大镜:四个阶段
在 src/Cordis/EffectScope.cs 中找这些入口:
| 阶段 | 公开信号 | 你要观察什么 |
|---|---|---|
| 注册 | OnDispose / Context.Effect | scope 和所有祖先都必须仍为 Active |
| 叫停 | Cancellation | disposal 开始时取消;新 effect、fork、plugin 被拒绝 |
| 回收 | DisposeAsync | 子 scope 先于自己;同层按 LIFO;异步 disposer 被等待 |
| 结束 | State == Disposed | 重复调用观察同一个 cleanup 结果 |
OnDispose 返回的 IDisposable 只会把某一项从清单里提前摘除,并不会调用它。相似地,Context.On 返回 subscription 允许提前退订;即使调用方没保存它,scope disposal 仍是兜底。
失败不是“做到一半就走”
若一个 disposer 抛异常,后续 disposer 仍会执行。EffectScope 在所有清理 settled 后以 AggregateException 报告失败。否则最早失败的一项会阻止剩余资源退出,留下更难诊断的泄漏。
动手实验:先写顺序,再看断言
实验目标:预测父子 scope 的清理顺序,并确认 listener、Cancellation 与异常不会绕过 scope 规则。
1. 预测
不要先看断言。根据下列注册代码,写出 order 的最终内容:
var ctx = new Context();
var order = new List<string>();
ctx.Effect(() => order.Add("first"));
ctx.Effect(() => order.Add("second"));
var fork = ctx.Fork();
fork.Effect(() => order.Add("child"));
await ctx.DisposeAsync();
再预测三件事:
- fork 上的 scoped listener 在 fork disposal 后还会响应吗?
- disposal 开始后再
Effect(...)会被忽略、立即执行,还是抛异常? second抛异常时,first是否仍会执行?
2. 运行
dotnet test tests/Cordis.Tests/Cordis.Tests.csproj \
--filter FullyQualifiedName~EffectScopeTests
3. 观察
对照 tests/Cordis.Tests/EffectScopeTests.cs:
- 精确顺序是
child → second → first。 - fork disposal 会从共享 Events 摘掉由 fork 注册的 listener。
Unloading与Disposed都拒绝新 effect、fork、listener 和 plugin,抛ObjectDisposedException。- cancellation token 在 disposal 开始时被取消。
- disposer failures 被聚合,但不会阻止其他 disposer 执行。
4. 解释
子 scope 可能使用父 scope 较早注册的服务或基础资源,所以孩子先清。一个 scope 内后注册的 effect 往往依赖先注册的 effect,所以 LIFO。先关闭依赖者,再关闭被依赖者,可以减少“清理时资源已经不在”的错误。
小心三个相近但不同的时刻
Cancellation requested
↓
work quiesced(不再产生新 effect)
↓
all cleanup settled
它们不是同一时刻。收到 cancellation 的异步 apply 需要结束;EffectScope 会等待关联 work 静止,再完成 cleanup。Cancellation.IsCancellationRequested == true 不能替代 await DisposeAsync()。
检查理解
1. 为什么父 scope 不先释放自己的 disposer,再清子 scope?
查看答案
子 scope 的资源可能依赖父 scope 先注册的资源。先清父项会让子 cleanup 在缺少依赖的环境里运行。Cordis 因而先回收 children,再发本 scope 的 dispose event,最后 LIFO 执行本 scope disposer。
2. 已经拿到 Context.On 返回的 IDisposable,是否还需要 scope 兜底?
查看答案
需要。返回值允许业务代码提前退订,但插件作者可能不保存、也可能在异常路径漏掉调用。注册时同时绑定 scope,才能保证正常卸载、apply 失败和父级退出都覆盖到。
3. 一个 disposer 失败后为什么还要运行剩余 disposer?
查看答案
清理项彼此可能独立。立即停止只会把一个可见错误变成多个隐藏泄漏。全部 settled 后聚合失败,既保留错误可见性,也尽可能释放资源。
本周带走
- EffectScope 把资源注册与 cleanup 所有权原子地绑在一起,是 plugin unload 与 hot reload 的地基。
- disposal 先取消、再清 children,最后对 own effects 做 LIFO,并等待异步 cleanup。
- 提前摘除只是便利接口;scope disposal 才是不可缺少的生命周期兜底。
下一周会把“共享 Registry”拆成 runtime service seam,并看到 Provider 离开为何会让 Consumer 自动停用。深入语义可查插件与 Context。