AI 写的并发代码总在偶发出错:竞态怎么稳定复现和修掉

2026-07-29

数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。

你遇到的偶发失败,多半不是模型写得差,而是它默认了一个你从没声明过的前提:同一时刻只有一个执行流碰这份状态。 AI 生成的代码在单请求、单进程、单线程的假设下基本都是对的;一旦有第二个执行流进来——用户手抖双击了提交、前端超时后自动重试、CI 里两个 job 同时往同一份报告文件写、你自己开了两个终端跑同一个脚本——这段代码就开始丢更新、写坏文件、产生重复记录。它通常不抛异常,只是结果时对时错,所以你的第一反应往往是怀疑网络、怀疑数据库、怀疑今天模型状态不好,然后让它”再改一版”,改出来的还是同一类代码。

先划清范围。如果你的现象是两个会话、两个窗口同时改同一批源码导致互相覆盖,那是协作层的并发,处理方式是隔离工作区,看 多会话并发冲突;如果代码结果一直是对的、只是越跑越慢,那是另一条路径,看 性能退化排查。本篇只管运行期的数据竞态:同样的输入,结果时对时错。

一、先分因:三类共享状态,现象不一样

竞态的本质只有一句话:两个执行流对同一份可变状态做了”读—改—写”,而这三步之间可以被插队。区别在于状态放在哪儿,因为放的位置决定了验证方法和修法完全不同。

第一类是进程内内存态。 模块级的字典、单例里的列表、类属性、函数的可变默认参数(def f(items=[]) 这种),还有各种”顺手加的缓存”。AI 特别爱写这类代码,因为它在示例语境里最短最好看。现象是计数偏小、缓存串号、偶尔读到别人的数据。多进程部署时它还会伪装成”只在某一台机器上出问题”。

第二类是外部存储态。 数据库里的余额、库存、序号,Redis 里的计数器。典型写法是先 SELECT 出来,在应用层加一,再 UPDATE 回去。单人点没问题,两个请求并排跑就丢一次更新。现象是账对不上、库存卖超、状态机跳了一格。

第三类是文件系统态。 两个进程同时往同一个 JSON、同一份 CSV、同一份日志汇总里写。文件写入不是原子的,读的一方可能读到写了一半的内容,写的一方可能互相截断。现象是解析失败、文件长度诡异、内容出现两次写入的拼接残骸。

判断落在哪一类,最省事的办法是问一句:这份状态在进程重启后还在吗?不在,是第一类;在数据库/缓存里,是第二类;在磁盘上是一个文件路径,是第三类。

二、判别表:从现象直接找验证方法

现象大概率成因怎么验证处置动作
同一次操作偶尔多出一条完全相同的记录接口没有幂等语义,双击或客户端重试各执行了一遍并发发 N 个带同一业务键的请求,看落库条数是否为 1业务键做唯一索引兜底,服务端按幂等键去重;前端禁用按钮只是缓解
计数器、余额、库存的终值比预期小读—改—写非原子,后写的覆盖了先写的N 个并发各加 1,断言终值等于 N改成存储层原子更新(自增、条件更新),或用行锁
文件被截断、结尾出现半截内容两个进程同时以写模式打开同一路径起两个进程反复写同一路径、各写长度明显不同的内容,跑几十秒后看文件是否只等于其中一份的完整内容写临时文件再原子重命名;跨进程用文件锁
配置或结果 JSON 偶尔解析失败读到了正在被写入的中间态一个进程反复写、另一个进程反复读并解析,统计解析失败次数是否随写入频率上升同上;只在读侧加”解析失败就重试”是掩盖,写侧不改成原子替换就不算修好
本地测试全绿,CI 偶尔红一条用例间共享临时目录、端口、全局变量随机化用例顺序;把并行度调到 1 再对比每个用例独立临时目录与随机端口,fixture 收尾清理
缓存偶尔返回了另一个用户的数据缓存 key 缺维度,或用了模块级可变默认值两个身份并发打同一接口,比对返回key 补齐身份维度,禁掉模块级可变共享
请求偶尔挂住不返回,重启即恢复两处以不同顺序获取两把锁形成死锁;也可能是连接池被占满挂住时打印全部线程栈:卡在互相等锁是前者,卡在获取连接是后者死锁则统一全局加锁顺序、锁获取一律带超时;连接池耗尽则查未归还的连接
定时任务被执行了两次多实例各自起了自己的调度器日志按实例名聚合计数单实例调度,或抢占式分布式锁,结果侧加唯一约束

这张表的用法是从左往右走,不要跳过”怎么验证”那一列。竞态排查最大的坑是靠读代码下结论,读出来的因果十有八九是编的。

三、把偶发变成必然:复现比修更重要

竞态之所以难缠,是因为它的触发概率取决于调度时机,而调度时机在你本地和在生产完全不同。所以第一步不是改代码,是造一个”必现”的环境。三个手段够用了。

同起跑线。 让所有执行流卡在同一个起点,一起放开。Python 里用屏障最直接:

import threading

N = 50
gate = threading.Barrier(N)
errors = []

def worker(i):
    gate.wait()            # 所有线程在这里等齐,再一起冲
    try:
        do_the_thing(i)    # 换成你要验的那段逻辑
    except Exception as exc:
        errors.append(exc)

threads = [threading.Thread(target=worker, args=(i,)) for i in range(N)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print("异常数:", len(errors), "终值:", read_final_state())

断言写终值,不要只看有没有报异常。丢更新型的竞态从头到尾一个异常都不会有。

接口层用并发请求。 不需要压测工具,shell 就能造:

for i in $(seq 1 30); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -X POST http://localhost:3000/api/orders \
    -H 'Content-Type: application/json' \
    -d '{"idempotencyKey":"k-1","amount":1}' &
done
wait

跑完去数库里有几条 k-1。如果是 30 条,幂等根本没做;如果是 2 到 3 条,说明有去重但去重本身也有竞态(先查后插,中间被插队),要靠唯一索引兜底。

放大时间窗。 在”读”和”写”之间临时插一行 time.sleep(0.05),把原本几微秒的窗口撑到几十毫秒,很多本来千分之一概率的问题会变成必现。这行代码只用于本地复现,验证完删掉,别提交。

复现脚本写完就留在仓库里,作为回归用例。你现在改完的这段并发代码,下次让 AI 重构时大概率会被改回去,只有测试能挡住。测试写不对反而更危险,具体见 测试假通过

四、按层修:三套处置动作

内存态:把共享变成不共享。 优先级最高的做法不是加锁,是消除共享。请求相关的状态放进请求上下文,不要挂在模块级;能用不可变结构就别用可变结构;函数的默认参数用 None 再在函数体里初始化。确实需要共享的(比如全局计数、连接池),用语言自带的原子结构或锁包住整段读—改—写,而不是只在写的那一行加锁。

存储态:让原子性下沉到数据库。 最省心的规则是:能一条语句表达的更新,就不要拆成读一次再写一次。库存扣减用带条件的更新,靠影响行数判断是否成功;去重靠唯一索引,捕获冲突错误后当成”已处理”返回,而不是先查再插。幂等键要落在业务语义上(订单号、外部单号),不要用时间戳或随机数——那种键每次重试都不一样,等于没做。

文件态:先写临时文件,再原子重命名。 同一目录内的重命名是原子的,读的一方要么看到旧文件要么看到新文件,不会看到半截。

import json, os, tempfile

def atomic_write_json(path, obj):
    directory = os.path.dirname(os.path.abspath(path))
    fd, tmp = tempfile.mkstemp(dir=directory)  # 必须同目录,跨盘符/跨挂载点不保证原子
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(obj, f, ensure_ascii=False)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp, path)
    except BaseException:
        os.unlink(tmp)
        raise

这段代码有两个容易被忽略的边角。一是权限:类 Unix 系统上 mkstemp 建出来的临时文件默认只允许属主读写,os.replace 换过去之后,目标路径的权限位就是这个临时文件的权限,而不是原文件的——如果这份文件本来要给别的账号或别的服务读,替换完就读不到了。需要的话在 os.replace 之前显式补一次 os.chmod,或者先用 os.stat 取回原文件的权限位再套上去。Windows 上没有这套权限语义,本地跑不出问题不代表部署到 Linux 上也没问题,这条只能靠对目标环境的认知,不能靠本机验证。二是”重命名原子”这条保证来自 POSIX,前提是源和目标在同一个文件系统上;Windows 上 os.replace 同样能覆盖已有文件,但底层不给出同等强度的原子性承诺,跨平台的东西不要把它当成唯一防线,落库或加锁仍然要有。

如果是多个进程要往同一个文件追加,那就上文件锁。Linux 下有现成的:

flock -x /tmp/report.lock -c "python build_report.py"

锁文件不存在时会被自动创建,命令退出锁自动释放,所以不用自己写清理逻辑。要注意的是这把锁只对同样走 flock 的进程有效,绕过它直接开文件写的进程照样能写进去——文件锁是协作式的约定,不是强制拦截。Windows 上没有这个命令,跨平台脚本别直接抄。

顺带说一句提问方式:把”这段代码有并发问题吗”换成”这个函数会被 N 个线程同时调用,共享状态是 X,请指出读—改—写不原子的位置”,得到的答案质量差一个档次。模型不缺并发知识,缺的是你没告诉它有并发。

五、什么时候别再折腾:止损点和回滚点

并发这块最容易掉进的坑是”再让它改一版试试”。给你三条硬线。

改到第三版还没稳定复现,停。 没有必现用例的情况下改并发代码,你无法区分”修好了”和”概率变低了”。这时候正确的动作是退回上一个已知状态,把时间全部花在复现脚本上。复现不出来就不要动生产代码。

修改开始蔓延到调用方,停并回滚。 一个竞态的正确修法通常很小:加一个唯一索引、把两条语句合成一条、把写文件换成原子替换。如果 AI 给出的方案要求你改动多个模块的函数签名、引入新的队列组件、把同步调用整体改成异步,那多半是它没找到成因,在用架构改造掩盖。这类范围失控的表现和处理见 AI 改动范围失控。回滚点很明确:git stash 或者 git checkout -- <文件> 退回到只有复现脚本、没有修改的状态。

线上正在出错,先止血再排查。 止血手段按代价排序:给关键表加唯一约束(数据库直接挡住重复,代价最小)、把并发实例数临时降到 1、给入口加限流。这三样都不解决根因,但能把损失停住,让你有时间在非生产环境慢慢复现。别在生产上一边猜一边改。

还有一种情况是”换条路”:如果这段逻辑本来就不该并发——比如一天跑一次的报表生成、离线数据清洗——那么把它改成单执行流串行跑,比给它做正确的并发控制便宜得多。并发是有成本的,不是每段代码都值得付。

六、避坑清单

只在写的那一行加锁。 为什么会踩:直觉上觉得”写”才是危险动作。但危险的是读到写之间那段间隙,你读出来的值在你写回去之前已经过期了。怎么避:锁的范围必须覆盖从读到写的整段,或者干脆不读,用存储层的原子操作。

用前端禁用按钮当作幂等方案。 为什么会踩:它确实把双击问题的复现率降到很低,看起来就像修好了。但网络重试、用户刷新、脚本调用都绕过前端。怎么避:前端可以做,但服务端必须独立具备幂等能力,用唯一约束兜底。

先查询再插入来做去重。 为什么会踩:这是最符合直觉的写法,AI 也最爱生成。但查询和插入之间就是竞态窗口,两个请求都查到”不存在”然后都插入。怎么避:让数据库的唯一索引做判定,把冲突异常当作正常分支处理。

在测试里用 sleep 等待另一个执行流。 为什么会踩:本地机器快,睡 100 毫秒总能等到,测试就绿了。CI 机器负载高,同样的睡眠不够,就变成偶发红。怎么避:用事件、屏障、条件变量做同步;实在要轮询就设总超时并轮询判定条件,而不是睡固定时长。注意和第三节区分:那里的 sleep 是临时插进被测代码里放大竞态窗口的调试手段,验证完就删;这里说的是把 sleep 当成同步原语写进用例并提交,两者用途相反。

多进程部署后沿用进程内的锁。 为什么会踩:单机跑得好好的,扩到两个实例就复发,而且现象和之前一模一样,容易误判成”没修干净”。怎么避:先确认锁的作用域和部署形态是否匹配,跨进程就必须用数据库或缓存层的锁。

追加日志/结果时直接用追加模式写同一个文件。 为什么会踩:小数据量下追加看起来一直没问题。超过一定长度,单次写入可能被拆分,两个进程的内容就交错了。怎么避:每个进程写自己的文件,收尾时合并;或者上文件锁。

让 AI”顺手加个多线程加速一下”。 为什么会踩:加速的收益立刻可见,引入的竞态要几周后才在生产暴露,因果链断了没人会联想到这次改动。怎么避:任何引入并发的改动,都要求同时给出共享状态清单;给不出清单就不合并。

收尾:合并前的六条自检

竞态排查的顺序不复杂,难的是忍住不跳步:先定位状态在哪一层,再造必现用例,再按层给最小修改,最后守住回滚线。改完并发代码,合并前过一遍这张清单:

  • 这段代码会被几个执行流同时进入?答不上来就先答这个。
  • 共享的可变状态列得出来吗?每一项是内存态、存储态还是文件态?
  • 有没有一个不加 sleep 也必现的测试用例,且它在修改前是红的?
  • 读—改—写有没有被完整覆盖,而不是只锁了写?
  • 数据库层面有没有唯一约束作为最后一道兜底?
  • 如果实例数从 1 变成 3,这段代码还成立吗?

多会话协作层面的并发隔离,以及性能维度的排查,分别在 多会话并发冲突性能退化排查 里;两条线一起走,才不会把”结果错了”和”跑得慢”混成同一个问题。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。