☰
Python fcntl 文件锁实战:多进程读写安全与踩坑指南
2026/10/15 4:51:09 网站建设 项目流程

写Python这么多年,真正开始重视fcntl模块,是从一次线上事故开始的。两个定时任务每隔几秒就会往同一个状态文件里更新进度,结果文件内容频繁变成两段互相覆盖的乱码,日志也跟着丢记录。排查到最后,问题就落在很朴素的一句话上:多进程同时读写同一份文件,必须在代码里加文件锁。如果你也准备让多个进程共享同一个配置文件、日志文件或者任务状态文件,fcntl就是标准库里能直接干这件事的模块。这篇文章我会把它的常用函数、底层逻辑、实际代码和最容易翻车的地方完整过一遍,保证你照着写完能直接用。

1. 为什么文件锁是协作纪律,不是文件本身的铁闸

多进程并发的时候,文件是最朴素、也最好用的状态共享手段。你不需要部署数据库,不需要引入消息队列,开一个文件,多个进程往里读、往里写,一切都很顺畅——直到某个瞬间,进程 A 刚读到一半,进程 B 把内容改了;进程 C 想追加,却从已经被截断的位置往下写。这种问题早期可能一个月都不出现一次,但一旦任务频率提高、进程数量变多,就会从“偶发”变成“必现”。

1.1 未加锁时,我实测的计数文件错乱现场

先说一个最简单、也最能说明问题的例子:让多个进程同时把一个计数文件的值加 1。看起来只需要三步:读取当前值、加一、写回。可这三步在并发环境下会被拆得四分五裂。

from multiprocessing import Process import os import time DATA_PATH = "/tmp/counter.txt" def init_data(): with open(DATA_PATH, "w") as f: f.write("0") def worker_without_lock(loop_times=200): for _ in range(loop_times): with open(DATA_PATH, "r") as f: cnt = int(f.read().strip() or "0") time.sleep(0.001) with open(DATA_PATH, "w") as f: f.write(str(cnt + 1)) if __name__ == "__main__": init_data() ps = [Process(target=worker_without_lock) for _ in range(5)] for p in ps: p.start() for p in ps: p.join() with open(DATA_PATH) as f: print(f"final value = {f.read()}")

5 个进程,每个循环 200 次,理论上文件里的值应该是 1000。但我实际跑下来,经常得到 300、500、700 这种完全不可预期的结果。原因是两个进程可能同时读到同一个旧值,各自加 1 后又各自写回,后写的人把先写的人的结果覆盖了。更糟的情况是读写交错,文件里出现换行残缺、数字拼接错乱,连解析都会报错。

这个现象叫竞态条件(race condition),本质就是“先读取、再处理、后写回”这三步不够原子。要让整个读改写过程不被打断,就需要一把锁把它包起来。

1.2 建议锁 vs 强制锁:锁了别人照样能写

很多第一次接触文件锁的人会误以为,给文件加锁之后,其他进程就被内核硬性禁止访问了。实际不是这样。文件锁在绝大多数 Unix 系统上属于建议锁(advisory lock),它更像会议室门口挂的“使用中”牌子:遵守规矩的人看到会排队,但不管不顾的人照样能推门进去。

也就是说,fcntl里的锁并不能防止“不遵守约定”的进程读写文件。它真正依赖的前提是:所有需要协作的进程,必须使用同一套加锁逻辑,都约定“先抢到锁,再碰文件”。只要有一个进程绕过锁直接读写,保护就失效了。这个前提看似简单,却是很多团队锁失效的真正原因——有人用锁,有人不用锁,结果用了等于没用。

2. fcntl 模块里真正在用的三组函数

fcntl模块并不只有flock一个函数,它包含三组常用接口,三者的底层机制和使用场景都有明显区别。

2.1 flock:把整个文件锁住的直球选手

flock是最直观的一组函数,操作对象是文件描述符,锁的作用范围是整个文件。

import fcntl import os fd = os.open("/tmp/demo.lock", os.O_CREAT | os.O_RDWR, 0o644) # 获取排他锁,如果锁被占用则阻塞等待 fcntl.flock(fd, fcntl.LOCK_EX) # 非阻塞获取排他锁,拿不到会抛 BlockingIOError fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) # 解锁 fcntl.flock(fd, fcntl.LOCK_UN) os.close(fd)

flock支持四种操作常量:LOCK_SH表示共享锁,适合多个进程同时读;LOCK_EX表示排他锁,适合写操作;LOCK_UN解锁;LOCK_NB控制是否非阻塞。加LOCK_NB后,如果锁拿不到,函数会直接抛出BlockingIOError,而不会一直挂起。

我个人的原则是:只要能保证整份文件统一处理,优先用flock,因为它简单、语义清楚,代码里别人一看就明白。

2.2 lockf:能锁文件局部区间的记录锁

fcntl.lockf是 POSIX 记录锁的 Python 封装,底层走的是 POSIX fcntl 锁机制。它比flock多出的能力是:可以只锁文件的某一段字节区间,而不是整个文件。

import fcntl fp = open("/tmp/record.txt", "r+") # 对整个文件加排他锁 fcntl.lockf(fp, fcntl.LOCK_EX) # 只锁从偏移 0 开始到 length 长度的区间 fcntl.lockf(fp, fcntl.LOCK_EX, length=10, start=0, whence=0) # 解锁 fcntl.lockf(fp, fcntl.LOCK_UN) fp.close()

lockf的典型场景是文件分段处理:比如一个大索引文件,不同进程分别维护不同区间的记录,那就按各自区间加锁,互不阻塞。如果只是普通的小文件读写,锁整个文件就够用了,不需要动用lockf。

注意lockf传的文件对象或文件描述符都可以,它对文件偏移位置敏感。如果你之前read过一段,当前偏移变了,再不加参数直接调用lockf,锁的起点就跟着变了,容易踩坑。

2.3 底层的 fcntl 命令:什么时候才需要自己拼 F_SETLK

fcntl.fcntl是模块里的通用函数,它能发送各种控制命令,包括底层记录锁相关的F_GETLK、F_SETLK、F_SETLKW。用起来要自己组装结构体,比较繁琐,日常业务代码里一般用不到。

import fcntl import struct # 非阻塞加写锁(需构造 struct flock) l_type = fcntl.F_WRLCK l_whence = 0 # SEEK_SET l_start = 0 l_len = 0 l_pid = 0 flock_struct = struct.pack("hhllhh", l_type, l_whence, l_start, l_len, l_pid, l_pid) fcntl.fcntl(fp, fcntl.F_SETLK, flock_struct)

底层的价值主要在于诊断。比如用F_GETLK去查询某个区间当前是否被其他进程锁住,可以拿到占用锁的进程 PID,这在排查锁冲突时非常有用。但写业务逻辑时直接拼结构体容易出错,可读性也差,我不建议在普通项目里这么干。

三个接口的对比,我整理成了一张表:

维度flockfcntl.lockf底层 fcntl 命令
锁粒度整个文件文件区间,默认整个文件文件区间
锁类型共享锁 / 排他锁读锁 / 写锁读锁 / 写锁
阻塞控制支持 LOCK_NB支持 LOCK_NBF_SETLK / F_SETLKW 区分
是否检测死锁不检测阻塞等待时可能因死锁睡眠不检测
日常使用难度最简单中等复杂,不建议直接用

3. 带锁写文件的标准姿势:从加锁到落盘的完整代码

工具函数了解清楚之后,关键还是把它们组合成一套能直接落地的读写流程。下面这套方案是我在多个项目里反复用过的,核心思路是“锁文件与数据文件分离”。

3.1 先决定锁加在哪个文件上

给文件加锁有两种做法:直接对数据文件加锁,或者单独准备一个锁文件、对锁文件加锁。

直接对数据文件加锁很直观,但有一个隐蔽问题:如果你用w模式打开文件,文件会在加锁成功之前就被清空。两个进程同时走到打开这步时,一个进程可能刚把文件截断,另一个进程还拿着旧内容准备写回,数据照样丢。用r+模式打开可以缓解,但要求文件必须存在,而且空文件的初始创建总要额外处理。

所以我更推荐锁文件模式:锁文件本身不存业务数据,它只承担“队列令牌”的职责。所有进程要操作数据文件时,先抢锁文件上的锁,拿到锁后再读写真正的数据文件。这样数据文件的打开方式不用迁就加锁流程,怎么操作都行。

3.2 一个能直接跑的多进程计数器示例

以下代码把前面的无锁版本改造成带锁版本,你复制下来就能跑。

import fcntl import os import time from multiprocessing import Process LOCK_PATH = "/tmp/counter.lock" DATA_PATH = "/tmp/counter.txt" def init_data(): with open(DATA_PATH, "w") as f: f.write("0") if not os.path.exists(LOCK_PATH): os.close(os.open(LOCK_PATH, os.O_CREAT | os.O_RDWR, 0o644)) def worker_with_lock(loop_times=200): for _ in range(loop_times): lock_fd = os.open(LOCK_PATH, os.O_CREAT | os.O_RDWR, 0o644) try: fcntl.flock(lock_fd, fcntl.LOCK_EX) with open(DATA_PATH, "r") as f: cnt = int(f.read().strip() or "0") time.sleep(0.001) with open(DATA_PATH, "w") as f: f.write(str(cnt + 1)) fcntl.flock(lock_fd, fcntl.LOCK_UN) finally: os.close(lock_fd) if __name__ == "__main__": init_data() ps = [Process(target=worker_with_lock) for _ in range(5)] for p in ps: p.start() for p in ps: p.join() with open(DATA_PATH) as f: print(f"final value = {f.read()}")

try/finally这个结构很关键:flock (LOCK_EX)抢占成功之后,无论读写过程是否抛异常,最后都要走到LOCK_UN解锁,并关闭描述符。万一中间代码出错导致锁没释放,这个进程会一直占着锁,其他进程只能无限等待。

跑起来之后,结果会稳定在 1000。这就在并发场景下保证了“读改写”的原子性。虽然open和write本身不是原子的,但整个受保护流程在同一把锁内执行,其他进程进不来,所以结果是正确的。

3.3 并发验证方法:肉眼确认锁生效

想亲眼确认锁到底有没有生效,最直接的办法是写一个持有锁后故意睡眠的脚本,然后开两个终端分别运行。

import fcntl import os import time import sys lock_path = "/tmp/test.lock" fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644) try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) print(f"PID {os.getpid()} got lock, sleep 10s", flush=True) time.sleep(10) finally: fcntl.flock(fd, fcntl.LOCK_UN) os.close(fd)

第一个终端会显示拿到了锁并开始睡眠;第二个终端会立刻抛出BlockingIOError: [Errno 11] Resource temporarily unavailable。如果去掉LOCK_NB,第二个终端不会报错,而是安静地阻塞,直到第一个终端结束才继续,打印拿到锁的信息。

这个实验能直观理解阻塞和非阻塞的差异:阻塞是等,非阻塞是报错。

4. 我在实际项目里踩过的 lock 细节坑

文件锁写起来不难,但工程里真正让人头疼的往往是几个容易被忽略的细节。下面这些坑我基本都真实遇到过。

4.1 w 模式截断文件的竞态

给数据文件直接加锁时,千万不要用w模式打开:

# 错误示范:锁还没拿到,文件已经被清空 with open(DATA_PATH, "w") as f: fcntl.flock(f.fileno(), fcntl.LOCK_EX) f.write(...)

w模式在open调用返回的瞬间就会把原文件截断成空文件,这一步在加锁之前就发生了。另一个进程可能正握着锁读取旧数据,文件却已经被你清空,它读到的就是半截内容或者直接为空。正确的做法是用r+模式先打开、再加锁、再操作,或者像我前面一样使用独立的锁文件。

这个坑在日志轮转、状态文件更新这类场景里非常隐蔽,因为大多数时候文件本来就是要重写,你不会第一时间怀疑是截断时机的问题。

4.2 解锁前必须 flush:否则锁释放了数据还没到内核

文件对象write之后,数据通常先进入用户态的缓冲区,由 Python 或操作系统决定什么时候真正写入到内核的文件系统层。如果你在write之后立即解锁,另一个进程紧接着加锁读文件,可能读到的是旧内容——锁释放了,但你的更新还没“到达”文件。

解决方法是解锁前调用flush(),确保数据从语言层的缓冲区刷到内核。如果对持久性要求更高,比如数据丢失会影响业务,那还得os.fsync(fd)把数据真正从内核刷到磁盘。

with open(DATA_PATH, "w") as f: f.write(str(cnt + 1)) f.flush() os.fsync(f.fileno()) # 按需使用,性能开销较大

我在一个状态上报项目里就是漏了这一步,导致“明明加了锁,另一个进程拿到的还是旧状态”的诡异现象。

4.3 fork 之后锁的归属问题:别让子进程瞎掺和

如果在多进程模型里用了fork,要特别注意:子进程会继承父进程打开的文件描述符,但锁的归属关系并没有简单到“父子各持一把锁”。flock的锁与文件描述符背后的打开文件描述(file description)关联,父子进程共享同一个打开文件描述时,它们操作的是同一把锁。

这带来的结果很反直觉:父进程持有锁时,子进程去close这个继承来的 fd,并不代表锁会被释放;但如果父子进程都对这个继承的 fd 执行了某些解锁或关闭操作,锁可能以你意料之外的方式消失。更稳妥的做法是:fork之后,子进程不要直接使用父进程创建的锁 fd。需要加锁就自己重新open一个 fd,各进程各管各的,避免共享同一个文件描述带来的混乱。

4.4 flock 与 lockf 混用等于没锁

这是团队协作里最容易出现的问题。在 Linux 上,flock走的是独立的 flock 系统调用,而lockf走的是 POSIX 记录锁,也就是 fcntl 系统调用。它们是两套彼此独立的锁机制。

场景还原一下:运维脚本用flock给一个文件加锁,业务代码内部用lockf给同一个文件加锁,双方互不知晓,你以为自己加了锁,实际上两个进程可以同时进入临界区。“锁”形同虚设。

所以项目里一定要约定好一套统一方案:要么全用flock,要么全用lockf。我不建议按个人喜好混着来,尤其不要在一个保护同一份数据的函数里,今天用flock明天改成lockf。

4.5 同一进程内多个 fd 抢锁,可能自己堵自己

还有一种偶发情况:同一个进程里,有两个独立的open调用指向同一个文件,分别拿到了两个不同的文件描述符。如果代码对这两个 fd 都执行flock (LOCK_EX),第二次调用可能会被阻塞,或者抛BlockingIOError——你以为自己在抢别人的锁,实际上是在抢自己的锁。

在你需要嵌套加锁、或者一个类里多个方法各自打开锁文件的时候,这个坑就特别容易出现。我的建议是:锁资源在进程内尽量复用同一个 fd。一个进程同时只需要一把锁时,就把锁 fd 定义为模块级别或类级别的对象,不要在函数内部反复打开同一个锁文件。

5. 两个实用小场景:进程单例与超时锁

文件锁除了保护共享数据,还能用在两个高频的场景里:防止程序重复启动,以及给抢锁过程加一个超时上限。

5.1 用 LOCK_NB 做进程单例

很多常驻脚本只允许在机器上运行一份实例。传统做法是写 PID 文件,再检查 PID 是否存在,但 PID 文件本身有清理不及时、PID 复用等问题。用文件锁做单例更可靠:

import fcntl import os import sys def ensure_single_instance(lock_path): fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644) try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: os.close(fd) print("另一个实例已经在运行,退出") sys.exit(1) return fd # 调用方需要保存这个 fd,进程运行期间不能关闭它 lock_fd = ensure_single_instance("/tmp/my_app.lock") # .... 业务逻辑 ....

这里的核心是:返回的lock_fd必须一直被持有,直到程序退出。一旦函数返回后就把它close掉,锁会立刻释放,单例限制就没了。我见过不少人在单例逻辑里写完就把 fd 扔了,脚本仍然反复启动,查起来又非常隐蔽。

进程被kill或者崩溃退出时,内核会自动释放它持有的文件锁,所以不会残留“僵尸锁”。这一点比 PID 文件干净很多。

5.2 手动实现带超时的抢锁轮询

flock的阻塞模式会无限等待,非阻塞模式又会立刻报错。如果业务不想无限等,也不想让调用方处理异常,可以自己包一层超时逻辑:

import fcntl import time def acquire_with_timeout(fd, timeout=5.0): end = time.monotonic() + timeout while True: try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) return True except BlockingIOError: if time.monotonic() >= end: return False time.sleep(0.05)

使用time.monotonic()是因为它不受系统时间调整影响,避免突然改时间导致超时计算失真。轮询间隔 0.05 秒是个比较平衡的选择:太短会让 CPU 空转,太长会放大锁等待的延迟。如果超时返回False,上层可以选择重试、丢弃任务或者记录告警。

6. 跨平台怎么办,以及最后的工程建议

fcntl模块是 Unix 体系下的标准库模块,在 Windows 上并没有。如果你的程序只跑在 Linux 上,可以忽略这个问题;但只要目标平台包含 Windows,就需要提前想好替代方案。

6.1 Windows 上没有 fcntl 的替代思路

Windows 上做文件加锁,标准库里有msvcrt.locking,可以对文件描述符的指定字节区间加锁,但它的语义和fcntl并不完全一致,而且接口相对底层的 DOS/Windows 风格。用起来大概是:

import msvcrt fd = open("data.txt", "r+") msvcrt.locking(fd.fileno(), msvcrt.LK_LOCK, 1) # ... msvcrt.locking(fd.fileno(), msvcrt.LK_UNLCK, 1)

如果你的项目必须跨平台,又不想维护两套锁代码,更省事的方案是引入第三方开源库,比如filelock。它在 Unix 内部用fcntl,在 Windows 上自动切换实现,提供给调用方的是统一的上下文管理器接口:

from filelock import FileLock lock = FileLock("/tmp/my.lock") with lock: # 在此读写共享文件

这个库还支持timeout参数,内部做了超时等待,比手写轮询干净。跨平台项目里我基本都推荐直接用它,而不是自己写平台分支。

6.2 用 /proc/locks 确认锁真的生效

排查锁问题的时候,Linux 上有个特别有用的文件:/proc/locks。它的内容是当前内核里所有活跃文件锁的记录,可以直接查看锁类型、锁住的文件 inode 和持有锁的进程 PID。

cat /proc/locks

输出里能看到类似下面的行:

1: FLOCK ADVISORY WRITE 12345 08:02:12345 0 EOF

拿着这个输出,对照lsof查一下对应的文件路径,可以快速确认某个进程到底有没有持锁、持的是什么类型的锁。我排查线上“锁失效”问题时,第一反应就是先看/proc/locks,确认锁到底加没加上,再往下分析是不是两个进程用了不同的锁机制。

6.3 我的一点工程建议

加锁逻辑写出来简单,但工程里最容易失控的地方是它散落在各处。每个函数都自己 open、自己 lock、自己 unlock,一旦需求变化,比如锁策略从阻塞改成超时,改起来就非常痛苦。

我的做法是把加锁封装成一个上下文管理器,项目里所有需要保护的文件操作统一走它:

import contextlib import fcntl import os @contextlib.contextmanager def file_lock(lock_path): fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644) try: fcntl.flock(fd, fcntl.LOCK_EX) yield finally: fcntl.flock(fd, fcntl.LOCK_UN) os.close(fd) with file_lock("/tmp/data.lock"): # 读写共享文件 ...

这样每次调用只需要一个with语句,解锁逻辑全部集中在包里,不会出现函数中途return忘了解锁的问题。锁文件路径也要固定下来,别放在容易被清理的临时目录里,和数据文件放同一个目录、带一个明确的.lock后缀是最省心的。

文件锁不是性能灵药,它本质上是在给并发操作排队。该加的地方不要省,不该加的地方也别乱加——比如单进程多线程场景,线程之间直接用threading.Lock就够了,没必要动用文件锁这个更重的机制。清楚自己的并发模型,选对锁的级别,这比记住几个 API 重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询