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

前面的章节介绍了进程能用来同步动作的各项技术,包括信号和信号量。本章将介绍专门为文件设计的同步技术。

55.1 概述

应用程序的一个常见需求是从一个文件中读取一些数据,修改这些数据,然后将这些数据写回文件。只要在一个时刻只有一个进程以这种方式使用文件就不会存在问题,但当多个进程同时更新一个文件时问题就出现了。假设各个进程按照下面的顺序来更新一个包含了一个序号的文件。

1.从文件中读取序号。

2.使用这个序号完成应用程序定义的任务。

3.递增这个序号并将其写回文件。

这里存在的问题是两个进程在没有采用任何同步技术的情况下可能会同时执行上面的步骤,从而导致(举例)出现图 55-1 中给出的结果

为防止出现这种情况就需要采用某种形式的进程间同步。尽管可以使用(比如说)信号量来完成所需的同步,但通常文件锁更好一些,因为内核能够自动将锁与文件关联起来

本章将介绍两组不同的给文件加锁的 API。

1.flock()对整个文件加锁。

2.fcntl()对一个文件区域加锁

flock()系统调用源自 BSD,而 fcntl()则源自 System V。 使用 flock()和 fcntl()的常规方法如下。

1.给文件加锁。

2.执行文件 I/O。

3.解锁文件使得其他进程能够给文件加锁。

尽管文件加锁通常会与文件 I/O 一起使用, 但也可以将其作为一项更通用的同步技术来使用。 协作进程可以约定一个进程对整个文件或一个文件区域进行加锁表示对一些共享资源(如一个共享内存区域)而非文件本身的访问。

混合使用加锁和 stdio 函数

由于 stdio 库会在用户空间进行缓冲(自以为写成功了,其实还在缓冲区),因此在混合使用 stdio 函数与本章介绍的加锁技术时需要特别小心。这里的问题是一个输入缓冲器在被加锁之前可能会被填满或者一个输出缓冲器在锁被删除之后可能会被刷新。 要避免这些问题则可以采用下面这些方法。

1.使用 read()和 write()(以及相关的系统调用)取代 stdio 库来执行文件 I/O。

2.在对文件加锁之后立即刷新 stdio 流,并且在释放锁之前立即再次刷新这个流。

3.使用 setbuf()(或类似的函数)来禁用 stdio 缓冲,当然这可能会牺牲一些效率。

劝告式和强制式加锁

在本章剩余的部分中会将锁分成劝告式和强制式两种。在默认情况下,文件锁是劝告式的,这表示一个进程可以简单地忽略另一个进程在文件上放置的锁。

要使得劝告式加锁模型能够正常工作,所有访问文件的进程都必须要配合,即在执行文件 I/O 之前首先需要在文件上放置一把锁。与之对应的是,强制式加锁系统会强制一个进程在执行 I/O 时需要遵从其他进程持有的锁。在 55.4 节中将会对这两种锁之间的差别进行详细介绍。

55.2 使用 flock()给文件加锁

尽管 fcntl()提供的功能涵盖了 flock()提供的功能,但这里仍然需要对其进行介绍,因为在一些应用程序中仍然使用着 flock()并且其在继承和锁释放方面的一些语义与 fcntl()是不同的。

#include<sys/file.h>
int flock(int fd,int operation);

flock()系统调用在整个文件上放置一个锁。待加锁的文件是通过传入 fd 的一个打开着的文件描述符来指定的。 operation 参数指定了表 55-1 中描述的:

LOCK_SH(共享锁)

LOCK_EX(互斥锁)

LOCK_UN(解锁)

LOCK_NB(发起一个非阻塞锁请求) 在默认情况下,如果另一个进程已经持有了文件上的一个不兼容的锁,那么 flock()会阻塞。如果需要防止出现这种情况,那么可以在 operation 参数中对这些值取 OR( |)。在这种情况下,如果另一个进程已经持有了文件上的一个不兼容的锁,那么 flock()就不会阻塞,相反它会返回-1 并将 errno 设置成 EWOULDBLOCK。

任意数量的进程可同时持有一个文件上的共享锁,但在同一个时刻只有一个进程能够持有一个文件上的互斥锁。 (换句话说,互斥锁会拒绝其他进程的互斥和共享锁请求。 )表 55-2

文件上有了共享锁,可以继续加共享锁,但不能加互斥锁;

文件上有了互斥锁,啥锁也不能加

对 flock()锁的兼容规则进行了总结。这里假设进程 A 首先放置了锁,表中给出了进程 B 是否能够放置一把锁

锁转换的过程不一定是原子的。在转换过程中首先会删除既有的锁,然后创建一个新锁。在这两步之间另一个进程对一个不兼容锁的未决请求可能会得到满足。如果发生了这种情况,那么转换过程会被阻塞,或者在指定了 LOCK_NB 的情况下转换过程会失败并且进程会丢失其原先持有的锁。 (在最初的 BSD flock()实现和很多其他 UNIX 实现上会出现这种行为。 )

55.2.1 锁继承与释放的语义

根据表 55-1, 通过 flock()调用并将 operation 参数指定为 LOCK_UN 可以释放一个文件锁。此外,锁会在相应的文件描述符被关闭之后自动被释放。

但问题其实要更加复杂,通过 flock()获取的文件锁是与打开的文件描述( 5.4 节)而不是文件描述符或文件( i-node)本身相关联的。这意味着当一个文件描述符被复制时(通过 dup()、 dup2()或一个 fcntl() F_DUPFD 操作),新文件描述符会引用同一个文件锁。

例如,如果获取了 fd 所引用的文件上的一个锁,那么下面的代码(忽略了错误检查)会释放这个锁。

flock(fd,LOCK_EX);
newfd=dup(fd);
flock(newfd,LOCK_UN);

如果已经通过了一个特定的文件描述符获取了一个锁并创建了该文件描述符的一个或多个副本,那么——如果不显式地执行一个解锁操作——只有当所有的描述符副本都被关闭之后锁才会被释放。

如果使用 open()获取第二个引用同一个文件的文件描述符(以及关联的打开的文件描述),那么 flock()会将第二个描述符当成是一个不同的描述符。例如执行下面这些代码的进程会在第二个 flock()调用上阻塞。

fd1=open("a.txt",O_RDWR);
fd2=open("a.txt",O_RDWR);
flock(fd1,LOCK_EX);
flock(fd2,LOCK_EX);/*阻塞*/

这样一个进程就能使用 flock()来将自己锁在一个文件之外。 读者稍后就会看到, 使用 fcntl()返回的记录锁是无法取得这种效果的。

当使用 fork()创建一个子进程时,这个子进程会复制其父进程的文件描述符,并且与使用dup()调用之类的函数复制的描述符一样,这些描述符会引用同一个打开的文件描述,进而会引用同一个锁。例如下面的代码会导致一个子进程删除一个父进程的锁。

flock(fd,LOCK_EX);/*父进程上锁*/
if(fork()==0)
   flock(fd,LOCK_UN)/*子进程解锁*/

有时候可以利用这些语义来将一个文件锁从父进程(原子地)传输到子进程:在 fork()之后,父进程关闭其文件描述符,然后锁就只在子进程的控制之下了。读者稍后就会看到使用 fcntl()返回的记录锁是无法取得这种效果的。

通过 flock()创建的锁在 exec()中会得到保留(除非在文件描述符上设置了 close-on-exec标记并且该文件描述符是最后一个引用底层的打开的文件描述的描述符)。

上面描述的 flock()在 Linux 上的语义与其在经典的 BSD 实现上的语义是一致的。在一些UNIX 实现上, flock()是使用 fcntl()实现的,读者稍后就会看到 fcntl()锁的继承和释放语义与flock()锁的继承和释放语义是不同的。由于 flock()创建的锁与 fcntl()创建的锁之间的交互是未定义的,因此应用程序应该只使用其中一种文件加锁方法。

55.2.2 flock()的限制

通过 flock()放置的锁存在几个限制。 1.只能对整个文件加锁。这种粗粒度的加锁会限制协作进程之间的并发性。例如,假设存在多个进程, 其中各个进程都想要同时访问同一个文件的不同部分, 那么通过 flock()加锁会不必要地阻止这些进程并发完成这些操作。

2.通过 flock()只能放置劝告式锁。

3.很多 NFS 实现不识别 flock()放置的锁。

下一节中介绍的 fcntl()加锁模型弥补了所有这些不足。 因为历史的原因, Linux NFS 服务器不支持 flock()锁。从内核 2.6.12 起, Linux NFS 服务器通过将 flock()锁实现成整个文件上的一个 fcntl()锁来支持 flock()锁。 这种做法在混合服务器上的 BSD 锁和客户端上的 BSD 锁时会导致一些奇怪的结果: 客户端通常无法看到看到服务器的锁,反之亦然。

55.3 使用 fcntl()给记录加锁

使用 fcntl()( 5.2 节)能够在一个文件的任意部分上放置一把锁,这个文件部分既可以是一个字节,也可以是整个文件。这种形式的文件加锁通常被称为记录加锁,但这种称谓是不恰当的,因为 UNIX 系统上的文件是一个字节序列,并不存在记录边界的概念,文件记录的概念只存在于应用程序中。 一般来讲, fcntl()会被用来锁住文件中与应用程序定义的记录边界对应的字节范围,这也是术语记录加锁的由来。术语字节范围、文件区域以及文件段很少被用到,但它们更加精确地描述了这种锁。 (由于这是唯一一种在最初的 POSIX.1 标准和 SUSv3 中予以规定的加锁技术,因此它有时候也被称为 POSIX 文件加锁。 )

图 55-2 显示了如何使用记录锁来同步两个进程对一个文件中的同一块区域的访问。(在这 幅图中假设所有的锁请求都会阻塞,这样它们在锁被另一个进程持有时就会等待。 ) 图 55-2:使用记录锁同步对一个文件的同一区域的访问 用来创建或删除一个文件锁的 fcntl()调用的常规形式如下。

struct flock flockstr;
fcntl(fd,cmd,&flockstr);

fd 参数是一个打开着的文件描述符,它引用了待加锁的文件。 在讨论 cmd 参数之前首先描述一下 flock 结构。

/*flock 结构*/
struct flock{
   short l_type;  /*锁类型:F_RDLCK,F_WRLCK,F_UNLCK*/
   short l_whence;/*起始位:SEEK_SET,SEEK_CUR,SEEK_END*/
   off_t l_start; /*锁起始位*/
   off_t l_en;    /*上锁的字节*/
   pid_t l_pid;   /*进程ID*/
}

flock 结构定义了待获取或删除的锁,其定义如下所示。 l_type 字段表示需放置的锁的类型,其取值为表 55-3 中列出的值中的一个。 从语义上来讲,读( F_RDLCK)和写( F_WRLCK)锁对应于 flock()施加的共享锁和互斥锁,并且它们遵循着同样的兼容性规则(表 55-2):任何数量的进程能够持有一块文件区域上的读锁,但只有一个进程能够持有一把写锁,并且这把锁会将其他进程的读锁和写锁排除在外。

将 l_type 指定为 F_UNLCK 类似于 flock() LOCK_UN 操作。

为了在一个文件上放置一把读锁就必须要打开文件以允许读取。类似地,要放置一把写锁就 必须 要打开 文件 以允许 写入 。要放 置两 种锁就 必须 要打开 文件 以允许读写( O_RDWR)。试图在文件上放置一把与文件访问模式不兼容的锁将会导致一个 EBADF错误。 l_whence、 l_start 以及 l_len 字段一起指定了待加锁的字节范围。前两个字段类似于传入lseek()的 whence 和 offset 参数( 4.7 节)。 l_start 字段指定了文件中的一个偏移量,其具体含义需根据下列规则来解释。 1.当 l_whence 为 SEEK_SET 时,为文件的起始位置。 2.当 l_whence 为 SEEK_CUR 时,为当前的文件偏移量。 3.当 l_whence 为 SEEK_END 时,为文件的结尾位置。 在后两种情况中, l_start 可以是一个负数,只要最终得到的文件位置不会小于文件的起始位置(字节 0)即可。 l_len 字段包含一个指定待加锁的字节数的整数,其起始位置由 l_whence 和 l_start 定义。对文件结尾之后并不存在的字节进行加锁是可以的,但无法对在文件起始位置之前的字节进行加锁。 从内核 2.4.21 开始, Linux 允许在 l_len 中指定一个负值。 这是请求对在 l_whence 和 l_start指定的位置之前的 l_len 字节(即范围在(l_start – abs(l_len))到(l_start – 1)之间的字节)进行加锁。 SUSv3 允许但并没有要求这种特性,其他几个 UNIX 实现也提供了这个特性。

一般来讲,应用程序应该只对所需的最小字节范围进行加锁,这样其他进程就能够同时对同一个文件的不同区域进行加锁,进而取得更大的并发性

将 l_len 指定为 0 具有特殊含义,即“对范围从由 l_start 和 l_whence 确定的起始位置到文件结尾位置之内的所有字节加锁,不管文件增长到多大”。这种处理方式在无法提前知道向一个文件中加入多少字节的情况下是比较方便的。要锁住整个文件则可以将 l_whence 指定为SEEK_SET,并将 l_start 和 l_len 都指定为 0。

cmd 参数

fcntl()在操作文件锁时其 cmd 参数的可取值有以下三个,其中前两个值用来获取和释放锁。 F_SETLK 获取( l_type 是 F_RDLCK 或 F_WRLCK)或释放( l_type 是 F_UNLCK)由 flockstr 指定的字节上的锁。 如果另一个进程持有了一把待加锁的区域中任意部分上的不兼容的锁时, fcntl()就会失败并返回 EAGAIN 错误。在一些 UNIX 实现上 fcntl()在碰到这种情况时会失败并返回EACCES 错误。 SUSv3 允许实现采用其中任意一种处理方式,因此可移植的应用程序应该对这两个值都进行测试。 F_SETLKW 这个值与 F_SETLK 是一样的,除了在有另一个进程持有一把待加锁的区域中任意部分上的不兼容的锁时,调用就会阻塞直到锁的请求得到满足。如果正在处理一个信号并且没有指定 SA_RESTART( 21.5 节),那么 F_SETLKW 操作就可能会被中断(即失败并返回 EINTR 错误)。开发人员可以利用这种行为来使用 alarm()或 setitimer()为一个加锁请求设置一个超时时间。 注意, fcntl()要么会锁住指定的整个区域,要么就不会对任何字节加锁,这里并不存在只锁住请求区域中那些当前未被锁住的字节的概念。剩下的一个 fcntl()操作可用来确定是否可以在一个给定的区域上放置一把锁。 F_GETLK 检测是否能够获取 flockstr 指定的区域上的锁,但实际不获取这把锁。 l_type 字段的值必须为 F_RDLCK 或 F_WRLCK。 flockstr 结构是一个值-结果参数,在返回时它包含了有关是否能够放置指定的锁的信息。如果允许加锁(即在指定的文件区域上不存在不兼容的锁),那么在 l_type 字段中会返回 F_UNLCK,并且剩余的字段会保持不变。如果在区域上存在一个或多个不兼容的锁,那么 flockstr 会返回与那些锁中其中一把锁(无法确定是哪把锁)相关的信息,包括其类型( l_type)、字节范围( l_start 和 l_len; l_whence 总是返回为 SEEK_SET)以及持有这把锁的进程的进程 ID( l_pid

注意,在使用 F_GETLK 之后接着使用 F_SETLK 或 F_SETLKW 的话就可能会出现竞争条件,因为在执行后面一个操作时, F_GETLK 返回的信息可能已经过时了,因此 F_GETLK的实际作用比其一开始看起来的作用要小很多。即使 F_GETLK 表示可以放置一把锁,仍然需要为 F_SETLK 返回一个错误或 F_SETLKW 阻塞做好准备

锁获取和释放的细节

有关获取和释放由 fcntl()创建的锁方面需要注意以下几点。

1.解锁一块文件区域总是会立即成功。即使当前并不持有一块区域上的锁,对这块区域解锁也不是一个错误。

2.在任何一个时刻,一个进程只能持有一个文件的某个特定区域上的一种锁。在之前已经锁住的区域上放置一把新锁会导致不发生任何事情(新锁的类型与既有锁的类型是一样的)或原子地将既有锁转换成新模式。在后一种情况中,当将一个读锁转换成写锁时需要为调用返回一个错误( F_SETLK)或阻塞( F_SETLKW)做好准备。 (这与flock()是不同的,它的锁转换不是原子的。 )

3.一个进程永远都无法将自己锁在一个文件区域之外,即使通过多个引用同一文件的文件描述符放置锁也是如此。 (这与 flock()是不同的,在 55.3.5 节中将会介绍更多有关这方面的信息。 )

4.在已经持有的锁中间放置一把模式不同的锁会产生三把锁: 在新锁的两端会创建两个模式为之前模式的更小一点的锁(参见图 55-3)。与此相反的是,获取与模式相同的一把既有锁相邻或重叠的第二把锁会产生单个覆盖两把锁的合并区域的聚合锁。除此之外,还存在其他的组合情况。如对一个大型既有锁的中间的一个区域进行解锁会在已解锁区域的两端产生两个更小一点的已锁住区域。如果一个新锁与一个模式不同的既有锁重叠了,那么既有锁就会收缩,因为重叠的字节会合并进新锁中。

在文件区域锁方面,关闭一个文件描述符具备一些不寻常的语义,在 55.3.5 节将会对这些语义进行介绍。

55.3.1 死锁

在使用 F_SETLKW 时需要弄清楚图 55-4 中阐述的场景类别。在这种场景中,每个进程的第二个锁请求会被另一个进程持有的锁阻塞。这种场景被称为死锁。如果内核不对这种情况进行抑制,那么会导致两个进程永远阻塞。

为避免这种情况,内核会对通过 F_SETLKW 发起的每个新锁请求进行检查以判断是否会导致死锁。如果会导致死锁,那么内核就会选中其中一个被阻塞的进程使其 fcntl()调用解除阻塞并返回错误 EDEADLK。 (在 Linux 上,进程会选中最近的 fcntl()调用,但 SUSv3 并没有要求这种行为,并且这种行为在后续的 Linux 版本或其他 UNIX 实现上可能不成立。 使用 F_SETLKW 的所有进程都必须要为处理 EDEADLK 错误做好准备。 )

即使在多个不同的文件上放置锁时也能检测出死锁情形, 即涉及多个进程的循环死锁。(举个例子,对于循环死锁,意味着进程 A 等待获取被进程 B 锁住的区域上的锁,进程 B 等待进程 C 持有的锁,进程 C 等待进程 A 持有的锁。 )

55.3.4 锁的限制和性能

SUSv3 允许一个实现为所能获取的记录锁的数量设置一个固定的、系统级别的上限。当达到这个限制时, fcntl()就会失败并返回 ENOLCK 错误。

Linux 并没有为所能获取的记录锁的数量设置一个固定的上限,至于具体数量则受限于可用的内存数量。(很多其他 UNIX 实现也采用了类似的做法。) 获取和释放记录锁的速度有多快呢?这个问题没有固定的答案,因为这些操作的速度取决于用来维护记录锁的内核数据结构和具体的某一把锁在这个数据结构中所处的位置。本章稍后就会介绍这个数据结构,在此之前首先来考虑几点能够影响其设计的需求。

1.内核需要能够将一个新锁和任意位于新锁任意一端的模式相同的既有锁(由同一个进程持有)合并起来。

2.新锁可能会完全取代调用进程持有的一把或多把既有锁。内核需要容易地定位出所有这些锁。

3.当在一把既有锁的中间创建一个模式不同的新锁时,分隔既有锁的工作(图 55-3)应该是比较简单的。

用来维护锁相关信息的内核数据结构需要被设计成满足这些需求。每个打开着的文件都有一个关联链表,链表中保存着该文件上的锁。列表中的锁会先按照进程 ID 再按照起始偏移量来排序。图 55-6 给出了一个这样的列表

55.3.6 锁定饿死和排队加锁请求的优先级

当多个进程必须要等待以便能够在当前被锁住的区域上放置一把锁时,一系列的问题就出现了。一个进程是否能够等待以便在由一系列进程放置读锁的同一块区域上放置一把写锁并因此可能会导致饿死?

在 Linux 上(以及很多其他 UNIX 实现上),一系列的读锁确实能够导致一个被阻塞的写锁饿死,甚至会无限地饿死。 当两个或多个进程等待放置一把锁时,是否存在一些规则来确定在锁可用时哪个进程会获取锁?例如,锁请求是否满足 FIFO 顺序?规则跟每个进程请求的锁的类型是否有关系(即一个请求读锁的进程是否会优先于请求一个写锁的进程,或反之亦然,或都不是)?

在 Linux上的规则如下所述。 1.排队的锁请求被准予的顺序是不确定的。如果多个进程正在等待加锁,那么它们被满足的顺序取决于进程的调度。 2.写者并不比读者拥有更高的优先权,反之亦然。

在其他系统上这些论断可能就是不正确的了。在一些 UNIX 实现上,锁请求的服务是按照 FIFO 的顺序来完成的,并且读者比写者拥有更高的优先权。

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