拓冰建站拓冰建站
首页 / 资讯中心 / 正文

深入理解 flock 文件锁:从原理到实战,解决并发写文件问题

先说个真实场景。我维护过一套定时任务系统每天凌晨两点会有十几个脚本并发启动其中好几个脚本要往同一个状态文件里写内容。最初上线时跑得好好的等任务一多写文件互相覆盖日志里全是串行错乱的数据排查了半天才定位到是文件读写竞争的问题。后来改用 flock 做文件级互斥锁把整组任务串起来问题才彻底解决。这也是我决定把 flock 函数从头到尾写清楚的原因。文件加锁在并发编程里属于看着简单、用起来细节极多的一类技术。多进程同时访问同一份文件时如果没有一个统一的“准入机制”轻则读到半截数据重则直接覆盖别人刚写好的内容。flock 就是 Linux 系统提供的文件锁接口它能把“谁先拿到锁、谁后执行”这件事交给内核统一调度避免用户态自己搞一套标记文件互相竞争。这篇文章适合正在写服务端脚本、批量任务、Cron 调度或者任何涉及多进程共享文件的开发者阅读。读到后面你会发现flock 不只是 [PHP 里的一个函数](、[Shell 里的一条命令]它背后关于锁的语义、进程与文件描述符的关系、阻塞与非阻塞的选择才是真正决定你的程序稳不稳的东西。1. 文件锁的本质与适用场景1.1 并发场景下的“互斥需求”从哪来先想清楚一个问题同一个文件被多个进程写到底会发生什么。假设进程 A 和进程 B 都执行“打开文件、写入内容、关闭文件”这三步。如果没有锁A 写入一半时 B 也拿到文件句柄开始写两个进程的文件偏移量各自独立互相覆盖是常态。更隐蔽的情况是A 先截断文件再写入而 B 在 A 截断后读取文件直接读到空文件。这类 bug 不会稳定复现只在并发量上来或任务恰好撞在一起时出现非常难查。本质原因是文件系统本身不提供“一次只能一个人写”的语义。你能做的就是在所有访问者之间建立一种约定每个人都遵守“先拿锁、再操作、最后释放”的规则。flock 就是这个约定的内核实现它不关心你要写什么内容只负责管理锁的获取与释放。1.2 flock 与 fcntl、lockf 的差异很多人会把 flock、fcntl、lockf 混为一谈其实它们是完全不同的三套机制。Linux 下锁定文件的方式主要有这几种API锁粒度关联对象释放方式典型用途flock整个文件打开文件描述open file descriptionclose 所有 fd 或显式 LOCK_UN轻量级、进程间互斥fcntl(F_SETLK)文件区域可指定字节范围进程进程退出或显式解锁精细控制读写区间lockf文件区域进程进程退出或显式解锁兼容 POSIX 锁语义实际选型时我的判断标准很简单如果是“我要保证同一时刻只有一个进程在做某件事”用 flock 就够了因为它够轻、够直接而且从 fd 维度管理fork 后行为相对好理解。如果是要精确锁文件的第几行到第几行比如多个进程分别写文件的不同区块那就得上 fcntl 的区域锁。flock 还有一个容易被忽略的好处在 2.3 节细说它与文件描述符的关闭行为强绑定进程崩溃时内核自动释放不会留下“死锁”状态。2. flock 核心 API 与关键参数2.1 四种锁模式的含义flock 的函数签名在几乎所有语言里都是一样的int flock(int fd, int operation);第二个参数 operation 传的是锁模式常见组合是这四种参数值含义说明LOCK_SH共享锁多个进程可以同时持有适合并发读场景LOCK_EX独占锁同一时刻只能一个进程持有适合写入场景LOCK_UN释放锁主动解锁LOCK_NB非阻塞标志拿不到锁时立即返回错误不等待组合使用就是LOCK_EX 或 LOCK_EX | LOCK_NBLOCK_SH 或 LOCK_SH | LOCK_NB。理解共享锁与独占锁的关系可以类比公共图书馆的座位共享锁就像几个朋友坐在同一张桌子看书互相不干扰独占锁就像一个人包了整个房间其他人只能等。读写分离场景中读者之间用 LOCK_SH写者之间用 LOCK_EX读者与写者之间由内核自动互斥。2.2 阻塞与非阻塞的实际表现默认情况下 flock 是阻塞的。这意味着如果锁被占用调用会一直挂起直到对方释放。这个特性在脚本场景里很有用我希望任务排队执行后到的进程就老实等着不需要我自己写重试逻辑。但阻塞也意味着风险。如果持有锁的进程异常挂死虽然内核会在进程退出后清理锁但如果是进程内某个线程死循环不释放所有等待者都会卡住。这时候 LOCK_NB 就派上用场了。非阻塞模式下拿不到锁会立即返回错误系统调用返回 -1错误码 EWOULDBLOCK即 EAGAIN。正确的处理方式是试图加锁失败后记录日志、等一个随机延迟再重试。这就是经典的“乐观锁加重试”方案。我记得有个同事写定时任务时做了一个“10 秒抢不到锁就退出”的需求实现方式就是用非阻塞加循环判断import fcntl import time import os def acquire_with_timeout(lock_file, timeout10): fd os.open(lock_file, os.O_RDWR | os.O_CREAT) start time.time() while True: try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) return fd except BlockingIOError: if time.time() - start timeout: raise TimeoutError(获取锁超时) time.sleep(0.5)2.3 锁的生命周期与自动释放机制flock 的锁与文件描述符的生命周期强相关这是它最重要的特性之一。下面三种情况都会导致锁被释放调用 flock(fd, LOCK_UN) 主动释放close(fd) 关闭文件描述符持有锁的进程退出无论正常还是崩溃最后一条是 flock 最实用的地方。fcntl 的 POSIX 记录锁虽然也会在进程退出时释放但它与进程的关联方式更复杂在 fork、dlopen 等场景容易踩坑。flock 从 open file description 维度管理锁同一个文件被 open 两次得到两个不同的描述时锁是互相独立的fork 出的子进程如果继承同一个 fd则共享同一个锁对象——这既是特性也是坑后面单独讲。我的经验是只要能用 flock 解决的需求优先别上 fcntl。安全、简单、好排查。3. 实操用 flock 构建可靠的互斥锁3.1 Python 实现文件锁的完整代码先给一个生产环境验证过的模板复制就能用。import fcntl import os import contextlib contextlib.contextmanager def file_lock(lock_path): lock_fd os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644) try: fcntl.flock(lock_fd, fcntl.LOCK_EX) yield finally: fcntl.flock(lock_fd, fcntl.LOCK_UN) os.close(lock_fd) # 使用方式 with file_lock(/tmp/myapp.lock): # 这里写需要互斥的代码比如写状态文件 with open(/var/lib/myapp/state.json, w) as f: f.write({status: running})这里有几个关键点。第一锁文件用 os.open 而不是内置 open是为了直接拿到文件描述符用内置 open 拿到的对象需要先调 fileno() 再传给 flock多一步反而容易漏。第二os.O_CREAT 保证锁文件不存在时会自动创建配合 0o644 权限不同用户也能读锁文件本身。第三把 fcntl.flock 和 os.close 放进 finally 里保证即使中间抛异常也能正确释放。3.2 Shell 脚本里 flock 命令的用法Shell 场景更常用的是直接调命令行工具。Linux 自带的 flock 命令有两种经典用法。第一种直接包裹整段命令flock -x /tmp/deploy.lock -c /opt/scripts/deploy.sh-x 表示独占锁-c 后面跟要执行的命令。如果 deploy.lock 被占用后面的脚本会阻塞等待直到拿到锁再执行。第二种配合文件描述符使用适合脚本内部局部加锁{ flock -x 200 echo 开始执行关键步骤 # ... 业务逻辑 echo 执行完毕 } 200/tmp/myapp.lock这里 200 是自定义的文件描述符编号只要不冲突就用它。把整个代码块包进去直到大括号结束才会释放锁。这种写法的好处是锁的持有范围完全由代码块控制不会出现命令执行完锁才释放的窗口期。3.3 锁内执行任务的边界控制加锁容易锁的范围控制才是难点。我见过不少滥用 flock 的代码把整个程序都包进锁里导致完全没有并发能力。正确做法是只锁需要互斥的“临界区”。举个例子一个系统每天要处理三批数据但是三批数据之间不能同时写同一个汇总文件。正确的锁范围是“写汇总文件”这一步而不是整批数据处理def process_batch(batch_id): data load_data(batch_id) # 这里不加锁允许并发读取 result compute(data) # 计算也不加锁提高并行度 with file_lock(/tmp/summary.lock): append_to_summary(result) # 只有写汇总文件时需要互斥锁的范围越小系统的并发能力越高。但也要注意锁内不要做耗时过长的 IO 操作否则其他进程等待时间过长。如果临界区内有网络请求这种不可控操作建议加超时控制避免把整个系统拖死。4. 实战中容易踩的坑与排查经验4.1 fork 之后的锁行为这个问题是我真正在生产环境踩过的。程序启动时先拿到一个锁文件然后 fork 子进程处理任务。父进程退出时 close 了锁文件结果子进程里的任务还在执行锁却已经释放了另一个进程立刻拿到了锁两边同时跑数据又乱了。原因在于 fork 后子进程会复制父进程的文件描述符表。如果父进程和子进程共享同一个 fd那么 flock 的锁对象也是共享的close任何一个进程中的这个 fd 都可能导致锁释放。flock 的锁和“open file description”绑定而不是和某个进程绑定。解决办法是在 fork 之后父进程立即关闭锁 fd让子进程成为唯一的锁持有者或者在父进程中不持有锁只在子进程里单独 open 锁文件再 flock。具体选哪种取决于业务模型但核心原则是锁的持有者必须是实际执行任务的那个进程且不能父子共同持有。pid os.fork() if pid 0: # 子进程继承的 fd 继续持有锁 do_worker_task() os._exit(0) else: # 父进程立刻释放锁避免子进程还跑着锁却丢了 os.close(lock_fd) os.waitpid(pid, 0)4.2 通过不同 fd 打开同一文件时的锁行为这个问题容易被忽视。flock 是“基于打开文件描述”的锁不是“基于文件 inode”的锁。同一个文件如果进程 A open 一次、进程 B open 一次它们拿到的是两个不同的 open file description锁是可以互相排斥的这一点没问题。但如果同一进程内部用两个独立 open 得到的 fd 去 flock 同一个文件内核会当成两个不同的锁请求有可能自己锁住自己。看下面这段典型的“自锁”代码fd1 os.open(/tmp/a.lock, os.O_RDWR | os.O_CREAT) fd2 os.open(/tmp/a.lock, os.O_RDWR | os.O_CREAT) fcntl.flock(fd1, fcntl.LOCK_EX) # 成功 fcntl.flock(fd2, fcntl.LOCK_EX | fcntl.LOCK_NB) # 失败阻塞fd1 和 fd2 是同一个进程的两条独立文件描述符但内核把它们视为不同的锁持有者第二个 flock 会失败。真实场景里这种问题常出现在封装库时底层库自己 open 文件、自己 flock上层的调用方又 open 了一次再去 flock直接把自己卡住。排查技巧遇到莫名其妙的多进程阻塞先看是不是同一个进程内部多次 open 同一个文件。用 lsof 查看进程持有的文件描述符数量通常一眼就能看出来。4.3 NFS 与网络文件系统上的局限flock 在 NFS 上的表现一直是个微妙的话题。不同 NFS 版本的实现差异很大NFSv3 时代 flock 经常表现为完全不生效NFSv4 开始才在协议层支持类似 POSIX 锁的语义实际上是映射到 NFS 的字节范围锁。在网络上搜 flock 和 NFS你会看到大量互相矛盾的结论。我的建议是如果你能避免在 NFS 上使用 flock就尽量避免。如果确实需要请做一次真实的并发压测验证锁语义是否符合预期并且要注意网络延迟导致锁获取时间变长——NFS 上 flock 要经过网络往返持有锁期间如果断网等待时间会更久。替代方案有几种一是把锁文件放在本地磁盘数据文件放在 NFS二是改用分布式锁如 Redis 的 SETNX配合服务注册中心三是用数据库的唯一约束做乐观锁。总之不要在同一份代码里混用多种锁机制否则语义混乱排查一次哭一次。4.4 锁与文件读写的顺序问题拿到锁之后立刻去 open 要操作的数据文件这里面还有个隐藏的坑。很多人会写这样的代码lock_fd os.open(/tmp/myapp.lock, os.O_RDWR | os.O_CREAT) fcntl.flock(lock_fd, fcntl.LOCK_EX) data_fd os.open(/var/lib/myapp/config.json, os.O_RDWR) # 写数据... fcntl.flock(lock_fd, fcntl.LOCK_UN) os.close(data_fd) os.close(lock_fd)问题在于如果两个进程都这么做进程 A 拿到锁后打开数据文件进程 B 在锁外也打开了同一个数据文件那么 B 完全可以在 A 写入的过程中直接写入数据因为 B 没有经过 flock 检查。锁的保护范围不是“文件本身”而是“所有访问者都遵守的协议”。所以真正安全的模式是把锁文件和数据文件分开。数据文件本身不要加锁而是通过锁文件的 flock 来串行化对数据文件的访问。所有访问者必须先获取锁文件的锁再打开数据文件读写。这是我反复强调的策略。5. 常见问题与排查技巧实录5.1 问题速查表现象可能原因排查方法解决方案进程卡住不动strace 显示阻塞在 flock 上其他进程持有锁未释放用 lslocks 查看锁持有者检查持锁进程为何不退出必要时 kill无论怎么加锁并发写入还是乱锁文件和数据文件不是同一套命名约定检查所有进程是否 open 了同一个锁文件路径统一锁文件路径检查是否有相对路径/绝对路径不一致子进程还在跑锁却提前释放fork 后父子进程共享 fd父进程 close 导致释放查看代码中锁 fd 是否被父进程不小心关闭按 4.1 的方案调整 fd 所有权同一进程内 flock 失败同一文件被 open 两次各自加锁导致自锁检查 fd 列表确认是否重复 open复用同一个 fd 完成加锁和解锁程序退出后锁文件还在锁文件是持久的锁只是内核状态查看文件是否存在锁文件可以保留不减影响下次 open 复用即可NFS 上 flock 疑似失效NFS 版本不支持或配置了不兼容的锁模式用 python 脚本在 NFS 上做并发测试换本地锁或改用分布式锁5.2 排查时必用的几个命令实际排查中有几个命令比看代码更直接。第一个是lslocks列出系统当前所有文件锁能看到哪个进程持有了哪把锁这对于定位“谁一直占着锁不释放”非常有效COMMAND PID TYPE SIZE MODE M START END PATHNAME python3 12345 FLOCK 0B WRITE 0 0 0 /tmp/myapp.lock第二个是strace -p pid跟踪进程的系统调用。如果进程阻塞在 flock 系统调用上一眼就能看到它卡在哪个 fd 哪把锁上。第三个是lsof 锁文件路径查看有哪些进程打开了这个文件以及打开方式是读写还是只读。如果发现多个进程都打开了同一个锁文件但用的路径不同一个是绝对路径、一个是相对路径那就能快速定位到为什么“锁不生效”了。5.3 新手最容易忽视的 3 个细节一是忘了加 O_CREAT 标志。锁文件如果不存在open 失败整个 flock 代码直接抛异常。这个错误很低级但在容器环境下很常见——锁文件路径对应的目录不存在或者没有写权限。二是忘了把锁文件和数据文件分开。很多人图省事直接对数据文件本身做 flock后来发现数据文件被截断重写后锁的 inode 变了所有锁全部失效。因为 flock 是锁“文件对象”的如果你用open(path, w)这种模式打开文件它内部会先截断文件如果锁是基于原文件描述符的截断后锁还在但如果你先 close 再重新 open比如用循环写文件的逻辑新的 fd 对应的是新的 open file description锁就丢了。三是不了解 flock 和 fcntl 不互通。进程内混用两套锁会造成锁语义混乱A 用 flock 加锁B 用 fcntl 加锁结果两边都认为自己持有了锁但互斥效果完全不存在。这也是我在前面强调选型时二选一、不要混用的原因。6. 从文件锁到分布式锁的扩展思考6.1 单机 flock 的边界在哪里flock 本质上是单机内核态机制它只能协调“同一台机器上”的进程竞争。当你的服务部署在多台机器上用同一份文件共享数据时flock 就无能为力了——因为不同机器上的文件根本就是两个独立的文件。这个边界必须在架构设计时想清楚。我曾经接手过一个项目最初是一台机器跑定时任务用了 flock 做互斥一切正常。后来架构升级到两台机器定时任务分别在各自机器上启动两个任务同时写同一个数据库表出现了重复数据。根因就是 flock 管不住跨机器的并发。从单机文件锁到分布式锁其实不是简单的替换而是一次思路升级。flock 让你学会的“临界区要小”“锁的生命周期要清晰”“释放路径要可靠”这些原则在分布式环境下同样适用只不过实现机制从内核换成了 Redis、ZooKeeper 或数据库。6.2 从 flock 到 Redis 锁的迁移路径如果了解分布式锁你会发现它的核心语义和 flock 几乎一模一样加锁、解锁、超时释放、互斥等待。区别只是锁的状态存储位置从内核换成了独立服务。用 Redis 分布式锁时有一个容易踩的坑是“锁超时自动删除”导致的问题。flock 没有超时概念而 Redis 的 SETNX 通常配合过期时间使用。A 加锁后执行任务耗时过久锁过期自动删除B 立刻加锁成功A 完成后误删 B 的锁。解决办法是给锁的 value 设置唯一标识删除时用 Lua 脚本对比再删除。这些细节掌握了 flock 的语义之后学起来会快很多。因为文件锁的朴素模型已经把并发控制的核心难点暴露出来了互斥、临界区、释放。剩下的只是把这三个概念迁移到不同组件上。我在实际项目中经常做这样的抽象凡是单机并发问题先问 flock 能不能解决凡是跨节点并发问题先问能不能把临界区收敛到单节点处理如果必须跨节点再上分布式锁。这个决策链帮我避免了很多不必要的架构复杂度。最后再分享一个小经验无论用什么锁都要保证锁文件或者锁 key 的命名是有业务含义的比如deploy.lock、batch-summary.lock不要用a.lock、1.lock这种。排查问题时一个含义清晰的锁文件名能帮你快速定位到是哪条业务链路的并发控制出了问题省下的时间远超你命名时多花的几秒钟。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门