第 6 周 · Tether 源码课

事件与异步:同一组 listener 的四种走法

预计 90 分钟 先修:第 4 周:EffectScope、会 async/await 与 Task.WhenAll

学完你能做到

  • 根据需求选择 Emit、Parallel、Serial/Bail 或 Waterfall
  • 预测 listener 的调用顺序、返回值与短路点
  • 解释普通 dispatch 与 contained notification 的异常差别
  • 用 EventsTests 验证并发等待、错误聚合与 next 链

课程进度

  1. 第 1 周
  2. 第 2 周
  3. 第 3 周
  4. 第 4 周
  5. 第 5 周
  6. 第 6 周
  7. 第 7 周
  8. 第 8 周
  9. 第 9 周
  10. 第 10 周
  11. 第 11 周
  12. 第 12 周
  13. 第 13 周
  14. 第 14 周
  15. 第 15 周
  16. 第 16 周

一句话先懂

事件名只决定“叫谁来”,dispatch mode 才决定“按什么规则叫、等多久、何时停止”。

Cordis 对同一组 listener 提供顺序通知、并行等待、首个命中和嵌套接力;异常默认直接回到调用方。

“发布一个事件”不是完整规格。若不说明顺序、返回值、失败和等待,两个实现即使 API 名相同,也可能产生完全不同的系统行为。

先看四幅小图

EmitAsync · 顺序通知 A B C 全部按注册顺序;忽略返回值 B 失败 → C 不运行,错误抛给 caller ParallelAsync · 并行小组 A B C 同时启动;等全部 settled;聚合全部失败 Serial / Bail · 首个命中 Afalse B"hit" C 跳过 null / false 继续;任何其他值都停止 0、空字符串也算命中 Waterfall · 嵌套接力 outer before inner → terminal → inner outer after 不调用 next() → 内层与 terminal 都不运行
四种 mode 不是性能开关,而是四份不同的调用方可观察契约。

四个生活场景,只借它们记规则

  • Emit 像按名单逐个通知:A 处理完才到 B,所有人的返回便条都丢掉。
  • Parallel 像把题目同时发给三个小组:等每组交卷,失败也要收齐再汇报。
  • Serial / Bail 像依次问“谁能处理?”:null 或 false 表示没接,首个其他值认领后停止。
  • Waterfall 像多层审阅:外层决定要不要把材料交给 next(),也能在内层返回后加工结果。

类比边界:人际场景无法表达 delegate 参数检查、ValueTask 调度、AggregateException 的组成或 downstream exception recovery。它们只帮助记住控制流形状。

精确契约对照表

API启动顺序返回值listener failure
EmitAsync注册顺序,一个完成再下一个忽略立即传播;后续不运行
ParallelAsync对 snapshot 中所有 listener 发起调用无业务结果等全部 settled 后抛含全部失败的 AggregateException
SerialAsync注册顺序首个非 null 且非 false立即传播;后续不运行
BailAsync与 Serial 相同首个命中,可用 typed overload立即传播;后续不运行
WaterfallAsync注册顺序成为由外到内的 wrapperoutermost 的最终结果沿 next() 链向外传播,外层可显式处理

SerialAsync 与 untyped BailAsync 在当前 Cordis 中语义相同,保留两个名字是为了对齐 Cordis 词汇。不要编造“Serial 一定跑完、Bail 才短路”这样的差异。

源码放大镜:snapshot 与 scope

src/Cordis/Events.cs 的每次 dispatch 先取得当前 listener snapshot。新增或移除 listener 会影响后续 dispatch,不会让当前 foreach 的集合在半途中改变。

订阅应优先从当前 Context 进入:

pluginContext.On<string>("message", text => Handle(text));
await pluginContext.EmitAsync("message", "hello");

Context.On 把 subscription 与当前 EffectScope 绑定;plugin deactivation 后,listener 自动离开共享 bus。

普通失败与 contained notification

普通 EmitAsync、SerialAsync、BailAsync、WaterfallAsync 的 listener 异常都直接使 dispatch 失败,不会偷偷改发 internal/error。ParallelAsync 也不吞错,只是等全部 listener 后聚合。

Cordis 另有明确选择的 EmitContained,用于无法等待的内部生命周期通知:它会观察失败并尽量路由到 internal/error。这是特殊 API,不是普通 dispatch 的默认行为。

动手实验:把顺序写在运行前

实验目标:先手算四种控制流,再让 EventsTests 证明哪些 listener 真正运行、caller 收到什么。

1. 预测

对下面四组 listener 写出顺序与结果:

Emit:      A → B(throw) → C
Parallel:  A(throw) | B(await then throw) | C(success)
Serial:    A(false) → B("stop") → C("late")
Waterfall: outer(before, next, after) → inner(before, next, after) → terminal(4)
           inner 返回 terminal * 10,outer 返回 inner + 1

特别回答:Waterfall 最终是 41 还是 50?如果 outer 不调用 next(),谁还会运行?

2. 运行

dotnet test tests/Cordis.Tests/Cordis.Tests.csproj \
  --filter FullyQualifiedName~EventsTests

3. 观察

在 tests/Cordis.Tests/EventsTests.cs 中定位这些测试:

  • Emit_failure_propagates_without_error_routing_and_stops_later_listeners
  • Parallel_waits_for_every_listener_and_aggregates_every_failure
  • Serial_stops_at_first_bailed_value_and_returns_it
  • Waterfall_nests_listeners_around_the_terminal
  • Waterfall_listener_can_short_circuit_the_chain

Waterfall 的顺序是:

outer-before → inner-before → terminal → inner-after → outer-after

terminal 返回 4,inner 乘 10 得 40,outer 加 1 得 41。outer 若不调用 next(),inner 与 terminal 都不会运行。

4. 解释

Waterfall listener 不是“排队修改同一个变量”,而是 middleware wrapper。每一层同时掌握进入内层前与返回后的控制权,也可以短路或处理内层异常。这个模型会在后面的 Agent pipeline 中再次出现。

检查理解

1. SerialAsync 的第一个 listener 返回整数 0,会继续吗?

查看答案

不会。bail 判定只跳过 null 与布尔 false;整数 0、空字符串和空集合都属于命中值。

2. Parallel 的一个 listener 立刻失败,另一个仍在等待;dispatch 会立刻抛吗?

查看答案

不会。Parallel 会启动 snapshot 中全部 listener,并等待每一个 settled,最后把所有失败聚合后抛出。这保证慢 listener 的成功、失败和 cleanup 都被观察。

3. 给 internal/error 注册 listener,能否让普通 Emit 的错误被自动吞掉?

查看答案

不能。普通 dispatch 的异常直接传播到 caller,且顺序模式会停止后续 listener。只有调用方显式选择 contained / fire-and-forget 通知时才走错误路由。

本周带走

  • event name 只是通道;dispatch mode 决定顺序、等待、短路、返回与错误。
  • 普通 listener failure 始终可见:顺序模式直接传播,Parallel 全 settled 后聚合。
  • Waterfall 是显式 next() 的嵌套链,不是普通广播。

下一周把这些 plugin、service 与顺序写进 YAML composition。完整 API 对照可查事件系统。

在 GitHub 上编辑此页