在 Linux 多进程或多线程开发中,文件锁(File Locking)是一个绕不开的坎。很多人在遇到进程间同步、防止程序多开、或者写入同一日志文件时,会随便搜一段代码,调个 flock 或者 fcntl 就上线了。
结果往往是:本地测试好好的,线上高并发或者引入某个第三方库后,文件锁莫名其妙失效了,或者干脆导致进程死锁、数据损坏。
要彻底搞懂 flock、fcntl 和 lockf 的区别,避开那些埋在 Linux 内核深处的“惊天巨坑”,我们需要从内核的数据结构开始剖析。
一、 内核视角:决定文件锁命运的三大结构
在 Linux 内核中,一个“打开的文件”由三个层次的结构体共同维护:
- 进程控制块(
task_struct):每个进程都有,里面包含一个文件描述符表(Fd Table),指向具体的struct file。 - 打开的文件描述(
struct file):每次调用open()都会创建一个。它记录了文件打开模式、当前偏移量(Offset)等。通过dup()、dup2()或fork()复制的文件描述符,会共享同一个struct file。 - 文件的物理节点(
struct inode):代表磁盘上真实的文件。多个不同的struct file(比如多次open同一个物理文件)最终都指向同一个struct inode。
这就是理解文件锁的关键:你的锁,到底挂在哪个结构体上?
二、 三种文件锁的本质区别
1. flock:简单粗暴的“文件级”锁
flock 源自 BSD 系统,它的特点是简单、关联于“打开的文件描述”(struct file)。
- 锁定范围:只能锁整个文件,不能锁文件的某一个区间(Byte-range)。
- 锁的归属:锁关联在
struct file上。 - 多进程/多线程行为:
- 通过
fork()创建的子进程,或者通过dup()复制的 FD,因为共享同一个struct file,所以它们共享同一把锁。子进程不需要重新加锁,释放时也需要所有关联的 FD 都关闭才真正释放。 - 如果是同一个进程内,通过两次独立的
open()打开同一个文件,会创建两个struct file。此时,这两个 FD 之间互斥。同一个进程的两个线程如果各自open了一次,它们会互相阻塞。
- 通过
2. fcntl:精细但扭曲的“进程级”锁(POSIX 记录锁)
fcntl 是 POSIX 标准的文件锁,支持记录锁(Record Locking),可以只锁定文件中的某一段字节。
- 锁定范围:可以精确到字节级别(offset + length)。
- 锁的归属:锁关联在
(Process, inode)二元组上。也就是说,这把锁不仅和文件节点(inode)绑定,还和具体的进程(PID)强绑定。 - 多进程/多线程行为:
fork()产生的子进程不会继承fcntl锁。因为子进程的 PID 变了。- 在同一个进程内部,无论你
open()了多少次这个文件,创建了多少个struct file,只要是在同一个进程里,它们都属于同一个 PID。因此,同进程内的不同线程/不同 FD 之间无法通过 fcntl 锁实现互斥。一个线程加了锁,另一个线程去读写,内核会认为“这是同一个所有者”,直接放行。
3. lockf:名不副实的“冒牌货”
在 Linux 中,lockf 并不是一种新的文件锁。它只是 fcntl 的一个封装函数(Wrapper)。
如果你去看 glibc 的源码,你会发现 lockf(fd, cmd, len) 在底层被转换成了 fcntl(fd, F_SETLK, ...) 的调用。因此,lockf 具有 fcntl 的所有特性、所有限制,以及接下来我们要讲的所有致命缺陷。
三、 那些埋在暗处的“致命陷阱”
如果你没有深入内核机制,以下三个陷阱随时可能让你的生产环境崩溃。
陷阱一:fcntl 的“无差别释放”灾难(The Silent Release)
这是 POSIX fcntl 锁设计上最受人诟病的“垃圾设定”。
规则:一个进程内,只要关闭了指向该文件的任何一个文件描述符(FD),该进程在这一文件上持有的所有 fcntl 锁都会被默默释放。
看一个真实发生的灾难场景:
- 你的业务代码
open("/data/db.lock")得到fd_1,并使用fcntl加了排他锁。 - 你的代码调用了一个第三方开源 SDK(比如某个日志库或者监控库)。
- 这个 SDK 内部不讲武德,也去
open("/data/db.lock")读了一下配置,得到fd_2。 - SDK 读取完毕,调用
close(fd_2)释放资源。 - 灾难发生:由于
fd_2对应的物理文件和fd_1一样(指向同一个inode),内核在处理close(fd_2)时,会直接把当前进程在/data/db.lock上所有的 fcntl 锁全部释放! - 此时,你的
fd_1以为自己还安全地持有锁,但实际上锁已经消失了。另一个进程乘虚而入,两个进程同时写入,数据瞬间损坏。
陷阱二:flock 在多线程下的“自己人打自己人”
很多开发者以为 flock 既然是文件级别的锁,那用来做多线程同步肯定没问题。
- 错! 如果你在同一个进程的两个线程里,分别调用
open()打开同一个文件,由于生成了两个不同的struct file,这两个线程在使用flock时会互相阻塞,甚至导致死锁。 - 如果你想在多线程里用
flock且不冲突,必须共享同一个 FD(通过dup或者是主线程传过来的同一个 FD),但这往往又失去了锁的初衷(无法起到互斥保护作用)。 - 结论:不要用
flock或fcntl做进程内的多线程同步,多线程同步请老老实实使用pthread_mutex。
陷阱三:fork 与 FD 泄露导致的锁不释放
对于 flock 来说:
- 父进程
open并flock锁定了文件。 - 父进程
fork()出子进程。此时子进程继承了 FD,也间接持有了这个flock(因为共享struct file的引用计数)。 - 父进程退出,或者显式调用了
close(fd)。 - 结果:文件锁依然生效!因为子进程还拿着那个 FD,引用计数不为 0,内核不会释放锁。如果你的子进程是一个常驻后台的 Daemon 进程,这个锁将永远无法被释放,导致后续启动的父进程永远无法再加锁。
四、 现代 Linux 的救星:OFD 锁(Open File Description Locks)
鉴于 fcntl 饱受诟病的“任意 close 导致锁释放”问题,以及 flock 无法支持分段锁的局限,Linux 内核在 3.15 版本引入了 OFD 锁(Open File Description Locks)。
OFD 锁在 fcntl 的基础上进行了完美的改良,它把锁关联到了 打开的文件描述(struct file) 上。
// 使用 OFD 锁的典型代码
struct flock lck;
lck.l_type = F_WRLCK;
lck.l_whence = SEEK_SET;
lck.l_start = 0;
lck.l_len = 0; // 锁整个文件
// 使用 F_OFD_SETLKW 代替普通的 F_SETLKW
fcntl(fd, F_OFD_SETLKW, &lck);
OFD 锁的降维打击优势:
- 安全的 Close 行为:关闭一个 FD,只有当它是该
struct file的最后一个引用时(即没有其他dup或子进程继承),锁才会释放。第三方库默默 open/close 同一个文件,绝对不会干扰你的锁。 - 支持分段锁:继承了
fcntl的优良传统,可以锁定文件的任意区间。 - 多线程安全:同一进程的不同线程,如果使用不同的
struct file(各自 open),可以实现互斥;如果共享同一个 FD,则共享锁。完美契合直觉。 - Fork 友好:和
flock一样,fork出来的子进程共享锁,不需要重新加锁。
五、 终极避坑指南:我该选哪个?
为了方便记忆,我们把这几种锁的行为做个横向对比:
| 特性 / 维度 | flock |
fcntl (POSIX) |
lockf |
OFD锁 (Linux 3.15+) |
|---|---|---|---|---|
| 锁关联的实体 | struct file (打开文件) |
(Process, inode) |
(Process, inode) |
struct file (打开文件) |
| 锁定粒度 | 整个文件 | 字节区间 (Record) | 字节区间 (Record) | 字节区间 (Record) |
| Fork 继承性 | 是 (共享锁) | 否 | 否 | 是 (共享锁) |
| 任意 Close 释放 | 否 | 是 (极度危险) | 是 (极度危险) | 否 |
| 多线程冲突 | 取决于是否独立 open |
无法在同进程内互斥 | 无法在同进程内互斥 | 取决于是否独立 open |
| 网络文件系统(NFS) | 早期不支持,现代支持 | 支持 | 支持 | 支持 |
最佳实践建议:
- 首选方案:如果你在较新的 Linux 环境(Kernel 3.15+,目前绝大多数生产环境都已满足),且需要高可靠性,强烈建议使用 OFD 锁(即通过
fcntl传入F_OFD_SETLK)。它完美解决了fcntl的短命问题和flock的粗粒度问题。 - 次选方案(简单进程排他):如果只是为了防止程序多开(Single Instance),或者做一个极简的进程互斥,用
flock锁一个特定的 pid 文件即可。代码简单,不容易出错。 - 坚决弃用:除非为了兼容极古老的 POSIX 标准系统,否则不要再使用传统的
fcntl记录锁和lockf。它们所带来的“任意 close 即释锁”特性,在现代复杂的、多模块嵌套的工程中就是一颗不定时炸弹。