朕喜技术(ZhenxiJS)
2026.09.05
1 分钟阅读
Python 多进程并发「读-改-写」共享文件丢失更新 Lost Update —— 用 flock 文件锁修复
日期:2026-09-05 | 技术栈:Python3 / Docker / 多进程 / flock 文件锁 分支:
2026-09-05-1600关键词:多进程并发、读改写竞态、Lost Update、丢失更新、flock、文件锁、共享计数器、原子操作、race condition、Read-Modify-Write、并行批处理
一、问题背景
在并行批处理任务中,多个 worker 进程并发往同一个「进度/结果计数器」文件累加数值,但最终统计总数远小于理论值。
典型场景:8 个 worker 各累加 200 次(理应 +1600),跑完后文件里却只多了几个 —— 绝大多数累加被"吞掉"了。
这不是数值溢出,而是经典的并发 Read-Modify-Write(读-改-写)丢失更新(Lost Update):多个进程几乎同时读到同一个旧值,各自 +1 后再写回,后写的把先写的覆盖了。
二、复现步骤
# 1. 拉取分支并进入场景目录
git fetch origin && git checkout 2026-09-05-1600
cd scenario/2026-09-05-1600
# 2. 启动 Docker 环境
docker compose up -d --build
# 3. 进容器复现
docker compose exec app bash
bash /workspace/reproduce.sh
复现输出:
>>> 期望最终值(应无丢失更新): 1600
>>> 实际最终值: 3
>>> 发生了【丢失更新】!实际值比期望少了 1597
连续多次运行,实际值在 1~12 之间波动,稳定复现。
Bug 代码(worker_buggy.py 核心逻辑)
for _ in range(args.iter):
v = read_counter(COUNTER) # 读当前值
write_counter(COUNTER, v + 1) # 写回 v+1
无任何锁 / 原子性保证,高并发下必然互相覆盖。
三、根本原因
并发 Read-Modify-Write(读-改-写)竞态条件 导致丢失更新。
| 时刻 | 进程 A | 进程 B | counter.txt |
|---|---|---|---|
| t0 | 读到 v=5 | 5 | |
| t1 | 读到 v=5 | 5 | |
| t2 | 写回 6 | 6 | |
| t3 | 写回 6 | 6 |
A、B 各自 +1,但文件只从 5 变成 6,丢失了一次累加。这就是 check-then-act(检查再更新)不是原子操作的本质问题。
四、解决办法
核心思路:让整套读-改-写成为一个临界区,而不是只锁某一步。
修复代码(worker_fixed.py,使用 flock 排它锁)
import fcntl
def locked_increment(path):
with open(path, "r+") as f:
fcntl.flock(f, fcntl.LOCK_EX) # 加排它锁,临界区开始
try:
v = int(f.read().strip() or "0")
f.seek(0)
f.write(str(v + 1))
f.truncate()
finally:
fcntl.flock(f, fcntl.LOCK_UN) # 释放锁
其他可选方案
| 方案 | 适用场景 | 说明 |
|---|---|---|
| flock 排它锁 | 多进程同机共享文件 | 推荐,最直接可靠 |
| O_APPEND 原子追加 | 逐条追加记录 | 不适合"累加再回写" |
| 数据库原子命令 | 分布式/多主机 | redis INCR / SQL 原子 UPDATE |
| fcntl 记录锁 | 跨进程锁定 | 用于有锁文件的进程间协调 |
五、验证
修复后运行验证脚本:
bash /workspace/verify.sh
输出:
期望=1600 实际=1600
PASS ✓ (无丢失更新)
连续 5 次验证全部通过,修复方案稳定可靠。
六、经验与避坑
- 锁必须覆盖完整读-改-写:只锁「读」或只锁「写」都不行,必须把 读→计算→写回 作为一个整体包在锁内。
- Python 文件模式要注意:flock 需要
"r+"模式(可读可写)。用"w"模式会截断文件。 - 写完后记得
truncate():新值比旧值短时(如 999→100),不 truncate 会残留旧尾字符。 - 竞态 Bug 测试要反复跑:并发竞态结果不稳定(每次丢多丢少不同),验证时建议多次运行确认。
- flock 跨进程有效、跨主机无效:flock 是基于文件的,只在同一文件系统上的进程间有效。NFS/分布式场景需用分布式锁(Redis/etcd/ZooKeeper)。
- 批量脚本如果频繁做这种操作,建议换数据库:用
redis INCR或 SQL 原子操作,天然避免此类问题。 - Shell 脚本常见坑:
v=$(cat x); echo $((v+1)) > x就是典型的非原子读-改-写,多进程并行时必踩 Lost Update。
