flock:被低估的文件锁,和它背后的 fd 语义

本文从 cron 任务重入这个最常见的翻车现场讲起,介绍 flock 的基本用法与 advisory lock 的设计哲学,重点拆解”锁挂在 open file description 上”这个最容易踩坑的语义细节,最后聊聊它的能力边界——什么时候该换分布式锁。

翻车现场:cron 的重入问题

几乎每个写过定时任务的人都见过这个场景:一个 ETL 脚本cron 每五分钟跑一次,绝大多数时候几分钟就结束,相安无事。直到某天数据源抽风,一轮任务跑了二十分钟——于是五个进程同时在写同一个文件、调同一个接口、删同一批临时目录。数据错乱还是轻的,重的会把下游索引直接写坏。

问题的本质是临界区没有互斥。解法其实古老得不能再古老:上锁。而 Unix 世界里最顺手的那把锁,就是 flock

最小用法一行就能说清:

*/5 * * * * flock -n /var/lock/etl.lock /usr/local/bin/etl.sh

flock 打开(必要时创建)etl.lock 这个文件,对它加一把排他锁,然后在锁的保护下执行命令,命令退出时锁自动释放。-n 是关键:拿不到锁就立刻退出,退出码为 1。也就是说,上一轮还没跑完时,新一轮 cron 进来,发现锁被占着,安静地走开,等下一个五分钟再来。没有等待、没有堆积、没有僵尸进程。

还有一个常用变体 -w 30:最多等 30 秒,等不到就走。适合”最好能跑,跑不了就算了”的场景。

值得强调的是,锁文件的内容完全不重要,它甚至可以是空文件,永远不写一个字节。flock 锁的不是”文件里有什么”,而是”有没有人对这个 inode 挂了锁”。文件名只是给多方一个约定的会合点,本质更接近一把有名字的互斥量。

advisory lock:一场君子协定

flock(2) 是 BSD 出身的系统调用,Linux 完整支持;/usr/bin/flock 命令则是 util-linux 对它的薄封装。但它提供的锁是 advisory lock(建议性锁)——内核只负责登记”谁想锁这个文件”,完全不阻止不参与这个协定的进程。换句话说,别的进程照样可以 open 这个文件、照样 write,内核一个字都不会拦。

第一次听到这个设计的人多半会皱眉:这算什么锁?其实 Linux 不是没做过”来硬的”。内核早年支持 mandatory lock(通过 mount -o mand 加特殊权限位启用),但它的语义漏洞太多——绕过方式一大把、NFS 上行为诡异、和 root 权限纠缠不清——以至于几乎没有生产系统敢开,最终在 5.15 内核里被整个移除了。Unix 世界的集体选择是:锁只对协作者有效,正确性是协议参与方自己拼出来的

这不完全是妥协,更像一种哲学。强制锁把仲裁责任全推给内核,代价是路径上的每一次读写都要过检查;advisory lock 把内核的开销压到最低——锁冲突时挂起在等待队列上,无冲突时几乎零成本——同时把”哪些代码会碰这个文件、它们是否都先拿锁”这个问题的责任交给开发者。只要所有参与方都老老实实走 flock,协定就是完整闭环的。怕的从来不是锁弱,而是有人在协定之外动手。

真正的坑:锁挂在 open file description 上

用法层面 flock 上一节就讲完了,但生产事故从来不出在用法上,而出在语义上。flock 最容易翻车的点在于:锁的持有者不是文件,不是进程,而是 open file description(open 之后内核里那个”打开的文件”对象)。这句话展开来是三个具体后果。

第一个后果:同一个进程自己能把自己锁死。下面这段 Python 足够说明问题(fcntl.flock 就是 flock(2) 的直接封装):

import fcntl, os
 
fd1 = os.open('/tmp/demo.lock', os.O_RDWR | os.O_CREAT)
fcntl.flock(fd1, fcntl.LOCK_EX)  # 拿到锁
 
fd2 = os.open('/tmp/demo.lock', os.O_RDWR)  # 再 open 一次
try:
    fcntl.flock(fd2, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
    print("自己把自己挡在门外了")

同一个进程、同一个文件,仅仅因为 open 了两次、拿到两个不同的 fd,第二把锁就加不上。如果锁是挂在进程上的,这里应该畅通无阻。这个行为反直觉,但它恰恰证明了锁的归属:两次 open 产生两个独立的 open file description,在内核看来这就是两个互不相让的”人”。顺带的推论是,对同一个 fd 重复 flock 是幂等的,不会自己死锁。

第二个后果:fork 出来的子进程会继承锁,而且继承的是”同一把”。锁要等所有指向这个 open file description 的 fd 副本全部关闭才释放,任何一个副本关闭都不行。这个设计让父子进程协作特别顺滑——比如经典的预取锁再 drop privilege 的模式——但也埋了一个非常隐蔽的雷:fd 泄漏等于锁泄漏。看这个例子:

flock /tmp/leak.lock -c 'nohup sleep 300 >/dev/null 2>&1 &'

flock 命令本身瞬间就返回了,但后台的 sleep 继承了锁文件的 fd。这把锁会莫名其妙地再挂三百秒,期间任何实例都进不了临界区,而 ps 里根本看不出原因。排查这类问题的手法是 lsof /tmp/leak.lock 看谁还开着这个文件,或者直接读 /proc/locks——里面能看到每把锁的类型(FLOCK 还是 POSIX)、持有进程和 inode。解法一是给命令加 -o 参数(执行前给锁 fd 打上 close-on-exec 标记),二是从设计上避免在锁的保护范围里孵化长命后台进程。

第三个后果是它反过来解释了 shell 惯用法的形态。网上流传的标准写法长这样:

(
  flock -n 9 || { echo "已有实例在运行"; exit 1; }
  # ... 临界区 ...
) 9>/run/backup.lock

很多人照抄却不理解为什么这么绕。拆开看:子 shell 结尾的 9>/run/backup.lock 把锁文件以 fd 9 打开,于是这个 fd 的生命周期就精确等于子 shell 的生命周期;flock -n 9 尝试对这个现成的 fd 加锁;子 shell 退出时 fd 9 关闭,锁随之释放。用 fd 而不是 flock file command 的好处是临界区里可以随便跑任意复杂的 shell 逻辑,包括函数、管道、再嵌套子 shell,而不用担心命令形态是否符合 flock file command 的参数格式。本质上,这是在用 shell 的 fd 语义手动对齐 flock 的锁语义——两套语义本来就是同构的。

与 fcntl 锁的对照:两套互不相通的体系

讲 flock 不得不提它的”对家”:POSIX 记录锁,即 fcntl(F_SETLK) / lockf。两者的对比如下:

维度flockfcntl 记录锁
粒度整个文件任意字节区间
归属open file description进程
fork 继承子进程继承(共享同一把)不继承
释放时机所有 fd 副本关闭任意一个 fd 关闭即全释放
生态shell、脚本、cron数据库、邮件服务器等系统软件

注意最后一行之前的每一行,两者几乎处处相反。fcntl 锁”任意 fd 关闭即全释放”是另一个著名大坑:对同一个文件 open 两次,close 其中一个 fd,另一个 fd 上精心加好的记录锁也一起没了——多少数据库早期版本在这个坑里挣扎过。而 fork 不继承的特性又让父子协作变麻烦。

更微妙的是,这两套锁互不感知。进程 A 用 flock 锁了文件,进程 B 用 fcntl 对同一文件加锁照样成功,两边都以为自己独占,实际临界区裸奔。所以工程上的铁律是:一个项目、一组会互相冲突的文件访问,必须统一用同一套锁机制,并且写进代码约定里。

边界:什么时候不该用它

flock 的能力边界同样清晰。首先别在 NFS 上赌它。flock 语义在 NFS 上的行为取决于客户端内核版本和挂载参数,历史上走过”完全不支持”、“映射成 POSIX 锁”、“仅本机有效”好几条不同的路,今天依然不构成可靠的跨主机互斥。一旦需要多台机器抢同一个任务、同一份资源,就该换 Redis 的 SET NX、etcd 的 lease、ZooKeeper 这类带租约语义的分布式锁——它们额外解决了 flock 从来不处理的持锁者宕机问题:flock 的”自动释放”依赖 fd 关闭,进程被 kill 时内核代劳没问题,但整台机器断电后锁何时释放、网络分区后谁是持锁者,这些它无能为力。

其次是别把它当通用同步原语。flock 只有互斥这一个语义,没有条件变量、没有读写外的信号通知、没有公平性承诺,等待方能不能拿到锁基本看内核等待队列的脸色。用线程思维在多进程间搭复杂的同步协议,选它一定痛苦。

顺带一提,systemd 时代有个更省心的替代:由 systemd timer 触发的同名 service,上一轮没跑完时 systemd 天然不会并发拉起第二个实例,等于框架层面免费送了防重入。如果任务本来就跑在 systemd 体系里,未必要手工加锁;cron + flock 的组合胜在零依赖、可移植、行为一眼见底。

收尾

flock 是个几行就能上手的工具,但把它真正用对,你得顺带理解 Unix 的 fd 模型:open 与 fd 的关系、fork 对 fd 的继承、close 才是锁的终点。这也是我一直觉得系统编程入门该讲讲它的原因——它是一个把”锁”和”文件描述符生命周期”这两块硬骨头串在一起的绝佳教具。

更重要的是它留下的那套通用提问法。任何锁,用之前问三个问题:谁持有?何时释放?有没有绕过它的路径?flock 的答案分别是”持有 open file description 的这组进程”、“所有 fd 副本关闭时”、以及”任何不参与协定却直接写文件的代码”。这三个问题放到 Redis 分布式锁、数据库行锁、甚至编程语言里的 mutex 上,一个字都不用改。工具会过时,提问的方式不会。