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

调试实战

数字对不上,原因是顺序

在 TT Lab 中继续学习

一句话总结

竞争条件不是“偶尔发生的事”,而是对齐起跑线之后总会发生的事,一旦能够复现,它就成了可以修复的缺陷。

为什么需要它

“昨天的结算少了 3 笔。”日志里没有错误。读代码,一切正常。运行一次,结果也是对的。这类报告中有相当一部分是竞争条件,它们有一个共同点——一个人单独跑,永远是对的。

最常见的形态是丢失更新(lost update)。进程读取一个值,用这个值做点什么,再加一后写回。如果在这期间另一个进程读走了同一个值,后写入的一方就会覆盖前面的结果。进来两笔,却只留下一笔。如果十笔完全重叠地进来,九笔就会消失。

调查在这里被卡住,原因通常是复现。两个进程必须在完全相同的时刻进入这段区域,而靠手在两个终端之间来回敲,这种事很难发生。所以要做出起跑线。先把所有进程都启动起来,等它们全部进入等待状态后,再创建一个出发标志,从那一刻起,竞争就不再是概率,而是可以复现的事实。

工作原理

防止的方法随问题的形态而异。只要区分三种情形,大部分就理清了。

1. 读取再修改写回的区域——用锁把它们绑在一起。必须把读取和写入合成一个整体。在 Python 中,用 fcntl 模块的 flock 给文件加排他锁。这种锁是建议性的(advisory)——不获取锁就直接打开文件的程序,可以毫无阻碍地写入。所以操作同一个文件的所有程序都必须遵守同一个约定。

这里有一个非常常见的陷阱。open(path, "w") 会在打开的瞬间清空文件。在下一行才获取锁,已经晚了。需要读写的地方,应当用 "r+" 打开;需要追加的地方,应当用 "a" 打开,然后再获取锁。

2. 先检查再执行的操作——把检查和执行合为一步。“如果文件不存在就创建它”“如果租约是空的就占用它”,两者之间存在缺口。消除这个缺口的方法是把创建本身当作判定。POSIX 的 open 规定,如果同时给出 O_CREAT 和 O_EXCL,文件已存在时就会失败,而这个检查与创建是原子性的。在 Python 中是 os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY),失败会以 FileExistsError 的形式到来。输的一方收到异常,是正常行为。

3. 整体重写的文件——以原子方式替换。写入配置文件或状态快照期间,如果另一方来读取,就会看到写了一半的内容。如果是 JSON,就会出现解析错误,而这个错误看起来像是读取方代码的 bug。解法是先把全部内容写入同一目录下的临时文件,然后重命名。POSIX 的 rename 规定,即使新名称已经存在,这种替换也是原子性的,在此期间任何时刻名称都不会消失。在 Python 中是 os.replace。如果忘了临时文件必须位于同一个文件系统这个条件,就会悄悄变成复制,原子性随之被破坏。

증상                         틈이 있는 자리            막는 방법
합계가 모자란다              읽기와 쓰기 사이          배타 잠금으로 한 덩어리
둘 다 자기가 주인이라 한다   확인과 생성 사이          O_CREAT 와 O_EXCL
읽는 쪽이 파싱에 실패한다    쓰는 도중의 파일          임시 파일에 쓰고 rename
파일이 비어 있다             잠금 전에 "w" 로 열기     "r+" 또는 "a" 로 열기

该代码块中的韩文说明依次为:合计不足——缺口在读取与写入之间,用排他锁合成一个整体;两方都说自己是主人——缺口在检查与创建之间,用 O_CREAT 与 O_EXCL;读取方解析失败——缺口在写入过程中的文件,写入临时文件后 rename;文件是空的——缺口在加锁前用 "w" 打开,改用 "r+" 或 "a" 打开。

在现场相遇的样子

第一,放弃复现,对着代码死盯。用眼睛找出来的竞争条件容易漏掉,即使自以为找到了,也无法确定它是否就是那个症状的原因。先做出起跑线,把症状拿到手再修复,那么修好了这件事也能用同样的方法来证明。

第二,加了锁,却依然如故。常见的两个原因是锁的范围和对象。如果读取在锁外进行,只有写入在锁内,就什么也拦不住。也有人锁的是不同的文件,或者每个进程锁的是各自不同的临时文件。

第三,只有一方守规矩。建议锁只有在所有人都遵守时才有意义。只要有一个运维脚本用 > 覆盖写入,当天所有的锁就全都失去意义。所以锁的约定不是靠代码,而是靠文档和评审来遵守的。

第四,以为只有一个核就不会出问题。进程随时可能被抢占,所以无论有几个核,读取和写入之间都可能有别的进程插进来。实际上,即使在 2 核的 Pod 里,让八个进程在同一时刻出发,也只会留下一次更新。

实际工作中真正重要的事

下一项实验要做什么

拿到对齐起跑线的 harness,亲眼确认没有锁的八次更新只留下一次。在排他锁内做同样的事,看到全部留下,再用 O_EXCL 堵上先检查后使用的缺口,使胜者永远只有一个。以原子方式替换整体写入的文档,使读取方的解析错误降为 0,确认在加锁之前清空文件的失误会如何毁掉数据,最后把证据汇总成一页纸进行报告。