来了八次更新,只留下一次
目标
先对齐起跑线,把竞争条件变成可以复现的事实,然后用排他锁防止丢失更新,用原子性创建堵上先检查后使用的缺口,用原子性重命名替换整体写入的文档。最后亲自确认,在获取锁之前清空文件的失误会毁掉什么。
为什么重要
竞争条件常常以“偶尔数字对不上”的形式被报告。因为一个人单独跑永远是对的,所以调查在复现这里被卡住。但如果先把进程全部启动起来,再用一个出发标志让它们同时出发,竞争就不再是概率,而成了总会发生的事。从能够复现的那一刻起,它就是可以修复的缺陷。
防止的方法随症状的形态而异。读取再修改写回的区域用锁绑在一起,先检查后使用改为一次调用,整体写入的文件用重命名替换。如果把这三者混用,即使加了锁,症状也依然存在。
最常踩的陷阱是锁的范围。open(path, "w") 会在打开的瞬间清空文件,所以在下一行才获取锁,已经晚了。这个失误出现在加锁的代码里,所以存活得更久。
评分器不会相信你的说明。评分器会用自己的 harness 实际同时启动你的 worker,并亲自统计最终值和胜者数。进程数每次运行都会变,所以无法把值背下来填进去。
步骤
- 创建并运行 /root/race/gen_race.py,生成 /root/race/spawn.py(对齐起跑线的 harness)。
- 用 /root/race/naive_add.py 同时发起没有锁的更新,并在 /root/race/lost.json 中写下丢失的更新。
- 用 /root/race/locked_add.py 在排他锁内做同样的事,并写入 /root/race/lock.json。
- 用 /root/race/claim.py 堵上先检查后使用的缺口,并在 /root/race/claim.json 中写下胜者和败者。
- 用 /root/race/safe_write.py 和 /root/race/read_probe.py 防止部分写入,并写入 /root/race/atomic.json。
- 用 /root/race/append_line.py 在不清空文件的情况下追加,并写入 /root/race/append.json。
- 把两个版本各运行多次,在 /root/race/evidence.json 中留下可以复现的证据。
- 用 /root/race/summary.json 和 /root/race/race_report.md 分四节进行报告。
参考
- harness 约定:
python3 /root/race/spawn.py --worker <스크립트> --procs <수> --barrier <파일> -- <워커 인자...>(占位符依次为脚本、数量、文件、工作进程参数)会按该数量启动 worker,等全部就绪后创建标志文件让它们同时出发,然后输出一个包含 procs、exit_codes 的 JSON。 - worker 约定:所有 worker 都会收到
--barrier <파일>(占位符为文件)。在等待之前留下就绪标志<barrier>.<pid>.up,然后一直等到标志文件出现才开始工作。harness 会统计这些就绪标志,等全部到齐后才让它们出发——靠等几秒钟的做法,会在负载高的日子漏掉启动得晚的进程,使复现失效。 - 更新 worker:
--file <카운터 파일> --barrier <파일> [--work-ms <밀리초>](占位符依次为计数器文件、文件、毫秒数)。计数器文件中有一个整数,worker 把这个值加一。默认的工作时间是 300 毫秒。 - 先占 worker:
--dir <디렉터리> --barrier <파일> --owner <이름>(占位符依次为目录、文件、名称)。赢的一方以退出码 0 结束,并把 owner 写入该目录的lease文件。输的一方以 9 结束。 - 写入与读取:
python3 /root/race/safe_write.py --file <파일> --rounds <횟수> --entries <개수>(占位符依次为文件、次数、数量)和python3 /root/race/read_probe.py --file <파일> --reads <횟수> [--out <json>](占位符依次为文件、次数、JSON 文件)输出一个包含 file、reads、bad_reads 的 JSON。 - 追加 worker:
--file <파일> --barrier <파일> --line <문자열>(占位符依次为文件、文件、字符串)。原有的行保持不动,只追加自己的一行。 - 锁是建议性的。不获取锁就直接打开文件的程序是拦不住的,所以操作同一个文件的所有代码都必须遵守同一个约定。
- 原子性重命名只有在临时文件位于同一个文件系统时才成立。在同一个目录中创建最为简单。
- 常见错误:在加锁之前用
"w"打开,把读取放在锁外,让每个进程锁不同的文件,在/tmp中创建临时文件后再移到其他位置。 - 不要编写负载测试。Pod 为 2 核,每次评分的预算是 60 秒。八个进程就足够了。
拿到对齐起跑线的 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 中分四节进行报告:## 무엇을 잃었나、## 왜 잃었나、## 무엇으로 막았나、## 남은 위험(韩文,依次意为“丢失了什么”“为什么丢失”“用什么防止”“剩余风险”)。
客户买的不是“我们加了锁”,而是“八笔中有七笔消失了,现在八笔全部保留”。请把数字原样写下。建议锁的局限也要写进剩余风险。