TLPI读书笔记第55章:文件加锁2

55.4 强制加锁

到目前为止介绍的锁都是劝告式锁。 这意味着一个进程可以自由地忽略 fcntl() (或 flock())的使用或简单地在文件上执行 I/O。内核不会阻止进程的这种行为。在使用劝告式锁时,应用程序的设计者需要:

1.为文件设置合适的所有权(或组所有权)以及权限以防止非协作进程执行文件 I/O;

2.通过在执行 I/O 之前获取恰当的锁来确保构成应用程序的进程相互协作。

与其他很多 UNIX 实现一样, Linux 也允许 fcntl()记录锁是强制式的。这表示需对每个文件 I/O 操作进行检查以判断其他进程在执行 I/O 所在的文件区域上是否持有任何不兼容的锁

为了在 Linux 上使用强制式加锁就必须要在包含待加锁的文件的文件系统以及每个待加锁的文件上启用这一项功能。通过在挂载文件系统时使用( Linux 特有的) –o mand 选项能够在该文件系统上启用强制式加锁。

mount -o mant /dev/sda10 /testfs

在程序中可以通过在调用 mount(2)( 14.8.1 节)时指定 MS_MANDLOCK 标记来取得同样的结果。 通过查看不带任何选项的 mount(8)命令的输出就能够看出一个挂载文件系统是否启用了强制式加锁。

mount |grep sda10

文件上强制式加锁的启用是通过开启 set-group-ID 权限位和关闭group-execute权限来完成的。这种权限位组合在其他场景中是毫无意义的,并且在之前的 UNIX 实现中并没有用到这种权限位组合。正因为如此,后面的 UNIX 系统在新增强制式加锁时就无需修改既有程序或添加新的系统调用了。在 shell 中可以按照下面的方法在一个文件上启用强制式加锁。

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

在一个程序中可以通过使用 chmod()或 fchmod()( 15.4.7 节)恰当地设置文件上的权限来启用该文件上的强制式加锁。 当显示一个启用了强制式加锁权限位的文件的权限时, ls(1)会在 group-execute 权限列中显示一个 S。

ls -l /testfs/file

所有原生 Linux 和 UNIX 文件系统都支持强制式加锁,但一些网络文件系统和非 UNIX文件系统可能就不支持强制式加锁了。 例如, 微软的 VFAT 文件系统没有 set-group-ID 权限位,因此在 VFAT 文件系统上就无法启用强制式加锁了。

强制式加锁对文件 I/O 操作的影响

如果在一个文件上启用强制式加锁时, 那么执行数据传输的系统调用(如 read()或 write())在碰到锁冲突(即在当前被读或写操作锁住的区域上执行一个写入操作或在当前被写锁住的区域上执行一个读操作)时会发生什么呢?

这个问题的答案取决于是以阻塞模式还是非阻塞模式打开了文件。如果以阻塞模式打开了文件,那么系统调用就会阻塞。如果在打开文件时使用了 O_NONBLOCK 标记,那么系统调用就会立即失败并返回 EAGAIN 错误。类似的规则同样适用于 truncate()和 ftruncate(),前提是它们尝试从中增加或删除字节的文件当前被另一个进程锁住(为了读或者写)了。 如果以阻塞模式打开了一个文件(即在 open()调用中没有指定 O_NONBLOCK),那么 I/O系统调用可能会导致死锁情形的出现。考虑图 55-7 中给出的例子,其中两个进程都打开了同一个文件以执行阻塞式 I/O,它们先获取了文件中不同部分上的写锁,然后分别尝试写入被对方锁住的区域。 内核在解决这个问题时采用的方式与解决由两个 fcntl()调用引起的死锁问题时所用的方式是一样的( 55.3.1 节):它选择死锁所涉及到的其中一个进程并使其 write()系统调用失败并返回 EDEADLK 错误。 使用 O_TRUNC 标记 open()一个文件在存在其他进程持有该文件任意部分上的一个读锁或写锁时会立即失败(返回 EAGAIN 错误)

如果存在进程持有了一个文件任意部分上的强制式读锁或写锁,那么就无法在该文件上创建一个共享内存映射(即在调用 mmap()时指定了 MAP_SHARED 标记)。同样,如果一个文件参与了一个共享内存映射,那么就无法在该文件的任意部分上放置一把强制式锁。在这两种情况中,相关的系统调用会立即失败并返回 EAGAIN 错误。之所以存在这些限制的原因在考虑内存映射的实现之后就变得清晰起来了。

在 49.4.2 节中曾经介绍过一个既从文件中读取又向文件写入的共享文件映射(特别是后一个操作会与文件上任意类型的锁产生冲突)。此外,这种文件 I/O 是通过内存管理子系统完成的,而这个子系统是不清楚系统中任意一个文件锁所处的位置的。因此为防止一个映射更新一个被放置了强制式锁的文件,内核需要执行一个简单的检查——在执行 mmap()调用时检查待映射的文件中所有位置上是否存在锁(对于fcntl()调用也是如此)。

强制式加锁警告

强制式锁所起的作用其实没有其一开始看起来那么大,它存在一些潜在的缺陷和问题。

1.在一个文件上持有一把强制式锁并不能阻止其他进程删除这个文件,因为只要在父目录上拥有合适的权限就能够与一个文件断开链接。

2.在一个可公开访问的文件上启用强制式锁之前需要经过深思熟虑,因为即使是特权进程也无法覆盖一个强制式锁。恶意用户可能会持续地持有该文件上的锁以制造拒绝服务的攻击。 (在大多数情况下可以通过关闭 set-group-ID 位来使得该文件再次可访问,但当强制式文件锁造成系统挂起时就无法这样做了。 )

3.使用强制式加锁存在性能开销。在启用了强制式加锁的文件上执行的每个 I/O 系统调用中,内核都必须要检查在文件上是否存在冲突的锁。如果文件上存在大量的锁,那么这种检查工作会极大地降低 I/O 系统调用的效率。

4.强制式加锁还会在应用程序设计阶段造成额外的开销,因为需要处理每个 I/O 系统调用返回 EAGAIN(非阻塞 I/O)或 EDEADLK(阻塞 I/O)错误的情况。

5.因为在当前的 Linux 实现中存在一些内核竞争条件,因此在有些情况下执行 I/O 操作的系统调用在文件上存在本应该拒绝这些操作的强制式锁时也能成功

总的来说,应该尽可能避免使用强制式锁

55.5 /proc/locks 文件

通过检查 Linux 特有的/proc/locks 文件中的内容能够查看系统中当前存在的锁。下面给出了一个示例文件所包含的信息(在本例中是四个锁)。

cat /proc/locks
#输出
1: POSIX ADVISORY WRITE 4387 fd:01:1050531 0 EOF
2: OFDLCK ADVISORY READ  -1 00:05:1028 0 EOF
3: FLOCK ADVISORY WRITE 833 00:14:19306 0 EOF
4: FLOCK ADVISORY WRITE 878 fd:01:1051347 0 EOF
5: POSIX ADVISORY WRITE 4385 fd:11:30935259 0 EOF
6: FLOCK ADVISORY WRITE 1055 fd:01:1051742 0 EOF
7: POSIX ADVISORY WRITE 2805 00:14:31860 0 EOF

/proc/locks 文件显示了使用 flock()和 fcntl()创建的锁的相关信息。每把锁的 8 个字段的含义如下(从左至右)。

1.锁在该文件上所有锁中的序号(参见 55.3.4 节)。

2.锁的类型。其中 FLOCK 表示 flock()创建的锁, POSIX 表示 fcntl()创建的锁。

3.锁的模式,其值是 ADVISORY 或 MANDATORY。

4.锁的类型,其值是 READ 或 WRITE(对应于 fcntl()的共享锁和互斥锁)。

5.持有锁的进程的进程 ID。

6.三个用冒号分隔的数字,它们标识出了锁所属的文件。这些数字是文件所处的文件系统的主要和次要设备号,后面跟着文件的 i-node 号。

7.锁的起始字节。对于 flock()锁来讲,其值永远是 0。

8.锁的结尾字节。其中 EOF 表示锁延伸到文件的结尾(即对于 fcntl()创建的锁来讲是将l_len 指定为 0)。对于 flock()锁来讲,这一列的值永远是 EOF。 在 Linux 2.4 以及之前的版本上, /proc/locks 文件中的每一行都还包含五个额外的十六进制值。它们是内核用来记录在各个列表中的锁的指针地址,这些值对于应用程序来讲是毫无用处的。使用/proc/locks 中的信息能够找出哪个进程持有了哪个文件上的锁。下面的 shell 会话显示了如何找出上面列表中序号为 3 的锁的此类信息。这个锁由进程 ID 为 312 的进程持有,其所属的文件在主要 ID 为 3、 次要 ID 为 7 的设备上的第 133853 个 i-node 上。 下面首先使用 ps(1)列出进程 ID 为 312 的进程的相关信息。

 ps -p 2805
#输出
PID TTY         TIME CMD
2805 ?        00:00:00 atd

从上面的输出可以看出持有锁的程序是 atd,即执行批处理作业的 daemon。 为找出被锁住的文件,下面首先在/dev 目录中搜索文件并确定 ID 为 3:7 的设备是/dev/sda7。 接着确定设备/dev/sda7 的挂载点并在该部分文件系统中搜索 i-node 号为 133853 的文件。

find –mount 选项防止 find 进入/下的子目录(表示其他文件系统的挂载点)进行搜索。

find / -mount -inum 1051751

最后显示被锁住的文件的内容。 这样就能看出 atd daemon 持有了/var/run/atd.pid 文件上的一把锁,而这个文件中的内容就是运行 atd 的进程的进程 ID。 这个 daemon 采用了一项技术来确保在一个时刻只有一个 daemon 实例在运行,在 55.6 节中将会对这项技术进行描述。 通过/proc/locks 还能够获取被阻塞的锁请求的相关信息,如下面的输出所示。 其中锁号后面随即跟着->字符的行表示被相应锁号阻塞的锁请求。 因此从上面的输出可以 看出一个请求被阻塞在锁 1 上,两个请求被阻塞在锁 2 上(使用 fcntl()创建的一把锁),一个 请求被阻塞在锁 3 上(使用 flock()创建的一把锁)。

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

一些程序——特别是很多 daemon——需要确保同一时刻只有一个程序实例在系统中运行。 完成这项任务的一个常见方法是让 daemon 在一个标准目录中创建一个文件并在该文件上放置一把写锁。 daemon 在其执行期间一直持有这个文件锁并在即将终止之前删除这个文件。

如果启动了 daemon 的另一个实例,那么它在获取该文件上的写锁时就会失败,其结果是它会意识到 daemon 的另一个实例肯定正在运行,然后终止。

很多网络服务器采用了另一种常规做法,即当服务器绑定的众所周知的 socket 端口号已经被使用时就认为该服务器实例已经处于运行状态了( 61.10 节)。

/var/run目录通常是存放此类锁文件的位置。或者也可以在 daemon 的配置文件中加一行来指定文件的位置。 通常daemon 会将其进程 ID 写入锁文件,因此这个文件在命名时通常将.pid 作为扩展名(如syslogd 会创建文件/var/run/syslogd.pid)。这对于那些需要找出 daemon 的进程 ID 的应用程序来讲是比较有用的。它还允许执行额外的健全检查——可以像 20.5 节中描述的那样使用 kill(pid, 0)来检查进程 ID 是否存在。(在较早的不提供文件加锁的 UNIX 实现上,这是一种不完美但很实用的方法,用于检查一个 daemon 实例是否在运行或前一个实例在终止之前是否没有成功删除这个文件。) 用来创建和锁住一个进程 ID 锁文件的代码存在很多微小的差异。

程序清单 55-4 根据[Stevens, 1999]提供的想法提供了一个函数 createPidFile(),它封装了上面描述的步骤。调用这个函数通常会使用下面这样的代码。 createPidFile()函数中的一个精妙之处是使用 ftruncate()来清除锁文件中之前存在的所有字符串。之所以要这样做是因为 daemon 的上一个实例在删除文件时可能因系统崩溃而失败。

在这种情况下, 如果新 daemon 实例的进程 ID 较小, 那么可能就无法完全覆盖之前文件中的内容。例如,如果进程 ID 是 789,那么就只会向文件写入 789 ,但之前的 daemon 实例可能已经向文件写入了 12345 ,这时如果不截断文件的话得到的内容就会是 789 5 。

从严格意义上来讲,清除所有既有字符串并不是必需的,但这样做显得更加简洁并且能排除产生混淆的可能。在 flags 参数中可以指定常量 CPF_CLOEXEC 将会导致 createPidFile()为文件描述符设置close-on-exec 标记( 27.4 节)。这对于通过调用 exec()重启自己的服务器来讲是比较有用的。如果在 exec()时文件描述符没有被关闭, 那么重新启动的服务器会认为服务器的另一个实例正处于运行状态

55.7 老式加锁技术

在较早的不支持文件加锁的 UNIX 实现上可以使用一些特别的加锁技术。尽管所有这些技术都已经被 fcntl()记录加锁所取代,但这里仍然要介绍它们,因为在一些较早的应用程序中仍然存在它们的身影。所有这些技术在性质上都是劝告式的。

open(file, 0_CREAT | 0_EXCL,...)加上 unlink(file)

SUSv3 要求使用了 O_CREAT 和 O_EXCL 标记的 open()调用有原子地执行检查文件的存在性以及创建文件两个步骤( 5.1 节)。

这意味着如果两个进程尝试在创建一个文件时指定这些标记,那么就保证只有其中一个进程能够成功。 (另一个进程会从 open()中收到 EEXIST 错误。 )

这种调用与 unlink()系统调用组合起来就构成了一种加锁机制的基础。获取锁可通过成功地使用 O_CREAT 和 O_EXCL 标记打开文件后,立即跟着一个 close()来完成。

释放锁则可以通过使用 unlink()来完成。尽管这项技术能够正常工作,但它存在一些局限。 1.如果 open()失败了, 即表示其他进程拥有了锁, 那么就必须要在某种循环中重试 open()操作,这种循环既可以是持续不停地(这将会浪费 CPU 时间),也可以在相邻两次尝试之间加上一定的延迟(意味着在锁可用的时刻和实际获取锁的时刻之间可能存在一定的延迟)。有了 fcntl()之后则可以使用 F_SETLKW 来阻塞直到锁可用为止。

2.使用 open()和 unlink()获取和释放锁涉及到文件系统的操作, 这比记录锁要慢很多。如果一个进程意外终止并且没有删除锁文件,那么锁就不会被释放。处理这个问题存在特别的技术,包括检查文件的上次修改时间和让锁的持有者将其进程 ID 写入文件,这样就能够检查进程是否存在,但这些技术中没有一项技术是安全可靠的。与之相反的是,在一个进程终止时记录锁的释放操作是原子的。

3.如果放置多把锁(即使用多个锁文件),那么就无法检测出死锁。如果发生了死锁,那么造成死锁的进程就会永远保持阻塞。 与之形成对比的是,内核会对 fcntl()记录锁进程死锁检测。

4.第二版的 NFS 不支持 O_EXCL 语义。 Linux 2.4 NFS 客户端也没有正确地实现O_EXCL,即使是第三版的 NFS 以及之后的版本也没能完成这个任务。

link(file, lockfile)加上 unlink(lockfile)

link()系统调用在新链接已经存在时会失败的事实可用作一种加锁机制,而解锁功能则还是使用 unlink()来完成。常规的做法是让需要获取锁的进程创建一个唯一的临时文件名,一般来讲需要包含进程 ID(如果锁文件被创建于一个网络文件系统上,那么可能的话再加上主机名)。

要获取锁则需要将这个临时文件链接到某个约定的标准路径名上。 (硬链接在语义上需要两个路径名位于同一个文件系统上。 )如果 link()调用成功,那么就是获取了锁。如果失败( EEXIST),那么就是另一个进程持有了锁,因此必须要在稍后某个时刻重新尝试获取锁。这项技术与上面介绍的 open(file, O_CREAT | O_EXCL,...)技术存在相同的局限。 当指定 O_TRUNC 并且写权限被拒绝时在一个既有文件上调用 open()会失败的事实可作为一项加锁技术的基础。要获取一把锁可以使用下面的代码(省略了错误检查)来创建一个新文件

如果 open()调用成功(即文件之前不存在),那么就是获取了锁。如果因 EACCES 而失败(即文件存在但没有人拥有权限),那么其他进程持有了锁,还需要在后面某个时刻尝试重新获取锁。这项技术与前面介绍的技术存在相同的局限,还需要注意的是不能在具备超级用户特权的程序中使用这项技术,因为 open()总是会成功,不管文件上设置的权限是什么。

55.8 总结

文件锁使得进程能够同步对一个文件的访问。 Linux 提供了两种文件加锁系统调用:从BSD 衍生出来的 flock()和从 System V 衍生出来的 fcntl()。

尽管这两组系统调用在大多数 UNIX实现上都是可用的,但只有 fcntl()加锁在 SUSv3 中进行了标准化。

flock()系统调用对整个文件加锁,可放置的锁有两种:一种是共享锁,这种锁与其他进程持有的共享锁是兼容的;另一种是互斥锁,这种锁能够阻止其他进程放置这两种锁。 fcntl()系统调用将一个文件的任意区域上放置锁(“记录锁”),这个区域可以是单个字节也可以是整个文件。可放置的锁有两种:读锁和写锁,它们之间的兼容性语义与 flock()放置的共享锁和互斥锁之间的兼容性语义类似。如果一个阻塞式( F_SETLKW)锁请求将会导致死锁,那么内核会让其中一个受影响的进程的 fcntl()失败(返回 EDEADLK 错误)。 使用 flock()和 fcntl()放置的锁之间是相互不可见的(除了在使用 fcntl()实现 flock()的系统)。 通过 flock()和 fcntl()放置的锁在 fork()中的继承语义和在文件描述符被关闭时的释放语义是不同的。 Linux 特有的/proc/locks 文件给出了系统中所有进程当期持有的文件锁。

原文地址:https://www.cnblogs.com/wangbin2188/p/14809450.html