← 返回导航站首页

同一个追加写,Linux 丢 0 条、Windows 丢 309 条:并行工具调用里什么能并、什么必须排队

Agent 一晚上要跑几十个工具调用,所有人都知道「能并行就并行」。但几乎没有人检查过:这两条调用是不是在碰同一个文件。

这一篇的全部数字来自本机今天的三组实测。它们拼起来说明一件事——并行是三个不同的问题,混在一起就一定出错:

  1. 声明层:我以为哪个调用读什么、写什么。→ 决定「能不能同批」。
  2. 实际层:这次运行里,每次调用真正占用的时间区间。→ 决定「有没有撞上」。
  3. 底层层:当前 OS 和文件系统上,open(O_APPEND) 到底是原子的,还是只是「打开时取了个位置」。→ 决定「撞上之后会不会丢数据」。

第 3 层不是理论问题。同一段 Python、同一个追加写、同样的 8 写者 × 10 条记录,WSL 的 Linux 丢 0/960,Windows 丢 309/960。 而第 1 层和第 2 层的检查在两台机器上都通过了。

工具在 tools/parbatch/par_batch.py(纯标准库,selftest 44/44,三个子命令分开做三层判定)。


1. 先问:并行到底能省多少

我用四种性质的负载各做了一轮实测。同一个并行度,加速比可以差 10 倍以上,所以「并行要开多大」不是一个可以拍脑袋的问题。

负载 串行 并行 加速比 上限由什么决定
24 个 3MB 文件 sha256sum(CPU+磁盘) 0.658s P2 0.513s / P4 0.416s / P8 0.413s / P24 0.420s 1.59x 封顶 磁盘吞吐 + 核数
20 次进程启动(true) 0.400s(20ms/次) P4 0.039s / P8 0.038s / P20 0.038s 10.4x 启动固定开销
12 次纯等待(0.25s sleep,线程) 3.004s P4 0.753s / P12 0.252s 3.99x / 11.94x 并发度本身(=理论上限)
12 次真 HTTP 请求(Pages CDN) 11.521s(960ms/次) P4 0.642s(17.96x) / P12 0.886s(13.00x) 4~18x,P12 反而更慢 服务端/连接池,不是我的并发度

三行读数里各有一个坑:

第一行:加并发不等于变快。 sha256sum 是计算密集 + 磁盘读取,P2 拿到 1.28x,P4 到 1.58x,再往上加并行度完全不动(0.416 → 0.413 → 0.420)。这时候把 max_parallel 从 4 调到 24,只是让 24 个进程抢同一块盘。判断依据是「再加并发还动不动」,不是「还能不能更快」。

第二行:加速比可以超过并发度。 12 次 0.25s 的等待,串行却花了 7.989s,而不是 3.0s;于是 P4 的加速比算出来是 19.53x——物理上不可能。

原因不在并行,在基线:那次串行用的是 bash -c 'sleep 0.25',每次调用除了实际的 0.25s,还有约 0.4s 的进程创建/回收开销,串行把 12 次开销全付了一遍。换成纯线程(无进程启动),同一个实验立刻给出 3.99x / 11.94x,与并发度严丝合缝。

所以那条 19.5x 是度量出来的假象,而它指向一个真事实:

并行加速比 = (固定开销 + 实际工作量) ÷ (固定开销/并发度 + 实际工作量)。
加速比超过并发度,只能说明串行基线里塞了大量固定开销——它衡量的是开销占比,不是并发能力。

第三行:更多的并发会更慢。 12 个请求 P4 用 0.642s,P12 用 0.886s。同一个 CDN、同一台机器,翻三倍并发反而慢 38%。这就是「并行度不是越大越好」的实证:到某个点之后,你排的是队列。


2. 写为什么必须排队:一次 2×2 实测

读可以并行,因为读不改状态。写不行,因为两个写者对同一路径做「读—改—写」,后一个会拿着前一个之前读到的值覆盖回去。

先看最朴素的形式(8 个进程各把 counter.txt 加 1 十次,期望 80):

{"mode": "counter", "writers": 8, "rounds": 10, "expect": 80, "got": 10, "lost": 70, "wall_s": 0.214}
{"mode": "counter", "writers": 8, "rounds": 10, "expect": 80, "got": 11, "lost": 69, "wall_s": 0.223}
{"mode": "counter", "writers": 8, "rounds": 10, "expect": 80, "got": 10, "lost": 70, "wall_s": 0.219}

丢了 87%,三次运行都一样。这个不奇怪。奇怪的是追加写也丢:

{"mode": "append", "expect": 80, "got": 62, "lost": 18}
{"mode": "append", "expect": 80, "got": 51, "lost": 29}
{"mode": "append", "expect": 80, "got": 56, "lost": 24}

追加写按定义不该丢记录——每次都往文件末尾放一行,谁也不覆盖谁。为了搞清是哪个变量在起作用,我做了一个 2×2:是否用屏障让 8 个写者同时开跑 × 是否每条记录重新 open 一次。

组合 Windows 三次读数(期望 80) 丢失合计 /240
屏障同步 + 每条重开 60 / 55 / 58 67
屏障同步 + 只开一次 fd 15 / 15 / 16 194
错峰启动 + 每条重开 80 / 80 / 80 0
错峰启动 + 只开一次 fd 80 / 80 / 80 0

然后同一份脚本、同一个 2×2,放进 WSL 的 Ubuntu 跑:

组合 Linux 三次读数(期望 80) 丢失合计 /240
屏障同步 + 每条重开 80 / 80 / 80 0
屏障同步 + 只开一次 fd 80 / 80 / 80 0
错峰启动 + 每条重开 80 / 80 / 80 0
错峰启动 + 只开一次 fd 80 / 80 / 80 0

Windows 两格合计丢 261 条,Linux 四格合计丢 0 条。同一段代码。

最能说明问题的是 Windows 那格「同步 + 只开一次 fd」:80 条记录里只活下来 15 条。如果 O_APPEND 的语义是「每次 write 都原子地落到当前文件末尾」,这个格子不可能出现。它只可能是另一种语义:

open(O_APPEND) 在 Windows 上取到的是一个「打开那一刻的文件末尾位置」,后续 write 落在这个位置上,每写一条位置就自己加一。8 个进程几乎同时打开,就几乎同时拿到了同一个位置——于是 8 条流互相覆盖在同一个偏移上,最终长度约等于「一个写者的 10 条 + 交错残片」。

这解释了为什么「错峰启动」两格是满的:进程启动时间差几十毫秒,8 个写者的写入窗口根本不重叠,所以取到哪个位置都无所谓。在这个实验里,正确性的来源不是 API 的原子性,而是时序恰好没重叠。 这类缺陷在任何单次测试里都会显示「通过」。

再补一条:probe 子命令重复跑这一套时,「错峰启动」两格也不是恒为零——某次读到 3 和 20 条丢失。所以准确说法是:

Linux 上 append 是每次 write 原子(四格全 0);Windows 上 append 是打开时取位置——突发并发必丢(67/194),错峰时通常不丢但不保证(实测 0 条和 20 条都出现过)。

对一个写审计日志、写台账、写 state.json 的 Agent 来说,这两句话的差别就是「日志可信」和「日志有时少几条,而你永远不会知道少了哪几条」。

一条命令就能在你自己的机器上得到这个结论:

python tools/parbatch/par_batch.py probe
# Windows: "verdict": "NOT_ATOMIC_UNDER_BURST", "lost_total": 309, "burst_lost": 286
# WSL Linux 跑同一个文件: "verdict": "ATOMIC", "lost_total": 0

退出码 2 表示「本机底层语义不允许并发追加」,0 表示四格全 0。


3. 三层判据必须分开,因为它们的失效方式不同

这就是我把工具拆成三个子命令的原因:

子命令 看的是 通过意味着 单独使用会错在哪
plan --spec 声明的 reads/writes/exclusive 我以为的并发是安全的 声明漏了一个写,计划完美通过
ledger --file 实际运行的 start/end 区间 这次运行没有重叠写 这次没撞上是运气,不代表下次不撞
probe 本机append/open 原子性 底层允许/不允许并发写 底层安全也可能因为逻辑错误丢数据(如丢失更新)

plan 的输入是一份纯声明,工具不执行任何命令:

{"id": "build_index", "reads": ["data/a.json","data/b.json","data/c.json"],
 "writes": ["index.html"], "est_s": 0.4}

冲突只有三类,每类对应一种确定会被搞坏的后果:

仓库里带了一份真实的夜间文档站刷新任务(9 个调用)。跑出来是这样:

jobs=9  批数=5  最大宽度=4  cap=8  冲突对=10
  批1 (宽度4): fetch_a fetch_b fetch_c notify
  批2 (宽度2): build_index render_post
  批3 (宽度1): render_sitemap
  批4 (宽度1): commit
  批5 (宽度1): push
  估算:串行 7.80s → 分批 4.50s(加速 1.73x)
  裁决:UNSAFE_AS_CLAIMED

值得看的是最后两行。9 个调用里只有前两批是真并行,尾巴上 sitemap → commit → push 三个必须一个一个来。所以理论上限只有 1.73 倍,而不是 9 个任务能想象的 4 倍。并行收益由最长的串行链决定,不由最大的批宽度决定。 想再快就得改设计(比如让 commit/push 挪出这个批次),调 max_parallel 一点用都没有。

UNSAFE_AS_CLAIMED 的意思是:spec 里写了 claims_parallel: true(我打算一次全提交),但按声明必须分成 5 批。此时 exit code 是 2——声明与事实不一致要能被 CI 抓住,而不是靠人看输出。


4. 分批器第一版把消费者排到了生产者前面

这是今天最值得记的一段,因为它的输出看起来完全合法。

第一版的贪心是「把 job 放进第一个装得下它的批」。对那份 9 任务的 spec,它给出:

批1 (宽度6): fetch_a fetch_b fetch_c render_sitemap push notify
批2 (宽度2): build_index render_post
批3 (宽度1): commit

render_sitemap 被排进了第 1 批,而它读的 index.html 要到第 2 批才由 build_index 产出。批是按序号顺序执行的,所以这个计划让 sitemap 用上一轮的旧 index 生成——不报错、不冲突、accounting_ok 为真,每一行都自洽。

原因是贪心只看「这批判装不装得下 + 有没有冲突」,从来没看方向:render_sitemap 和 build_index 确实冲突(RW index.html),但冲突只是让它俩不同批,没规定谁先谁后。而当 render_sitemap 被评估时,build_index 还没落进任何批,所以「不早于它」这个约束根本不存在。

修法是一条约束:

批号是执行顺序,所以一个 job 只能落在「比它冲突过的所有前置 job 的批号都大」的批里。

改完之后批数从 3 变成 5,估算加速比从 2.69x 掉到 1.73x——数字变难看了,但它是对的。我把它写成 5 条自检断言钉住:链式依赖批号严格递增、读者晚于其写者、无关的写者仍能同批、宽批不被依赖约束拆碎、分批结果确定可复现。

顺带一条通则:冲突检测(会不会撞)和依赖排序(谁先谁后)是两个断言,一个通过不能代替另一个。 只要有「按批序号执行」的设计,就必须单独验证方向。


5. 一个批失败到一半,没有回滚

这是「批量」这个抽象自带的性质,不是实现的 bug。同一个批里 5 个写操作,第 2 个失败:

{"batch": [true, false, true, true, true],
 "landed": [1, 3, 4, 5], "rolled_back": [],
 "note": "失败发生在第2项时第3-5项仍会执行(continue)或全部中止(fail-fast),两种都不会回到0",
 "files_on_disk": ["w1.txt", "w3.txt", "w4.txt", "w5.txt"]}

continue 和 fail-fast 都不回滚——一个是「做了 4 件」,一个是「做了 1 件但第 3~5 件被跳过」,没有任何一种会回到 0 件。所以批的语义是:

这就是为什么 plan 输出的不是一个平坦列表,而是有序的批:批号就是断点号。cron 里重跑时从「最后一个完整批」继续,比从「最后一个成功调用」继续更容易说清楚状态。

另外 plan_report 里那个 accounting_ok 字段值得单说:它断言「每个 job 在分批中恰好出现一次」。第一版分空批的实现里曾经静默吞掉一个 job(表现为 max_width 正常、批数正常、总数少 1)。任何分批/分桶/分组,都必须有一个守恒核对行,否则漏掉一行是看不出来的。


6. ledger 拿真实运行对账:抓到的那一对,正是丢了数据的那一对

声明层通过了,实际层还要验。我让 8 个写者并发读改写同一个 state.json,每个写者记录自己的 start/end 单调时钟:

python tools/parbatch/make_ledger_samples.py
{"mode": "parallel", "writers": 8, "expected_n": 8, "final_n": 5, "lost": 3,
 "records": 8, "overlapping_pairs": 1, "wall_s": 0.155}
{"mode": "serial",   "writers": 8, "expected_n": 8, "final_n": 8, "lost": 0,
 "records": 8, "overlapping_pairs": 0, "wall_s": 0.605}

并发版最终计数是 5(期望 8,丢 3);串行版是 8、0 丢失。两份 ledger 分别喂给对账:

$ par_batch.py ledger --file samples/ledger_parallel.jsonl
记录=8  检查的写相关对=28  竞态=1  → RACE
  state.json: w7 × w6 (write/write) 重叠 0.0150s
exit=2

$ par_batch.py ledger --file samples/ledger_serial.jsonl
记录=8  检查的写相关对=28  竞态=0  → NO_OVERLAP
exit=0

关键不是「检出 1 对」,而是检出的这一对(w7 × w6)确实是丢掉更新的那一对——探测器的结论与实际损失一致,这才叫对账。两个判定细节:


7. 一页规则:什么能并行

调用性质 能并行吗 依据
不同路径的读(hash、grep、读配置) ✅ 能 无共享可变状态
同一路径的多个读 ✅ 能 读不改状态
不同路径的写 ⚠️ 能,但收益常低于预期 磁盘/服务端会饱和(实测 1.59x 封顶)
同一路径的写 ❌ 必须排队 丢失更新,静默(实测丢 87%)
一个写 + 一个读同一路径 ❌ 必须排队 读到之前还是之后不确定
共享全局资源(cwd/env/端口/git 仓库/限流接口) ❌ 必须排队 与路径无关的 EX 冲突
进程启动本身 ✅ 收益巨大 固定开销在并行下重叠(实测 10.4x)
网络请求 ✅ 但要看服务端 P4 17.96x,P12 掉到 13.00x
追加写日志(以为安全) ⚠️ 看平台 Windows 突发必丢(261/480),Linux 原子

最后一行是这一篇最想让人记住的一条:「追加写是安全的」这句话没有平台限定就是错的。


8. 用法

python tools/parbatch/par_batch.py selftest                    # 44 项断言,不联网不写盘
python tools/parbatch/par_batch.py plan --spec samples/spec_docs_refresh.json
python tools/parbatch/par_batch.py plan --spec x.json --claims-parallel   # 声明与事实不符则 exit 2
python tools/parbatch/par_batch.py ledger --file samples/ledger_parallel.jsonl
python tools/parbatch/par_batch.py probe --workers 8 --rounds 10 --trials 3
python tools/parbatch/make_ledger_samples.py                   # 重新生成两份真实 ledger

退出码:0 干净 / 2 检出冲突或竞态或底层非原子 / 3 用法错误或零输入 / 5 自检失败。
3 和 2 分开是因为它们的处理方式完全不同:3 意味着「重读输入再跑」,2 意味着「你得去改计划或改代码」。

plan 只读声明文件、不执行任何命令;probe 会真的在临时目录里 spawn 8 个进程写文件(跑完删掉)。想审计别人的计划而不带副作用,用 plan 就够了。


9. 边界声明

一句话收尾:并行不是「一起发出去」,是「先证明它们不共享任何可变状态,再一起发出去」。 前一句是速度,后一句才是正确性——而丢数据的那个版本,跑得更快。


📚 更多创作资源: https://afdian.com/a/felix007