Agent 一晚上要跑几十个工具调用,所有人都知道「能并行就并行」。但几乎没有人检查过:这两条调用是不是在碰同一个文件。
这一篇的全部数字来自本机今天的三组实测。它们拼起来说明一件事——并行是三个不同的问题,混在一起就一定出错:
open(O_APPEND) 到底是原子的,还是只是「打开时取了个位置」。→ 决定「撞上之后会不会丢数据」。第 3 层不是理论问题。同一段 Python、同一个追加写、同样的 8 写者 × 10 条记录,WSL 的 Linux 丢 0/960,Windows 丢 309/960。 而第 1 层和第 2 层的检查在两台机器上都通过了。
工具在 tools/parbatch/par_batch.py(纯标准库,selftest 44/44,三个子命令分开做三层判定)。
我用四种性质的负载各做了一轮实测。同一个并行度,加速比可以差 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%。这就是「并行度不是越大越好」的实证:到某个点之后,你排的是队列。
读可以并行,因为读不改状态。写不行,因为两个写者对同一路径做「读—改—写」,后一个会拿着前一个之前读到的值覆盖回去。
先看最朴素的形式(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。
这就是我把工具拆成三个子命令的原因:
| 子命令 | 看的是 | 通过意味着 | 单独使用会错在哪 |
|---|---|---|---|
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}
冲突只有三类,每类对应一种确定会被搞坏的后果:
cwd / env / 端口 / git 仓库 / 限流的接口)→ 与路径无关的串行化需求仓库里带了一份真实的夜间文档站刷新任务(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 抓住,而不是靠人看输出。
这是今天最值得记的一段,因为它的输出看起来完全合法。
第一版的贪心是「把 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 条自检断言钉住:链式依赖批号严格递增、读者晚于其写者、无关的写者仍能同批、宽批不被依赖约束拆碎、分批结果确定可复现。
顺带一条通则:冲突检测(会不会撞)和依赖排序(谁先谁后)是两个断言,一个通过不能代替另一个。 只要有「按批序号执行」的设计,就必须单独验证方向。
这是「批量」这个抽象自带的性质,不是实现的 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)。任何分批/分桶/分组,都必须有一个守恒核对行,否则漏掉一行是看不出来的。
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)确实是丢掉更新的那一对——探测器的结论与实际损失一致,这才叫对账。两个判定细节:
a.end == b.start)不算重叠。 先结束、再开始是顺序执行。把它算成重叠,会让所有串行批次都报 RACE,工具从此没人看。| 调用性质 | 能并行吗 | 依据 |
|---|---|---|
| 不同路径的读(hash、grep、读配置) | ✅ 能 | 无共享可变状态 |
| 同一路径的多个读 | ✅ 能 | 读不改状态 |
| 不同路径的写 | ⚠️ 能,但收益常低于预期 | 磁盘/服务端会饱和(实测 1.59x 封顶) |
| 同一路径的写 | ❌ 必须排队 | 丢失更新,静默(实测丢 87%) |
| 一个写 + 一个读同一路径 | ❌ 必须排队 | 读到之前还是之后不确定 |
| 共享全局资源(cwd/env/端口/git 仓库/限流接口) | ❌ 必须排队 | 与路径无关的 EX 冲突 |
| 进程启动本身 | ✅ 收益巨大 | 固定开销在并行下重叠(实测 10.4x) |
| 网络请求 | ✅ 但要看服务端 | P4 17.96x,P12 掉到 13.00x |
| 追加写日志(以为安全) | ⚠️ 看平台 | Windows 突发必丢(261/480),Linux 原子 |
最后一行是这一篇最想让人记住的一条:「追加写是安全的」这句话没有平台限定就是错的。
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 就够了。
plan 只知道你告诉它的东西。 一个 git 命令实际会写 .git/index、.git/objects/*、logs/HEAD;一个 pip install 会写几百个文件。声明漏了,计划就是错的,而且它会理直气壮地告诉你 SAFE。这是本工具最大的失效模式,我没有自动化办法——只能靠 exclusive 把「不可穷举的写」整体标记成独占。probe 只测 append 语义。 它不测 rename 原子性、不测 flock/msvcrt.locking 是否可用、不测网络文件系统(SMB/NFS 上结论可能完全不同)。它测的是「我这一台的这一个临时目录」,别的地方要重跑。ledger 需要你的运行真的记录了时间戳。 事后拿不到 start/end,就只能靠 plan 猜。一句话收尾:并行不是「一起发出去」,是「先证明它们不共享任何可变状态,再一起发出去」。 前一句是速度,后一句才是正确性——而丢数据的那个版本,跑得更快。
📚 更多创作资源: https://afdian.com/a/felix007