流式结束后内容凭空消失:一次时序 race condition 复盘

2439 字
12 分钟
流式结束后内容凭空消失:一次时序 race condition 复盘

这是一个让我花了两次会话才真正收敛的 bug,现象很简单但根因在两层不同的时序问题里,哪一层单独修都不够。

现象#

一个聊天界面,消息支持流式渲染。操作序列是这样的:

  1. 发消息,开始流式输出,流式过程中画面一切正常,字一段一段往外蹦
  2. 流式结束的那一刻,整条消息突然从界面上消失
  3. 这时候你切到另一个会话,再切回来——消息又出现了,而且是完整的

你应该能感觉到,这个现象已经在”偷偷告诉你答案”:最终内容其实存在,只是在某个瞬间被错误地清掉或者没能及时加载上来。切走再切回的动作强制触发了一次重新渲染,内容就回来了。

这是那种最容易被归类为”UI 渲染 bug”的现象,而这一类归类通常会把人带进沟里。

诊断路径#

我走了三层才到真正的根因,把这三层完整写下来——这是这篇文章里唯一真正有价值的部分。

第一层:以为是 UI 渲染问题#

第一反应很自然:流式时好好的,流结束后坏,说明有某个”流式结束”的回调在清理界面。我去找这个回调,找到了类似下面的结构(示意代码,不是原文件):

// 收到 .result 事件(代表流结束)
onResult: {
clearStreaming() // 清掉流式缓冲区
Task { await loadMessages() } // 从持久化文件里重新加载完整消息
}

看着没毛病。clearStreaming 把临时的流式状态清掉,loadMessages 从持久化文件里把完整的消息加载回来,两步衔接刚好把”临时态”换成”最终态”。

但那为什么最终态没出现?我开始怀疑是 loadMessages 内部判重时把”没变化”的情况 early return 了。

第二层:发现是弱 hash#

翻到 loadMessages 内部,看到了这个:

let newHash = records.count
if newHash == lastHash { return } // 没变就不更新
lastHash = newHash
render(records)

看到这一眼我就停了下来。records.count 做变化检测是很典型的弱 hash。它只能检测”数量变了”,检测不了”数量相同但内容变了”。

在这个场景里很容易出问题:流式过程中画面已经显示了 N 条记录(来自流式缓冲区),流一结束从文件里重新加载到的也是 N 条记录(因为它们其实是同一回事)——count 相等,loadMessages 直接 return。问题是 clearStreaming() 却已经执行了。

这就解释了现象:流式缓冲被清掉,真正的加载又被 early return 跳过,结果就是什么都没有。而切走再切回的时候,因为 lastHash 被组件销毁重置了,loadMessages 就会真的执行一次。

看到这里我以为结案了。把 recordsHashrecords.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 秒自检#

这三条留给你下一次看到类似现象时过一遍脑子:

  1. 现象里有没有”切一下就好”、“刷一下就好”的模糊时序线索?有就先当时序问题看
  2. 变化检测的 key 是基于 count 还是基于内容?基于 count 的立刻可疑
  3. 我正在依赖的”事件顺序”有没有显式同步点?没有就不能相信它

这三条不覆盖所有情况,但能让你在动手之前少走几条弯路。

Profile Image of the Author
xt53
Hi
分类
标签
站点统计
文章
10
分类
2
标签
19
总字数
22,614
运行时长
0
最后活动
0 天前

目录