流式结束后内容凭空消失:一次时序 race condition 复盘
这是一个让我花了两次会话才真正收敛的 bug,现象很简单但根因在两层不同的时序问题里,哪一层单独修都不够。
现象
一个聊天界面,消息支持流式渲染。操作序列是这样的:
- 发消息,开始流式输出,流式过程中画面一切正常,字一段一段往外蹦
- 流式结束的那一刻,整条消息突然从界面上消失
- 这时候你切到另一个会话,再切回来——消息又出现了,而且是完整的
你应该能感觉到,这个现象已经在”偷偷告诉你答案”:最终内容其实存在,只是在某个瞬间被错误地清掉或者没能及时加载上来。切走再切回的动作强制触发了一次重新渲染,内容就回来了。
这是那种最容易被归类为”UI 渲染 bug”的现象,而这一类归类通常会把人带进沟里。
诊断路径
我走了三层才到真正的根因,把这三层完整写下来——这是这篇文章里唯一真正有价值的部分。
第一层:以为是 UI 渲染问题
第一反应很自然:流式时好好的,流结束后坏,说明有某个”流式结束”的回调在清理界面。我去找这个回调,找到了类似下面的结构(示意代码,不是原文件):
// 收到 .result 事件(代表流结束)onResult: { clearStreaming() // 清掉流式缓冲区 Task { await loadMessages() } // 从持久化文件里重新加载完整消息}看着没毛病。clearStreaming 把临时的流式状态清掉,loadMessages 从持久化文件里把完整的消息加载回来,两步衔接刚好把”临时态”换成”最终态”。
但那为什么最终态没出现?我开始怀疑是 loadMessages 内部判重时把”没变化”的情况 early return 了。
第二层:发现是弱 hash
翻到 loadMessages 内部,看到了这个:
let newHash = records.countif newHash == lastHash { return } // 没变就不更新lastHash = newHashrender(records)看到这一眼我就停了下来。用 records.count 做变化检测是很典型的弱 hash。它只能检测”数量变了”,检测不了”数量相同但内容变了”。
在这个场景里很容易出问题:流式过程中画面已经显示了 N 条记录(来自流式缓冲区),流一结束从文件里重新加载到的也是 N 条记录(因为它们其实是同一回事)——count 相等,loadMessages 直接 return。问题是 clearStreaming() 却已经执行了。
这就解释了现象:流式缓冲被清掉,真正的加载又被 early return 跳过,结果就是什么都没有。而切走再切回的时候,因为 lastHash 被组件销毁重置了,loadMessages 就会真的执行一次。
看到这里我以为结案了。把 recordsHash 从 records.count 改成了基于内容的哈希,跑起来试——大部分时候好了,但偶尔还是复现。
第三层:更深一层的时序
到这一步我必须承认”我以为的根因”只解决了一部分。这种时候最容易的错误是继续在同一层做补丁。我强迫自己后退一步,把问题重新归类:如果不是 hash 的问题,那一定是”加载时文件内容就是旧的”。
为什么会”文件内容就是旧的”?因为流式事件的传递路径大概是这样的:
CLI 子进程 stdout → 我们的 AsyncStream 解析器 → for try await line in stream { ... } ↘ CLI 进程写 JSONL 文件CLI 进程在输出 result JSON 的同时,也在往 JSONL 文件里追加写入。这两条路径是并行的,没有显式同步点。
我们的解析器一看到 stdout 里出现 result 那一行,就立刻 yield .result 给上层——但此时 CLI 进程还没退出,JSONL 文件的最后几个字节可能还没 flush。上层收到 .result 立即调用 loadMessages 读文件,读到的是一个比真实完成状态更旧一点的版本。
这时就算我把 hash 改对了,loadMessages 确实跑了,它读到的也是旧内容。然后屏幕上显示的就是”少了最后一段”的不完整状态;再切走切回的时候,文件已经写完了,重新加载就拿到了完整内容。
两个根因叠加在一起才形成这个现象:弱 hash 让 early return 有时候跳过真加载,race condition 让真加载有时候读到旧文件。任何一个单独修都会让 bug 变得”更少见但没消失”——这是补丁式修复最典型的征兆。
根因
把两层明确写下来:
根因 1:recordsHash = records.count 作为变化检测太弱。 它只能检测数量变化,在”流式缓冲和持久化内容数量相同”的情形下会错误地 early return,把真正的加载跳过。
根因 2:.result 事件在 CLI 子进程退出之前就被 yield。 stdout 和文件写入是两条并行路径,result JSON 出现在 stdout 上不代表 JSONL 文件已经写完。上层收到 .result 立即读文件会读到一个中间状态。
值得强调一下第二个根因的性质:异步事件顺序不能靠”应该发生的顺序”来推理。CLI 进程的内部逻辑可能是”先 flush 文件再打印 result”,听起来很合理——但操作系统层面,stdout 的 flush 和文件系统的 flush 是两个独立的 syscall,谁先完成不保证。依赖”应该的顺序”就是依赖一个没有被任何机制保证的事实。
修复
两个根因各修一层。
修复 1:把 hash 改成基于内容。
把 records.count 换成真正的内容哈希(比如对每条 record 的关键字段做一次累积 hash)。这一步不难,唯一要注意的是:既然你不信任 count,那整个”是否需要重新渲染”的判断就要基于内容本身,不能在这个基础上再混入 count。
副作用确认:原来依赖 count 作为快速判重,是为了在”真的没变化”时跳过一次渲染节省开销。换成内容 hash 后这个优化还在——因为 hash 计算虽然比 count 贵一点,但比一次真正的 render 便宜得多,且这一步只在收到完成信号时发生一次,开销可以忽略。
修复 2:把 .result 事件延迟到 CLI 进程真正退出后再 yield。
伪代码大概是这样:
// 原来for try await line in stdout { if let result = parseResult(line) { yield .result(result) // 立即 yield }}
// 改后var pendingResult: Result?for try await line in stdout { if let result = parseResult(line) { pendingResult = result // 先缓存 }}// stdout EOF 意味着子进程 stdout 关闭try await process.waitUntilExit() // 等进程真正退出if let r = pendingResult { yield .result(r) // 这时 JSONL 文件已经 flush}关键点是 waitUntilExit()——CLI 进程退出意味着它打开的所有文件句柄都已经关闭,这是操作系统保证的,不是我们猜的。
副作用确认:这个修改会让 .result 事件比原来晚一点点到达。会不会让用户感觉”完成”变慢?实测几乎无感——stdout EOF 到 waitUntilExit 之间通常只有几毫秒(子进程 stdout 关闭后马上就进入退出流程)。比起”内容消失后还得手动切会话”的体验,这个延迟完全可以接受。
通用经验
从这个 bug 里抽出几条我觉得可以带走的原则:
“显示问题”里经常藏着时序问题。 只要现象里有”切走切回就好了”、“刷新一下就行”、“第一次不对第二次对”这类描述,几乎可以直接把它归到时序 / 缓存 / 异步加载这类问题,而不是 UI 渲染。界面没问题,是它拿到的数据不对。
弱 hash 是一种常见的反模式。 凡是用 .count / .length / .size 做”是否有变化”的判断,都值得检查一遍——它只能覆盖一部分 case,剩下那部分会以”偶发诡异”的形式出现。判重要嘛别判,要判就基于内容。
异步事件的”顺序”不能靠上游逻辑推理。 如果你需要”事件 B 必须在事件 A 完成后发生”,就必须有一个显式的同步点(waitUntilExit、信号量、promise chain、synchronization 原语……任意一种)。不能依赖”上游应该是先 A 再 B”,因为上游的先 A 再 B 可能在某个系统层被重排。
多层根因并存是常态,不是例外。 补丁式修复”看起来好多了但偶尔还复现”几乎总是第二层根因的特征。遇到这种情况,后退一步重新归类问题比在同一层继续打补丁更有效。
动手前的 5 秒自检
这三条留给你下一次看到类似现象时过一遍脑子:
- 现象里有没有”切一下就好”、“刷一下就好”的模糊时序线索?有就先当时序问题看
- 变化检测的 key 是基于 count 还是基于内容?基于 count 的立刻可疑
- 我正在依赖的”事件顺序”有没有显式同步点?没有就不能相信它
这三条不覆盖所有情况,但能让你在动手之前少走几条弯路。