骏马金龙

网名骏马金龙,钟情于IT世界里的各种原理和实现机制,强迫症重症患者。爱研究、爱翻译、爱分享。特借此一亩三分田记录自己成长点滴!!!
我本问道人,道心不坚,必将与道无缘!

Perl IO:文件锁

文件锁

当多个进程或多个程序都想要修同一个文件的时候,如果不加控制,多进程或多程序将可能导致文件更新的丢失。

例如进程1和进程2都要写入数据到a.txt中,进程1获取到了文件句柄,进程2也获取到了文件句柄,然后进程1写入一段数据,进程2写入一段数据,进程1关闭文件句柄,会将数据flush到文件中,进程2也关闭文件句柄,也将flush到文件中,于是进程1的数据被进程2保存的数据覆盖了。

所以,多进程修改同一文件的时候,需要协调每个进程:

  • 保证文件在同一时间只能被一个进程修改,只有进程1修改完成之后,进程2才能获得修改权
  • 进程1获得了修改权,就不允许进程2去读取这个文件的数据,因为进程2可能读取出来的数据是进程1修改前的过期数据

这种协调方式可以通过文件锁来实现。文件锁分两种,独占锁(写锁)和共享锁(读锁)。当进程想要修改文件的时候,申请独占锁(写锁),当进程想要读取文件数据的时候,申请共享锁(读锁)。

独占锁和独占锁、独占锁和共享锁都是互斥的。只要进程1持有了独占锁,进程2想要申请独占锁或共享锁都将失败(阻塞),也就保证了这一时刻只有进程1能修改文件,只有当进程1释放了独占锁,进程2才能继续申请到独占锁或共享锁。但是共享锁和共享锁是可以共存的,这代表的是两个进程都只是要去读取数据,并不互相冲突。

        独占锁       共享锁
独占锁     ×           ×
共享锁     ×           √

文件锁:flock和lockf

Linux上的文件锁类型主要有两种:flock和lockf。后者是fcntl系统调用的一个封装。它们之间有些区别:

  • flock来自BSD,而fcntl或lockf来自POSIX,所以lockf或fcntl实现的锁也称为POSIX锁
  • flock只能对整个文件加锁,而fcntl或lockf可以对文件中的部分加锁,即粒度更细的记录锁
  • flock的锁是劝告锁,lockf或fcntl可以实现强制锁。所谓劝告锁,是指只有多进程双方都遵纪守法地使用flock锁才有意义,某进程使用flock,但另一进程不使用flock,则flock锁对另一进程完全无限制
  • flock锁是附加在(关联在)文件描述符上的(见下文更深入的描述),而lockf是关联在文件实体上的。本文后面将详细分析flock锁在文件描述符上的现象

Perl中主要使用flock来实现文件锁,也是本文的主要内容。

Perl的flock

flock FILEHANDLE, flags;

flock两个参数,第一个是文件句柄,第二个是锁标志。

锁标志有4种,有数值格式的1、2、8、4,在导入Fcntl模块的:flock后,也支持字符格式的LOCK_SHLOCK_EXLOCK_UNLOCK_NB

字符格式      数值格式      意义
-----------------------------------
LOCK_SH        1        申请共享锁
LOCK_EX        2        申请独占锁
LOCK_UN        8        释放锁
LOCK_NB        4        非阻塞模式

独占锁和独占锁、独占锁和共享锁是冲突的。所以,当进程1持有独占锁时,进程2想要申请独占锁或共享锁默认将被阻塞。如果使用了非阻塞模式,那么本该阻塞的过程将立即返回,而不是阻塞等待其它进程释放锁。非阻塞模式可以结合共享锁或独占锁使用。所以,有下面几种方式:

use Fcntl qw(:flock);

flock $fh, LOCK_SH;    # 申请共享锁
flock $fh, LOCK_EX;    # 申请独占锁
flock $fh, LOCK_UN;    # 释放锁
flock $fh, LOCK_SH | LOCK_NB;  # 以非阻塞的方式申请共享锁
flock $fh, LOCK_EX | LOCK_NB;  # 以非阻塞的方式申请独占锁

flock在操作成功时返回true,否则返回false。例如,在申请锁的时候,无论是否使用了非阻塞模式,只要没申请到锁就返回false,否则返回true,而在释放锁的时候,成功释放则返回true。

例如,两个程序(不是单程序内的两个进程,这种情况后面分析)同时运行,其中一个程序写a.txt文件,另一个程序读a.txt文件,但要保证先写完再读。

程序1的代码内容:

#!/usr/bin/perl

use strict;
use warnings;
use Fcntl qw(:flock);

open my $fh, '>', "a.txt"
    or die "open failed: $!";

flock $fh, LOCK_EX;
print $fh, "Hello World1\n";
print $fh, "Hello World2\n";
print $fh, "Hello World3\n";

flock $fh, LOCK_UN;

程序2的代码内容:

#!/usr/bin/perl

use strict;
use warnings;
use Fcntl qw(:flock);

open my $fh, '<', "a.txt"
    or die "open failed: $!";

# 非阻塞的方式每秒申请一次共享锁
# 只要没申请成功就返回false
until(flock $fh, LOCK_SH | LOCK_NB){
    print "waiting for lock released\n";
    sleep 1;
}
while(<$fh>){
    print "readed: $_";
}

flock $fh, LOCK_UN;

fork、文件句柄、文件描述符和锁的关系

在开始之前,先看看在Perl中的fork、文件句柄、文件描述符、flock之间的结论。

  • 文件句柄是指向文件描述符的,文件描述符是指向实体文件的(假如是实体文件的描述符的话)
  • fork只会复制文件句柄,不会复制文件描述符,而是通过复制的不同文件句柄指向同一个文件描述符而实现文件描述符共享
  • 通过引用计数的方式来计算某个文件描述符上文件句柄的数量
  • close()一次表示引用数减1,直到所有文件句柄都关闭了即引用数为0时,文件描述符才被关闭
  • flock是附在文件描述符上的,不是文件句柄也不是实体文件上的。(实际上,flock是在vnode/generic-inode上的,它比fd底层的多(fd->fd table->open file table->vnode/g-inode),只不过对于perl的fork而言,因为不会复制文件描述符,使得将flock认为附在文件描述符上也没什么问题,只有open操作才会在vnode上检测flock的互斥性,换句话说,在perl中,只有多次open才需要考虑flock的互斥性)
  • flock是进程级别的,不适用于在多线程中使用它来锁互斥
  • 所以fork后的父子进程在共享文件描述符的同时也会共享flock锁
  • flock $fh, LOCK_UN会直接释放文件描述符上的锁
  • 当文件描述符被关闭时,文件描述符上的锁也会自动释放。所以使用close()去释放锁的时候,必须要保证所有文件句柄都被关闭才能关闭文件描述符从而释放锁
  • flock(包括加锁和解锁)或close()都会自动flush IO Buffer,保证多进程间获取锁时数据同步
  • 只要持有了某个文件描述符上的锁,在这把锁释放之前,自己可以随意更换锁的类型,例如多次flock从EX锁变成SH锁

(图注:fd是用户空间的内容,图中放在内核层是为了概括与之关联的内核层的几个结构:fd对应内核层的这几个结构)

下面是正式介绍和解释。

在C或操作系统上的fork会复制(dup)文件描述符,使得父子进程对同一文件使用不同文件描述符。但Perl的fork只会复制文件句柄而不会复制文件描述符,父子进程的不同文件句柄会共享同一个文件描述符,并使用引用计数的方式来统计有多少个文件句柄在使用这个文件描述符

之所以复制文件句柄是因为文件句柄在Perl中是一种变量类型,在不同作用域内是互相独立的。而文件描述符对Perl来说相对更底层一些,属于操作系统的数据资源,对Perl来说是属于可以共享的数据。

也就是说,如果只fork了一次,那么父子进程的两个文件句柄都共享同一个文件描述符,都指向这个文件描述符,这个文件描述符上的引用计数为2。当父进程close关闭了该文件描述符上的一个文件句柄,子进程需要也关闭一次才是真的关闭这个文件描述符。

不仅如此,由于文件描述符是共享的,导致加在文件描述符上的锁(比如flock锁)在父子进程上看上去也是共享的。尽管只在父子某一个进程上加一把锁,但这两个进程都将持有这把锁。如果想要释放这个文件描述符上的锁,直接unlock(flock $fh, LOCK_UN)或关闭文件描述符即可

但是注意,close()关闭的只是文件描述符上的一个文件句柄引用,在文件描述符真的被关闭之前(即所有文件句柄都被关掉),锁会一直存在于描述符上。所以,很多时候使用close去释放时的操作(之所以使用close而非unlock类操作,是因为unlock存在race condition,多个进程可能会在释放锁的同时抢到那个文件的锁),可能需要在多个进程中都执行,而使用unlock类的操作只需在父子中的任何一进程中即可释放锁。

例如,分析下面的代码中父进程三处加独占锁位置(1)、(2)、(3)对子进程中加共享锁的影响。

use Fcntl qw(:flock);

open my $fh, ">", "a.log";
# (1) flock $fh, LOCK_EX;

# 这里开始fork子进程
my $pid = fork;
# (3) flock $fh, LOCK_EX;

unless($pid){
    # 子进程
    # flock $fh, LOCK_SH;
}

# 父进程
# (2) flock $fh, LOCK_EX;

首先分析父进程在(3)处加锁对子进程的影响。(3)是在fork后且进入子进程代码段之前运行的,也就是说父子进程都执行了一次flock加独占锁,显然只有一个进程能够加锁。但无论是谁加锁了,这个描述符上的锁对另一个进程都是共享的,也就是两个进程都持有EX锁,这似乎违背了我们对独占锁的独占性常识,但并没有,因为实际上文件描述符上只有一个锁,只不过这个锁被两个进程中的文件句柄持有了。因为子进程也持有EX锁,自己可以直接申请SH锁实现自己的锁切换,如果父进程这时还没有关闭文件句柄或解锁,它也将持有SH锁。

再看父进程中加在(1)或(2)处的独占锁,他们其实是等价的,因为在有了子进程后,无论在哪里加锁,锁(文件描述符)都是共享的,引用计数都会是2。这时子进程要获取共享锁是完全无需阻塞的,因为它自己就持有了独占锁。

也就是说,上面无论是在(1)、(2)还是(3)处加锁,在子进程中都能随意无阻塞换锁,因为子进程在换锁前已经持有了这个文件描述符上的锁。

那么上面的示例中,如何让子进程申请互斥锁的时候被阻塞?只需在子进程中打开这个文件的新文件句柄即可,它会创建一个新的文件描述符,在两个文件描述符上申请锁时会检查锁的互斥性。但是必须记住,要让子进程能成功申请到互斥锁,必须在父进程中unlock或者在父子进程中都close(),往往我们会忘记在子进程中也关闭文件句柄而导致文件描述符继续存在,其上的锁也继续保留,从而导致子进程在该文件描述符上持有的锁阻塞了自己去申请其它描述符的锁

例如,下面在子进程中打开了新的$fh1,且父子进程都使用close()来保证文件描述符的关闭、锁的释放。当然,也可以直接在父或子进程中使用一次flock $fh, LOCK_UN来直接释放锁。

use Fcntl qw(:flock);

open my $fh, ">", "a.log";
# (1) flock $fh, LOCK_EX;

# 这里开始fork子进程
my $pid = fork;
# (3) flock $fh, LOCK_EX;

unless($pid){
    # 子进程
    open $fh1, ">", "a.log";
    close $fh;     # close(1)
    # flock $fh1, LOCK_SH;
}

# 父进程
# (2) flock $fh, LOCK_EX;
close $fh;         # close(2)
posted @ 2019-02-27 23:59 骏马金龙 阅读(...) 评论(...) 编辑 收藏