多进程之间的文件锁

多进程之间的文件锁


这些日子用到了这些知识点,所以来记录一下.
文章转载自这里:http://liwei.life/2016/07/31/file_and_filelock/
但是添加了自己的理解和一些新的内容.

下面的例子都省略了错误处理,为了使代码更加简洁明了.

竞争条件

我们的第一个例子是多个进程写文件的例子,虽然还没做到通信,但是这比较方便的说明一个通信时经常出现的情况:竞争条件。假设我们要并发100个进程,这些进程约定好一个文件,这个文件初始值内容写0,每一个进程都要打开这个文件读出当前的数字,加一之后将结果写回去。在理想状态下,这个文件最后写的数字应该是100,因为有100个进程打开、读数、加1、写回,自然是有多少个进程最后文件中的数字结果就应该是多少。但是实际上并非如此,可以看一下这个例子:

#include <unistd.h> 
#include <stdlib.h> 
#include <stdio.h> 
#include <errno.h> 
#include <fcntl.h> 
#include <string.h> 
#include <sys/file.h> 
#include <wait.h> 
#define COUNT 100 
#define NUM 64 
#define FILEPATH "/tmp/count" 

int do_child(const char *path) { 
    /*-
    * 当多个进程同时执行这个过程的时候,就会出现racing:竞争条件,
    * 多个进程可能同时从文件独到同一个数字,并且分别对同一个数字加1并写回, 
    * 导致多次写回的结果并不是我们最终想要的累积结果。
    */
    
    int fd; 
    int ret, count; 
    char buf[NUM]; 
    fd = open(path, O_RDWR); /* 1. 打开FILEPATH路径的文件  */
    ret = read(fd, buf, NUM); /* 2. 读出文件中的当前数字  */
    buf[ret] = '\0'; 
    count = atoi(buf); /* 3. 将字符串转成整数  */
    ++count; /* 4. 整数自增加1  */
    sprintf(buf, "%d", count); /* 5. 将整数转成字符串 */
    lseek(fd, 0, SEEK_SET); /* 6. lseek调整文件当前的偏移量到文件头  */
    ret = write(fd, buf, strlen(buf)); /* 7. 将字符串写会文件 */ 
    close(fd); 
    exit(0);
} 

int main() { 
    pid_t pid; 
    int count; 
    for (count=0;count<COUNT;count++)
    {
        pid = fork(); 
        
        if (pid == 0) {
            do_child(FILEPATH);
        }
    } 
    for (count=0;count<COUNT;count++) { 
        wait(NULL); 
    } 
}

这个程序做后执行的效果如下:

[zorro@zorrozou-pc0 process]$ echo 0 > /tmp/count 
[zorro@zorrozou-pc0 process]$ ./racing 
[zorro@zorrozou-pc0 process]$ cat /tmp/count 
71
[zorro@zorrozou-pc0 process]$ 

[zorro@zorrozou-pc0 process]$ echo 0 > /tmp/count 
[zorro@zorrozou-pc0 process]$ ./racing 
[zorro@zorrozou-pc0 process]$ cat /tmp/count 
61
[zorro@zorrozou-pc0 process]$ 

[zorro@zorrozou-pc0 process]$ echo 0 > /tmp/count 
[zorro@zorrozou-pc0 process]$ ./racing 
[zorro@zorrozou-pc0 process]$ cat /tmp/count 
64
[zorro@zorrozou-pc0 process]$

我们执行了三次这个程序,每次结果都不太一样,第一次是71,第二次是61,第三次是64,全都没有得到预期结果,这就是竞争条件(racing)引入的问题。仔细分析这个进程我们可以发现这个竞争条件是如何发生的:
最开始文件内容是0,假设此时同时打开了3个进程,那么他们分别读文件的时候,这个过程是可能并发的,于是每个进程读到的数组可能都是0,因为他们都在别的进程没写入1之前就开始读了文件。于是三个进程都是给01,然后写了个1回到文件。其他进程以此类推,每次100个进程的执行顺序可能不一样,于是结果是每次得到的值都可能不太一样,但是一定都少于产生的实际进程个数。于是我们把这种多个执行过程(如进程或线程)中访问同一个共享资源,而这些共享资源又有无法被多个执行过程存取的的程序片段,叫做临界区代码。

那么该如何解决这个racing的问题呢?对于这个例子来说,可以用文件锁的方式解决这个问题。就是说,对临界区代码进行加锁,来解决竞争条件的问题。哪段是临界区代码?在这个例子中,两端/ /之间的部分就是临界区代码。一个正确的例子是:

... 
    flock(fd, LOCK_EX); /* 对整个文件加锁 */
    read(fd, buf, NUM);
    buf[ret] = '\0'; 
    count = atoi(buf); 
    ++count; 
    sprintf(buf, "%d", count); 
    lseek(fd, 0, SEEK_SET); 
    write(fd, buf, strlen(buf));  
    flock(fd, LOCK_UN); /* 解锁 */
...

我们将临界区部分代码前后都使用了flock的互斥锁,防止了临界区的racing。这个例子虽然并没有真正达到让多个进程通过文件进行通信,解决某种协同工作问题的目的,但是足以表现出进程间通信机制的一些问题了。当涉及到数据在多个进程间进行共享的时候,仅仅只实现数据通信或共享机制本身是不够的,还需要实现相关的同步或异步机制来控制多个进程,达到保护临界区或其他让进程可以处理同步或异步事件的能力。我们可以认为文件锁是可以实现这样一种多进程的协调同步能力的机制,而除了文件锁以外,还有其他机制可以达到相同或者不同的功能,我们会在下文中继续详细解释。
再次,我们并不对flock这个方法本身进行功能性讲解。这种功能性讲解大家可以很轻易的在网上或者通过别的书籍得到相关内容。本文更加偏重的是Linux环境提供了多少种文件锁以及他们的区别是什么?

flock和lockf

从底层的实现来说,Linux的文件锁主要有两种:flocklockf。需要额外对lockf说明的是,它只是fcntl系统调用的一个封装。从使用角度讲,lockffcntl实现了更细粒度文件锁,即:记录锁。我们可以使用lockffcntl对文件的部分字节上锁,flock只能对整个文件加锁。这两种文件锁是从历史上不同的标准中起源的,flock来自BSD,而lockf来自POSIX,所以lockffcntl实现的锁在类型上又叫做POSIX锁。

除了这个区别外,fcntl系统调用还可以支持强制锁(Mandatory locking)。强制锁的概念是传统UNIX为了强制应用程序遵守锁规则而引入的一个概念,与之对应的概念就是建议锁(Advisory locking)。我们日常使用的基本都是建议锁,它并不强制生效。这里的不强制生效的意思是,如果某一个进程对一个文件持有一把锁之后,其他进程仍然可以直接对文件进行各种操作的,比如openreadwrite。只有当多个进程在操作文件前都去检查和对相关锁进行锁操作的时候,文件锁的规则才会生效。这就是一般建议锁的行为。而强制性锁试图实现一套内核级的锁操作。当有进程对某个文件上锁之后,其他进程即使不在操作文件之前检查锁,也会在openreadwrite等文件操作时发生错误。内核将对有锁的文件在任何情况下的锁规则都生效,这就是强制锁的行为。由此可以理解,如果内核想要支持强制锁,将需要在内核实现openreadwrite等系统调用内部进行支持。

从应用的角度来说,Linux内核虽然号称具备了强制锁的能力,但其对强制性锁的实现是不可靠的,建议大家还是不要在Linux下使用强制锁。事实上,在我目前手头正在使用的Linux环境上,一个系统在mount -o mand分区的时候报错(archlinux kernel 4.5),而另一个系统虽然可以以强制锁方式mount上分区,但是功能实现却不完整,主要表现在只有在加锁后产生的子进程中open才会报错,如果直接write是没问题的,而且其他进程无论open还是readwrite都没问题(Centos 7 kernel 3.10)。鉴于此,我们就不在此介绍如何在Linux环境中打开所谓的强制锁支持了。我们只需知道,在Linux环境下的应用程序,flocklockf在是锁类型方面没有本质差别,他们都是建议锁,而非强制锁。
flocklockf另外一个差别是它们实现锁的方式不同。这在应用的时候表现在flock的语义是针对文件的锁,而lockf是针对文件描述符(fd)的锁。我们用一个例子来观察这个区别:

#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>
#include <wait.h>

#define PATH "/tmp/lock"

int main()
{
    int fd;
    pid_t pid;

    fd = open(PATH, O_RDWR | O_CREAT | O_TRUNC, 0644);

    flock(fd, LOCK_EX); /* 对整个文件加锁 */

    printf("%d: locked!\n", getpid());

    pid = fork();

    if (pid == 0) { /* 子进程 */
        /*
        fd = open(PATH, O_RDWR|O_CREAT|O_TRUNC, 0644); /* 重新打开文件 */
        */
        flock(fd, LOCK_EX); /* 加锁 */
        printf("%d: locked!\n", getpid());
        exit(0);
    }
    wait(NULL);
    unlink(PATH);
    exit(0);
}

上面代码是一个flock的例子,其作用也很简单:

  • 打开/tmp/lock文件。
  • 使用flock对其加互斥锁。
  • 打印“PID:locked!”表示加锁成功。
  • 打开一个子进程,在子进程中使用flock对同一个文件加互斥锁。
  • 子进程打印“PID:locked!”表示加锁成功。如果没加锁成功子进程会推出,不显示相关内容。
  • 父进程回收子进程并退出。

这个程序直接编译执行的结果是:

[zorro@zorrozou-pc0 locktest]$ ./flock 
23279: locked!
23280: locked!

父子进程都加锁成功了。这个结果似乎并不符合我们对文件加锁的本意。按照我们对互斥锁的理解,子进程对父进程已经加锁过的文件应该加锁失败才对。我们可以稍微修改一下上面程序让它达到预期效果,将子进程代码段中的注释取消掉重新编译即可:

...
/*
// 重新打开的文件会丢失以前的锁,同时对已经持有锁的文件再加锁也会成功
    fd = open(PATH, O_RDWR|O_CREAT|O_TRUNC, 0644);
*/
...

将这段代码上下的/ /删除重新编译。之后执行的效果如下:

[zorro@zorrozou-pc0 locktest]$ ./flock 
23437: locked!

此时子进程flock的时候会阻塞,让进程的执行一直停在这。这才是我们使用文件锁之后预期该有的效果。而相同的程序使用lockf却不会这样。这个原因在于flocklockf的语义是不同的。使用lockffcntl的锁,在实现上关联到文件结构体,这样的实现导致锁不会在fork之后被子进程继承。而flock在实现上关联到的是文件描述符,这就意味着如果我们在进程中复制了一个文件描述符,那么使用flock对这个描述符加的锁也会在新复制出的描述符中继续引用。在进程fork的时候,新产生的子进程的描述符也是从父进程继承(复制)来的。在子进程刚开始执行的时候,父子进程的描述符关系实际上跟在一个进程中使用dup复制文件描述符的状态一样(参见《UNIX环境高级编程》8.3节的文件共享部分)。这就可能造成上述例子的情况,通过fork产生的多个进程,因为子进程的文件描述符是复制的父进程的文件描述符,所以导致父子进程同时持有对同一个文件的互斥锁,导致第一个例子中的子进程仍然可以加锁成功。这个文件共享的现象在子进程使用open重新打开文件之后就不再存在了,所以重新对同一文件open之后,子进程再使用flock进行加锁的时候会阻塞。另外要注意:除非文件描述符被标记了close-on-exec标记,flock锁和lockf锁都可以穿越exec,在当前进程变成另一个执行镜像之后仍然保留。

上面的例子中只演示了fork所产生的文件共享对flock互斥锁的影响,同样原因也会导致dupdup2所产生的文件描述符对flock在一个进程内产生相同的影响。dup造成的锁问题一般只有在多线程情况下才会产生影响,所以应该避免在多线程场景下使用flock对文件加锁,而lockf/fcntl则没有这个问题。

为了对比flock的行为,我们在此列出使用lockf的相同例子,来演示一下它们的不同:

#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>
#include <wait.h>

#define PATH "/tmp/lock"

int main()
{
    int fd;
    pid_t pid;

    fd = open(PATH, O_RDWR | O_CREAT | O_TRUNC, 0644);

    lockf(fd, F_LOCK, 0) 
    printf("%d: locked!\n", getpid());

    pid = fork();


    if (pid == 0) {
        /*
        fd = open(PATH, O_RDWR|O_CREAT|O_TRUNC, 0644);
        */
        lockf(fd, F_LOCK, 0);
        printf("%d: locked!\n", getpid());
        exit(0);
    }
    wait(NULL);
    unlink(PATH);
    exit(0);
}

编译执行的结果是:

[zorro@zorrozou-pc0 locktest]$ ./lockf 
27262: locked!

在子进程不用open重新打开文件的情况下,进程执行仍然被阻塞在子进程lockf加锁的操作上。关于fcntl对文件实现记录锁的详细内容,大家可以参考《UNIX环境高级编程》中关于记录锁的14.3章节。

fcntl锁

既然都扯到了锁这一块了,就不得不说一下用fcntl这个函数对文件进行加锁,正如前面所提到的,fcntl可以实现颗粒度更小的加锁.

UNP第二卷中给我们封装了这样一系列函数:

int lock_reg(int fd, int cmd, int type, off_t offset, int whence, off_t len) {

    struct flock lock;
    lock.l_type = type;
    lock.l_start = offset;
    lock.l_whence = whence;
    lock.l_len = len;
    return (fcntl(fd, cmd, &lock));
}
/* 读锁 */
#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)

关于这个struct flock,我这里稍微摘抄一下UNP v2上面的一些解释:

l_type可以取3种值, F_RDLCK表示读锁,F_WRLCK表示写锁,F_UNLCK表示解锁.

l_start伴随着l_whence来解释:
SEEK_SET:l_start相对于文件的开头解释.
SEEK_CUR:l_start相对于文件的当前字节偏移(即当前的读写指针的位置)解释.
SEEK_END: l_start相对于文件的末尾解释.
l_len成员指定从该偏移开始的连续字节数,长度为0代表"从起始偏移到文件偏移的最大可能值".因此锁住整个文件有两种方式:

  1. 指定l_whence成员为SEEK_SET, l_start成员为0,l_len成员为0.
  1. 使用lseek将读写指针定位到文件头,然后指定l_whence成员为SEEK_CUR, l _start0,l_len0.

fcntl加锁的规则是什么呢?

  • 对于一个文件的任意字节,最多只能存在一种类型的锁(读锁或者写锁);
  • 而且,一个给定字节可以有多个读锁,但是只能有一个写锁;
  • 当一个文件描述符不是打开来用于读时,如果我们对它请求一个读锁,会出错,同理,如果一个描述符不是打开用于写时,如果我们对它请求一个写锁,也会出错;
  • 正如前面所讲的,锁不能通过fork由子进程继承.

这样说起可能不形象,我给一个很小的例子:

#include <iostream>
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>
#include <wait.h>
#include <string.h>
#include "fileLocker.h"
#define PATH "/tmp/count"

int lock_reg(int fd, int cmd, int type, off_t offset, int whence, off_t len) {
    struct flock lock;
    lock.l_type = type;
    lock.l_start = offset;
    lock.l_whence = whence;
    lock.l_len = len;
    return (fcntl(fd, cmd, &lock));
}

#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)


int main(int argc, char *argv[])
{
    int fd = open(PATH, O_RDONLY, NULL); /* 打开文件 */

    readw_lock(fd, 0, SEEK_SET, 0); /* 加读锁 */

    char buf[512] = { 0 };
    read(fd, buf, sizeof(buf));
    printf("%s\n", buf);
    sleep(10);

    un_lock(fd, 0, SEEK_SET, 0); /* 解锁 */

    return 0;
}

这个例子读取/temp/count文件中的内容,然后打印出来,假设count里面是1,那么我们先运行这段程序的一个实例,很好,这个程序加了写锁,输出了1,2~3秒后我们运行这段程序的另外一个实例,然后也输出了1,这代表加锁也成功了,所以可以一个字节上可以加多个读锁.

我们可以对写锁也进行相同的一个实验:

#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>
#include <wait.h>
#include <string.h>
#include "fileLocker.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;
    lock.l_start = offset;
    lock.l_whence = whence;
    lock.l_len = len;
    return (fcntl(fd, cmd, &lock));
}

#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)

#define PATH "/tmp/count"

int main() {
    int fd = open(PATH, O_RDWR, NULL); /* 打开文件 */
    
    writew_lock(fd, 0, SEEK_SET, 0); /* 对整个文件加写锁 */

    char line[512] = { 0 };
    int seqno; /* 序列号 */
    lseek(fd, 0L, SEEK_SET);
    int n = read(fd, line, sizeof line);
    printf("line : %s", line);
    sscanf(line, "%d", &seqno);
    printf("seqno : %d\n", seqno);
    seqno++;
    snprintf(line, sizeof line, "%d\n", seqno);
    lseek(fd, 0L, SEEK_SET); /* 读写指针移到文件头部 */
    write(fd, line, strlen(line));
    sleep(25); /* 等待25秒 */
    
    un_lock(fd, 0, SEEK_SET, 0); /* 解锁 */
    
    close(fd);
    return 0;
}

实验很简单,直接说结论吧:
加了读锁的话,不能加写锁,但可以继续加读锁.
加了写锁的话,不能加读锁,同时也不能继续加写锁了,除非这个写锁释放.

一般来说,使用UNP v2上给我们封装好的函数,一切就都够用了.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容