第 6 周 · Tether 源码课
事件与异步:同一组 listener 的四种走法
学完你能做到
- 根据需求选择 Emit、Parallel、Serial/Bail 或 Waterfall
- 预测 listener 的调用顺序、返回值与短路点
- 解释普通 dispatch 与 contained notification 的异常差别
- 用 EventsTests 验证并发等待、错误聚合与 next 链
课程进度
- 第 1 周
- 第 2 周
- 第 3 周
- 第 4 周
- 第 5 周
- 第 6 周
- 第 7 周
- 第 8 周
- 第 9 周
- 第 10 周
- 第 11 周
- 第 12 周
- 第 13 周
- 第 14 周
- 第 15 周
- 第 16 周
一句话先懂
事件名只决定“叫谁来”,dispatch mode 才决定“按什么规则叫、等多久、何时停止”。
Cordis 对同一组 listener 提供顺序通知、并行等待、首个命中和嵌套接力;异常默认直接回到调用方。
“发布一个事件”不是完整规格。若不说明顺序、返回值、失败和等待,两个实现即使 API 名相同,也可能产生完全不同的系统行为。
先看四幅小图
四个生活场景,只借它们记规则
- 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 | 注册顺序成为由外到内的 wrapper | outermost 的最终结果 | 沿 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_listenersParallel_waits_for_every_listener_and_aggregates_every_failureSerial_stops_at_first_bailed_value_and_returns_itWaterfall_nests_listeners_around_the_terminalWaterfall_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 对照可查事件系统。