
多进程或者多线程脚本写得多的朋友大概率都遇到过这种诡异场景两个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 到 lockffcntl模块关于锁的部分核心是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(b0123456789) # 解锁 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, moder, non_blockingTrue): 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, exclusiveTrue): 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, exclusiveTrue): 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用起来问题会清爽得多。