操作系统内功 · 第 39 篇|文件锁:flock、fcntl 与分布式文件锁

一个每周都发生的经典事故:运维写了个 cron 任务每 5 分钟跑一次批处理,某天上游变慢,任务从 3 分钟拖到 8 分钟——于是两个实例同时在跑,数据被写坏。标准解法是在脚本开头加一把锁:第二个实例发现已有实例在跑就直接退出。但当你真去实现时,Linux 提供了两套语义完全不同的文件锁 API——flock 与 fcntl——选错一个,锁在某个角落静默失效:比如你用 fcntl 锁住了文件,然后代码里随手 close 了 dup 出来的一个 fd,锁没了,并发实例乘虚而入。文件锁的水远比「加锁-解锁」深:所有权绑在 fd 还是进程上、锁范围是全文件还是字节区间、进程退出是否自动释放、NFS 上还能不能工作、死锁谁来检测——每一项两个 API 的答案都不同。这一篇把两套语义逐项对比,讲清那个坑死无数人的 close 释放规则,介绍 Linux 3.15 之后的第三种选择 OFD 锁,最后给出选型决策图与 lslocks 观测方法。

一、flock:锁跟着打开实例走

flock(fd, LOCK_EX) 是 BSD 血统的锁,语义最简单:锁与 OFD(open file description)绑定。整把锁只有三种操作——排它锁(LOCK_EX)、共享锁(LOCK_SH)、解锁(LOCK_UN),不锁范围,一锁就是整个文件。默认阻塞等待,传 LOCK_NB 变成非阻塞(拿不到立即返回 EWOULDBLOCK)。它的「所有权」规则由此派生:同一进程可以对同一文件重复 flock(引用计数递增),所有指向同一 OFD 的 fd(dup、fork 继承而来)共享同一把锁,任何一个 close 只是减引用,最后一个指向该 OFD 的 fd 关闭时锁才释放;而 close 一个指向其他 OFD 的 fd(比如重新 open 同一文件得到的另一个 fd)不影响这把锁。锁的持有与进程身份无关——把 fd 传给子进程,子进程持有的还是同一把锁。Shell 下的用法最直观:

$ cat batch.sh
#!/bin/bash
exec 9>>/var/lock/batch.lock          # 打开锁文件,fd 9
flock -n 9 || exit 0                  # 非阻塞加排它锁,失败即退出
# ... 长时间批处理 ...
$ ./batch.sh & ./batch.sh &           # 第二个实例立即 exit 0
[1] 22301
[2] 22302

flock 命令行工具(util-linux)封装了同样的逻辑,flock -n /var/lock/batch.lock -c '...' 一行搞定单实例。注意锁文件 /var/lock/batch.lock 本身不需要预先存在、也不需要删除——它只是锁的「挂点」,真正的互斥由内核保证。很多人纠结「锁文件要不要清理」,答案是不要:清理动作本身有竞态(A 判断没人锁、删除文件、B 恰好在此刻锁上旧 inode、A 又新建同名文件——此后两个进程锁的是两个不同的 inode,互斥彻底失效)。锁文件永远留着,这是文件锁与信号量等同步原语的一个重要差异。

二、fcntl:锁跟着进程走,还能锁区间

fcntl(fd, F_SETLK, &fl) 是 POSIX 血统的锁(System V 记录锁),能力更强、语义更绕。三个关键差异。第一,锁与「(进程,inode)」二元组绑定,与 fd 无关:同一进程对同一文件的任何 fd 执行 close,都会释放该进程在此 inode 上的全部 fcntl 锁——不管锁当初是通过哪个 fd 加的。这条规则是 POSIX 规范明文规定的(设计动机是防止长进程泄漏锁),也是 Linux 文件锁事故的第一大坑,下节专门展开。第二,支持字节区间锁(l_start/l_len/l_whence 定位,l_len=0 表示锁到文件尾),数据库和编辑器按行/按记录加锁靠这个能力。第三,F_GETLK 可以查询「这个区间被谁锁着」而不阻塞——flock 没有查询接口,fcntl 因此能实现死锁检测(F_SETLKW 阻塞等待时,内核自动沿等待链检测死锁环,检测到就打破并返回 EDEADLK,flock 阻塞时只会永远等下去)。另外 fcntl 锁是进程级互斥:同一进程对同一区间的第二次加锁会替换第一次(不会自阻塞),这意味着 fcntl 锁不能用来做同进程内的线程互斥——它对同进程的所有线程一视同仁,天然「放自己人进去」。

fcntl 锁还有一个与 flock 截然不同的身份设定:它是建议锁(advisory lock)——内核不阻止任何 read/write 系统调用,锁只对「也调用 fcntl 检查锁的其他进程」有效。恶意或无知的进程可以直接绕过锁写文件,内核不会拦。flock 同样是建议锁。这与 Windows/某些 UNIX 变体的强制锁(mandatory lock)不同;Linux 的强制锁机制(mount -o mand + setgid 位)自 4.5 内核起已标记为废弃并在 5.15 移除,社区结论是「强制锁的实现有漏洞且没人用」。所以文件锁防的是「规矩的合作者」,不是「恶意进程」——真正的强制互斥靠权限体系(capabilities、ACL)实现。

三、两大死亡陷阱:close 释放与 dup 语义

把两个 API 的所有权差异并排列出,才能看清陷阱在哪:

/* 陷阱现场:fcntl 锁被一次「无关的 close」悄悄释放 */
int lock_fd = open("/data/state.db", O_RDWR);
struct flock fl = { .l_type = F_WRLCK, .l_whence = SEEK_SET, .l_start = 0, .l_len = 0 };
fcntl(lock_fd, F_SETLK, &fl);          /* 进程级加锁成功 */

int tmp = open("/data/state.db", O_RDONLY);  /* 同一文件的另一个 fd */
close(tmp);                             /* 陷阱触发!
   POSIX 规定:对 inode 的任何 fd 执行 close,
   释放「本进程」在该 inode 上的所有锁。
   此时锁已消失,其他进程立刻能拿到锁。 */

/* flock 的对照行为:锁绑在 lock_fd 的 OFD 上,
   close(tmp) 关的是另一个 OFD,锁安然无恙。 */

第二个陷阱是 fork 场景下两套语义的方向相反。flock:子进程继承 fd 即继承锁,父子持有同一把锁(同一 OFD),父 close 后子还持有——适合「父进程拿锁、子进程干活」的模型。fcntl:锁属于进程本身,fork 之后子进程不继承锁(锁的进程身份不传递)——如果你期望「子进程也能被这把锁保护」,会直接落空。两个 API 一个「锁随 fd 走」一个「锁随进程走」,dup 又把它们交叉出第三种组合:fcntl 锁住后 dup(lock_fd) 得到 fd2,close(fd2) 也会释放锁(还是那个 close 规则)——代码评审时「随手关 fd」的审查点必须覆盖所有指向锁文件的描述符。Shell 脚本里这个陷阱换了个马甲:exec 9>lockfile; flock 9; exec 9>&- 若写两遍(第二次重开 fd 9 再关),第二遍的 close 会把锁放掉。

四、OFD 锁与选型决策

Linux 3.15(2014)补上了两套 API 之间的空档:OFD 锁(F_OFD_SETLK 等命令),所有权绑定 OFD 而非进程——既保留 fcntl 的区间锁、查询、死锁检测能力,又改用 flock 式的「锁随打开实例走」规则:close 任一 fd 只在「这是该 OFD 最后一个 fd」时释放锁,fork 的子进程不自动继承。它修复的正是上一节的两个陷阱,是新代码(尤其是库代码)的推荐选择——SQLite 3.35+(WAL 模式锁实现)等主流软件已逐步迁移。三套机制的选型逻辑画成图:

flowchart TD
    A{文件锁选型} --> B{锁的粒度?}
    B -- "整个文件" --> C["flock<br/>语义最简单,shell 可用<br/>fork 继承、引用计数释放"]
    B -- "字节区间 / 需要查询 / 死锁检测" --> D{运行环境?}
    D -- "Linux 3.15+ 内核" --> E["OFD 锁 F_OFD_SETLK<br/>区间锁 + OFD 所有权,避开两大陷阱"]
    D -- "需要 POSIX 可移植" --> F["fcntl F_SETLK<br/>务必包一层封装防止误 close<br/>记住同进程重复锁会替换"]
    C --> G{是否在 NFS 上?}
    E --> G
    F --> G
    G -- "是" --> H["flock→NLM 锁、fcntl→NFSv4 原生锁<br/>跨客户端语义仍有差异,务必实测"]
    G -- "否" --> I["本地语义可靠"]

NFS 一栏值得单独一句:flock 在 NFS 上经 NLM(Network Lock Manager)协议映射到服务端,fcntl POSIX 锁在 NFSv3 走 NLM、NFSv4 走协议内置的锁状态;「锁在哪个客户端崩溃后多久释放」(NFSv4 的 lease 期限,常见 90 秒)这类参数直接决定故障恢复行为。分布式文件系统(GlusterFS、CephFS)对 flock 的支持程度各异,CephFS 原生支持 fcntl/flock 且语义对齐本地,但跨节点的锁等待延迟受网络 RTT 支配——「本地微秒、NFS 毫秒、跨数据中心几十毫秒」的量级差异意味着:文件锁只适合同主机或同文件系统内的互斥。跨机器的互斥属于分布式锁的领域(Redis SETNX+TTL、etcd lease、ZooKeeper 临时顺序节点),那些方案的权衡(锁续期、脑裂、 fencing token)是另一个话题——但边界必须刻在心里:看到有人在 NFS 挂载点上用 flock 做跨机房互斥,基本可以预言一场故障。

还有一个排障时极易迷惑的事实:flock 与 fcntl 是两套独立的锁表,互相不可见。内核为每个 inode 维护着两条链——flock 链表与 POSIX 链表——进程 A 用 flock 拿到排它锁后,进程 B 用 fcntl 对同一文件加写锁照样成功,两套机制完全互不感知。这个设计的历史原因是 BSD(flock)与 System V(fcntl)两个血统在 90 年代合并时谁也没吞掉谁。它的工程含义很具体:同一个互斥场景必须全程用同一种锁;如果锁的加放散落在多个工具(shell 脚本用 flock 命令、Python 用 fcntl)里,互斥就是幻觉。判断存量代码用的是哪种锁,lslocks 命令输出的 NAME 列没有区分,但 cat /proc/locks 可以:每行开头的 FLOCK 标记是 flock 锁,POSIX ADVISORY 标记是 fcntl 锁,OFDLCK 是 OFD 锁——混用时一眼看出「谁锁了谁没锁」。另外两个文件系统层面的细节:其一,网络文件系统上 flock 的支持取决于挂载时的 local_lock 选项(内核 3.15+ 允许 local_lock=all 把锁降级为纯本地语义,跳过 NLM 往返——单机场景的性能优化,但跨客户端互斥随之失效);其二,overlayfs(容器的联合文件系统)在 5.x 早期内核对 fcntl 锁的支持有已知缺陷(copy-up 时锁丢失),容器里跑 SQLite 等依赖 fcntl 锁的软件遇到诡异并发问题,先查内核版本的 overlayfs 修复历史。

五、实战观测:lslocks 与锁争用定位

锁问题排查的第一工具是 lslocks(util-linux),它把内核锁表(/proc/locks)翻译成人话:

$ lslocks
COMMAND           PID  MODE  M  START  END PATH
nginx             9120 WRITE  0      0     0 /var/log/app.log
mysqld           12045 WRITE  0  1073741826 1073744182 /data/mysql/ibdata1
tmux...attach    22103 READ*W 0   128     128 /dev/ptmx

MODE 列的 READ*W 表示有进程在等待(W)这个读锁——锁争用的直接证据。START/END 是字节区间偏移(除以 4096 后的单位),0/0 表示全文件锁(flock 或 fcntl 全文件锁)。cat /proc/locks 可以看原始格式(每行一条锁,含 fl_flag 状态位),strace -e trace=flock,fcntl -p <pid> 能实时确认进程正在执行哪类锁调用。性能视角最后补一句:文件锁的加锁/解锁是系统调用,flock 在本地 ext4 上的开销大约 1~2μs(无争用时),争用激烈时阻塞在内核等待队列,perf 火焰图会出现 flock_wait/posix_lock_inode 帧——文件锁不是零成本原语,高频短锁(每请求一把)应改用进程内互斥或 futex,文件锁的合理粒度是「单实例保护」「批量任务互斥」这类低频长周期场景。

小结

Linux 文件锁两套 API 的所有差异都源于所有权模型:flock 的锁绑定 OFD——fork 继承、dup 共享、引用计数归零才释放,无区间、无查询、简单可靠,shell 单实例场景的首选;fcntl 的锁绑定「进程+inode」——能力更强(区间、查询、死锁检测),但「任一 fd close 即释放全锁」与「fork 不继承」两条规则制造了无数静默失效事故,使用必须封装隔离;OFD 锁(F_OFD_SETLK,Linux 3.15+)继承了 fcntl 的能力与 flock 的所有权模型,是新代码的推荐路径。所有文件锁都是建议锁,内核不拦绕过锁的写入,强制互斥要靠权限体系实现;文件锁的适用边界止于同一文件系统——NFS 上的锁经网络协议映射、延迟与故障语义都变了,跨机器互斥请直接进入分布式锁领域。锁文件永不删除(删了就出现双 inode 双锁竞态)、close 审查覆盖锁 fd、用 lslocks 看 W 标记定位争用——这三条纪律能把文件锁事故拦下大半。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?