多进程或者多线程脚本写得多的朋友,大概率都遇到过这种诡异场景:两个Python进程同时在往同一个文件里写日志,跑一会儿打开文件一看,内容变成了一堆交错的行,甚至整段JSON直接被打烂。我自己早期写过一套爬虫调度系统,起了十几个worker并发上报状态到同一个临时文件,跑了不到两个小时,状态文件里全是残缺的键值对,排查了半天才意识到是并发写文件导致的互相覆盖。
文件锁就是为了解决这类问题存在的。在Unix/Linux系统上,Python标准库里的fcntl模块就是干这个的,它直接暴露了系统底层的文件控制能力,用法非常直接:给文件加一把锁,别的进程没拿到锁就只能等着,或者干脆报错跳过,从机制上杜绝并发写导致的脏数据。这篇文章不讲虚的,直接拆解fcntl的锁到底怎么用、锁的类型怎么选、实战场景怎么落地、还有那些文档里不会写但实际特别容易踩的坑。适合所有用过Python写多进程任务、日志系统、定时调度、或者自己实现简单文件型消息队列的同学参考。
1. 为什么需要文件锁:并发写文件的那些坑
先别急着抄代码,搞清楚“为什么要加锁”比“怎么加锁”重要得多。很多人觉得文件锁是个冷门功能,那是因为单进程编程根本碰不到这个问题,一旦上了多进程,文件的并发写就是绕不开的坎。
1.1 多进程同写一个文件的乱象现场
先还原一下最常见的灾难现场。假设你有一个进度上报需求,每个worker进程都要定期把自己的进度写进同一个状态文件,最朴素的做法是这样:
def update_progress(worker_id, progress): with open("status.txt", "a") as f: f.write(f"{worker_id}:{progress}\n")单线程跑一点问题没有。可一旦开了十个进程同时往里面追加内容,操作系统层面的write调用并不是你想象中“一次性写完一整行”的。底层IO是有缓冲的,每个进程都可能在同一时刻把半截数据写到文件的同一片区域,于是文件里就会交替出现来自不同进程的碎片内容,最直接的表现就是行被拼接、字符被打乱。
同样的道理也出现在“读改写”场景。比方说你要更新文件里某个数字字段:先读出来,加一,再写回。这个读-改-写三步操作本身不是原子的,进程A读到1,进程B也读到1,两个都加一写回,最后文件里存的是2而不是3。这就是经典的竞态条件,不加锁的情况下,多进程操作同一个文件的正确性完全无法保障。
1.2 文件锁的核心价值:让机器学会排队
文件锁做的事说白了就是让多个进程在操作同一个文件之前先达成一个约定:谁先拿到锁,谁就先操作,其他人要么阻塞等待,要么明确知道“锁被别人占着”然后走别的逻辑。
可以拿公共厕所来类比。一个坑位,一群人排队使用,进去的人把门锁上,外面的人必须等他出来才能进。文件锁就是那个插销,区别在于这个插销是操作系统内核帮你管理的,它不依赖某个进程自觉,而是由内核在进程之间强制执行。任何遵守“先拿锁再操作”规则的进程,都不可能跟别人同时操作同一个文件。
这个机制在分布式场景不够用,因为跨机器的进程没法共享同一套文件锁协议。但在单机多进程场景下,它是最可靠、最简单、零额外依赖的并发控制手段。Python的fcntl模块就是把这个插销的访问权限公开给开发者。
1.3 fcntl 模块到底管什么
fcntl是“file control”的缩写,在Unix系统里是一组针对文件描述符(file descriptor)的操作命令。Python标准库里的fcntl模块把这些系统调用封装成了Python函数,其中最常被用到的就是flock和lockf。
很多人在学习Python时根本接触不到这个模块,因为它在Windows上压根不提供(后面我会给跨平台的替代方案),而且普通脚本编程几乎用不到。但只要你开始用multiprocessing开多进程、用Celery跑分布式任务、或者写任何需要多进程共享文件状态的服务端程序,fcntl几乎是Python标准库里唯一能直接解决文件并发问题的原生工具。
它的核心就一句话:对一个已打开的文件描述符执行加锁、解锁、测试锁状态等操作。用法不复杂,但里面的细节很容易出错,接下来逐个拆解。
2. 锁的类型与 API 全景:从 flock 到 lockf
fcntl模块关于锁的部分,核心是flock这个函数,它直接封装了系统调用flock(2)。理解了这个函数的四个操作码,80%的场景都能直接套用。
2.1 flock 的四个基本操作
flock的操作码在Python里是四个常量,分别对应加共享锁、加排他锁、解锁、非阻塞标记:
import fcntl # LOCK_SH:共享锁,也叫读锁 # LOCK_EX:排他锁,也叫写锁 # LOCK_UN:解锁 # LOCK_NB:非阻塞标记,需要跟上面三个组合使用 fd = open("data.txt", "r+") fcntl.flock(fd, fcntl.LOCK_SH) # 加共享锁 # ... 读文件内容 ... fcntl.flock(fd, fcntl.LOCK_UN) # 解锁 fd2 = open("data.txt", "w") fcntl.flock(fd2, fcntl.LOCK_EX) # 加排他锁 # ... 写文件 ... fcntl.flock(fd2, fcntl.LOCK_UN) # 解锁共享锁和排他锁的区别是理解整个文件锁体系的钥匙。共享锁相当于“大家都在同一个房间读资料,互不干扰”,只要你只读文件,不打算修改,加共享锁就够了,别的进程也能加共享锁一起读。排他锁则相当于“我进来写东西,其他人全都出去”,一旦某个进程加了排他锁,其他进程无论想加共享锁还是排他锁都会失败或阻塞。
用一张表总结四种操作组合:
| 操作 | 常量 | 说明 |
|---|---|---|
| 加共享锁 | fcntl.LOCK_SH | 允许多个进程同时持有,用于读共享 |
| 加排他锁 | fcntl.LOCK_EX | 同一时间只允许一个进程持有,用于写保护 |
| 解锁 | fcntl.LOCK_UN | 释放当前持有的锁 |
| 非阻塞 | fcntl.LOCK_NB | 与上面任一操作组合,拿不到锁立即返回异常而非等待 |
2.2 锁粒度:整文件锁与区域锁怎么选
flock管的是整个文件,简单粗暴。还有一类更精细的锁叫“区域锁”,通过lockf或者fcntl的F_SETLK命令实现,可以只锁文件的某一段字节范围。举个例子,一个文件里同时存放多条记录,进程A只修改第一条记录,进程B只修改第二条记录,用flock这把整文件级的锁会把两个进程完全互斥掉,性能白白浪费。而区域锁允许A锁住第一条记录的范围,B锁住第二条记录的范围,两者互不干扰。
import fcntl fd = open("records.txt", "r+") # 锁定从偏移量0开始、长度10字节的区域 fcntl.lockf(fd, fcntl.LOCK_EX, 10, 0, 0) # 写自己的区域 fd.seek(0) fd.write(b"0123456789") # 解锁 fcntl.lockf(fd, fcntl.LOCK_UN, 10, 0, 0)lockf接收的参数分别是:文件描述符、操作码、锁定长度、起始偏移、相对位置( whence)。最后一个参数传0表示从文件开头算起,传1表示相对当前读写位置,传2表示相对文件末尾。
实际项目里我用flock居多,因为绝大多数场景就是要锁整个文件:写日志、状态文件、锁文件,不存在“同一文件里多个进程各写各的区域”这种精细需求。只有在做自定义格式的数据文件、比如自己实现的简易消息队列时,区域锁才有意义。新手建议先玩熟flock,区域锁按需再学。
2.3 阻塞、非阻塞与死锁风险
flock默认是阻塞行为。一个进程对文件加了排他锁,另一个进程想加排他锁,就会卡在那里等待第一个进程释放锁。这在某些场景下是好事,比如生产者消费者模型里,消费者拿不到锁就老老实实等着,拿到锁立刻处理。
但阻塞也意味着风险:如果持锁进程崩溃了,或者写代码时忘了在异常路径解锁,其他进程会无限期堵下去。处理办法无非两招:
第一招,加锁时带上LOCK_NB,拿不到锁不等待,立刻抛BlockingIOError,我们捕获异常后决定是重试还是放弃。这在“拿不到锁就别干了”的定时任务场景特别有用。
import fcntl import time fd = open("job.lock", "w") try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: print("另一个实例正在运行,本任务退出") exit(0) # 拿到锁,继续干活第二招,给整个加锁操作做超时控制,循环尝试加锁,超过一定时间还没拿到就放弃。这种模式在需要确保“任务必须执行但不想无限等锁”的时候很实用。
至于死锁,flock场景下比较少见,但不是没有。两个进程各持有一把锁,然后互相去请求对方的锁,就会死锁。比如进程A锁了文件1想锁文件2,进程B锁了文件2想锁文件1。解决办法是加锁的顺序要全局统一,所有进程都按同样的顺序去拿锁,别搞交叉请求。
3. 三个高频实战场景的完整实现
理论说完了,直接上实战。我梳理了三个我实际项目中反复用到的场景,每个都给可以直接抄的代码,并解释关键步骤为什么这么写。
3.1 日志写入保护:一个安全的 append 函数
最通用的场景就是并发写日志。所有worker都在同一个文件里追加日志行,必须保证每一次write都是原子性的,行与行之间不能交错。
import fcntl import time def append_log(file_path, line): with open(file_path, "a") as f: fcntl.flock(f, fcntl.LOCK_EX) f.write(line + "\n") f.flush() fcntl.flock(f, fcntl.LOCK_UN)这里有几个细节值得讲清楚。
第一个细节是打开模式。追加日志必须用"a"模式打开,它会自动把读写位置移到文件末尾,而且POSIX系统上用"a"模式打开文件后,每次write都是原子追加操作,底层通过O_APPEND标志实现。配合flock排他锁,可以做到“同一时刻只有一个进程能写”的双保险。
第二个细节是flush。默认情况下Python的文件对象是有缓冲的,write之后数据可能还在内存缓冲区里,没真正落到内核。对于日志场景,不加flush的话,进程崩溃可能会丢最后一段日志。调用flush可以把缓冲区的数据推给内核,虽然不保证立刻落盘(那需要fsync),但对日志来说这个程度基本够了。
第三个细节是作用域。加锁的代码被限定在with块内部,锁在解锁之前必须确保执行,否则一旦中间抛异常,锁会等文件对象关闭时才释放,可能导致其他进程多等几秒。严谨的写法是包一层try/finally,当然用上下文管理器是最省心的做法,我在后面单独会讲。
3.2 单实例守护:只允许一个进程在跑
很多常驻进程程序都有一个硬性要求:同一时刻只能有一个实例在运行。比如定时任务调度器、队列消费进程、爬虫主控程序。如果误启动了两个,轻则重复处理任务,重则数据错乱。
用文件锁实现单实例守护是Linux上最经典的做法:进程启动时打开一个固定的锁文件,对它加排他非阻塞锁。加锁成功说明没有其他实例在运行,当前进程持有锁继续干活;加锁失败说明锁已经被别人持有,直接退出。
import fcntl import os import sys LOCK_FILE = "/tmp/my_service.lock" def acquire_single_instance_lock(): fd = open(LOCK_FILE, "w") try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: print("检测到已有实例在运行,退出") sys.exit(1) # 把fd保存在全局变量里,防止被GC回收导致锁失效 return fd if __name__ == "__main__": lock_fd = acquire_single_instance_lock() # 把pid写进锁文件,方便排查 os.ftruncate(lock_fd, 0) os.write(lock_fd, str(os.getpid()).encode()) # 进入主循环...这里有一个特别容易犯的错误:拿到锁的fd如果被垃圾回收了,文件描述符会被关闭,锁也就随之释放了。所以一定要用一个全局变量引用住了这个fd,保证整个进程生命周期内它都不会被关闭。
另一个细节是为什么用"/tmp"路径。因为单实例锁通常是进程级约束,所有实例跑在同一台机器上,用/tmp路径可以避免锁文件污染项目目录。如果多个不同服务各有各的锁文件,文件名各自起,互不影响。
3.3 生产者和消费者的读写协调
第三种场景更进阶一点:文件里存着一批待处理任务,一个生产者进程往里写任务,多个消费者进程从里面读取任务。这种场景用“共享锁+排他锁”的搭配能明显提升效率。
# 生产者:往任务文件里追加新任务 def produce(task): with open("tasks.txt", "a") as f: fcntl.flock(f, fcntl.LOCK_EX) f.write(task + "\n") f.flush() fcntl.flock(f, fcntl.LOCK_UN) # 消费者:读取并清空任务列表 def consume(): with open("tasks.txt", "r+") as f: fcntl.flock(f, fcntl.LOCK_EX) lines = f.readlines() f.seek(0) f.truncate() fcntl.flock(f, fcntl.LOCK_UN) return [line.strip() for line in lines]生产者和消费者都使用排他锁,因为两者都在修改文件内容。如果只有消费者在改,生产者只是追加,也可以给生产者的操作留下一丝并发空间——读操作加共享锁,写操作加排他锁。不过实际文件IO操作本身很快,加共享锁带来的并行收益并不明显,为了简单,大多数情况下统一用排他锁就够了。
这个场景的核心价值在于:文件在这里承担了“跨进程消息队列”的角色,而文件锁保证了每个任务要么被完整写入、要么被完整消费,绝不会出现两个消费者同时读到同一个任务然后重复处理的局面。
3.4 把锁封装成上下文管理器
前面几个例子每次都要写“加锁、操作、解锁”三板斧,代码一多就显得啰嗦,而且很容易忘了解锁。推荐把文件锁封装成一个上下文管理器,用with语句自动处理加锁和解锁,既安全又清爽。
import fcntl from contextlib import contextmanager @contextmanager def file_lock(path, mode="r+", non_blocking=True): fd = open(path, mode) try: operation = fcntl.LOCK_EX if non_blocking: operation |= fcntl.LOCK_NB fcntl.flock(fd, operation) yield fd finally: fcntl.flock(fd, fcntl.LOCK_UN) fd.close() # 使用 with file_lock("data.txt", "a") as f: f.write("hello\n")用上下文管理器最大的好处是,无论with块里的代码是正常执行还是抛出异常,finally里的解锁逻辑一定会执行,不会出现锁被某个异常路径漏掉的隐患。文件对象也会在退出时被自动关闭,双保险。
我自己的项目里就是默认使用这个封装,真正需要手动控制的地方才直接调flock。封装一次,整个项目复用,代码可读性也高很多。
4. 常见问题与排查技巧实录
这一节是我最想写的部分。flock表面上就几个常量、几行代码,真正深入之后发现坑全藏在细节里,很多问题不实际踩一遍根本想不到。
4.1 锁和进程、文件描述符到底是什么关系
一个最基础但很多人理解错误的概念:flock锁的并不是一个“文件路径”,而是一个底层的文件描述符对应的打开文件描述(open file description)。
这个区别在什么地方体现?如果同一个进程里用两个不同的open调用打开同一个文件,得到两个不同的文件描述符,那么对这两个fd分别加锁,效果上flock可能会放行,因为内核把它们看作两个不同的打开文件描述。在同一个进程里,如果你对fd1加了排他锁,然后尝试对fd2也加排他锁,flock的行为在某些系统上会成功,这种情况完全可能让锁的意义失效。
所以一定要记住:加锁必须基于进程间共享的同一个文件描述符语义,通常的做法是保证所有进程都通过open或者文件路径操作,并且把锁直接加在正在操作的那个fd上。
至于锁的释放,有三个途径:显式调用LOCK_UN解锁;关闭对应的文件描述符;进程退出时内核自动释放该进程持有的所有锁。没有像锁超时这种自动释放机制,持锁进程不死、锁文件不关,锁就一直存在。所以代码里务必要处理好异常路径,否则一个写崩了的进程可能让所有等着加锁的兄弟进程全都卡死。
4.2 同一个进程重复加锁会不会把自己锁死
这是非常高频的问题。答案是:flock允许同一个进程对同一个打开文件描述重复加锁,不会自锁。内核在判定锁冲突时会区分“持有锁的进程是不是自己”,自己对自己加同样的锁会被直接合并,不会阻塞。
但这带来一个隐藏问题:如果你在一个进程里依次对fd加LOCK_EX,再次对同一个fd加LOCK_EX,第二次操作会成功,而且不会改变锁的状态。如果你在模块A加了一次锁,又在模块B对同一个fd加了一次锁,其中一处调用了LOCK_UN,那么这个打开文件描述上的锁可能就被提前解除了,哪怕模块A的逻辑任务还没干完。
这个坑在多人协作或者封装不好时特别容易踩。解决办法是,加锁解锁要严格对称,最好用上下文管理器把加锁和解锁的逻辑限制在同一段代码块里,不要让锁的生命周期散落在多个函数之间。
4.3 多线程场景下锁的作用
还有个容易混淆的点:flock是针对进程的,但它和线程是什么关系?如果在同一个进程里开了多个线程,这些线程共享同一个文件描述符,那么一个线程加了锁,另一个线程对同一个fd尝试加锁,这种行为取决于具体系统实现,但通常会被视为同一进程内部的锁请求,不会被阻塞。
也就是说,fcntl/flock不适合用来做同一个进程内多个线程之间的同步。你如果是在开发多线程应用,想让多个线程互斥写同一个文件,应该用Python标准库里的threading.Lock,而不是fcntl。只有跨进程互斥,才轮到fcntl上场。
实际项目里最常见的混合场景是:多个进程之间用fcntl做锁,进程内部多个线程之间用threading.Lock做锁。两者各管一段,别混用。我在一个数据处理服务里就同时用过这两层锁,效果很好。
4.4 不同系统平台的兼容性
fcntl模块在Windows上不存在,直接import fcntl会报ModuleNotFoundError。Windows上Python提供了另一个机制,msvcrt.locking,虽然方法和常量完全不一样,但功能类似。如果项目必须跨平台,建议封装一层统一的锁接口,在Linux上用fcntl,在Windows上用msvcrt,调用方不用关心底层差别。
我自己写的跨平台文件锁封装大概长这样:
import os import sys if sys.platform.startswith("win"): import msvcrt def acquire_lock(fd, exclusive=True): mode = msvcrt.LK_NBLCK if exclusive else msvcrt.LK_NBRLCK try: msvcrt.locking(fd.fileno(), mode, 1) return True except OSError: return False def release_lock(fd): msvcrt.locking(fd.fileno(), msvcrt.LK_UNLCK, 1) else: import fcntl def acquire_lock(fd, exclusive=True): mode = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH try: fcntl.flock(fd, mode | fcntl.LOCK_NB) return True except BlockingIOError: return False def release_lock(fd): fcntl.flock(fd, fcntl.LOCK_UN)这种封装不能处理所有平台差异(比如Windows上文件锁的强制语义、Linux上NFS挂载文件的锁语义),但应付常规开发完全够了。关键点是模块导入本身要做平台判断,否则在Windows上连启动都会失败。
4.5 网络文件系统与锁的注意点
另一个实战中很容易翻车的场景是网络文件系统。如果文件放在NFS、SMB这类网络存储上,flock的语义会和本地文件系统有差异。有些网络文件系统不完整支持flock,锁可能只是“看起来加成功了”,实际在跨服务器访问时并不能真正互斥。更糟的是,某些网络文件系统实现下,锁的释放时机也会因为服务端的缓存策略而延迟,导致其他进程拿到锁后还要多等一会儿。
所以我的原则很简单:能用本地磁盘就别用网络盘,尤其是存放锁文件和频繁读写的状态文件。如果业务要求在共享存储上跑服务,锁文件一定放在本地,只有真正的数据文件放网络盘。
Linux的/proc文件系统或者sysfs上的文件是不能用来加flock的,因为这类伪文件系统不提供锁支持。实际开发中我也遇到过有人拿/proc/meminfo开锁文件,结果flock调用直接报无效参数,排查了半天才发现是文件本身不支持。用普通目录下的普通文件作为锁文件才是正道。
5. 一些值得长期保留的实操心得
最后分享几个我长期用下来觉得特别有价值的心得,不是教科书上会写的,但真正影响代码质量。
第一个心得是:锁文件路径要尽量可配置。项目里我通常会把锁文件路径放到配置项里,而不是硬编码在代码中。原因很简单,生产环境有时候需要换目录,比如/tmp空间不够了、或者要配合容器挂载。写死路径的话,每次变更都要改代码重发版本,非常痛苦。
第二个心得是:调试多进程锁问题时,别急着加日志。我见过有人在加锁前后都print一堆东西,结果print本身就是IO操作,反转影响了时序。正确做法是用strace或者PYTHONFAULTHANDLER,直接看系统调用层面的行为,一眼就能看出哪个进程在等锁、哪个进程持有了锁。
第三个心得是:能锁就锁得小。这里的“小”指的是锁的作用域要尽量小,锁住整个程序生命周期会大幅降低并发度。把加锁范围控制在写文件前后的几行核心操作内,比锁住全局让所有逻辑串行高得多。
第四个心得是:别滥用文件锁替代数据库。文件锁适合轻量级的进程间互斥、单机状态维护,一旦涉及复杂查询、事务回滚、多表关联,老老实实用数据库,别硬拿文件硬扛。我在小型项目里确实用文件锁实现过简易队列,但到了需要排序、去重、按条件查询的时候,马上换成了数据库,这是理智的选择。
我自己实际使用过程中,文件锁帮我在好几个项目里避免了严重的并发事故:一次是定时任务的重复派发,锁住调度文件之后每天只会有一个调度进程跑;一次是日志分析服务的状态文件错乱,加锁之后数据完整率直接拉满;还有一次是跨进程数据同步的读改写冲突,用排他锁解决了两个进程互相覆盖的问题。
fcntl这个模块很小,知识点也不复杂,但它是解决一类具体问题的最佳钥匙。下次再遇到多进程写文件导致的数据异常,别再靠“加延时”、“碰运气”来规避了,把flock用起来,问题会清爽得多。