Grok Bot 几个子 Bot 抢同一份文件 / 先看写入顺序还是加锁?

文章导读
顺序和加锁不是二选一,先看产物被破坏的形态:如果新版本把旧版本整段盖掉、文件结构还完整,问题多半出在写入顺序或最后一次写的覆盖;如果文件里出现半句拼接、同一段落两种措辞互相咬合,那通常是两个写操作同时落盘、互相穿插,光排队不做互斥也躲不掉。判断清楚之后再决定是让子 Bot 各写各的分片、最后合并,还是对共享目标加一道串行队列。
📋 目录
  1. 在产物历史里找出被覆盖的那一次写入
  2. 让每个子 Bot 只写自己那份中间产物
  3. 必须共享写时加一道串行队列
  4. 复现一次并发写来验证改动
  5. 把并发写入约定写进任务模板
A A

顺序和加锁不是二选一,先看产物被破坏的形态:如果新版本把旧版本整段盖掉、文件结构还完整,问题多半出在写入顺序或最后一次写的覆盖;如果文件里出现半句拼接、同一段落两种措辞互相咬合,那通常是两个写操作同时落盘、互相穿插,光排队不做互斥也躲不掉。判断清楚之后再决定是让子 Bot 各写各的分片、最后合并,还是对共享目标加一道串行队列。

先分类型再选手段:产物整段被替换、后写的版本完整覆盖前一个,通常属于写入顺序问题,用串行队列或固定合并顺序就能收敛;如果记录里出现半句拼接、同一段落两种措辞交替,多半是两个写操作在文件内交错,需要让每个子 Bot 只写自己的分片再统一合并。两者可以同时用,边界是:分片降低了共享写的必要性,但不能替代对最终合并动作的互斥。

在产物历史里找出被覆盖的那一次写入

先把前后两个可比的版本拿出来,逐段对齐看差异落在哪里。常见的两种形态在记录上表现不同:

  • 覆盖型:差异集中在某一段或某几段,被换掉的内容是一整块,替换后的内容本身语义完整。这说明两次写都是完整写入,只是后一次把前一次的结果顶掉了。定位方法是看该段落后一次写入的时间戳,通常能在子 Bot 的日志里找到一条对应的“写入完成”记录,两条记录的时间间隔会非常近。
  • 交错型:差异位置碎、断句处出现半句、标点错位,甚至同一句话里混进两个子 Bot 各自的措辞。这不是谁覆盖谁,而是两个写句柄同时在同一偏移量附近追加或改写。定位方法是对比写入日志里的开始与结束时间区间,两个区间有明显重叠的那一段就是冲突现场。

如果产物有版本历史或备份,建议按时间顺序排一遍,找出内容第一次变得“不像任何一个子 Bot 单独产出”的那一版,冲突时间点基本就在它前一版到它之间。没有版本历史时,可以先用 ls -l `--time-style`=full-iso 看修改时间,再配合子 Bot 日志里的任务 ID 交叉确认。

让每个子 Bot 只写自己那份中间产物

能用分片就不要共享写。约定一个带序号或子 Bot 标识的命名规则,让每个子 Bot 的输出落在彼此不重叠的路径上,合并只在一个地方做:

Grok Bot 几个子 Bot 抢同一份文件 / 先看写入顺序还是加锁?
out/
  part-01-<botA>.md
  part-02-<botB>.md
  part-03-<botC>.md
  merged.md          # 只由合并步骤产生

命名里带固定宽度序号,排序即最终顺序,避免靠修改时间决定先后。合并前设两个校验点:分片非空、分片开头符合约定标记(例如都有段落标题或固定前缀)。合并脚本骨架可以这样写,注意先写临时文件再原子替换,避免合并过程本身被读到半成品:

import glob, os
parts = sorted(glob.glob('out/part-*.md'))
tmp = 'out/merged.md.tmp'
with open(tmp, 'w') as w:
    for p in parts:
        text = open(p).read()
        if not text.strip():
            raise SystemExit('空分片: ' + p)      # 校验点一
        if not text.startswith('## '):
            raise SystemExit('分片头缺失: ' + p)  # 校验点二
        w.write(text)
os.replace(tmp, 'out/merged.md')  # 原子替换,读到的是完整文件

分片方案的前提是产物可以拆分。如果最终产物必须是一份被连续追加的日志或状态文件,分片名就失去意义,这时直接进入下一节的排队。

必须共享写时加一道串行队列

共享写的最简做法是用一个原子操作占位。以目录创建为例,mkdir 在本地文件系统上是原子的,成功即持锁,失败即有人在写:

import os, time

def run_with_lock(name, timeout=30):
    lock = '/tmp/bot-' + name + '.lock'
    deadline = time.time() + timeout
    while True:
        try:
            os.mkdir(lock)          # 成功即持锁
            break
        except FileExistsError:
            if time.time() > deadline:
                return 'timeout'    # 超时分支,交给调用方决定
            time.sleep(0.5)
    try:
        do_write()                  # 临界区:只有这里碰共享产物
        return 'ok'
    finally:
        os.rmdir(lock)              # 必须释放,异常路径也要走到

超时后的处理要有明确分支,常见选择有三种:放弃本次写入并把任务标记为待重试;退化成“只写自己的分片、不参与合并”,留到下一轮再并;或者记录冲突并转人工确认。不要静默超时,否则问题会从覆盖变成丢内容,更难查。

Grok Bot 几个子 Bot 抢同一份文件 / 先看写入顺序还是加锁?

判断串行是否真的生效,看调用方记录即可:同一产物路径上,各子 Bot 的“开始写入 / 结束写入”时间区间应当不再重叠,等待时间应当出现在持锁之前而不是写入中间。若日志里仍能看到区间重叠,先确认所有写路径都走了同一把锁,而不是有的分支绕过了它。

复现一次并发写来验证改动

改完之后不要只看单次结果,人为制造一次并发:

  1. 清空产物目录,确认 merged.md 不存在。
  2. 在同一秒内触发两个子 Bot(例如同一条命令加不同参数,或用两个终端几乎同时回车),让它们的写入窗口尽量靠近。
  3. 等两者都结束后,检查产物长度与段落顺序:长度应当接近各分片之和,段落顺序应当与分片命名顺序一致,不出现半句。
  4. 再看持锁日志或分片文件,确认两个子 Bot 的时间区间要么不重叠,要么各自只落在互不相同的分片上。

建议连着触发几轮,因为时序问题往往不是每次都出现。如果仍然出问题,先判断是哪一类:产物完整但内容属于某一个子 Bot,说明合并顺序或最后一次替换仍在覆盖,检查是否还有绕过合并的直写路径;产物出现半句或断行,说明互斥没覆盖到真正的写动作,检查锁的粒度和释放时机;分片缺失,则更可能是任务本身失败,先看子 Bot 自己的报错,而不是继续加锁。

Grok Bot 几个子 Bot 抢同一份文件 / 先看写入顺序还是加锁?

把并发写入约定写进任务模板

把这次踩到的约定固化下来,下一次派发任务时直接填,不用临场判断。模板里至少声明四个字段:

  • 产物路径:写清是分片目录还是共享文件,路径写全,避免子 Bot 各自拼路径。
  • 是否独占:标记该产物是否同一时间只允许一个写入方;独占的必须走串行队列,非独占的走分片。
  • 合并方式:按命名顺序拼接、按键合并,还是只追加;写明由谁触发合并。
  • 冲突时的处理选择:等待、跳过本轮、写分片留待下轮,或转人工确认,选一个默认值。

模板示例可以很短:

产物路径: out/part-{seq}-{bot}.md
是否独占: 否(合并步骤 out/merged.md 独占)
合并方式: 按 seq 排序拼接,合并前校验非空与分片头
冲突处理: 等待 30s,超时则保留分片跳过合并

约定写在模板里,新加子 Bot 时就不会再撞同一份文件;需要改行为时也只需要改模板字段,而不是逐个翻子 Bot 的代码。