7.2 文件锁定
文件锁定是多用户、多任务操作系统中一个非常重要的组成部分。程序经常需要共享数据,而这通常是通过文件来实现的。因此,对于这些程序来说,建立某种控制文件的方式就非常重要了。只有这样,文件才可以通过一种安全的方式更新,或者说,当一个程序正在对文件进行写操作时,文件就会进入一个暂时状态,在这个状态下,如果另外一个程序尝试读这个文件,它就会自动停下来等待这个状态的结束。
Linux提供了多种特性来实现文件锁定。其中最简单的方法就是以原子操作的方式创建锁文件,所谓“原子操作”就是在创建锁文件时,系统将不允许任何其他的事情发生。这就给程序提供了一种方式来确保它所创建的文件是唯一的,而且这个文件不可能被其他程序在同一时刻创建。
第二种方法更高级一些,它允许程序锁定文件的一部分,从而可以独享对这一部分内容的访问。有两种不同的方式可以实现第二种形式的文件锁定。我们将只对其中的一种做详细介绍,因为两种方式非常相似——第二种方式只不过是程序接口稍微不同而已。
7.2.1 创建锁文件
许多应用程序只需要能够针对某个资源创建一个锁文件即可。然后,其他程序就可以通过检查这个文件来判断它们自己是否被允许访问这个资源。
这些锁文件通常都被放置在一个特定位置,并带有一个与被控制资源相关的文件名。例如,当一个调制解调器正在被使用时,Linux通常会在/var/spool目录下创建一个锁文件。
注意,锁文件仅仅只是充当一个指示器的角色,程序间需要通过相互协作来使用它们。用术语来说,锁文件只是建议锁,而不是强制锁,在后者中,系统将强制锁的行为。
为了创建一个用作锁指示器的文件,你可以使用在fcntl.h头文件(你在前面的章节中见过这个文件)中定义的open系统调用,并带上O_CREAT和O_EXCL标志。这样能够以一个原子操作同时完成两项工作:确定文件不存在,然后创建它。
实 验 创建锁文件
你可以在下面的程序lock1.c中看到锁文件是如何创建的:
第一次运行这个程序时,它的输出是:
但当你再次运行这个程序时,它的输出是:
实验解析
这个程序调用带有O_CREAT和O_EXCL标志的open来创建文件/tmp/LCK.test。第一次运行程序时,由于文件并不存在,所以open调用成功。但对程序的后续调用失败了,因为文件已经存在了。如果想让程序再次执行成功,你必须删除那个锁文件。
至少在Linux系统中,错误号17代表的是EEXIST,这个错误用来表示一个文件已存在。错误号定义在头文件errno.h或(更常见的)它所包含的头文件中。在本例中,这个错误号实际定义在头文件/usr/include/asm-generic/errno-base.h中:
这是一个适合于表示open(O_CREAT | O_EXCL)失败的错误号。
如果一个程序在它执行时,只需独占某个资源一段很短的时间——这用术语来说,通常被称为临界区,它就需要在进入临界区之前使用open系统调用创建锁文件,然后在退出临界区时用unlink系统调用删除该锁文件。
你可以通过编写一个示例程序并同时运行它的两份副本,来演示程序是如何利用这个锁机制来协调工作的。你将用到在第4章中见过的getpid调用,它返回进程标识符:一个对于每个当前运行的程序都唯一的数字编号。
实 验 协调性锁文件
(1)下面是测试程序lock2.c的源代码:
(2)临界区从这里开始:
(3)在这里结束:
在运行这个程序之前,你应该先用下面的命令来确保锁文件不存在:
然后用下面这个命令来运行这个程序的两份副本:
这个命令在后台运行lock2的一份副本,在前台运行另一份副本。下面是它的输出结果:
上面的例子显示了同一个程序的两个实例是如何协调工作的。当运行这个例子的时候,你几乎肯定会看到与上述输出不同的进程标识符,但程序的行为将是一样的。
实验解析
出于演示目的,你使用while语句让程序循环10次。这个程序然后通过创建一个唯一的锁文件/tmp/LCK.test2来访问临界资源。如果因为文件已存在而失败,程序将等候一小段时间后再次尝试。如果成功,它就可以开始访问资源。在标记为“临界区”的部分,你可以执行任何需要独占式访问的处理。
因为这只是一个演示程序,所以你只等待了一小段时间。程序使用完资源后,它将通过删除锁文件来释放锁。然后它可以在重新申请锁之前执行一些其他的处理(本例中只是调用sleep函数)。这里锁文件扮演了类似二进制信号量的角色,就问题“我可以使用这个资源吗?”给每个程序一个“是”或“否”的答案。你将在第14章进一步学习信号量。
这是一个进程间协调性的安排,你必须正确地编写代码以使其正常工作,意识到这一点是非常重要的。当程序创建锁文件失败时,它不能通过删除文件并重新尝试的方法来解决此问题。或许这样做可以让它创建锁文件,但另一个创建锁文件的程序将无法得知它已经不再拥有对这个资源的独占式访问权了。
7.2.2 区域锁定
用创建锁文件的方法来控制对诸如串行口或不经常访问的文件之类的资源的独占式访问,是一个不错的选择,但它并不适用于访问大型的共享文件。假设你有一个大文件,它由一个程序写入数据,但却由许多不同的程序同时对这个文件进行更新。当一个程序负责记录长期以来连续收集到的数据,而其他一些程序负责对记录的数据进行处理时,这种情况就可能发生。处理程序不能等待记录程序结束,因为记录程序将一直不停地运行,所以它们需要一些协调方法来提供对同一个文件的并发访问。
你可以通过锁定文件区域的方法来解决这个问题,文件中的某个特定部分被锁定了,但其他程序可以访问这个文件中的其他部分。这被称为文件段锁定或文件区域锁定。Linux提供了至少两种方式来实现这一功能:使用fcnt1系统调用和使用lockf调用。我们将主要介绍fcnt1接口,因为它是最常使用的接口。lockf和fcnt1非常相似,在Linux中,它一般作为fcnt1的备选接口。但是,fcnt1和lockf的锁定机制不能同时工作:它们使用不同的底层实现,因此决不要混合使用这两种类型的调用,而应坚持使用其中的一种。
你已在第3章中见过fcnt1调用,它的定义如下所示:
fcnt1对一个打开的文件描述符进行操作,并能根据command参数的设置完成不同的任务。它为我们提供了3个用于文件锁定的命令选项:
❑ F_GETLK
❑ F_SETLK
❑ F_SETLKW
当使用这些命令选项时,fcnt1的第三个参数必须是一个指向flock结构的指针,所以实际的函数原型应为:
flock(文件锁)结构依赖具体的实现,但它至少包含下述成员:
❑ short l_type
❑ short l_whence
❑ off_t l_start
❑ off_t l_len
❑ pid_t l_pid
l_type成员的取值定义在头文件fcnt1.h中,如表7-1所示。
表 7-1
l_whence、l_start和l_len成员定义了文件中的一个区域,即一个连续的字节集合。l_whence的取值必须是SEEK_SET、SEEK_CUR、SEEK_END(在头文件unistd.h中定义)中的一个。它们分别对应于文件头、当前位置和文件尾。l_whence定义了l_start的相对偏移值,其中,l_start是该区域的第一个字节。l_whence通常被设为SEEK_SET,这时l_start就从文件的开始计算。l_len参数定义了该区域的字节数。
l_pid参数用来记录持有锁的进程,参见下面对F_GETLK的介绍。
文件中的每个字节在任一时刻只能拥有一种类型的锁:共享锁、独占锁或解锁。fcnt1调用可用的命令和选项的组合相当多,我们将在下面依次介绍它们。
1.F_GETLK命令
第一个命令是F_GETLK。它用于获取fildes(第一个参数)打开的文件的锁信息。它不会尝试去锁定文件。调用进程把自己想创建的锁类型信息传递给fcnt1,使用F_GETLK命令的fcnt1就会返回将会阻止获取锁的任何信息。
flock结构中使用的值如表7-2所示。
表 7-2
进程可能使用F_GETLK调用来查看文件中某个区域的当前锁状态。它应该设置flock结构来表明它需要的锁类型,并定义它感兴趣的文件区域。fcnt1调用如果成功就返回非-1的值。如果文件已被锁定从而阻止锁请求成功执行,fcnt1会用相关信息覆盖flock结构。如果锁请求可以成功执行,flock结构将保持不变。如果F_GETLK调用无法获得信息,它将返回-1表明失败。
如果F_GETLK调用成功(例如,它返回一个非-1的值),调用程序就必须检查flock结构的内容来判断其是否被修改过。因为l_pid的值被设置成持有锁的进程(如果有的话)的标识符,所以通过检查这个字段就可以很方便地判断出flock结构是否被修改过。
2.F_SETLK命令
这个命令试图对fildes指向的文件的某个区域加锁或解锁。flock结构中使用的值(与F_GETLK命令中用到的不同之处)如表7-3所示。
表 7-3
与F_GETLK一样,要加锁的区域由flock结构中的l_start、l_whence和l_len的值定义。如果加锁成功,fcnt1将返回一个非-1的值;如果失败,则返回-1。这个函数总是立刻返回。
3.F_SETLKW命令
F_SETLKW命令与上面介绍的F_SETLK命令作用相同,但在无法获取锁时,这个调用将等待直到可以为止。一旦这个调用开始等待,只有在可以获取锁或收到一个信号时它才会返回。我们将在第11章讨论信号。
程序对某个文件拥有的所有锁都将在相应的文件描述符被关闭时自动清除。在程序结束时也会自动清除各种锁。
7.2.3 锁定状态下的读写操作
当对文件区域加锁之后,你必须使用底层的read和write调用来访问文件中的数据,而不要使用更高级的fread和fwrite调用,这是因为fread和fwrite会对读写的数据进行缓存,所以执行一次fread调用来读取文件中的头100个字节可能(事实上,是几乎肯定如此)会读取超过100个字节的数据,并将多余的数据在函数库中进行缓存。如果程序再次使用fread来读取下100个字节的数据,它实际上将读取已缓冲在函数库中的数据,而不会引发一个底层的read调用来从文件中取出更多的数据。
为了说明这为什么是一个问题,让我们来考虑这样一个例子:两个程序都打算更新同一个文件。假设这个文件由200个全为零的字节组成。第一个程序先开始运行,并获得该文件头100个字节的写锁。它然后使用fread来读取这100个字节。但是正如我们在前面章节中所看到的,fread会一次读取多达BUFSIZ个字节的数据,因此,它实际上把整个文件都读到了内存中,但仅把头100个字节传递给程序。
接着,第二个程序开始运行。它获得了文件后100个字节的写锁。这个操作将会成功,因为第一个程序只锁定了文件的前100个字节。第二个程序将100~199字节的数据都写成2,关闭文件并解锁,最后退出程序。这时,第一个程序锁定了文件的后100个字节,然后调用fread来读取数据。尽管真正存在于文件中的数据是100个字节的2,但是因为先前数据已经被缓存,所以程序实际上读到的数据将是100个字节的零。但如果你使用read和write,这个问题就不会发生。
上述关于文件锁的描述看起来似乎很复杂,但实际上是说起来难,做起来反而要容易一些。
实 验 使用fcnt1锁定文件
下面,我们通过示例程序lock3.c来看文件锁定是如何工作的。为了试验锁定,你需要两个程序:一个用来锁定而另外一个进行测试。第一个程序完成锁定。
(1)程序从包含头文件和变量声明开始:
(2)打开一个文件描述符:
(3)给文件添加一些数据:
(4)把文件的10~30字节设为区域1,并在其上设置共享锁:
(5)把文件的40~50字节设为区域2,并在其上设置独占锁:
(6)现在锁定文件:
(7)然后等一会儿:
实验解析
程序首先创建一个文件,并以可读可写方式打开它,然后再在文件中添加一些数据。接着在文件中设置两个区域:第一个区域为10~30字节,使用共享(读)锁;第二个区域为40~50字节,使用独占(写)锁。然后程序调用fcnt1来锁定这两个区域,并在关闭文件和退出程序前等待一分钟。
图7-1显示了当程序开始等待时文件锁定的状态。
图 7-1
这个程序本身并不是非常有用。你需要用第二个程序lock4.c来测试锁。
实 验 测试文件上的锁
在本例中,你将编写一个程序来测试可能会用在文件不同区域上的各种类型的锁。
(1)与往常一样,程序从包含头文件和声明变量开始:
(2)打开一个文件描述符:
(3)设置希望测试的文件区域:
(4)现在测试文件上的锁:
(5)用共享(读)锁重复测试一次,再次设置希望测试的文件区域:
(6)再次测试文件上的锁:
为了测试锁,需要首先运行程序lock3,然后再运行程序lock4来测试锁。你可以通过在后台运行程序lock3来达到这个目的,下面是执行的命令:
命令提示符又出现了,这是因为lock3是在后台运行的,紧接着你用下面的命令来运行程序lock4:
下面是得到的输出,为简洁起见,输出内容做了一些省略:
实验解析
lock4程序把文件中的每5个字节分成一组,为每个组设置一个区域结构来测试锁,然后通过使用这些结构来判断对应区域是否可以被加写锁或读锁。返回信息将显示造成锁请求失败的区域字节数和从字节0开始的偏移量。因为返回结构中的l_pid元素包含当前拥有文件锁的程序的进程标识符,所以程序先把它设置为-1(一个无效值),然后在fcnt1调用返回后检测其值是否被修改过。如果该区域当前未被锁定,l_pid的值就不会被改变。
为了理解程序输出的含义,你需要查看程序中包含的头文件fcnt1.h(通常是/usr/include/fcnt1.h),l_type的值为1对应的定义为F_WRLCK,l_type的值为0对应的定义为F_RDLCK。因此,l_type的值为1表明锁失败的原因是已经存在一个写锁了,而l_type的值为0是因为已经存在一个读锁了。在文件中未被lock3程序锁定的区域上,无论是共享锁还是独占锁都将会成功。
你可以看到10~30字节上可以设置一个共享锁,因为程序lock3在该区域上设置的是共享锁而不是独占锁。而在40~50字节的区域上,两种锁都将失败,因为lock3已经在该区域上设置了一个独占(F_WRLCK)锁。
当程序lock4执行结束后,你需要等待一小段时间让程序lock3完成它的sleep调用并退出。
7.2.4 文件锁的竞争
现在你已知道如何测试一个文件上的已有锁,下面让我们来看看当两个程序争夺文件同一区域上的锁时会发生什么情况。你将再次用lock3程序来锁定文件,然后用一个新的程序lock5来尝试对它进行加锁。为了使这个示例程序更完整,你还将在lock5程序中添加一些解锁的调用。
实 验 文件锁的竞争
下面的程序lock5.c的作用不再是测试文件中不同部分的锁状态,而是试图对文件中已经被锁定的区域再次加锁。
(1)在#include语句和变量声明之后,打开一个文件描述符:
(2)程序的其余部分指定文件的不同区域,并尝试在它们之上执行不同的锁定操作:
如果首先在后台运行lock3程序,然后立刻运行这个新程序:
你得到的输出如下所示:
实验解析
首先,这个程序尝试用共享锁来锁定文件中10~15字节的区域。这块区域已被一个共享锁锁定,但共享锁允许同时使用,因此加锁成功。
它然后解除它自己对这块区域的共享锁,这也成功了。接下来,这个程序试图解除这个文件前50字节上的锁,虽然它实际上并未对这块区域进行锁定,但这也成功了,因为虽然这个程序并未对这个区域加锁,但解锁请求最终的结果取决于这个程序在文件的头50个字节上并没有设置任何锁。
这个程序接下来试图用一把独占锁来锁定文件中16~21字节的区域。由于这块区域上已经有了一个共享锁,独占锁无法创建,所以这个锁定操作失败了。
然后,程序又尝试用一把共享锁来锁定文件中40~50字节的区域。由于这个区域上已有了一把独占锁,因此这个锁定操作也失败了。
最后,程序再次尝试在文件中16~21字节的区域上获得一把独占锁,但这次它用F_SETLKW命令来等待直到它可以获得一把锁为止。于是程序的输出就会遇到一个很长的停顿,直到已锁住这块区域的lock3程序因为完成sleep调用、关闭文件而释放了它先前获得的所有锁为止。lock5程序继续执行,成功锁定了这块区域,最后它也退出了运行。
7.2.5 其他锁命令
还有另外一种锁定文件的方法:lockf函数。它也通过文件描述符进行操作。其原型为:
function参数的取值如下所示。
❑ F_ULOCK:解锁。
❑ F_LOCK:设置独占锁。
❑ F_TLOCK:测试并设置独占锁。
❑ F_TEST:测试其他进程设置的锁。
size_to_lock参数是操作的字节数,它从文件的当前偏移值开始计算。
lockf有一个比fcnt1函数更简单的接口,这主要是因为它在功能性和灵活性上都要比fcnt1函数差一些。为了使用这个函数,必须首先搜寻你想锁定的区域的起始位置,然后以要锁定的字节数为参数来调用它。
与文件锁定的fcnt1方法一样,lockf设置的所有锁都是建议锁,它们并不会真正地阻止你读写文件中的数据。对锁的检测是程序的责任。混合使用fcnt1锁和lockf锁的效果未被定义,因此你必须决定使用哪种类型的锁定方法并坚持用下去。
7.2.6 死锁
在讨论锁定时如果未提到死锁的危险,那么这个讨论就不能算是完整的。假设两个程序想要更新同一个文件。它们需要同时更新文件中的字节1和字节2。程序A选择首先更新字节2,然后再更新字节1。程序B则是先更新字节1,然后才是字节2。
两个程序同时启动。程序A锁定字节2,而程序B锁定字节1。然后程序A尝试锁定字节1,但因为这个字节已经被程序B锁定,所以程序A将在那里等待。接着程序B尝试锁定字节2,但因为这个字节已经被程序A锁定,所以程序B也将在那里等待。
这种两个程序都无法继续执行下去的情况,就被称为死锁(deadlock或deadly embrace)。这个问题在数据库应用程序中很常见,当许多用户频繁访问同一个数据时就很容易发生死锁。大多数的商业关系型数据库都能够检测到死锁并自动解开,但Linux内核不行。这时就需要采取一些外部干涉手段,例如强制终止其中一个程序来解决这个问题。
程序员必须对这种情况提高警惕。当有多个程序都在等待获得锁时,你就需要非常小心地考虑是否会发生死锁。在本例中,死锁是非常容易避免的:两个程序只需要使用相同的顺序来锁定它们需要的字节或锁定一个更大的区域即可。
在这里,我们没有足够的篇幅来讲解开发并发程序的原理。如果你有兴趣了解更多,请参阅Principles of Concurrent and Distributed Programming(《并发和分布式程序设计原理》,M. Ben-Ari,Prentice Hall,1990)。