分类:
2012-07-31 13:23:04
当两个人同时编辑同一个文件会发生什么呢?在多数UNIX系统里,文件的最终状态对应于最后写这个文件的进程。然而,在一些应用里,比如一个数据库 系统,一个进程需要明确单独地写一个文件。为了给进程提供这样的能力,商业UNIX系统提供了记录锁。(在20章,我们使用记录锁开发一个数据库库)。
记录锁是通常描述一个正在读或写一个文件的某块区域的进程能够阻止其它进程修改这个文件的这块区域的术语。在UNIX系统下,形容词“记录”是一个误用,因为UNIX内核不了解文件里的记录。一个更好的术语是字符范围锁,因为它是被锁文件里的一个范围(可能是整个文件)。
历史
早期UNIX系统的一个批评是它们不能用来运行数据库系统,因为没有锁住文件的部分的功能。由于UNIX找到了进程商业计算环境的方法,各种组加入了记录锁的支持(当然,有区别地)。
早期Berkeley版本只支持flock函数。这个函数锁住整个文件,而不是一个文件的区域。
记录锁通过fcntl函数加入到系统V的第3个版本里。lockf函数基于这个被建立,支持了简化的接口。这些函数允许调用者锁住一个文件里的任意字节范围,从整个文件到文件里的单个字节。
POSIX.1选择了标准化fcntl的方法。下表展示了各种系统提供记录锁的形式。注意SUS在XSI扩展里包含了lockf。
系统 | 建议的 | 强制的 | fcntl | lockf | flock |
---|---|---|---|---|---|
SUS | * | * | XSI | ||
FreeBSD 5.2.1 | * | * | * | * | |
Linux 2.4.22 | * | * | * | * | * |
Mac OS X 10.3 | * | * | * | * | |
Solaris 9 | * | * | * | * | * |
记 录锁最早由John Bass在1980加入到版本7。进入内核的系统调用是一个名为locking的函数。这个函数提供了强制记录锁并传播到许多系统III的版本。 Xenis系统选择了这个函数,一些基于Intel的系统V的后代,比如OpenServer 5,仍然在Xnenix兼容的库里支持它。
fcntl记录锁
让我们重复下3.14节的fcntl函数的原型。
对于记录锁,cmd是F_GETTLK、F_SETLK或F_SETLKW。第三个参数(我们称之为flockptr)是一个flock结构体指针。
struct flock {
short l_type; /* F_RDLCK, F_WRLCK, or F_UNLCK */
off_t l_start; /* offset in bytes, relative to l_whence */
short l_whence; /* SEEK_SET, SEEK_CUR, or SEEK_END */
off_t l_len; /* length, in bytes; 0 means lock to EOF */
pid_t l_pid; /* returned with F_GETLK */
};
这个结构体描述了:
1、渴望的锁的类型:F_RDLCK(一个共享读锁)、F_WRLCK(一个互斥写锁)、或F_UNLCK(解锁一个区域);
2、被锁或解锁的区域的开始字节偏移量(l_start和l_whence);
3、区域的字节尺寸(l_len);
4、可以阻塞当前进程的握住锁的进程ID(l_pid)(只由F_GETTLK返回)。
关于加锁和解锁的区域的指定有许多规则:
1、指定区域开始偏移量的两个元素和lseek函数(3.6节)的最后两个参数相似。事实上,l_whence成员被指定为SEEK_SET、SEEK_CUR或SEEK_END。
2、锁可以在当前的末尾之后开始和扩展,但是不能在文件开头之前开始或扩展。
3、如果l_len为0,它表示锁扩展到文件的最大偏移量。这允许我们锁住文件里从任何地方开始的区域,通过并包含添加到文件的任何数据(我们不必尝试猜测多少字节可能被添加到文件里)。
4、为了锁住整个文件,我们设置l_start和l_whence来指到文件的开头和指定一个0的长度(l_len)。(有几种方法来指定文件的开头,但是多数应用把l_start指定为0而l_whence作为SEEK_SET。)
我们提到两种类型的锁:一个共享读锁(F_DLCK的l_type)和一个排斥写锁(F_WRLCK)。基本的规则是任意数量的进程可以在一个给定字节上有 一个共享读锁,但是只有一个进程在一个给定的字节上有一个排斥写锁。更甚,如果在一个字节上有一个或多个读锁,那么在这个字节上不能有任何写锁;如果在一 个字节上有一个排斥写写,那么在这个字节上不能有任何写锁。
这个兼容性规则应用到由不同进程发出的锁请求,而不是单个进程发出的多个锁请 求。如果一个进程已经在文件的一个范围有有了已有的锁,那么同一个进程的后续在相同区域放置一个锁的尝试会用新的锁代替已有的那个。因而,如果一个进程有 一个文件的字节16-32上的写锁,然后在字节16-32上放置一个读锁,请求将会成功(假设我们没有其它进程尝试尝试锁住文件相同区域的竞争),而写锁 会被一个读锁代替。
为了得到一个读锁,文件描述符必须为读打开;为了得到一个写锁,文件必须为写打开。
我们现在可以描述fcntl函数的三个命令。
F_GETLK: 决定flockptr描述的锁是否被其它锁阻塞。如果一个会阻止我们的被创建的锁存在,在已有锁上的信息会覆盖flockptr指向的信息。如果没有阻止 我们的被创建的锁存在,那么flockptr指向的结构体保持不变,除了l_type成员,它被设为F_UNLCK。
F_SETLK:设置 flockptr描述的锁。如果我们尝试得到一个读锁(F_RDLCK的l_type)或一个写锁(F_WRLCK的l_type),而兼容性规则阻止系 统给我们这个锁,fcntl立即返回,errno被设为EACCES或EAGAIN。尽管POSIX允许一个实现返回两个错误码中的一个,本文所有四个实 现在锁请求不被满足时返回EGAIN。这个命令也用来清除由flockptr描述的锁(F_UNLCK的l_type)。
F_SETLKW:这个命令是F_SETLK的一个阻塞版本。(命令名里的W意思是等待。)如果请求的读锁或写锁不能得到,因为另一个进程正在锁住请求区域的部分,那么调用进程被催眠。进程在当锁变为可用时或当被一个信号中断时会醒过来。
注 意用F_GETLK测试一个锁和尝试用F_SETLK或F_SETLKW得到那个锁不是一个原子操作。我们没有保证,在两个fcntl调用之间,一些别的 进程不会进入并获得相同的锁。如果我们不想在等待一个锁可用时阻塞,那么我们必须处理从F_SETLK返回的可能错误。
注意POSIX.1 不规定当一个进程写锁住一个文件的一个区域时,第二个进程在尝试得到相同区域的写锁时阻塞,第三个进程然后试图得到这个区域的另一个写锁时会发生什么。如 果第三个进程被允许在这个区域放置一个读锁,只因为区域已经被写锁,那么实现可能会由于让写锁待定从而饿死进程。这意味着当在相同区域的更多的读锁请求到 达时,有待定写锁的进程必须等待的时间会变长。如果读锁请求到达地足够迅速,在到达速度上没有消停,那么写者可能会等待很长的时间。
当设置或释放文件上的一个锁时,系统根据请求可以结合或切割邻接的区域。例如,如果我们锁住100-199字节,然后解锁150,内核会维护100-149和151-199上的锁。
如果我们锁住150,系统会把相邻的锁住的区域合并为100-199的单个区域。
为了让我们不必每次分配一个flock结构体并填充它的所有元素,下面代码里的lock_reg函数处理了所有这些细节。
下面的代码定义了lock_test函数,我们将使用它测试一个锁。
当两个进程都在等待对方锁住的的资源时,死锁会发生。如果一个控制了一个被锁资源并在尝试锁住另一个进程的所控制的一个资源时被催眠,会有潜在的死锁。
下面的代码展示了死锁的一个例子。
当一个死锁被察觉时,内核必须选择一个进程来接收返回的错误。在这个例子,子进程被选择了,但是这是一个实现细节。在一些系统上,子进程总是收到错误。在其它系统上,父进程总是收到错误。在一些系统上,你可能甚至看到错误在多次锁的尝试中在子进程和父进程都会出现。
隐含的继承和锁的释放
三条规则管理自动继承和记录锁的释放。
1、锁被关联到一个进程和一个文件。这有两个实现。第一个很明显:当一个进程终止时,它所有的锁都被释放。第二个很不明显:每当一个描述符被关闭时,在那个进程里的被那个描述符引用的文件上的锁都会被释放。这意味着如果我们:
fd1 = open(pathname, ...);
read_lock(fd1, ...);
fd2 = dup(fd1);
close(fd2);
那么在close(fd2)之后,在fd1上获得的锁会被释放。如果我们用open来代替dup会发生相同的事情,如:
fd1 = open(pathname, ...);
read_lock(fd1, ...);
fd2 = open(pathname, ...);
close(fd2);
在另一个描述符上打开相同的文件。
2、 锁决不会通过fork被子进程继承。这意味着如果一个进程获得了一个锁然后调用fork,那么子进程被认为是另一个进程,在考虑由父进程得到的锁的时候。 子进程必须调用fcntl来获得它在通过fork继承的任何描述符上的自己的锁。这是合理的,因为锁是用来阻止多个进程同时写相同的文件。如果子进程通过 一个fork继承了锁,那么父子都可以同时向相同文件写。
3、锁被一个通过exec产生的新程序继承。然而,注意如果close-on-exec标志被设置到一个文件描述符时,当描述符的关闭作为exec的一部分时,所有底下文件的锁都被释放。
FreeBSD 实现
让我们简明看下FreeBSD实现使用的数据结构。这应用帮助规则1的验证,锁被关联到一个进程和一个文件。
考虑一个进程执行了下面的语句(忽略错误返回)。
fd1 = open(pathname, ...);
write_lock(fd1, 0, SEEK_SET, 1); /* parent write locks byte 0 */
if ((pid = fork()) > 0) { /* parent */
fd2 = dup(fd1);
fd3 = open(pathname, ...);
} else if (pid == 0) {
read_lock(fd1, 1, SEEK_SET, 1) /* child read locks byte 1 */
}
pause();
下图显示了在父子都暂停后导致的数据结构:
我 们之前已经展示过open、fork和dup会导致的数据结构。这里新的是lockf结构体,从i-node结构体链到一起。注意每个lockf结构体都 为一个给定进程描述了一个锁住的区域(由offset和length定义)。我们展示了这些结构体的两个:一个是父进程的write_lock的调用,另 一个是子进程的read_lcok的调用。每个结构体都包含了对应的进程ID。
在父进程里,关闭fd1、fd2或fd3中的任何一个都会导致父进程的锁被释放。当这些文件描述符中的任一个被关闭时,内核遍历对应的i-node的锁的链表,并释放由调用进程握住的锁。内核不知道(也不关心)这三个描述符里哪个被父进程用来获得这个锁。
在13.5节的already_running的代码里,我们展示了守护进程如何使用一个文件的锁来确保只有一个守护进程的拷贝正在运行。下面的代码展示了守护进程用来在一个文件上放置一个写锁的lockfile函数的实现。
文件末尾的锁
当 锁和解锁文件尾时要小心。多数实现把SEEK_CUR或SEEK_END的l_whence值转换为一个绝对文件偏移量,使用l_start和文件当前位 置或当前长度。然而,我们经常需要指定一个相对于当前位置或当前长度的锁,因为我们不能调用lseek来得到当前文件的偏移量,因为我们还没有这个文件的 锁。(其它进程有机会可能在lseek调用和这个锁调用之间改变文件的长度。)
考虑下面的的步骤:
write_lock(fd, 0, SEEK_END, 0);
write(fd, buf, 1);
un_lock(fd, 0, SEEK_END);
write(fd, buf, 1);
代 码的结果可能不是你期望的。它从当前文件尾得到一个写锁,包括你可能添加到文件的任何未来的数据。假定当我们执行第一个write时我们在文件末尾,那将 把文件扩展一个字节,然后那个字节将被锁住。接下来的解锁会删除添加数据到文件的将来的写,但是它在文件的最后一个字节上保留了一个锁。当第二个写发生 时,文件末尾被扩展了一个字节,但是这个字节没有被锁。
当一个文件的某个部分被锁住时,内核把指定的偏移量转换为一个绝对的文件偏移量。除 了指定一个绝对的文件偏移量(SEEK_SET),fcntl允许我们指定这个偏移量相对于文件的一个点:当前(SEEK_CUR)或文件末尾 (SEEK_END)。内核需要记住当前文件偏移量或文件末尾无关的锁,因为当前偏移量和文件末尾可能会改变,而这些属性的改变不应该影响已有锁的状态。
如果我们想要删除覆盖我们第一次write里写的字节的锁,那么我们可以指定长度为-1。负长度的值表示指定偏移量之前的字节。
建议锁和强制锁(Advisory versus Mandatory Locking)
考 虑一个数据库访问例程库。如果库里所有的函数都以统一 方式处理记录锁,那么我们说使用这些函数来访问数据库的任何进程集是协同操作进程。这些数据库访问函数可以使用建议锁,如果它们只被用来访问数据库。但是 建议锁不阻止其它一些有数据库写权限的进程来写入任何它想写到数据库文件的东西。这个无赖进程是一个不合作的进程,因为它不使用接受的方法(数据库函数 库)来访问数据库。
强制锁导致内核检查每个open、read和write来验证调用进程没有违反正被访问的文件上的锁。强制锁有时被称为强制模式(enforcement-mode)锁。
我 们在前面的表里看到Linux 2.4.22和Solaris 9提供强制记录锁,但是FreeBSD 5.2.1和Mac OS X 10.3不是。强制记录锁不是SUS的一部分。在Linux上,如果你想要强制锁,你需要在mount命令上使用-o mand选项来为每个文件系统基于启用它。
强制锁为一个特定文件被启用,通过打开设置组ID位并关闭组执行位。因为设置组ID位在组执行位关闭时没有意义,SVR3的设计者选择这种方式来指定一个文件的锁是强制锁而不是建议锁。
当一个进程尝试读或写一个启用强制锁的文件,而文件的指定部分当前正被其它进程读锁或写锁时会发生什么呢?答案取决于操作的类型(读或写),另一个进程握住的锁的类型(读锁或写锁),和read或write的描述符是否是非阻塞的。下表展示了8种可能。
被另一个进程在区域上握住的已有的锁的类型 | 阻塞的描述符,尝试: | 非阻塞的描述符,尝试: | ||
read | write | read | write | |
读锁 | 没问题 | 阻塞 | 没问题 | EAGAIN |
写锁 | 阻塞 | 阻塞 | EAGAIN | EAGAIN |
除了上表的read和write函数,open函数也被其它进程握住的强制锁影响。通常,open成功,即使文件被外面的强制锁打开。接下来的read或 write会遵循上表中的规则。但是如果文件被外面的强制记录锁打开,并且open调用的标志指定O_TRUNC或O_CREAT,那么open立即返回 一个EAGAIN错误,不管O_NONBLOCK是否被指定。
只有Solaris把O_CREAT作为一个错误情况对待。Linux允许 O_CREAT标志被指定,当打开一个有外部强制锁的文件时。为O_TRUNC产生open错误是有意义的,因为文件不能被裁切,如果它被其它进程读锁或 写锁。然而,为O_CREAT产生错误没有什么意义;这个标志说只当文件不存在时才会创建这个文件,但是被其它进程锁的文件必须存在。
和 open冲突的锁的处理会导致奇怪的结果。当开发本节的练习时,一个测试程序被运行,它打开一个文件(它的模式被指定为强制锁),在整个文件上建立一个读 锁,然后睡一会。(回想上表读锁应该阻止其它进程的写。)在这个睡眠期间,以下的行为会在其它典型的UNIX系统程序里看到:
1、相同的文 件可以被ed编辑器编辑,结果被写到磁盘里!强制锁完全没有效果。使用UNIX系统的某些版本提供的系统调用跟踪特性,可以看到ed把新的内容写到一个临 时文件,删除了原始的文件,然后重命名这个临时文件为原始文件。强制锁在unlink函数上没有效果,它允许这个发生。在Solaris下,进程的系统调 用跟踪可以由truss命令得到。FreeBSD和Mac OS X使用ktrace和kdump命令。Linux提供了strace命令来跟踪进程执行的线程调用。
2、vi编辑器无法编辑这个文件。它能读文件的内容,但是每当它想写入新数据时,EAGAIN被返回。如果我们尝试工向文件添加新数据,write阻塞。vi的行为是我们期望的。
3、使用Korn外壳的>和>>操作符来覆写或添加到文件导致错误“不能创建”。
4、 在Bourne外壳里使用相同的两个操作符导致>的一个错误,但是>>操作符只阻塞,直到强制锁被删除,然后执行。(添加操作符的处理 的区别在于因为Korn外壳open文件,使用O_CREAT和O_APPEND,我们之前提过指定O_CREAT产生一个错误。然而,Bourne外 壳,没有指定O_CREAT如果文件已经存在,所以open成功但下一个write阻塞。)
根据你正使用的操作系统的版本,结果会不同。这个练习的底线是小心强制记录锁。正如ed例子里看到的,它可以被回避。
强制记录锁可以被恶意用户用来握住一个公开可读的文件上的读锁。这会阻止任何人写这个文件。(当然,文件必须启用强制记录锁才会发生这个,这需要这个用户有 能够改变这个文件的权限位。)考虑一个数据库文件,它是所有人可读的并启用了强制记录锁。如果一个恶意用户要在整个文件上握住一个读锁,那么文件不能被其 它进程写。
下面的程序确定一个系统是否支持强制锁。
如果我们看下系统头文件或intro手册页,我们看到11的errno对应于EAGAIN。在FreeBSD 5.2.1,我们得到:
$ ./a.out tmp.foo
read_lock of already-locked region returns 35
read OK (no mandatory locking), buf = ab
这里35的errno对应于EAGAIN。强制锁没有被支持。(我的Linux3.0也不支持强制锁,不知道是不是因为mount时没有选择这项。)
让我们回到本节的第一个问题:当两个人同时编辑同一个文件会发生什么?普通的UNIX系统文本编辑器不使用记录锁,所以答案是仍然为对应于最后写这个文件的进程的文件结果。
一些版本的vi使用建议记录锁。即使我们用某个这样的vi,它仍然无法阻止用户使用另一个不使用建议记录锁的编辑器。
如 果系统提供强制记录锁,那么我们可以修改我们最喜爱的编辑器来使用它(如果我们有源码)。没有编辑器的源码时,我们可能做以下尝试。我们写自己的程序作为 vi的前端。这个程序立即调用fork,而父进程只是等待子进程结束。子进程打开在命令行指定的文件,启用强制锁,得到整个文件的写锁,然后执行vi。当 vi运行时,文件是被写锁的,所以其他用户不能修改它。当vi终止时,父进程的wait返回,而我们的前端终止。
一个小的这种类型的前端程序可以写出来,但是不能工作。问题是多数编辑器普遍读它们的输入文件然后关闭它。每当一个引用到文件的描述符被关闭时这个文件上的锁会被释放。这表示当编辑器在读取内容后关闭文件时,锁会丢失。在前端程序里无法阻止。
我们将在第20章使用记录锁,在我们的数据库库里来提供多个进程的并发访问。我们也将提供一些时间测量来看记录锁在一个进程上的影响。