有两个进程要同时写一个日志文件。进程 A 写 "[start] 处理订单 1001",进程 B 写 "[start] 处理订单 1002"。你期望的日志是:
[start] 处理订单 1001
[end] 处理订单 1001
[start] 处理订单 1002
[end] 处理订单 1002
但跑起来经常变成:
[start] 处理订单 1002
[start] 处理订单 1001
[end] 处理订单 1001
[end] 处理订单 1002
两个进程的写操作交叉了。不是 write() 线程不安全——write() 本身是原子的(在一次系统调用内),问题是两次 write() 之间,内核调度了另一个进程,它也调了 write(),内容就交叉了。
加文件锁,就是告诉内核:"这两行 write() 之间,别让别的进程插进来。"
最简单的用法
#include <fcntl.h>
#include <unistd.h>
int fd = open("/var/log/app.log", O_WRONLY | O_APPEND);
struct flock lock = {
.l_type = F_WRLCK, // 写锁
.l_whence = SEEK_SET,
.l_start = 0,
.l_len = 0, // 0 表示锁整个文件
};
// 阻塞等锁
fcntl(fd, F_SETLKW, &lock);
// 临界区:这两行 write 不会被其他进程打断
write(fd, "[start] 处理订单 1001\n", 29);
write(fd, "[end] 处理订单 1001\n", 27);
// 解锁
lock.l_type = F_UNLCK;
fcntl(fd, F_SETLKW, &lock);
close(fd);
F_SETLKW 的 W 是 wait——拿不到锁就等。如果是 F_SETLK(不带 W),拿不到锁就立刻返回 -1,errno 是 EAGAIN。
关于 l_len = 0 的精确含义:按照 POSIX 标准,l_len = 0 表示从 l_start 开始一直锁到文件的最大偏移量(即锁的范围是 [l_start, 文件末尾]),并且会随着文件增长而增长。如果文件后来追加了数据(比如 O_APPEND),新数据也会落在锁的范围内。
能锁整个文件,也能锁文件的一部分:
struct flock lock = {
.l_type = F_WRLCK,
.l_whence = SEEK_SET,
.l_start = 4096, // 从第 4096 字节开始
.l_len = 1024, // 锁 1024 字节
};
这个"字节范围锁"是 POSIX 锁最有用的特性——多个进程可以同时锁同一个文件的不同区域,互不影响。
内核里锁是怎么存的
你调了 fcntl(F_SETLKW),内核拿到这个请求,第一步是找到这个文件对应的 inode。
每个文件的 inode 里有一个指针 i_flctx,指向一个 struct file_lock_context:
// include/linux/fs.h
struct file_lock_context {
spinlock_t flc_lock; // 保护下面三根链表的自旋锁
struct list_head flc_posix; // POSIX 锁挂这里
struct list_head flc_flock; // flock 锁挂这里
struct list_head flc_lease; // lease 锁挂这里
};
你加的每把锁,内核分配一个 struct file_lock:
struct file_lock {
struct file_lock_context *fl_ctx; // 反向指针,指向所属的 context
struct list_head fl_list; // 挂到 flc_posix 链表的节点
struct hlist_node fl_link; // 挂到全局哈希表的节点
fl_owner_t fl_owner; // 这把锁属于谁(POSIX:进程的文件表)
unsigned int fl_flags; // FL_POSIX / FL_FLOCK / FL_OFD
unsigned char fl_type; // F_RDLCK / F_WRLCK / F_UNLCK
loff_t fl_start; // 锁定起始字节
loff_t fl_end; // 锁定结束字节(OFFSET_MAX = 整个文件)
struct list_head fl_blocked; // 被这把锁阻塞的锁,挂在这个链表上
struct list_head fl_blocked_requests; // 阻塞请求链表
};
图里的关系很清晰:一个 inode 对应一个 file_lock_context,context 里三根链表分别挂不同类型的锁,每把锁除了挂链表,还挂在全局的 file_lock_hashtable 上(用来做死锁检测,后面会讲)。
加锁时内核在做什么
从 fcntl() 系统调用开始,调用链是:
sys_fcntl() → do_fcntl() → fcntl_setlk() → locks_lock_inode() → posix_lock_inode()
核心逻辑全在 posix_lock_inode() 里,这个函数大概 200 行,但核心就三件事。
第一件:查冲突
遍历 flc_posix 链表,对每一把已存在的锁,判断"我要加的锁"和"这把已存在的锁"是否有冲突。
冲突的判断条件:
// fs/locks.c 里的简化逻辑
static int locks_conflict(struct file_lock *caller, struct file_lock *blocker)
{
// 1. 范围不重叠 → 不冲突
if (caller->fl_end < blocker->fl_start ||
blocker->fl_end < caller->fl_start)
return 0;
// 2. 同一个 owner → 不冲突(自己加的锁,自己可以再碰)
if (caller->fl_owner == blocker->fl_owner)
return 0;
// 3. 都是读锁 → 不冲突
if (caller->fl_type == F_RDLCK && blocker->fl_type == F_RDLCK)
return 0;
// 4. 其他情况 → 冲突
return 1;
}
注意第 2 条:同一个进程,先加了读锁,再加写锁,不会冲突。内核会帮你把读锁"升级"成写锁(实际上是在链表上再插一把写锁,范围重叠的读锁还在)。
第二件:没冲突 → 插入链表
把新锁按 fl_start 排序,插入 flc_posix 链表。fl_start 小的在前,相同的按 fl_owner 排。
插入后返回 0,fcntl() 返回 0,你的进程拿到锁,继续往下走。
第三件:有冲突 → 阻塞或返回错误
如果调用的是 F_SETLK(非阻塞),直接返回 -EAGAIN,你的 fcntl() 返回 -1,errno = EAGAIN。
如果调用的是 F_SETLKW(阻塞),把你挂到冲突锁的 fl_blocked 链表上,然后调用 wait_event():
// 简化版
long __locks_lock_inode_wait(struct inode *inode, struct file_lock *fl)
{
int error;
might_sleep();
for (;;) {
error = posix_lock_inode(inode, fl, NULL);
if (error != FILE_LOCK_DEFERRED)
break;
// 挂到等待队列,等锁释放时被唤醒
wait_event(fl->fl_wait, !locks_is_locked(fl));
}
return error;
}
wait_event() 会让出 CPU,进程状态变成 TASK_INTERRUPTIBLE。锁持有者调用 F_UNLCK 释放锁时,内核遍历 fl_blocked 链表,调用 wake_up() 唤醒所有等这把锁的进程。被唤醒的进程重新调 posix_lock_inode() 尝试加锁。
解锁时内核在做什么
解锁(F_UNLCK)走的是同一个 posix_lock_inode(),但参数 fl_type = F_UNLCK。
流程:
- 在
flc_posix 链表里找到和 fl_owner + fl_start + fl_end 匹配的锁 - 调用
locks_wake_up_blocks(),遍历这把锁的 fl_blocked 链表 - 对
fl_blocked 链表上的每一把等待锁,调用 wake_up() 唤醒对应的进程
被唤醒的进程从 wait_event() 返回,重新进入 posix_lock_inode() 尝试加锁。这次大概率能成功(除非又被新来的锁挡住)。
进程崩溃了,锁怎么办
这是 POSIX 文件锁最巧妙的地方。
每个进程的 struct files_struct(文件描述符表)里有一个指针 file_lock_ctx。进程退出时,内核调用:
// fs/locks.c
void locks_remove_posix(struct files_struct *files, struct file *filp)
{
struct file_lock_context *ctx;
struct file_lock *fl, *tmp;
ctx = file_inode(filp)->i_flctx;
if (!ctx)
return;
spin_lock(&ctx->flc_lock);
list_for_each_entry_safe(fl, tmp, &ctx->flc_posix, fl_list) {
if (fl->fl_owner == files) {
// 找到属于这个进程的锁
fl->fl_type = F_UNLCK;
posix_lock_inode(file_inode(filp), fl, NULL); // 解锁
locks_delete_lock(fl); // 从链表移除
}
}
spin_unlock(&ctx->flc_lock);
}
exit_files() → close_files() → locks_remove_posix(),这条路径在进程退出时一定会被调用——不管进程是正常退出、被 kill -9、还是 OOM killer 杀的。
所以你不用担心"进程崩了锁没释放"的问题。这是 POSIX 锁比 pthread mutex 更"崩溃安全"的地方——pthread mutex 需要 ROBUST 属性才能处理这种情况,POSIX 锁是内核自动处理的。
死锁检测
两个进程互相等对方的锁,就死锁了:
// 进程 A
lock_range(fd, 0, 100); // 锁 [0, 100]
sleep(1);
lock_range(fd, 200, 100); // 等进程 B 释放 [200, 300] → 死锁!
// 进程 B
lock_range(fd, 200, 100); // 锁 [200, 300]
sleep(1);
lock_range(fd, 0, 100); // 等进程 A 释放 [0, 100] → 死锁!
POSIX 标准要求 F_SETLKW 必须检测死锁。posix_lock_inode() 里调用 posix_locks_deadlock() 做检测:
// 简化版死锁检测
static int posix_locks_deadlock(struct file_lock *caller, struct file_lock *blocker)
{
struct file_lock *fl = blocker;
// 沿着阻塞链往上走
do {
if (fl->fl_owner == caller->fl_owner)
return 1; // 环路!死锁了
fl = fl->fl_blocked_by; // 我看谁的锁,继续往上走
} while (fl);
return 0;
}
检测到了,fcntl() 返回 -1,errno = EDEADLK。
但检测有局限:posix_locks_deadlock() 沿着 fl_blocked_by 指针链一直往上遍历,只要形成了环路(A 等 B,B 等 C,C 等 A,不管涉及几个进程、是否跨多个文件),内核都能检测到。
真正的局限是:如果你混用了不同类型的锁(POSIX 锁 + OFD 锁,或者 POSIX 锁 + flock()),它们的等待链表是分开的(flc_posix 和 flc_flock 是独立的),内核无法检测这种混合死锁。
所以不要依赖死锁检测来做应用层的死锁避免——正确的做法是在应用层约定"所有进程按同样的顺序加锁",并且项目中统一使用同一种锁协议(要么全用 POSIX,要么全用 OFD,要么全用 flock()),从根源上避免死锁。
致命陷阱:flock() 与 fcntl() 互不兼容
Linux 里,POSIX 锁(fcntl)和 BSD 锁(flock)是两套完全独立的锁系统。它们在内核里挂在不同的链表上(flc_posix vs flc_flock),互相不可见。
这意味着:如果进程 A 用 fcntl() 加了一把写锁,进程 B 用 flock() 加锁,进程 B 会成功拿到锁,完全绕过进程 A 的锁!
这是一个非常常见的并发 Bug——两个进程各自用了不同的锁协议,以为锁能保护临界区,结果根本没保护到。
在 /proc/locks 里能看到这种现象:
$ cat /proc/locks
1: POSIX ADVISORY WRITE 12345 08:03:174325 0 100
2: FLOCK ADVISORY WRITE 12346 08:03:174325 0 EOF
POSIX 和 FLOCK 条目挂在不同的链路上,内核不会帮它们做冲突检测。
所以,在项目中必须统一锁协议:要么全用 fcntl()(POSIX 或 OFD),要么全用 flock(),不能混用。
还有一个补充:flock() 在 NFS 上通常只是本地伪锁(早期实现),不能跨越 NFS 服务器限制其他客户端。只有使用 fcntl() 的 POSIX 锁,才会通过 rpc.lockd 真正发往 NFS 服务器,实现多机互斥。
一个真实的坑:读锁饿死写锁
POSIX 锁允许同一段范围上有多把读锁。如果读者一直不停来,写锁可能永远拿不到。
// 进程 A(读者)
while (1) {
lock_range(fd, 0, 100, F_RDLCK);
read(fd, buf, 100);
unlock_range(fd, 0, 100);
}
// 进程 B(写者)
lock_range(fd, 0, 100, F_WRLCK); // 等 A 释放读锁
// 如果 A 的循环很快,每次只持有读锁很短时间,
// 但一直有读者进来 → B 永远拿不到写锁
Linux 内核没有"读锁优先"或"写锁优先"的策略,就是 FIFO——谁先被阻塞,谁先被唤醒。但读者可以叠加(因为多个读锁彼此不冲突),所以实际上读者更容易拿到锁。当写锁被阻塞时,后续如果再来一个读者,只要中间没有写锁插入,它就能立刻拿到读锁。
对策:如果写锁很重要,用 F_SETLK 非阻塞模式,拿不到就重试(带退避)。但注意:如果项目中其他地方用了 flock(),就不能用 fcntl(),见上一节的致命陷阱。
OFD 锁:解决 POSIX 锁的一个设计缺陷
POSIX 锁的 fl_owner 是进程的 files_struct。这意味着:同一个进程,不管多少个线程、多少个 fd,加的锁都算"同一个 owner"。
// 线程 1
int fd1 = open("/data/file", O_RDWR);
lock_range(fd1, 0, 100, F_WRLCK);
// 线程 2(同一个进程)
int fd2 = open("/data/file", O_RDWR); // 同一个文件,不同 fd
lock_range(fd2, 0, 100, F_WRLCK); // 不冲突!因为同一个 owner
上面这个行为,有时候是你想要的(进程内部的锁共享),有时候不是(你想让不同线程独立加锁)。
Linux 3.15 引入了 OFD(Open File Description)锁,API 和 POSIX 锁几乎一样,但 fl_owner 是 struct file *(打开文件描述):
struct flock lock = {
.l_type = F_WRLCK,
.l_whence = SEEK_SET,
.l_start = 0,
.l_len = 100,
.l_pid = -1, // -1 表示 OFD 锁
};
fcntl(fd, F_OFD_SETLK, &lock);
l_pid = -1 不是随便写的。传统 POSIX 锁依靠 l_pid 来标识属主(PID),但 OFD 锁的属主是文件描述符 struct file *。使用 fcntl() 的 F_OFD_SETLK 命令时,内核会忽略 l_pid,将其设为 -1 是一种显式标记,表示不依赖 PID。
行为上的关键区别:
- 如果两个线程都执行
open() 同一个文件,拿到两个不同的 fd,使用 OFD 锁可以互相独立加锁(互不冲突)——因为每个 open() 都分配一个新的 struct file * - 如果是同一个 fd(比如线程 A 把 fd 传给了线程 B),即使不同线程加 OFD 锁,它们依然是同一个
struct file * 属主,因此不会冲突
在多线程模型下,这个区别值得注意。
怎么观察系统里的文件锁
/proc/locks 导出了内核里所有 POSIX/flock/OFD 锁:
$ cat /proc/locks
1: POSIX ADVISORY WRITE 12345 08:03:174325 0 100
2: POSIX ADVISORY READ 12346 08:03:174325 4096 5120
3: FLOCK ADVISORY WRITE 12347 08:03:174325 0 EOF
每一行的含义:
| 字段 |
含义 |
1: |
锁的序号 |
POSIX |
锁类型(POSIX/FLOCK/OFDLCK) |
ADVISORY |
锁模式(advisory/mandatory,Linux 5.14 后 mandatory 已删除) |
WRITE |
锁的读写类型(READ/WRITE) |
12345 |
持有锁的进程 PID |
08:03:174325 |
设备号:inode 号 |
0 100 |
锁的范围(字节 0~100) |
EOF |
锁的范围(0 到文件末尾) |
要追踪某个文件的锁,先查文件的 inode:
$ stat /var/log/app.log
File: /var/log/app.log
Device: 803h/2051d Inode: 174325
$ grep 174325 /proc/locks
1: POSIX ADVISORY WRITE 12345 08:03:174325 0 100
在 NFS 上用文件锁
NFS 的文件锁不是本地概念——锁的状态存在 NFS 服务器上。
Linux NFS 客户端用 rpc.lockd 和服务器通信。加锁时,客户端发 NLM_LOCK 请求到服务器;释放锁时,发 NLM_UNLOCK。
问题来了:如果客户端崩溃了(网络断了、机器宕机),服务器上的锁不会自动释放——因为服务器没收到 NLM_UNLOCK。
NFS 用 lease 机制解决这个问题:服务器给每个锁一个 lease 时间(默认 45 秒),客户端必须定期续租。客户端崩溃了,续租停止,45 秒后服务器自动释放锁。
但在这 45 秒内,其他客户端看不到这个文件被锁了(因为服务器还认为锁有效)。如果你的应用对锁的实时性要求很高,NFS 文件锁不是一个好选择。
本地文件系统(ext4、XFS、btrfs)没有这个问题——锁状态在内存里,进程退出时 locks_remove_posix() 立刻清理。
回到开头的问题
文章开头说的那个审计日志交叉问题,最终是怎么解决的?
最开始用了 fcntl() 范围锁,每次只锁要写的那个区域。后来发现审计日志的写入都是 append 模式(只在文件末尾追加),根本不需要范围锁——直接用 flock() 锁整个文件就够了,而且 flock() 的语义更简单,不会因为范围计算错误导致锁冲突。
但有一个前提:项目中所有进程都必须用 flock(),不能有的用 fcntl() 有的用 flock(),否则就会出现前面说的"致命陷阱"——锁根本没生效,两个进程以为各自锁住了,实际上谁也没锁住。
另一个教训:如果你的应用是多线程的,用 POSIX 锁要小心——同一进程的不同线程加的锁共享 owner,可能导致你以为"线程 A 的锁不会和线程 B 的锁冲突",结果两个线程同时写,数据还是乱了。这种场景用 OFD 锁(F_OFD_SETLK)更合适,每个 open() 的文件描述符有独立的锁 owner。
文件锁不是万能的。如果你的并发场景很复杂(比如多个进程同时写同一个文件的随机位置),文件锁的粒度可能不够——这时候应该考虑用数据库(SQLite 支持并发读、串行写,而且有 WAL 模式),或者把数据拆成多个文件,每个文件只由一个进程写。