为什么媒体源不能在回调仍执行时提前报告 finish?

5 阅读6分钟

问题与范围

在 Swift / AVFoundation 的预览、录制或导出管线里,媒体源读到文件末尾后很容易立刻通知下游 finish。这在串行、没有在途回调的理想场景中看似成立;一旦帧交付发生在异步或并发 callback queue 上,最后一帧可能仍在执行,甚至还有已排队但尚未开始的交付。

若下游据此关闭写入器、释放预览资源或把任务标为完成,随后抵达的回调就会变成丢帧、重复完成或状态污染。本文只讨论媒体事件的终态时序合同;不把源码测试扩展为真机、性能、长视频或端到端导出验证。

先给结论

  • 媒体源不再产生新帧,与本次运行可以安全完成,是两个不同阶段。
  • finish 应在活跃交付结束、排队帧按合同处理之后只报告一次。
  • 取消、完成和失败是一次 run 的终态;旧 run 的迟到回调不能改写它。
  • 重新处理时创建新的 pipeline、processor chain 与 sink,比复用终态对象更容易保持并发边界可验证。

finish 是一项收口承诺

媒体源的完成至少同时涉及三件事:源不会再产生新事件;此前已经接受的事件完成了交付或被明确丢弃;本次运行进入不可逆终态。它们并不总在同一刻发生。

假设一个资产源已经读完文件,但最后一帧的 delegate 回调仍在另一个队列里执行。若此时先通知完成,事件顺序就可能变成:

frame A
finish
frame B

对预览,结果可能停在较早画面;对导出,尾部样本可能落在写入器关闭之后。这里的问题不是 finish 这个名称,而是它向调用方表达的语义太弱:调用方真正需要知道的是“这次运行不会再改动我的结果”。

更可靠的顺序是:

请求停止
→ 阻止新的交付
→ 等待已经开始的交付返回
→ 按合同丢弃尚未开始的排队帧
→ 只发送一次 finish
→ stop 返回

这不是要求无条件等待所有工作。关键在于区分 active delivery 和 queued delivery:前者已经访问了本次运行的状态与资源,必须完成或走协议化取消;后者可以被丢弃,但这种丢弃必须发生在完成承诺之前。

不要用延迟代替顺序保证

在并发 callback queue 中,源可能依次提交多个 frame event 和一个 completion event。提交次序并不自动等于实际执行次序;单纯给 finish 加一点延迟,也不能证明所有 frame callback 都已退出。

可复用的选择通常是串行交付队列、在途计数、dispatch group 或等价的收口屏障。无论选哪一种,finish 都应建立在可检查的 happens-before 关系上:已经接受的帧先完成交付,再允许完成事件抵达下游。

Kakapos 的 AssetSource 将 callback 投到专用 delivery queue,并用 delivery fence 包住实际 delegate 调用。其集成测试覆盖了两个调用方关心的观察结果:活跃 output 正在执行时 stop() 不会提前返回;自然完成时,所有 frame event 都先于唯一一次 finish。这比仅断言状态最终变为 finished 更能固定时序合同。

终态必须拥有状态的最后解释权

第二个陷阱是迟到回调。一次运行已经取消并公开报告 cancelled 后,旧处理器仍可能稍后返回 failure。若这个 failure 继续写入共享 lastError,UI 就会把取消误读为失败;如果终态对象还能再次 start,旧 run 的回调还可能干扰新的工作。

应把状态机收紧为一次运行的单向转移:

running → finished
running → cancelled
running → failed

终态之后的普通事件不再有改变结果的权限。Kakapos 的 MediaPipeline 明确把 finished、cancelled 与 failed 定义为不可逆终态。测试还验证:取消一个含延迟处理回调的 pipeline 后,再次 start() 不会二次启动 source;旧 run 后来的 failure 也不会改写 cancelled 或填入最后错误。

所以“只发一次 finish”还不够。每个回调还需要属于某一次 run,状态更新前要确认它仍拥有这次运行的交付权。

GPU 提交也需要同样的终态纪律

这不只属于媒体源。逐帧 GPU 渲染任务同样会在异步队列中完成:上层取消后,底层可能正要回传结果。若重复取消导致多次 failure,或取消后的成功仍交付给上层,调用方就无法可靠地决定页面、预览或任务状态。

Harbeth 的 ImageNode.transmitFrame 返回可取消的 RenderSubmissionHandle。当前 XCTest 验证了对同一 handle 重复 cancel() 后,只交付一次终态失败,并在快照中保留 cancelled / callerCancelled。makeFrameAsync() 则通过 Swift Task 的取消处理器把取消转发到底层 handle。

Harbeth 在这里是独立的 Apple GPU 图像与逐帧处理案例;Kakapos 仍负责媒体生命周期与编排。二者并非绑定,但都说明同一个原则:取消是状态转移,不是可以重复广播的普通信号;终态原因需要保留,迟到完成不能覆盖已经确认的终态。

如何验证这种合同

测试重点不应只放在“最后收到了 finish”。更有价值的是验证可观察顺序:

  • 活跃 callback 执行时调用 stop,确认 stop 不提前返回;
  • stop 返回后,不再有新的 output;
  • 自然结束时,所有 frame event 都先于唯一 finish;
  • 重复 cancel 不产生多次终态回调;
  • 终态后的迟到 failure 不覆盖 cancelled;
  • 终态对象的 start 不会重新启动 source。

这些 XCTest 证明的是并发合同,不是所有设备、编码配置或压力下的媒体体验。只有先把事件顺序固定,后续的真机、性能和导出验收才有清晰的判断基础。

小结与讨论

finish 不是“暂时没看到更多数据”,而是“本次运行已经收口”的承诺。阻止新事件、等待活跃交付、隔离迟到回调,再把完成限制为一次 run 的终态,能让预览、录制与导出上层不必猜测资源是否还会被访问。

你的媒体管线会用串行队列、在途计数,还是独立的 run identity 来建立这个完成边界?

源码与参考实现