55 文件锁:flock() 和 fcntl() 加锁

要点

Linux 提供了两种文件加锁系统调用:从 BSD 衍生出来的 flock() 和从 System V 衍生出来的 fcntl()。尽管这两组系统调用在大多数 UNIX 实现上都是可用的,但只有 fcntl() 加锁在 SUSv3 中进行了标准化。

要理解 flock()fcntl() 的锁分别和什么东西有关联,才能理解什么行为会导致锁的意外释放。“锁和什么有关联”意思就是以什么来标识锁的主人。而锁的对象则分别是整个文件(flock())和指定区域(fcntl())。锁是要放在锁的对象上的,所以锁链表在打开文件上记录。

flock() 对整个文件加锁

从 BSD 衍生而来。加的是劝告锁(advisory locking)。

flock(2):

SYNOPSIS
       #include <sys/file.h>

       int flock(int fd, int operation);

操作有三种:LOCK_SHLOCK_EXLOCK_UN。还可以上标志 LOCK_NB 表示非阻塞。从语义来看是标准的读写锁,但是却没有明确提到“读写”这个概念。

flock() 锁转换不是原子的

如果已经持有了文件的锁,现在指定了另外一种类型的锁,则会先解除锁定,再重新请求锁。这个过程称为锁转换,这个过程不是原子的!有可能在释放锁之后另外一个进程获得了锁,导致阻塞,或者(在非阻塞情况下)丢失了锁而失败返回。

flock() 锁的释放和继承

flock() 锁和打开的文件项(是 file description 而不是 descriptor)相关,如果打开文件项从系统打开文件表中撤下(引用计数归 0),则对应的文件项的锁会被释放。

另外一方面,显式释放锁会导致系统打开文件表上的这个文件项中记录的锁的释放!

  1. dup() 来的 fd 会指向同一个打开文件项,关闭其一会使得文件被关闭,锁也会被释放。如果想要一个 fd 一把锁,那就不要 dup(),而是打开同一个文件多次。
  2. flock() 锁可以跨越 fork(),但是子进程和父进程共享同样的打开文件项,因此共享同样的锁,操作不当(比如子进程关闭文件)将会导致父进程的锁一起丢失(实际上,锁并不以进程标识)。
  3. flock() 锁能跨越 exec(),除非文件项设置了 close-on-exec,这样锁会随着文件的关闭而解除。dup() 来的 fd、以及子进程继承的 fd 都指向同样的打开文件项,只有都关闭了之后 flock() 的锁才会释放,因此父进程可以通过关闭 fd 而将锁转让给子进程(这时只有子进程能通过 fd 控制锁)。

flock() 的限制

  1. 只能加劝告锁。
  2. 只能对整个文件加锁。
  3. 很多 NFS 实现对 flock() 支持不好。

内核如何维护 flock() 锁?

内核在与一个打开着的文件相关联的锁链表中维护着 flock() 锁与文件租用。/proc/locks 文件中可能会记录相关信息?

fcntl() 对文件部分区域加锁

从 System V 衍生而来。加的是劝告锁还是强制锁(mandatory locking)取决于文件是否打开了强制锁支持(权限位)。

fcntl() 可以对文件的一部分加锁,这种形式的锁也叫做记录锁(record locking)。SUSv3 要求至少能对普通文件加锁,Linux 实现中所有类型的文件都能加锁。

fcntl() 操作锁时可以指定 F_SETLK(非阻塞加锁)、F_GETLK(试探能否加锁)、F_SETLKW(阻塞加锁) 三种操作。

fcntl() 的锁在接口上有了“读写”的概念,因此有权限检查。为了加写锁,必须对文件有写入权限;为了加读锁,必须对文件有读取权限;如果同时加读写锁,则必须有读写权限(在同一个区域上只能有一把锁,但是文件的不同区域可以有不同类型的锁)。

fcntl() 的锁转换是原子的。如果同一个位置放新锁,锁的类型相同则直接返回,锁的类型不同则会发生原子转换,不会在丢掉锁的情况下返回。

在一块已锁定区域的中间一小片区域放一把类型不同的锁,会导致持有三把锁。

fcntl() 加锁有死锁检测功能。

记录锁的释放和继承

  1. fcntl() 记录锁不会跨越 fork(),这是因为记录锁和进程有关系(见第 3 点)。
  2. fcntl() 记录锁在 exec() 中会得到保留!
  3. fcntl() 记录锁和进程 + i-node 关联。同一个进程打开同一个文件多次,通过一个 fd 对文件上锁之后,关闭另外一个 fd 会因为文件关闭事件而使得文件锁丢失。其他 fd 虽然指向的是不同的系统打开文件项,但在同一个进程中依然共享一个锁。这和 flock() 不同,flock() 和文件描述关联,如果不显式释放锁,只有所有文件描述符都关闭之后文件描述对应的锁才会释放。

内核如何维护记录锁?

每个打开文件上都有一个记录锁链表。链表是有序的:先按进程号排序,再按起始偏移量排序。

强制锁支持

用 mand 选项挂载文件系统可以启用强制锁的支持。

mount -o mand /dev/sda10 /testfs

这种支持是通过打开文件的 set-group-ID 权限位、关闭 group-execute 权限位实现的。对单个文件启用强制锁支持:

chmod g+s,g-x /testfs/file

如果一个文件上被启用了强制锁支持,那么相当于每个用于读写的系统调用都会临时获取对应区域的锁(如果还没有锁的话)。

在一个开启了强制锁支持的文件上,mmap() 共享文件映射和任何的读写锁都是排斥的。这是因为 mmap() 共享文件映射需要读写权限,相当于读写锁的有效期是整个映射期间。不过,内核检查的更严格一点:只要是同一个文件,任意位置的强制读 / 写锁都和任意位置的 mmap() 排斥。

一些缺点:

  1. 强制读写检查并不影响删除文件。
  2. 特权进程也不能覆盖强制锁。(但可以杀掉持有锁的进程。)
  3. 强制上锁有性能开销。
  4. 读写系统调用更可能失败或阻塞了,应用设计变得复杂。

/proc/locks 文件和 lslocks 命令

记录了锁由什么进程持有(可以配合 ps 等工具查看进程信息)。如果锁类型前面加了 ->,表示这是一个被阻塞的锁请求(还没有获得锁),下面的例子中没有这种情况。

(base) xxx@yyy:~$ cat  /proc/locks
1: FLOCK  ADVISORY  WRITE 2356 08:01:258736157 0 EOF
2: FLOCK  ADVISORY  READ 184242 00:229:260005901 0 EOF
3: POSIX  ADVISORY  READ 3903125 103:03:13243512 128 128
4: POSIX  ADVISORY  READ 3903125 103:03:13238296 1073741826 1073742335
5: FLOCK  ADVISORY  WRITE 2356 08:01:258867202 0 EOF
6: POSIX  ADVISORY  READ 2141698 00:dc:262314226 128 128
7: POSIX  ADVISORY  READ 3811720 103:03:13255374 128 128
8: POSIX  ADVISORY  READ 3811720 103:03:13255370 1073741826 1073742335
9: FLOCK  ADVISORY  WRITE 5472 00:e5:15466515 0 EOF
10: FLOCK  ADVISORY  WRITE 2356 08:01:258736387 0 EOF
11: POSIX  ADVISORY  WRITE 1640 00:19:2400 0 EOF
12: POSIX  ADVISORY  WRITE 2268 00:19:2635 0 EOF
13: FLOCK  ADVISORY  WRITE 2356 08:01:258736388 0 EOF
14: FLOCK  ADVISORY  WRITE 2356 08:01:258736386 0 EOF
15: FLOCK  ADVISORY  WRITE 2356 08:01:258736247 0 EOF
16: OFDLCK ADVISORY  WRITE -1 08:01:258736332 0 EOF
17: POSIX  ADVISORY  READ 2372 103:03:14946021 128 128
...

POSIX 表示 fcntl() 申请的锁,FLOCK 表示 flock() 申请的锁。/proc/locks 还显示了文件租用的信息。

查找一个锁记录对应的进程是什么、文件是什么:

还可以用 lslocks 命令查看系统中创建的锁的情况(有点像 ipcs)。

(base) xxx ~ $ lslocks
COMMAND PID  TYPE SIZE MODE  M START END PATH
cron     82 FLOCK      WRITE 0     0   0 /run...

文件租用(书上没有,但是 man 手册很详细)

fcntl(2) 的 F_SETLEASE / F_GETLEASE 操作。

如果一个进程成为租用持有者(lease holder),其他进程(lease breaker)打开或截断文件(open()truncate())文件时,租用持有者会收到信号通知。

在使用 F_SETLEASE 操作使用以下参数之一:

  1. F_RDLCK:读锁。
  2. F_WRLCK:写锁。
  3. F_UNLCK:释放锁。

文件租用只能在普通文件上发生。非特权(CAP_LEASE)程序只能租用 owner 的 UID 和进程文件系统 UID 匹配的文件。

手册:

Leases are associated with an open file description (see open(2)). This means that duplicate file descriptors (created by, for example, fork(2) or dup(2)) refer to the same lease, and this lease may be modified or released using any of these descriptors. Furthermore, the lease is released by either an explicit F_UNLCK operation on any of these duplicate file descriptors, or when all such file descriptors have been closed.

租用和打开文件描述相关,这一点和 flock() 相似。

仅运行一个程序的单个实例

一般把 xxx.pid 这样的锁放在 /var/run 文件夹下。Daemon 在其执行期间一直持有这个文件锁并在即将终止之前删除这个文件。

比如,/var/run/syslogd.pid 是由 syslogd 创建的。(测试的 wsl、服务器都没有这个文件,可能没有启用 syslogd 这个功能?)我还在 wsl 上面发现了 /var/run/crond.pid 这个文件。

Debian 上可以安装 rsyslogd,但是我还是没有找到 /var/run/rsyslogd.pid 这个文件。

书上例子用的是 fcntl() 完成加锁(封装在了作者自己实现的 lockRegion() 函数中)。

老的进程加锁技术

  1. open(file, O_CREAT | O_EXCL, ...) + unlink(file)
  2. link(file, lockfile) + unlink(lockfile)
  3. open(file, O_CREAT | O_TRUNC | O_WRONLY, 0) + unlink(file)

前两种方法都是用文件是否存在(当前进程是否为文件的创建者)来表示锁的持有。缺点:

  1. 模拟加锁比调用新系统调用创建 xxx.pid 更慢。
  2. 没有阻塞的机制,失败只能忙等待或者睡眠重试。
  3. 没有死锁检查。
  4. 如果进程意外终止,锁文件不会被删除,从而导致“锁”没有被释放。有一些补救方法,比如在锁文件中记录主人的进程号、检查文件上次修改时间,但是这些方法都不是很可靠。

第 3 种方法的机制是:无法获得写权限时,含有 O_TRUNC 标志也会使得 open() 失败。文件创建时指定的 mode_t 为 0,所以一旦创建其他进程就不能获得写权限。除了包含以上缺点外,还多了一个特权进程总是会成功的缺点。