7.2 文件锁定
这篇为linux的文件锁定,代码在文件锁定代码下载。文件锁定是多用户、多任务操作系统中一个非常重要的组成部分。程序经常需要共享数据,而这通常是通过文件来实现的。因此,对于这些程序来说,建立某种控制文件的方式就非常重要。只有这样,文件才可以通过一种安全的方式更新,或者说,当一个程序正在对文件进行写操作时,文件就会进入一个暂时状态,在这个状态下,如果另外一个程序尝试读这个文件,它就会自动停下来等待这个状态的结束。
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中:
#define EEXIST 17 /*File exitsts*/
这是一个适合与表示open(O_CREATE | O_EXCL)失败的错误号。
如果一个程序在它执行时,只需独占某个资源一段很短的时间——临界区,它就需要在进入临界区之前使用open系统调用创建锁文件,然后在退出临界区时用unlink系统调用删除该锁文件。
编写程序lock2.c
./lock2.exe & ./lock2.exe
这个命令运行程序的两份副本,在后台运行lock2的副本,在前台运行另一份副本。
这个程序使用while语句让程序循环10次,然后通过创建一个唯一的锁文件/tmp/LCK.test2来访问临界资源。如果因为文件已存在而失败,程序将等候一小段时间后再次尝试。如果成功,它就可以访问资源。在标记为“临界区”的部分,可以执行任何需要独占式访问的处理。
程序使用完资源后,通过删除锁文件来释放锁,然后可以在重新申请锁之前执行一些其他的处理(例如sleep(2))。这里锁文件扮演了类似二进制信号量的角色,就问题“可以使用这个资源吗?”给每个程序一个“是”或者“否”的答案,十四章进一步学习信号量。
这是进程间协调性的安排,必须正确地编写代码以使其正常工作,意识到这一点是非常重要的。当程序创建锁文件失败时,它不能通过删除文件并重新尝试的方法来解决此问题。或许这样可以让它创建锁文件,但是另一个创建锁文件的程序将无法得知它已经不再拥有对这个资源的独占式访问权了。
7.2.2 区域锁定
用创建锁的方法来控制对诸如串行口或者不经常访问的文件之类的资源的独占式访问,是一个不错的选择,但它并不适合用于访问大型的共享文件。假设有一个大文件,它由一个程序写入数据,但却由许多不同的程序同时对这个文件进行更新。当一个程序负责记录长期以来连续收集到的数据,而其他一些程序负责对记录的数据进行处理时,这种情况就可能发生。处理程序不能等待记录程序结束,因为记录程序将一直不停地运行,所以它们需要一些协调方法来提供同一个文件的并发访问。
可以通过锁定文件区域的方法来解决这个问题,文件中的某个特定部分被锁定了,但其他程序还可以访问这个文件的其他部分,这被成为文件段锁定或者文件区域锁定。linux提供了至少两种方式来实现这一功能:使用fcntl系统调用和使用lockf调用。我们将主要介绍fcntl接口,因为它是最常使用的接口。lockf和fcntl非常相似,在linux中,它一般作为fcntl的备选接口。但是,fcntl和lockf的锁定机制不能同时工作:它们使用不同底层实现,因此绝对不能混合使用这两种类型的调用,而应该坚持使用其中的一种。
第三章中讲过fcntl调用的定义如下:
#include <fcntl.h>
int fcntl(int filds, int command, ...);
fcntl对一个打开的文件描述符进行操作,并能根据command参数的设置完成不同的任务。它提供了3个用于文件锁定的命令选项:
F_GETLK
F_SETLK
F_SETLKW
当使用这些命令选项时,fcntl的第三个参数必须是一个指向flock结构的指针,所以实际的函数原型为:
int fcntl(int fildes, int command, struct flock* flock_structure);
flock(文件锁)结构依赖具体的实现,但它至少包含下述成员:
short l_type
short l_whence
off_t l_start
off_t l_len
pid_t l_pid
l_type成员的取值定义在头文件fcntl.h中
取值 说明
F_RDLCK 共享(或读)锁,许多不同的进程可以拥有文件同意区域上的共享锁。只要任一进程拥有一把共享锁,那么就没有进程可以再获得该区域上的独占锁。为了获 得一把共享锁,文件必须以“读”或“读/写”方式打开。
F_UNLCK 解锁,用来清除锁
F_WRLCK 独占(或写)锁。只有一个进程可以在文件任意特定区域拥有一把独占锁。一旦一个进程拥有了写锁,其他进程无法在该区域获得任何类型的锁。为了 获得独占锁,文件必须以“写”或“读/写”方式打开。
l_whence、l_start和l_len成员定义了文件中的一个区域,即一个连续的字节集合。l_whence的取值必须是SEEK_SET、SEEK_CUR、SEEK_END中的一个。它们分别对应于文件头、当前位置和文件尾。l_whence定义了l_start的相对偏移值,其中,l_start是该区域的第一个字节。l_whence通常被设为SEEK_SET,这时l_start就从文件的开始计算。l_len参数定义了该区域的字节数。
l_pid参数用来记录持有锁的进程。
文件中的每个字节在任一时刻只能拥有一种类型的锁:共享锁、独占锁或解锁。fcntl调用可用的命令和选项的组合非常多。
1.F_GETLK命令
第一个命令是F_GETLK,它用于获取fildes打开的文件的锁信息。它不会尝试去锁定文件。调用进程把想创建的锁类型信息传递给fcntl,使用F_GETLK命令的fcntl就会返回将会阻止获取锁的任何信息。
flock结构中使用的值如下所示:
取值 说明
l_type 如果是共享(只读)锁则取值为F_RDLCK,如果是独占(写)锁则取值为F_WRLCK
l_whence SEEK_SET、SEEK_CUR、SEEK_END中的一个
l_start 文件区域的第一个字节的相对位置
l_len 文件区域的字节数
l_pid 持有锁的进程的标识符
进程可能使用F_GETLK调用来查看文件中某个区域的当前锁状态,它应该设置flock结构来表明它需要的锁类型,并定义它感兴趣的文件区域。fcntl调用如果成功就返回非-1的值。如果文件已被锁定从而阻止锁请求成功执行,fcntl会用相关信息覆盖flock结构。如果锁请求可以成功执行,flock结构将保持不变。如果F_GETLK调用无法获得信息,它将返回-1表明失败。
如果F_GETLK调用成功,调用程序就必须检查flock结构的内容来判断其是否被修改过。因为l_pid的值被设置成持有锁的进程的标识符,所以通过检查这个字段就可以很方便地判断出flock结构是否被修改过。
2.F_SETLK命令
这个命令试图对fildes指向的文件的某个区域加锁或解锁。flock结构中使用的值如下所示:
取值 说明
l_type 如果是共享(读)锁则取值为F_RDLCK,如果独占(写)锁则为F_WRLCK,如果是解锁则为F_UNLCK
l_pid 不使用
要加锁的区域由flock结构中的l_start、l_whence和l_len的值定义。如果加锁成功,fcntl将返回一个非-1的值;如果失败,则返回-1.
3.F_SETLKW命令
F_SETLKW与F_SETLK命令相同,但在无法获取锁时,这个调用将等待直到可以为止。一旦这个调用开始等待,只有在可以获取锁或收到一个信号时它才返回。
程序对某个文件拥有的所有锁都将在相应的文件描述符被关闭时自动清除。在程序结束时也会自动清除各种锁。
7.2.3 锁定状态下的读写操作
当对文件区域加锁之后,必须使用底层的read和write调用来访问文件中的数据,而不要使用更高级的fread和fwrite调用,这是因为fread和fwrite会对读写的数据进行缓存,所以执行一次fread调用来读取文件中的头100个字节可能会读取超过100个字节的数据,并将多余的数据在函数库中进行缓存。如果程序再次使用fread来读取下100个字节的数据,它实际上将读取已缓存在函数库中的数据,而不会引发一个底层的read调用来从文件中取出更多的数据。
编写程序lock3.c
程序首先创建一个文件,并以可读可写方式打开它,然后再在文件中添加一些数据。接着在文件中设置两个区域:第一个区域为10~30字节,使用共享锁;第二个区域为40~50字节,使用独占锁。然后程序调用fcntl来锁定这两个区域,并在关闭文件和退出程序前等待10秒钟。
编写程序lock4.c
为了测试锁,需要首先运行程序lock3.exe,然后再运行程序lock4.exe来测试锁。
./lock3.exe & ./lock4.exe
程序中l_type的值为1对应的定义为F_WRLK,l_type的值为0对应的定义为F_RDLK。因此l_type的值为1表明锁失败的原因是已经存在一个写锁了,而l_type的值为0是因为已经存在一个读锁了。在文件中未被lock3程序锁定的区域上,无论是共享锁还是独占锁都将会成功。
可以看到10~30字节上可以设置一个共享锁,因为程序lock3在该区域上设置的是共享锁而不是独占锁。而在40~50字节的区域上,两种锁都将失败,因为lock3已经在该区域上设置了一个独占锁(F_WRLCK)。
7.2.4 文件锁的竞争
接下来测试两个程序争夺文件夹同一区域上的锁时会发生什么情况。再次用lock3.exe来锁定文件,然后用一个新的程序lock5.exe来尝试对它进行加锁。
lock5.exe的作用不再是测试文件中不同部分的锁状态,而是试图对文件中已经锁定的区域再次加锁。
顺便再提一下第三章就讲过的fcntl系统调用,fcntl系统调用对底层文件描述符提供了很多操作方法。
#include <fcntl.h>
int fcntl(int fields, int cmd);
int fcntl(int fileds, int cmd, long arg);
利用fcntl系统调用,可以对打开的文件描述符执行各种操作,包括对它们的复制、获取和设置文件描述符标志、获取和设置文件状态标志,以及管理建议性文件锁等。
7.2.5 其他锁命令
还有另外一种锁定文件的方法;lockf函数,它也通过文件描述符进行操作。其原型为:
#include <unistd.h>
int lockf(int fildes, int function, off_t size_to_lock);
function参数的取值如下所示:
F_ULOCK: 解锁
F_LOCK: 设置独占锁
F_TLOCK: 测试并设置独占锁
F_TEST: 测试其他进程设置的锁
size_to_lock参数是操作的字节数,它从文件的当前偏移值开始计算。
lockf有一个比fcntl函数更简单的接口,这主要是因为它在功能性和灵活性上都要比fcntl函数差一些。为了使用这个函数,必须首先搜寻想要锁定的区域的起始位置,然后以要锁定的字节数为参数来调用它。
与文件锁定的fcntl方法一样,lockf设置的所有锁都是建议锁,它们并不会真正阻止读写文件中的数据。对锁的检测是程序的责任。
7.2.6 死锁
假设两个程序都要更新同一个文件,它们需要同时更新文件中的字节1和字节2。程序A选择首先更新字节1,然后更新字节2,程序B选择首先更新字节2,然后更新字节1。
两个程序同时启动,程序A锁定字节1,程序B锁定字节2,然后A尝试字节2不成功,所以A需要等待,同样,B也需要等待。
这种两个程序都无法继续执行下去的情况,就被称为死锁(deadlok或者deadly embrace)。这个问题在数据库应用程序中很常见,当许多用户频繁访问同一个数据时就很容易发生死锁。大多数的商业关系型数据库都能够检测到死锁并且自动解开,但linux内核不行,这是需要采取一些外部干涉手段,例如强制终止其中一个程序来解决这个问题。