TT Lab
开始
学习 学习路径 课程

调试实战

来了八次更新,只留下一次

在 TT Lab 中继续学习

目标

先对齐起跑线,把竞争条件变成可以复现的事实,然后用排他锁防止丢失更新,用原子性创建堵上先检查后使用的缺口,用原子性重命名替换整体写入的文档。最后亲自确认,在获取锁之前清空文件的失误会毁掉什么。

为什么重要

竞争条件常常以“偶尔数字对不上”的形式被报告。因为一个人单独跑永远是对的,所以调查在复现这里被卡住。但如果先把进程全部启动起来,再用一个出发标志让它们同时出发,竞争就不再是概率,而成了总会发生的事。从能够复现的那一刻起,它就是可以修复的缺陷。 防止的方法随症状的形态而异。读取再修改写回的区域用锁绑在一起,先检查后使用改为一次调用,整体写入的文件用重命名替换。如果把这三者混用,即使加了锁,症状也依然存在。 最常踩的陷阱是锁的范围。open(path, "w") 会在打开的瞬间清空文件,所以在下一行才获取锁,已经晚了。这个失误出现在加锁的代码里,所以存活得更久。 评分器不会相信你的说明。评分器会用自己的 harness 实际同时启动你的 worker,并亲自统计最终值和胜者数。进程数每次运行都会变,所以无法把值背下来填进去。

步骤

  1. 创建并运行 /root/race/gen_race.py,生成 /root/race/spawn.py(对齐起跑线的 harness)。
  2. 用 /root/race/naive_add.py 同时发起没有锁的更新,并在 /root/race/lost.json 中写下丢失的更新。
  3. 用 /root/race/locked_add.py 在排他锁内做同样的事,并写入 /root/race/lock.json。
  4. 用 /root/race/claim.py 堵上先检查后使用的缺口,并在 /root/race/claim.json 中写下胜者和败者。
  5. 用 /root/race/safe_write.py 和 /root/race/read_probe.py 防止部分写入,并写入 /root/race/atomic.json。
  6. 用 /root/race/append_line.py 在不清空文件的情况下追加,并写入 /root/race/append.json。
  7. 把两个版本各运行多次,在 /root/race/evidence.json 中留下可以复现的证据。
  8. 用 /root/race/summary.json 和 /root/race/race_report.md 分四节进行报告。

参考

拿到对齐起跑线的 harness

创建并运行 /root/race/gen_race.py,生成 /root/race/spawn.py。这个 harness 会启动多个 worker,然后用一个标志文件让它们同时出发。

竞争条件调查被卡住的地方就是复现。靠手在两个终端之间来回敲,很难在同一时刻进入。请把这个 harness 原样保存并运行。worker 还没有,下一步再创建。

看着八次变成一次

创建 /root/race/naive_add.py(读取、工作 300 毫秒、加一后写回),用 8 个进程同时运行,并在 /root/race/lost.json 中写入 procs、expected、observed、lost、work_ms。计数器文件从 0 开始。

worker 必须在等待之前留下就绪标志(<barrier>.<pid>.up),然后等到 --barrier 文件出现才开始。在读取与写入之间休息 --work-ms 这么久,其间别的进程就会读走同一个值。看到结果不要惊讶——那正是这一步的目的。

用排他锁把它们合成一个整体

创建 /root/race/locked_add.py,把从读取到写入的全过程放进排他锁内,用同一个 harness 运行 8 个,并在 /root/race/lock.json 中写入 procs、expected、observed、lost、method。observed 必须是 8。

用 fcntl.flock 给文件加 LOCK_EX。在获取锁之前读取值,什么也拦不住,所以要注意顺序。而且如果用 "w" 打开,在获得锁之前文件就会被清空——这里需要读写,所以用 "r+" 打开。

堵上先检查后使用的缺口

创建 /root/race/claim.py,让它先占租约(赢了返回 0,输了返回 9),同时运行 8 个,并在 /root/race/claim.json 中写入 procs、winners、losers、owner_in_lease、method。胜者必须恰好只有一个。

如果用 os.path.exists 检查后再创建,别人会在这期间创建。给 os.open 同时指定 O_CREAT 和 O_EXCL,检查与创建就会一次完成,输的一方会收到 FileExistsError。收到异常是正常行为,这是这种方式的关键。

不再读到写了一半的文件

用 /root/race/safe_write.py 以原子方式替换文档,并用 /root/race/read_probe.py 一边读取一边统计,在 /root/race/atomic.json 中写入 rounds、entries、reads、bad_reads、method。bad_reads 必须为 0,rounds 至少为 50,reads 至少为 100。

如果在写入过程中读取,JSON 解析就会失败,而这个错误看起来像是读取方代码的 bug。把全部内容写入同一目录下的临时文件,再用 os.replace 移过去,读取方就只会看到旧文件或新文件。如果把临时文件创建在另一个文件系统上,原子性就会被破坏。

在加锁之前清空文件的失误

创建 /root/race/append_line.py,让它在不删除原有行的情况下追加自己的一行,并对预先放入几行内容的文件同时运行 8 个,在 /root/race/append.json 中写入 procs、lines_before、lines_after、kept_original、duplicate_lines。

这一步的陷阱不在锁,而在打开方式。"w" 会在打开的瞬间清空文件,所以在下一行才获取锁已经晚了。需要追加的地方要用 "a" 打开,然后再获取锁。lines_after 必须等于 lines_before 加上进程数。

留下可以复现的证据

把没有锁的版本和有锁的版本各用同一个 harness 运行至少 3 次,并在 /root/race/evidence.json 中写入 trials、procs、expected、naive_finals、locked_finals。两个列表的长度必须与 trials 相同。

一次观察可能只是偶然。用同一个 harness 运行多次,把两个列表并排写下,就会看到没有锁的一方总是停留在同一个位置,而有锁的一方总能达到预期值。这就是要给客户看的证据。

报告丢失了什么,又用什么防住了

在 /root/race/summary.json 中写入 procs、expected、naive_observed、locked_observed、claim_winners、bad_reads、lines_after,并在 /root/race/race_report.md 中分四节进行报告:## 무엇을 잃었나、## 왜 잃었나、## 무엇으로 막았나、## 남은 위험(韩文,依次意为“丢失了什么”“为什么丢失”“用什么防止”“剩余风险”)。

客户买的不是“我们加了锁”,而是“八笔中有七笔消失了,现在八笔全部保留”。请把数字原样写下。建议锁的局限也要写进剩余风险。