文件锁这事儿,看起来简单,但真要写对代码,坑比想象中多。先抛一个核心判断:别指望靠os.open(..., os.O_EXCL)来搞定跨进程文件锁。
为什么呢?os.O_EXCL这玩意儿,只在创建新文件的那一瞬间才有原子性保障。一旦目标文件已经存在——这在日常开发中太常见了,比如日志文件、状态记录文件——它直接就给你甩一个 OSError: [Errno 17] File exists。说白了,它的设计初衷是“防止文件被重复创建”,而不是“给已存在的文件装把锁”。
更致命的是,它根本管不了跨进程。两个Python进程几乎同时去打开同一个文件,os.O_EXCL毫无约束力,尤其在NFS这类共享文件系统上,它基本等于形同虚设。所以,真正靠谱的方案,还得落到内核级别的文件锁机制上。Linux/macOS 上有 flock,Windows 上有 LockFileEx,而 fcntl.flock 和 portalocker 就是封装了这些底层能力的实用工具。
flock vs portalocker:选哪个?看运行环境和需求
flock 是POSIX标准下的老兵,Linux和macOS上原生支持,稳定且轻量。但如果你的代码需要跑在Windows上,那就尴尬了——它不支持。portalocker 则是一个聪明的跨平台封装,底层在Linux/macOS调用 flock,在Windows调用 msvcrt.locking 或 WinAPI 的 LockFileEx,自动帮你处理兼容性差异。
一个很实际的选择标准:如果你的代码只跑在纯Linux服务器上,而且不想引入额外依赖,直接用 flock 就够用了。但只要你涉及开发机(macOS)、测试环境(Windows WSL或本地)、或者部署环境不确定,那 portalocker 就能省掉一堆条件判断,避免“本地跑得欢,线上报错懵”的尴尬场面。
另外,有个常见错误现象值得注意:IOError: [Errno 37] No locks a vailable。这通常是因为NFS挂载点禁用了 flock。这种情况下,portalocker 也会跟着失效。解决方案是改用进程间信号量,比如Redis分布式锁,或者在应用层做串行化处理。
使用上的几个要点:
flock(fd, LOCK_EX | LOCK_NB)可以加非阻塞锁,一旦失败就抛OSError,适合轮询或超时重试的场景。portalocker.lock(file_obj, portalocker.LOCK_EX | portalocker.LOCK_NB)行为与flock一致,但注意,file_obj必须是一个已打开的可写文件对象,不能传文件路径字符串。- 需要特别留意的是,两者锁的都是文件描述符(fd),而不是文件路径。子进程继承fd时,锁也跟着继承。如果你
fork了,记得把旧fdos.close()掉再重新打开,避免锁的混乱。
用portalocker写一个安全的日志追加函数
这是最典型、也最容易翻车的场景:多个进程同时 open(..., 'a') 并 .write(),结果日志内容要么交错在一起,要么部分字节被覆盖。原因在于,'a' 模式本身并不保证原子性——内核只保证单次 write() 系统调用的原子性,但Python的 .write() 可能被拆分成多次系统调用去执行,尤其是在写入大字符串时。
正确的做法,不是“先open再锁”,而是“先以读写模式打开,再加锁,最后写入”。来看一个示例:
import portalocker
def append_log(filepath: str, message: str) -> bool:
try:
with open(filepath, 'r+b') as f: # 必须r+b,a模式无法flock
portalocker.lock(f, portalocker.LOCK_EX | portalocker.LOCK_NB)
f.seek(0, 2) # 移动到文件末尾
f.write((message + '\n').encode('utf-8'))
return True
except (OSError, portalocker.LockException):
return False # 加锁失败,跳过写入或走降级逻辑
这里的 'r+b' 是关键。虽然 flock 对只读打开的文件描述符也有效,但你得能往里面写内容才行。如果文件不存在,open('r+b') 会报 FileNotFoundError,所以需要提前创建文件,比如执行一句 open(filepath, 'a').close() 就搞定了。
fcntl.flock的常见误用和释放时机
很多人误以为 flock 锁会随着 file.close() 自动释放。其实不然。锁是绑定在文件描述符(fd)上的,只要这个fd没有被 os.close(),哪怕Python的file对象已经被垃圾回收或者超出了作用域,锁依然牢牢地攥在手里。更危险的是,fork 之后子进程会复制fd,导致父子进程共持一把锁,很容易引发死锁或者意外的锁释放。
因此,必须显式管理锁的生命周期:
- 用
try/finally结构确保os.close(fd)一定会被执行,不要指望垃圾回收器来帮你擦屁股。 - 避免在
with open()语句块里直接调用flock,因为__exit__只负责关闭file对象,并不会关闭底层的fd(除非你手动调用os.close(f.fileno()))。 flock(fd, LOCK_UN)可以主动解锁,但一般情况下没必要——只要关闭fd,锁就会自动释放。- 如果进程崩溃,内核会自动回收所有fd并释放锁,这一点比应用层的锁(比如Redis锁)要可靠得多。
还有一个容易被忽略的细节:flock 锁的是整个文件,而不是文件中的某一段偏移。如果你需要“只锁定文件中某一块区域”,flock 就无能为力了。这时得用 fcntl.fcntl(fd, F_SETLK, ...) 这种带偏移的记录锁(record locking),但Windows不支持这种方式,portalocker 也没有封装这个功能。