深度解析Linux文件锁flock:原理、实战与避坑指南
2026/9/13 2:05:40 网站建设 项目流程

1. 为什么必须搞懂 flock:一次让我印象深刻的线上事故

先讲个真实的事。前几年我维护过一套订单同步服务,每天凌晨有定时任务从第三方拉取数据,另外还有一个人工触发的重跑脚本。某个周五,业务同学连点了三次“重新同步”,结果三份任务同时跑起来,数据库里瞬间多出几万条重复订单,等到发现时已经过了半小时。当时第一反应是骂代码写得烂,但事后复盘,根子在于“没有并发控制”。

那次事故之后,我把项目里所有涉及多进程协作的入口全过了一遍,只要存在“可能被同时拉起两遍”的风险,就在代码里加上文件加锁。而整个方案的核心,就是 Linux 自带的 flock 函数。

很多人对文件锁的理解停留在表面,觉得它不过是个“防止重复运行”的小工具。但实际用下来你会发现,flock 的价值远不止于此——它是轻量级的进程间同步原语,不需要依赖 Redis、数据库或者额外的锁服务,只要你有一个文件系统,就能构建可靠的互斥和协调机制。这篇文章我会把 flock 的原理、用法、坑点一次说透,无论你是写 shell 脚本的运维,还是写 C、Python、Go 的服务端开发,都应该能从这里拿走直接能用的方案。

2. 先从原理下手:flock 到底锁住了什么

很多人第一反应是“锁文件”,这没错,但不够准确。flock 锁住的并不是文件本身的内容,而是“打开文件描述符”所对应的那个内核对象。换句话说,锁的作用对象,是你在调用 flock 时传入的那个 fd,而不是磁盘上的路径。

这句话怎么理解?我举个例子。你用两个进程分别以只读和只写方式打开同一个路径,得到的是两个不同的 fd。即便它们指向同一个 inode,flock 的语义也会让它们有机会互相竞争同一把锁——因为 flock 在内核层面是通过文件 inode 来关联锁的,所以两个 fd 最终竞争的是同一个锁。但是如果你在同一进程里用两次 open 打开同一个路径,这两个 fd 是各自独立的,flock 也会把它们当成两个不同的加锁者来处理,这里面的细节容易绕晕,后面我会专门讲。

flock 提供了四种基本操作:

含义
LOCK_SH1共享锁,多个进程可以同时持有
LOCK_EX2独占锁,同一时间只能有一个进程持有
LOCK_UN8释放锁
LOCK_NB4非阻塞模式,拿不到锁立即返回错误

共享锁与独占锁的关系,可以类比成图书馆的座位:共享锁就像多人共读区,谁都可以进来坐;独占锁就像单人研讨室,一个人进去了,其他人只能在门外等。如果你要写数据,必须拿独占锁;如果你只是读,拿共享锁即可。这种设计让多个读操作可以并行,不会互相阻塞。

阻塞模式与非阻塞模式的选择也很有讲究。阻塞模式下,flock 会一直等待,直到拿到锁才返回;非阻塞模式下,拿不到锁会立即返回 EWOULDBLOCK 错误。对于需要快速失败、主动退出的场景,比如单实例脚本,非阻塞模式几乎是必然选择。而对于必须等待资源就绪的场景,比如消费者任务,阻塞模式反而更简单可靠。

还有个关键特性必须牢记:flock 的锁是随“文件描述符”自动释放的,前提是进程退出或关闭文件时。但是,如果你在同一个进程内多次调用 open 拿到多个 fd,再分别加锁,这种场景下锁的释放逻辑会稍微复杂一些。后面我会详细梳理。这里我先强调最容易忽略的一点——进程崩溃时,内核会自动释放它持有的所有 flock 锁。这个特性让 flock 比很多基于“写 pid 文件”的互斥方案可靠得多。pid 文件方案最大的问题是:进程异常退出后 pid 文件还留着,需要额外逻辑去判断 pid 是否真实存在;而 flock 天然不存在这个坑。

3. 基本用法:从命令行到代码,先跑起来再说

3.1 shell 脚本里最实用的 flock 用法

Linux 自带的 flock 命令,在脚本中做单实例控制时非常顺手。最经典的写法是:

#!/bin/bash exec 9>/var/lock/myscript.lock flock -n 9 || { echo "另一个实例正在运行,退出" exit 1 } # 真正的业务逻辑 echo "开始执行任务..." sleep 30 echo "任务完成"

这行代码我们拆开看。

第一步,exec 9>/var/lock/myscript.lock,这是 bash 内置的 fd 重定向,它用 fd 9 打开锁文件,如果文件不存在会自动创建。这里用 9 是因为 0、1、2 分别对应标准输入、输出、错误,上面的大于号是在以写模式打开文件,从头截断文件内容。注意,文件内容对 flock 本身不重要,重要的是这个 fd。

第二步,flock -n 9,-n 表示非阻塞,尝试对 fd 9 加独占锁。如果失败,说明有其他进程已经持锁,直接走 error 分支退出。如果成功,锁会一直持有到进程结束或 fd 关闭。

最关键的一点:锁在脚本结束时自动释放,因为 fd 9 会被关闭。就算你忘了写flock -u 9,内核也会在进程退出时把所有 fd 关掉,锁自然就没了。这个特性意味着你不用担心脚本中途异常退出导致锁残留。

如果你想在加锁的同时自动执行命令,flock 命令本身也支持直接跟命令:

flock -n /var/lock/myscript.lock -c "python3 myscript.py"

这里的语义是:flock 先尝试获取锁,成功后再用-c执行后面的命令,执行完自动释放锁。这种写法适合更简单的场景,不需要在脚本内反复操作 fd。

3.2 C 语言中使用 flock

如果你写 C 程序,flock 的系统调用签名如下:

#include <sys/file.h> int flock(int fd, int operation);

一个典型的互斥初始化逻辑:

int fd = open("/var/run/daemon.pid", O_RDWR | O_CREAT, 0644); if (fd < 0) { perror("open"); exit(1); } if (flock(fd, LOCK_EX | LOCK_NB) != 0) { if (errno == EWOULDBLOCK) { fprintf(stderr, "另一个实例已经在运行\n"); exit(1); } perror("flock"); exit(1); } // 这里写真正的初始化逻辑

注意 open 时的权限标志。如果你要加独占锁,最好用 O_RDWR 或 O_WRONLY 打开,只读打开也能加锁,但是在某些系统上,只读打开后再加独占锁可能存在兼容性问题。稳妥起见,锁文件一律用读写模式打开并创建。

3.3 Python 和 Go 里的等价实现

Python 的 fcntl 模块封装了 flock:

import fcntl import sys lock_file = open('/var/lock/myapp.lock', 'w') try: fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: print("另一个实例正在运行") sys.exit(1) # 业务逻辑 while True: ...

Go 语言没有内置的 flock 封装,但可以通过 golang.org/x/sys/unix 包调用:

package main import ( "fmt" "os" "syscall" ) func main() { f, err := os.OpenFile("/var/lock/myapp.lock", os.O_RDWR|os.O_CREATE, 0644) if err != nil { panic(err) } defer f.Close() err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) if err == syscall.EWOULDBLOCK { fmt.Println("另一个实例正在运行") os.Exit(1) } if err != nil { panic(err) } // 业务逻辑 }

无论哪种语言,核心思路一致:打开文件,加锁,判断失败就退出,成功则继续。掌握这一套,你可以在任何语言里快速实现进程互斥。

4. 实战场景拆解:flock 不只用于“防止重复运行”

4.1 定时任务防重入

最常见的使用场景是 cron。假设你的脚本平均执行 10 分钟,但某次数据量陡增,跑了 30 分钟,此时 cron 又在整点触发了第二次执行。如果没有锁,两个任务会并行抢数据,轻则效率下降,重则数据错乱。

用前面 shell 的写法,只需在脚本开头加 4 行判断就够了。这里有一个高级技巧:如果你希望“后来的任务等待而不是直接失败”,可以改用阻塞模式:

#!/bin/bash exec 9>/var/lock/sync.lock flock 9 # 不带 -n,阻塞等待 # 业务逻辑

这样,第二个任务会默默等待第一个任务完成后再执行。对于“任务可以晚点跑,但最好不要丢掉”的场景,这种方式更合适。而如果任务重跑成本高、宁可放弃也不要堆积,那就用-n立即退出。

4.2 配置文件热更新的读写锁

假设一个服务启动时读取配置文件,业务运行中你希望支持“热更新”,也就是在不重启进程的情况下重新加载配置。如果同时有多个工作线程在读取配置,而主线程在替换配置,你可能会得到一个写完一半的中间状态。在资源管理器场景下,比如图片文件在缩略图生成过程中被读取,也可能读到不完整的文件。这种情况下,可以用 flock 对配置文件的更新操作加独占锁、读取操作加共享锁,保证读写互斥。

读取端逻辑:

int fd = open("/etc/myapp/config.yaml", O_RDONLY); flock(fd, LOCK_SH); // 读取并解析配置 flock(fd, LOCK_UN); close(fd);

更新端逻辑:

int fd = open("/etc/myapp/config.yaml", O_WRONLY); flock(fd, LOCK_EX); // 写入新配置 flock(fd, LOCK_UN); close(fd);

有人会问:为什么不直接用互斥锁?因为配置文件读多写少、全进程锁的成本太高,如果读之间也要互相等待,性能会明显下降。共享锁让多个读者并行,只有写者才独占,性能和安全性兼顾。

4.3 多进程任务分发器

我做过一个批处理系统,主进程从消息队列拉取任务,分发给多个 worker 进程并行处理。worker 之间需要避免处理同一个任务,需要一个全局互斥机制。当时没有引入 Redis,就用了一个简单的文件锁:

// worker 进程 int fd = open(task_lock_file, O_RDWR | O_CREAT); if (flock(fd, LOCK_EX | LOCK_NB) != 0) { // 拿不到锁,说明任务已被其他 worker 取走 return TASK_BUSY; } process_task(task); flock(fd, LOCK_UN);

这和数据库里的SELECT ... FOR UPDATE思路很像,本质上是用锁来保护“判断任务状态”和“标记任务状态”之间的临界区,避免两个 worker 同时拿到同一任务。文件锁的好处是零依赖,任何 Linux 机器都能跑,不需要额外部署服务。缺点是只能用于单机场景,多机之间共享文件系统才能互斥。跨多个独立节点的分布式锁,你要么用数据库,要么上 Redis 或 ZooKeeper,这就超出 flock 的职责范围了。

4.4 日志轮转中的并发保护

日志切割是个容易被忽略的场景。logrotate 把 access.log 重命名为 access.log.1 后,服务还在往原来的 fd 上写,这时候日志会丢。当然成熟的日志框架有自己的处理,但如果你自己写日志模块,用 flock 也可以做协调:写日志前加共享锁,轮转时加独占锁。

不过说实话,日常场景中我不会用 flock 做日志锁,因为每次写日志都加锁的开销太大,对高吞吐日志来说完全不可接受。更常见的做法是服务端监听信号,收到 USR1 信号后重新打开日志路径,配合 logrotate 的 copytruncate 模式。这里提它只是说明 flock 的思路可以扩展,但具体场景要具体取舍,不是所有并发问题都适合上文件锁。

5. flock 与 fcntl、lockf 的区别:到底该用哪个

很多人把 flock、fcntl、lockf 混在一起,但它们其实是三套不同的锁机制,不能简单互相替换。

flock 是 BSD 系统调用,与文件描述符关联,属于“咨询锁”(advisory lock)。它不阻止进程读写文件,只在所有参与方都自觉调用 flock 时才生效。

fcntl 的 F_SETLK 才是 POSIX 标准定义的锁,它与进程关联,加锁的粒度和协议比 flock 更复杂。它最大的特点是支持“锁的合并与分裂”——你可以只锁文件的某一段字节范围,而不是整个文件。这是 flock 做不到的。顺便说一句,历史上 POSIX 标准曾规定“在同一个进程中是单锁模式”,即一个进程在同一文件上只能持有一把锁,如果加新锁,即使区域不重叠,旧锁也可能被合并或覆盖。Linux 的实现遵循了这个语义,所以同一个进程内、同一个 inode 上的 fcntl 锁存在互相覆盖的风险。这意味着在进程内部,不要用两次独立的 fcntl 调用去锁同一个文件的不同区域。而 flock 没有这个限制,同一个进程可以多次打开文件加多个锁。如果你要在高并发场景下用 fcntl 锁文件的部分区域,这个细节很关键,建议实测确认。

lockf 本质上是 fcntl 锁的便捷封装,用于对文件分段加锁,常用于数据库或者队列这类需要精细控制“记录级锁”的场景。

维度flockfcntllockf
系统来源BSDPOSIX是 fcntl 的封装
锁粒度整个文件字节区域字节区域
关联对象打开文件描述进程进程
进程退出锁释放自动自动自动
同进程多次加锁有限支持(各自独立 fd 时)锁会被合并/覆盖同 fcntl
NFS 支持较老版本不完全支持部分支持部分支持

那实际项目怎么选?我的经验是这样的:

  • 脚本、守护进程、防重入、进程互斥,首选 flock。简单、跨语言、不容易出问题。
  • 需要锁文件的某一段、比如操作大文件时锁局部,用 fcntl 或 lockf。
  • 追求 POSIX 规范可移植性,用 fcntl。
  • 只想快速实现“全局只有一个实例”,无脑 flock,没有之一。

说到 NFS,这里多提一句。在网络文件系统上,flock 的语义在不同平台、不同挂载参数下表现不一致。老的 NFS 客户端可能完全无视 flock,导致锁失效。如果你的服务跑在 NFS 挂载的目录上,建议先实测:在机器 A 上加独占锁,再在机器 B 上尝试加锁,看是否真的会失败。实测通过才能放心用。考虑 NFS 网络延迟带来的锁性能影响,不要在 NFS 上做频繁的加锁解锁,否则整个系统会被拖垮。

6. 那些年我踩过的 flock 的坑

6.1 坑一:同一个进程内重复 open,锁会被绕过

这是我最初犯的错误。当时在同一进程中,函数 A 用 open 打开文件加锁,函数 B 又用 open 再次打开同一路径解锁,想着这样能一加一解。结果发现锁根本没被释放,因为这是两把独立的新锁,B 获取了另一个新的锁,跟 A 加的那个锁根本不是同一个。

正确做法是:在整个进程内只 open 一次文件,持有同一个 fd,所有加锁、解锁、关闭操作都通过这个 fd 进行。如果你需要多个函数协作,把 fd 作为参数传递,或者放进全局变量。最简单有效的做法是把 fd 作为参数传给那些函数。

6.2 坑二:打开模式与锁不匹配

flock 最容易被忽略的约束是:它不会阻止对文件的读写。也就是说,你的锁能不能达到预期效果,完全取决于所有写者是否都会先获取锁。如果一个写者没加锁就写文件,那锁就是摆设。

典型场景是:A 进程拿独占锁写文件,B 进程直接 open 后 write,完全不加锁。B 的写入会成功,A 的锁挡不住它。这要求团队内约定好规则:凡是访问该文件,一切路径都要先加锁,这是咨询锁的特性。

6.3 坑三:fd 复制导致的锁共享

flock 锁有一次则需要注意:通过 dup、dup2 或者 fork 继承得到的 fd,与原始 fd 共享同一把锁。什么意思?fork 之后,父子进程持有同一个打开文件描述,它们加的是同一把锁,不能独立持有两把锁。这意味着如果你在 fork 之后想让子进程自己加锁,需要先把锁释放,再重新加锁,否则两次加锁操作实际上是在操作同一把锁。

这种场景下,用 fcntl 反而更有优势,因为它与进程关联,父子进程可以各自持有独立的锁。

6.4 坑四:阻塞模式下的死锁

阻塞模式看着简单,但要注意“锁顺序”问题。假设进程 A 持有锁文件 1,等待锁文件 2;进程 B 持有锁文件 2,等待锁文件 1,这就是经典的死锁。因为 flock 没有超时机制,如果你用阻塞模式,死锁会无限持续,进程永远挂起。解决方法是:要么统一所有进程的加锁顺序,确保先锁 1 再锁 2;要么改用非阻塞模式加重试,超过一定次数后主动报错退出,避免无限等待。更好的方案是尽量把所需资源抽象成一把锁,避免多锁场景。

6.5 坑五:锁和写缓存之间的坑

文件锁保证的是“操作顺序”,不保证“数据可见性”。什么意思?进程 A 写完文件,释放锁;进程 B 拿到锁后读文件,可能读到的是 A 写入前的旧数据。这听起来很奇怪,但有几种可能:文件系统有延迟、NFS 缓存、或者写入操作根本没有 flush 到磁盘。

如果对数据一致性要求高,写文件后你应该调用 fsync 再释放锁,读取方拿到锁后正常读。在 NFS 场景下,fsync 的开销很大,但数据安全性优先,该用还是要用。

6.6 快速排查清单

如果你遇到“文件锁不生效”,按这个顺序排查:

  1. 检查所有访问该文件的关键路径是否都加了锁——咨询锁不会自动阻止不加锁的进程。
  2. 检查加锁使用的 fd 是否有副本或 fork——如果锁和原始 fd 共享,就可能导致相互覆盖,锁就形同虚设。
  3. 检查你的锁是 flock 还是 fcntl——这两套锁互不感知,A 用 flock 加锁,B 用 fcntl 尝试加锁,两边都能成功。
  4. 检查代码里是否在同一进程内多次 open 了同一个文件——这会导致锁对象不一致,进程自锁互不干扰。
  5. 检查文件系统是否支持 flock——在 NFS 或某些 FUSE 文件系统上,flock 行为可能不符合预期。
  6. lsof或者/proc/locks查看当前锁状态,确认锁是否真的被持有。

/proc/locks是排查锁问题的利器,它会列出当前内核中所有的文件锁,内容包括锁类型、持有进程、锁定的文件等。当你在生产环境里不知道锁被谁持有时,去看一眼这个文件,往往立竿见影:

cat /proc/locks

你会看到类似这样的输出:

1: POSIX ADVISORY READ 1234 08:01:12345 0 EOF 2: FLOCK ADVISORY WRITE 5678 08:01:12346 0 EOF

FLOCK 那一行就是 flock 产生的锁,POSIX 那行是 fcntl 产生的锁。从 PID 可以快速定位持锁进程。

7. 一个完整的实战模板:用 flock 构建带超时的互斥工具

这里我分享一个可以直接用的模板,实现“带超时的阻塞等待锁”效果。由于 flock 的阻塞模式没有超时参数,我们需要用非阻塞加重试来模拟。下面是个 Python 版本:

import fcntl import time import os def acquire_lock_with_timeout(lock_path, timeout=30, poll_interval=0.5): """ 尝试获取文件锁,最多等待 timeout 秒 成功返回 fd,失败抛出 TimeoutError """ start = time.time() fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644) while True: try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) return fd except BlockingIOError: if time.time() - start >= timeout: os.close(fd) raise TimeoutError(f"等待文件锁超时: {lock_path}") time.sleep(poll_interval)

调用方式:

try: fd = acquire_lock_with_timeout("/var/lock/worker.lock", timeout=10) except TimeoutError: print("10 秒内没拿到锁,放弃执行") exit(1) try: # 业务逻辑 do_work() finally: fcntl.flock(fd, fcntl.LOCK_UN) os.close(fd)

这个模板的价值在于它同时保留了阻塞模式的便捷性和非阻塞模式的超时保护。实际生产中,任务排队等待远比直接失败更有意义,但无限期等待又可能掩盖系统故障,所以超时是一种可靠的控制方法。

如果你用 C,同样的逻辑用flock(fd, LOCK_EX | LOCK_NB)+usleep即可实现。

8. 最后再分享几个实战中的小建议

用 flock 几年下来,我觉得值得单独拿出来提醒大家的有这么几个点。

第一,锁文件路径尽量放在固定目录,比如 /var/lock 或者 /tmp。要留意不同发行版的目录权限和清理策略。对于多用户可执行的脚本,锁文件路径最好带上用户信息,可以避免 A 用户持锁导致 B 用户无法执行。

第二,锁文件不要手动删除。即使业务逻辑结束了,如果锁状态已经没有作用,保留一个空的锁文件不影响任何操作。但如果你手动删除它,就会破坏 flock 的语义:删除文件后,之前持有的锁仍然存在,但其他进程创建一个同路径的新文件后,锁对象变成了另一个,原来的锁就“丢了”。这会导致两个进程各持一把锁,互斥彻底失效。所以我从来不让脚本删除锁文件,只创建、只用、只自动释放 fd。

第三,锁文件命名要有规范。不要在同一个目录下随便建,尽量使用统一的命名前缀,比如appname_scope.lock。否则时间一久,你根本分不清某个锁文件是哪个业务在用的,排查问题时会多花很多时间。

第四,优先用非阻塞模式。就算你确实希望等待锁,也建议用上一节提到的“非阻塞加重试”模式。阻塞模式下进程一旦被锁阻塞,如果锁被系统持有一段时间且没有释放,你的进程无任何干预手段。改用非阻塞加重试,你至少能看到日志输出、能响应外部的退出信号,这对可观测性和运维友好度帮助很大。

最后,不要把所有并发问题都交给文件锁。文件锁只适合“单机、低频、互斥或读写协调”这类场景。高频的计数器累加、复杂的多资源同步、分布式多机协作,这些该用数据库、Redis 或者专门的一致性协议,文件锁的简单反而会变成它的致命短板。选对工具,比盲目堆技术重要得多。

就写到这里。如果你正准备给脚本加防重入,或者排查一个奇怪的并发问题,希望这篇文章能帮你避开我踩过的那些坑。

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

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

立即咨询