Python 跨平台文件锁:fcntl 与 msvcrt 从零实现
一个 GUI 应用和一个后台进程同时写同一个配置文件,时机不对就会互相覆盖,最坏情况文件损坏。文件锁是"多进程并发写"的标准答案。但要在纯 Python 里同时覆盖 Unix 系和 Windows,会直接撞墙:两个平台暴露完全不同的 API。这篇文章从零拆解这堵墙。
为什么不用第三方库
filelock 这类成熟包完全能用。但加依赖有真实成本——比如用 PyInstaller 打包桌面应用时,每个额外依赖都增加构建复杂度和分发体积。只用标准库的 fcntl(Unix 系)和 msvcrt(Windows),就能零依赖实现进程间锁。
注意这里的"锁"是进程间锁(协调同一台机器上的多个独立进程),区别于单进程内线程间的 threading.Lock。
Unix 系:fcntl.flock 是建议锁
fcntl.flock() 在 Linux/macOS 上最重要的性质是:它是建议锁(advisory lock)。操作系统内核跟踪锁状态,但不会强制阻止无视锁继续写入的进程——只有所有参与进程都遵守同一套获取协议,锁才有效。
fd = open('target.lock', 'w')
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) # LOCK_EX 排他锁 / LOCK_NB 非阻塞
except OSError:
pass # 另一个进程已持锁
不加 LOCK_NB 的 flock() 会原地阻塞直到锁可用。GUI 应用在主线程无防护地调用,界面可能看起来像死机。多数实现用非阻塞获取,失败就短暂等待重试——轮询方案。
Windows:msvcrt.locking 锁的是显式字节范围
Windows 没有 fcntl 模块,等价物是 msvcrt.locking(),锁定文件内的一个显式字节范围。msvcrt.locking(fd.fileno(), msvcrt.LK_NBLCK, 1) 从当前位置起锁 1 个字节。当锁目标是专用 sidecar 文件时,锁 1 字节就够——无需管理复杂的字节范围簿记。
核心抽象:按 sys.platform 分支
把两个 API 包进一个类,核心就是一行 sys.platform 分支。导入放在分支内而非模块顶层,因为 import fcntl 在 Windows 上立刻抛 ModuleNotFoundError——只在真正运行的平台分支里导入,两个平台的代码才能共存于同一文件而不互相破坏。
设计选择:不要锁目标文件本身
朴素做法是直接打开并锁定正在写的文件,但这踩 Windows 专属坑:一个进程以排他模式打开文件时,Windows 可能拒绝其他进程的基础操作,包括读或以不同模式打开。让锁机制本身妨碍写数据就本末倒置了。
通行做法是引入一次性 sidecar 文件 <目标名>.lock 作为唯一加锁对象,数据文件本身从不为了加锁而打开——获取和释放锁完全变成创建和删除 sites.json.lock。这把平台相关的文件打开怪癖完全隔离在数据 I/O 之外。
用 O_CREAT | O_EXCL 让获取原子化
创建 sidecar 文件也有自己的竞争条件:两个进程各自"检查文件是否存在,再创建",另一个进程可能插进检查和创建之间。修复方式是传给 os.open() 的组合标志 O_CREAT | O_EXCL——"文件不存在就创建,否则失败"在内核里作为单个原子操作执行,检查和创建之间没有窗口让别的进程插队,无需额外锁层。
遗留缺口:陈旧锁
持锁进程崩溃而非正常退出时,sidecar 文件可能无限期残留,别的进程再也拿不到锁。实用缓解:检查 sidecar 修改时间,超过阈值(如 30 分钟)视为陈旧,删除后重试获取。这方案不完美——无法区分"进程真的还持着锁只是运行了很久"和"崩溃遗留的锁"。实践上用明显长于受保护操作正常耗时的阈值来缓解。
收尾:上下文管理器
把平台分支、原子文件创建、陈旧检查、超时重试全部装进一个类,实现 enter/exit,调用方拿到干净接口:
with FileLock('sites.json', timeout=10):
write_json('sites.json', data) # 这个块跨进程排他
# 离开块自动释放锁
只要 exit 总是释放锁并删除 sidecar 文件,就能保证正常完成和 with 块内抛异常时锁都必然释放——这正是上下文管理器存在的意义,省去每次手写 try/finally。
来源:Cross-platform file locking in Python — fcntl vs msvcrt from scratch - DEV Community