高级I/O之记录锁

若两个人同时编辑一个文件,其后果将如何呢?在很多UNIX系统中,该文件的最后状态取决于写该文件的最后一个进程。但是对于有些应用程序(例如数据库),进程有时需要确保它正在单独写一个文件。为了向进程提供这种功能,商用UNIX系统提供了记录锁机制。

记录锁(record locking)的功能是:当一个进程正在读或修改文件的某个部分时,它可以阻止其他进程修改同一文件区。对于UNIX系统而言,“记录”这个词是一种误用,因为UNIX系统内核根本没有使用文件记录这种概念。更适合的术语可能是字节范围锁(byte-range locking),因为它锁定的只是文件中的一个区域(也可能是整个文件)。

1、历史

对早期UNIX系统的一种批评是它们不能用来运行数据库系统,其原因是这些系统不支持部分地对文件加锁。在UNIX系统开始进入商用计算领域时,很多系统开发小组以各种不同方式增加了对记录锁的支持。

早期的伯克利版本只支持flock函数。该函数锁整个文件,不能锁文件中的一部分。

SVR3通过fcntl函数增加了记录锁功能。在此基础上构造了lockf函数,它提供了一个简化的接口。这些函数允许调用者锁一个文件中任意字节数的区域,长至整个文件,短至文件中的一个字节。

POSIX.1标准的基础是fcntl。

2、fcntl记录锁

http://www.cnblogs.com/nufangrensheng/p/3500350.html中已经给出了fcntl函数的原型。

#include <fcntl.h>
int fcntl(int filedes, int cmd, ... /* struct flock *flockptr */);
返回值:若成功则依赖于cmd,若出错则返回-1

对于记录锁,cmd是F_GETLK、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 */
};

对flock结构说明如下:

  • 所希望的锁类型:F_RDLCK(共享读锁)、F_WRLCK(独占性写锁)或F_UNLCK(解锁一个区域)。
  • 要加锁或解锁区域的起始字节偏移量,这由l_start和l_whence两者决定。
  • 区域的字节长度,由l_len表示。
  • 具有能阻塞当前进程的锁,其持有进程的ID存放在l_pid中(仅由F_GETLK返回)。

关于加锁和解锁区域的说明还要注意下列各点:

  • l_start是相对偏移量(字节),l_whence则决定了相对偏移量的起点。这与lseek函数(http://www.cnblogs.com/nufangrensheng/p/3498080.html)中最后两个参数类似。确实,l_whence可选用的值是SEEK_SET、SEEK_CUR或SEEK_END。
  • 该区域可以在当前文件尾端处开始或越过其尾端处开始,但是不能在文件起始位置之前开始。
  • 如若l_len为0,则表示锁的区域从其起点(由l_start和l_whence决定)开始直至最大可能偏移量为止,也就是不管添写到该文件中多少数据,它们都处于锁的范围内(不必猜测会有多少字节被追加到文件之后)。
  • 为了锁整个文件,我们设置l_start和l_whence,使锁的起点在文件起始处,并且说明长度(l_len)为0。(有多种方法可以指定文件起始处,但常用的方法是将l_start指定为0,l_whence指定为SEEK_SET。)

上面提到了两种类型的锁:共享读锁(l_type为F_RDLCK)和独占写锁(F_WRLCK)。基本规则是:多个进程在一个给定的字节上可以有一把共享的读锁,但是在一个给定字节上只能有一个进程独用的一把写锁。进一步而言,如果在一个给定字节上已经有一把或多把读锁,则不能在该字节上再加上写锁;如果在一个字节上已经有一把独占性的写锁,则不能再对它加任何读锁。在表14-2示出了这些规则。

1364815984_5781

上面说明的兼容性规则适用于不同进程提出的锁请求,并不适用于单个进程提出的多个锁请求。如果一个进程对一个文件区间已经有了一把锁,后来该进程又企图在同一文件区间再加一把锁,那么新锁将替换老锁。例如,若一进程在某文件的16-32字节区间有一把写锁,然后又试图在16-32字节区间加一把读锁,那么该请求将成功执行(假定其他进程此时并不试图向该文件的同一区间加锁),原来的写锁被替换为读锁。

加读锁时,该描述符必须是读打开;加写锁时,该描述符必须是写打开。

以下说明fcntl函数的三种命令:

F_GETLK          判断由flockptr所描述的锁是否会被另外一把锁所排斥(阻塞)。如果存在一把锁,它阻止创建由flockptr所描述的锁,则把该现存锁的信息写到flockptr指向的结构中。如果不存在这种情况,则除了将l_type设置为F_UNLCK之外,flockptr所指向结构中的其他信息保持不变。

F_SETLK          设置由flockptr所描述的锁。如果试图建立一把读锁(l_type设为F_RDLCK)或写锁(l_type设为F_WRLCK),而按上述兼容性规则不能允许,则fcntl立即出错返回,此时errno设置为EACCES或EAGAIN。此命令也用来清除由flockptr说明的锁(l_type为F_UNLCK)。

F_SETLKW       这是F_SETLK的阻塞版本(命令名中的W表示等待(wait))。如果因为当前在所请求区间的某个部分另一进程已经有一把锁,因而按兼容性规则由flockptr所请求的锁不能被创建,则使调用进程休眠。如果请求创建的锁已经可用,或者休眠由信号中断,则该进程被唤醒。

应当了解,用F_GETLK测试能否建立一把锁,然后用F_SETLK和F_SETLKW企图建立一把锁,这两者不是一个原子操作。因此不能保证在这两次fcntl调用之间不会有另一个进程插入并建立一把相关的锁,从而使原来测试到的情况发生变化。如果不希望在建立锁时可能产生的长期阻塞,则应使用F_SETLK,并对返回结果进行测试,以判别是否成功地建立了所要求的锁。

在设置或释放文件上的锁时,系统按要求组合或裂开相邻区。例如,若字节100-199是加锁的区,需解锁地150字节,则内核将维持两把锁,一把用于字节100-149,另一把用于字节151-199。图14-1说明了这种情况。

1346927157_4200

                                    图14-1 文件字节范围锁

假定我们又对第150字节设置锁,那么系统将会把三个相邻的加锁区合并成一个区(从字节100至199)。其结果如图14-1中的第一图所示,于是我们又回到了出发点。

实例:请求和释放一把锁

为了避免每次分配flock结构,然后又填入各项信息,可以用程序清单14-2中的函数lock_reg来处理所有这些细节。

程序清单14-2 加锁和解锁一个文件区域的函数

#include "apue.h"
#include <fcntl.h>

int
lock_reg(int fd, int cmd, int type, off_t offset, int whence, off_t len)
{
    struct flock    lock;

    lock.l_type = type;        /* F_RDLCK, F_WRLCK, F_UNLCK */
    lock.l_start = offset;        /* byte offset, relative to l_whence */
    lock.l_whence = whence;        /* SEEK_SET, SEEK_CUR, SEEK_END */
    lock.l_len = len;        /* #bytes (0 means to EOF) */

    return(fcntl(fd, cmd, &lock));
}

   因为大多数锁调用是加锁或解锁一个文件区域(命令F_GETLK很少使用),故通常使用下列5个宏,它们都定义在apue.h中。 

#define    read_lock(fd, offset, whence, len) \
                lock_reg((fd), F_SETLK, F_RDLCK, (offset), (whence), (len))
#define    readw_lock(fd, offset, whence, len) \
                lock_reg((fd), F_SETLKW, F_RDLCK, (offset), (whence), (len))
#define    write_lock(fd, offset, whence, len) \
                lock_reg((fd), F_SETLK, F_WRLCK, (offset), (whence), (len))
#define    writew_lock(fd, offset, whence, len) \
                lock_reg((fd), F_SETLKW, F_WRLCK, (offset), (whence), (len))
#define    un_lock(fd, offset, whence, len) \
                lock_reg((fd), F_SETLK, F_UNLCK, (offset), (whence), (len))

实例:测试一把锁

程序清单14-3中定义了一个函数lock_test,可用其测试一把锁。

程序清单14-3 测试一个锁状态的函数

#include "apue.h"
#include <fcntl.h>

pid_t
lock_test(int fd, int type, off_t offset, int whence, off_t len)
{
    struct flock    lock;
    lock.l_type = type;        /* F_RDLCK or F_WRLCK */
    lock.l_start = offset;     /* byte offset,  relative to l_whence */
    lock.l_whence = whence;    /* SEEK_SET, SEEK_CUR, SEEK_END */
    lock.l_len = len;          /* bytes (0 means to EOF) */

    if(fcntl(fd, F_GETLK, &lock) < 0)
        err_sys("fcntl error");

    if(lock.l_type == F_UNLCK)
        return(0);         /* false, region isn't locked by another proc */
    return(lock.l_pid);    /* true, return pid of lock owner */
}

如果存在一把锁,它阻塞由参数说明的锁请求,则此函数返回持有这把现存锁的进程ID,否则此函数返回0。通常用下面两个宏来调用此函数(它们也定义在apue.h中)。

#define    is_read_lockable(fd, offset, whence, len) \
              (lock_test((fd), F_RDLCK, (offset), (whence), (len)) == 0)
#define    is_write_lockable(fd, offset, whence, len) \
              (lock_test((fd), F_WRLCK, (offset), (whence), (len)) == 0)

注意,进程不能使用lock_test函数测试它自己是否在文件的某一部分持有一把锁。F_GETLK命令的定义说明,返回信息指示是否有现存的锁阻止调用进程设置它自己的锁。因为F_SETLK和F_SETLKW命令总是替换调用进程现存的锁(若已存在),所以调用进程决不会阻塞在自己持有的锁上;于是,F_GETLK命令决不会报告调用进程自己持有的锁。

实例:死锁

如果两个进程相互等待对方持有并且锁定的资源时,则这两个进程就处于死锁状态。如果一个进程已经控制了文件中的一个加锁区域,然后它又试图对另一个进程控制的区域加锁,则它就会休眠,在这种情况下,有发生死锁的可能性。

程序清单14-4给出了一个死锁的例子。子进程锁字节0,父进程锁字节1。然后,它们又都试图锁对方已经加锁的字节。在该程序中使用了http://www.cnblogs.com/nufangrensheng/p/3510306.html中介绍的父、子进程同步例程(TELL_xxx和WAIT_xxx),使得每个进程能够等待另一个进程获得它设置的第一把锁。运行程序清单14-4所示程序得到:

未命名

程序清单14-4 死锁检测实例

#include "apue.h"
#include <fcntl.h>

static void
lockabyte(const char *name, int fd, off_t offset)
{
    if(writew_lock(fd, offset, SEEK_SET, 1) < 0)
        err_sys("%s: write_lock error", name);
    printf("%s: got the lock, byte %ld\n", name, offset);
}

int 
main(void)
{
    int    fd;
    pid_t    pid;

    /*
    * Create a file and write two bytes to it.
    */
    if((fd = creat("templock", FILE_MODE)) < 0)
        err_sys("creat error");
    if(write(fd, "ab", 2) != 2)
        err_sys("write error");

    TELL_WAIT();
    if((pid = fork()) < 0)
    {
        err_sys("fork error");
    }
    else if(pid == 0)            /* child */
    {
        lockabyte("child", fd, 0);
        TELL_PARENT(getppid());
        WAIT_PARENT();
        lockabyte("child", fd, 1);
    }
    else                    /* parent */
    {
        lockabyte("parent", fd, 1);
        TELL_CHILD(pid);
        WAIT_CHILD();
        lockabyte("parent", fd, 0);
    }
    exit(0);
}

检测到死锁时,内核必须选择一个进程接收出错返回。在本实例中选择了子进程,这是一个实现细节。在某些系统上,总是子进程接收到出错信息;在另一些系统上,总是父进程接到出错信息。在某些系统上,当试图使用多把锁时,有时是子进程接到出错信息,有时则是父进程接到出错信息。

3、锁的隐含继承和释放

关于记录锁的自动继承和释放有三条规则:

(1)锁与进程和文件两方面有关。这有两重含义:第一重很明显,当一个进程终止时,它所建立的锁全部释放;第二重意思就不很明显,任何时候关闭一个描述符时,则该进程通过这一描述符可以引用的文件上的任何一把锁都被释放(这些锁都是该进程设置的)。这就意味着如果执行下列四步:

fd1 = open(pathname, ...);
read_lock(fd1, ...);
fd2 = dup(fd1);
close(fd2);

则在close(fd2)后,在fd1上设置的锁被释放。如果将dup换为open,以打开另一描述符上的同一文件,其效果也一样:

fd1 = open(pathname, ...);
read_lock(fd1, ...);
fd2 = open(pathname, ...);
close(fd2);

(2)由fork产生的子进程不继承父进程所设置的锁。这意味着,若一个进程得到一把锁,然后调用fork,那么对于父进程获得的锁而言,子进程被视为另一个进程,对于从父进程处继承过来的任一描述符,子进程需要调用fcntl才能获得它自己的锁。这与锁的作用是相一致的。锁的作用是阻止多个进程同时写同一文件(或同一文件区域)。如果子进程继承父进程的锁,则父、子进程就可以同时写同一个文件。

(3)在执行exec后,新程序可以继承原执行程序的锁。但是注意,如果对一个文件描述符设置了close-on-exec标志,那么当作为exec的一部分关闭该文件描述符时,对相应文件的所有锁都被释放了。

4、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();

图14-2显示了父、子进程暂停后的数据结构情况。

1346079230_9409

                                               图14-2 关于记录锁的FreeBSD数据结构

http://www.cnblogs.com/nufangrensheng/p/3498736.html中的图3-3和http://www.cnblogs.com/nufangrensheng/p/3509492.html中的图8-1已显示了open、fork以及dup后的数据结构。有了记录锁后,在原来的这些图上新加了lockf结构,它们由i节点结构开始相互链接起来。注意,每个lockf结构说明了一个给定进程的一个加锁区域(由偏移量和长度定义)。图中显示了两个lockf结构,一个是由父进程调用write_lock形成的,另一个则是由子进程调用read_lock形成的。每一个结构都包含了相应进程ID。

在父进程中,关闭fd1、fd2和fd3中的任意一个都将释放由父进程设置的写锁。在关闭这三个描述符中的任意一个时,内核会从该描述符所关联的i节点开始,逐个检查lockf链接表中各项,并释放由调用进程持有的各把锁。内核并不清楚也不关心父进程是用哪一个描述符来设置这把锁的。

实例

在程序清单13-2(http://www.cnblogs.com/nufangrensheng/p/3544370.html)中,我们了解到,守护进程可用一把文件锁以保证只有该守护进程的唯一副本正在运行。程序清单14-5示出了lockfile函数的实现,守护进程可用该函数在文件上加锁。

程序清单14-5 在文件整体上加锁

#include <unistd.h>
#include <fcntl.h>

int
lockfile(int fd)
{
    struct flock fl;
    
    fl.l_type = F_WRLCK;
    fl.l_start = 0;
    fl.l_whence = SEEK_SET;
    fl.l_len = 0;
    return(fcntl(fd, F_SETLK, &fl));
}

另一种方法是,用write_lock函数定义lockfile函数:

#define lockfile(fd)    write_lock((fd), 0, SEEK_SET, 0)

5、在文件尾端加锁(不是很明白)

在接近文件尾端加锁或解锁时需要特别小心。大多数实现按照l_whence的SEEK_CUR或SEEK_END值,用l_start以及文件当前位置或当前长度得到绝对文件偏移量。但是,常常需要相对于文件的当前位置或当前长度指定一把锁。其原因是,我们在该文件上没有锁,所以不能调用lseek以正确无误地获得加锁时的当前文件偏移量。(在lseek和加锁调用之间,另一个进程可能改变该文件长度。)

考虑以下代码序列:

writew_lock(fd, 0, SEEK_END, 0);
write(fd, buf, 1);
un_lock(fd, 0, SEEK_END);
write(fd, buf, 1);

该代码序列所作的可能并不是你所期望的。它得到一把写锁,该写锁从当前文件尾端起,包括以后可能添加到该文件的任何数据。假定在文件尾端时执行第一个write,它给文件添写了1个字节,而该字节将被加锁。跟随其后的解锁,其作用是对以后添写到文件上的数据不再加锁,但在它之前刚添写的一个字节则保留加锁。当执行第二个写时,文件尾端又延伸了1个字节,但该字节并未加锁。

当对文件的一部分加锁时,内核将指定的偏移量变换成绝对文件偏移量。另外,除了指定一个绝对偏移量(SEEK_SET)之外,fcntl还允许我们相对于文件中的某个点(当前偏移量(SEEK_CUR)或文件尾端(SEEK_END))指定该偏移量。当前偏移量和文件尾端是可能不断变化的,而这种变化又不应影响现存锁的状态,所以内核必须独立于当前文件偏移量或文件尾端而记住锁。

如果我们想要解除第一次write所写1个字节上的锁,那么应指定长度为-1。负的长度值表示在指定偏移量之前的字节数。

6、建议性锁和强制性锁(结合http://hi.baidu.com/24688395/item/93381c477d8e82ec1381da3d中的内容来理解)

考虑数据库访问例程库。如果该库中所有函数都以一致的方法处理记录锁,则称使用这些函数访问数据库的任何进程集为合作进程(cooperating process)。如果这些函数是仅有的用来访问数据库的函数,那么它们使用建议性锁是可行的。但是建议性锁并不能阻止对数据库文件有写权限的任何其他进程对数据库文件进行随意的写操作。没有使用被认可的方法(数据库函数库)访问数据库的进程是一个非合作进程

强制性锁使内核对每一个open、read和write系统调用都进行检查,检查调用进程对正在访问的文件是否违背了某一把锁的作用。强制性锁有时也被称为强迫方式锁(enforcement-mode locking)。

Linux2.4.22和Solaris 9提供强制性记录锁,而FreeBSD 5.2.1和Mac OS X 10.3则不提供。强制性记录锁不是Single UNIX Specification的组成部分。在Linux中,如果用户想要使用强制性锁,则要在各个文件系统基础上,对mount命令用 -o mand 选项打开该机制。

对一个特定文件打开其设置组ID位并关闭其组执行位,则对该文件开启了强制性锁机制(回忆http://www.cnblogs.com/nufangrensheng/p/3502457.html中的程序清单4-4)。因为当组执行位关闭时,设置组ID位不再有意义,所以SVR3的设计者借用两者的这种组合来指定对一个文件的锁是强制性的而非建议性的。

如果一个进程试图读、写一个强制性锁起作用的文件,而欲读、写的部分又由其他进程加上了读或写锁,此时会发生什么呢?对这一问题的回答取决于三方面的因素:操作类型(read或write),其他进程保有的锁的类型(读锁或写锁),以及有关描述符是阻塞还是非阻塞的。表14-3列出了8种可能性。

表14-3 强制性锁对其他进程读、写的影响

其他进程在文件区段中持有的现存锁的类型 阻塞描述符,试图
read          write
非阻塞描述符,试图
read             write
读锁
写锁
允许           阻塞
阻塞           阻塞
允许             EAGAIN
EAGAIN       EAGAIN

除了表14-3中的read和write函数,其他进程持有的强制性锁也会对open函数产生影响。通常,即使正在打开的文件具有强制性记录锁,该打开操作也会成功。后随的read或write依从于表14-3所示的规则。但是,如果欲打开的文件具有强制性记录锁(读锁或写锁),而且open调用中的flag指定为O_TRUNC或O_CREAT,则不论是否指定O_NONBLOCK,open都立即出错返回,errno设置为EAGAIN。

实例

程序清单14-6用于确定一个系统是否支持强制性锁机制

程序清单14-6 确定是否支持强制性锁

#include "apue.h"
#include <errno.h>
#include <fcntl.h>
#include <sys/wait.h>

int
main(int argc, char *argv[])
{
    int        fd;
    pid_t        pid;
    char        buf[5];
    struct stat    statbuf;

    if(argc != 2)
    {
        fprintf(stderr, "usage: %s filename\n", argv[0]);
        exit(1);
    }
    if((fd = open(argv[1], O_RDWR | O_CREAT | O_TRUNC, FILE_MODE)) < 0)
        err_sys("open error");
    if(write(fd, "abcdef", 6) != 6)
        err_sys("write error");

    /* turn on set-group-ID and turn off group-execute */
    if(fstat(fd, &statbuf) < 0)
        err_sys("fstat error");
    if(fchmod(fd, (statbuf.st_mode & ~S_IXGRP) | S_ISGID) < 0)
        err_sys("fchmod error");

    TELL_WAIT();

    if((pid = fork()) < 0)
    {
        err_sys("fork error");
    }
    else if(pid > 0)  /* parent */
    {
        /* write lock entire file */
        if(write_lock(fd, 0, SEEK_SET, 0) < 0)
            err_sys("write_lock error");

        TELL_CHILD(pid);

        if(waitpid(pid, NULL, 0) < 0)
            err_sys("waitpid error");
    }
    else    /* child */
    {
        WAIT_PARENT();     /* wait for parent to set lock */

        set_fl(fd, O_NONBLOCK);    /* set_fl()参见http://www.cnblogs.com/nufangrensheng/p/3500350.html */

        /* first let's see what error we get if  region is locked */
        if(read_lock(fd, 0, SEEK_SET, 0) != -1)    /* no wait */
            err_sys("child: read_lock succeeded");
        printf("read_lock of already-locked region returns %d\n", errno);
        
        /* now try to read the mandatory locked file */
        if(lseek(fd, 0, SEEK_SET) == -1)
            err_sys("lseek error");
        if(read(fd, buf, 2) < 0)
            err_ret("read failed (mandatory locking works)");
        else
            printf("read OK (no mandatory locking), buf = %2.2s\n", buf);
    }
    exit(0);
}

此程序首先创建一个文件,并使强制性锁机制对其起作用。然后程序分裂为父进程和子进程。父进程对整个文件设置一把写锁,子进程则将该文件的描述符设置为非阻塞的,然后企图对该文件设置一把读锁,我们期望这会出错返回,并希望看到系统返回是EACCES或EAGAIN。接着,子进程将文件读、写位置调整到文件起点,并试图读(read)该文件。如果系统提供强制性锁机制,则read应返回EACCES或EAGAIN(因为该描述符是非阻塞的),否则read返回所读的数据。

在RedHat Linux 2.6.18上运行此程序(该系统不支持强制性锁机制),得到:

未命名 

本篇博文内容摘自《UNIX环境高级编程》(第二版),仅作个人学习记录所用。关于本书可参考:http://www.apuebook.com/

posted @ 2014-02-18 14:02  ITtecman  阅读(1891)  评论(1编辑  收藏  举报