被遗忘的桃源——flock 文件锁

时间:2022-06-03 08:01:45

缘起

在后台开发中,随着多线程应用日益增多,人们对多进程的关注也在逐渐减弱。但是在实际应用中,多进程之间的通信及资源共享,还是会不时的遇到,如果不认真处理,就会出现数据异常,甚至导致系统资源耗尽的情形。今天,我在这里为大家介绍一下 flock 文件锁——一个处理多个进程间文件共享冲突、解决crontab 中单任务多实例问题的利器。

在开发过程中,经常会出现这样一种情况:服务依赖的数据变动不是特别频繁,例如天或周级别的更新。这类数据通常是格式化后存储在数据文件中,即以数据文件的形式进行数据更新。

文件的更新的基本流程如下:

被遗忘的桃源——flock 文件锁

可以看出,对同一个文件,有两个不同的进程在分别进行写和读,这就出现了多进程下对文件的读写冲突问题。在这种场景下的多进程读写文件冲突,与常规的多进程读写冲突是有区别的。主要区别的点在于,常规的多进程是同一服务中通过 fork 派生出来的多进程,读写冲突通常是对内存中的数据结构进行读写时发生的。而数据文件更新场景下的多进程,产生冲突的操作对象是文件,而且这里的进程可能是两个独立的、无任何关系的进程,例如写进程是python 脚本,而读进程是一个 C++ 服务。由于文件读写进程的独立性,对这种读写冲突的处理需要操作系统层面的支持。

文件锁 flock

为解决多进程对同一文件的读写冲突,在linux 系统中,提供了 flock 这一系统调用,用来实现对文件的读写保护,即文件锁的功能。文件锁保护文件的功能,与 fork 多进程及 pthread 库中多线程使用读写锁来保护内存资源的方式是类似的。 flock 的 man page 中有如下介绍:

flock - apply or remove an advisory lock on an open file

从中可以解读出两点内容:

  • flock 提供的文件锁是建议性质的。所谓 “建议性锁”,通常也叫作非强制性锁,即一个进程可以忽略其他进程加的锁,直接对目标文件进行读写操作。因而,只有当前进程主动调用 flock去检测是否已有其他进程对目标文件加了锁,文件锁才会在多进程的同步中起到作用。表述的更明确一点,就是如果其他进程已经用 flock 对某个文件加了锁,当前进程在读写这一文件时,未使用 flock 加锁(即未检测是否已有其他进程锁定文件),那么当前进程可以直接操作这一文件,其他进程加的文件锁对当前进程的操作不会有任何影响。这就种可以被忽略、需要双方互相检测确认的加锁机制,就被称为 ”建议性“ 锁。
  • 从man page 的描述中还可以看到,文件锁必须作用在一个打开的文件上,即从应用的角度看,文件锁应当作用于一个打开的文件句柄上。知所以需要对打开的文件加锁,是和 linux 文件系统的实现方式、及多进程下锁定文件的需求共同决定的,在下文对其原理有详细介绍。

共享锁与排他锁

linux 中 flock 系统调用的原型如下:

#include <sys/file.h>
int flock(int fd, int operation);

当 flock 执行成功时,会返回0;当出现错误时,会返回 -1,并设置相应的 errno 值。

在flock 原型中,参数 operation 可以使用 LOCK_SH 或 LOCK_EX 常量,分别对应共享锁和排他锁。这两个常量的定义在 file.h 中。与 flock 相关的常量定义如下:

/* Operations for the `flock' call. */                                          
#define LOCK_SH 1 /* Shared lock. */ 
#define LOCK_EX 2 /* Exclusive lock. */ 
#define LOCK_UN 8 /* Unlock. */ 

/* Can be OR'd in to one of the above. */                                       
#define LOCK_NB 4 /* Don't block when locking. */ 

当使用 LOCK_SH 共享锁时,多个进程可以都使用共享锁锁定同一个文件,从而实现多个进程对文件的并行读取。由此可见,LOCK_SH 共享锁类似于多线程读写锁 pthread_rwlock_t 类型中的读锁。当使用LOCK_EX 排他锁时,同一时刻只能有一个进程锁定成功,其余进行只能阻塞(阻塞与非阻塞在下文介绍),这种行为与多线程读写锁中的写锁类似。

以两个进程为例,假设进程1先对文件加了锁,而后进程2又尝试对同一文件加锁,这时进程2的行为如下表所示:

进程1 (先加锁) 进程2(后加锁) 进程2表现
LOCK_SH LOCK_SH 不阻塞
LOCK_SH LOCK_EX 阻塞
LOCK_EX LOCK_SH 阻塞
LOCK_EX LOCK_EX 阻塞

可见,只有两个进程都对文件加的都是共享锁时,进程可以正常执行,不会阻塞,其他情形下,后加锁的进程都会被阻塞。

在 flock 的man page 中,关于文件锁的互斥有如下描述:

A call to flock() may block if an incompatible lock is held by another process.

这里所说的不相容的锁就是指会导致阻塞的两个锁,上面表格中后三行中的锁,都是两两不相容的,只有第一行的两个共享锁是相容的。

阻塞与非阻塞

flock 文件锁提供了阻塞和非阻塞两种使用方式。当处于阻塞模式时,如果当前进程无法成功获取到文件锁,那么进程就会一直阻塞等待,直到其他进程在对应文件上释放了锁,本进程能成功持有锁为止。在默认情况下,flock 提供是阻塞模式的文件锁。

在日常使用中,文件锁还会使用在另外一种场景下,即进程首先尝试对文件加锁,当加锁失败时,不希望进程阻塞,而是希望 flock 返回错误信息,进程进行错误处理后,继续进行下面的处理。在这种情形下就需要使用 flock 的非阻塞模式。把flock 的工作模式设置为非阻塞模式非常简单,只要将原有的 operation 参数改为锁的类型与 LOCK_NB 常量进行按位或操作即可,例如:

int ret = flock(open_fd, LOCK_SH | LOCK_NB);
int ret = flock(open_fd, LOCK_EX | LOCK_NB);

在非阻塞模式下,加文件锁失败并不影响进程流程的执行,但要注意加入错误处理逻辑,在加锁失败时,不能对目标文件进行操作。

flock 实现细节

在flock 的 man page 中有关于 flock 细节的一些描述。其中说明了flock 是与打开文件的文件表项相关联的。根据《Unix 环境高级编程》对打开文件的介绍,打开的文件在进程表和操作系统中的对应的结构如下图所示:

被遗忘的桃源——flock 文件锁

每个进程在进程表中都一个对应的项目,叫做进程表项,上图是最左边展示了进程表中两进程表项,分别对应两个独立的进程。在进程表项中,有一个文件描述符表,其中存储了所有本进程打开的文件描述符信息及指向对应文件表项的指针。而操作系统中,还另外有一张表,叫做文件表,其中存储的是系统中所有进程打开的文件的相关信息,其中的项目叫做文件表项(上图中间蓝色部分)。

在进程表项的文件描述符表中每个描述符都对应一个文件表项指针,指向文件表中的一项。v 节点表中的项目称为 v 节点表项,可以认为其中存储了真正的文件内容。

从图中可以看出,进程1对同一个文件打开了两次,分别对应本进程中的文件描述符 fd0 和 fd2。而下面的进程对这个文件又打开了一次,对应此进程中的 fd1描述符。要注意的是,不论是同一进程还是不同的进程,对同一文件打开时,都建立了与各fd 对应的独立的文件表项。

在flock 的man page 中关于文件表项有如下描述:

Locks created by flock() are associated with an open file table entry.

这说明进程使用 flock 对已打开文件加文件锁时,是加在了上图中间蓝色部分的文件表项上。假如图中位于下方的进程对fd1 加上了排他锁,实际就是加在了fd1 指向的文件表项上,这时上方的进程对 fd0 或 fd2 再加任何类型的文件锁都会失败。这是因为操作系统会检测到上方的两个文件表项和下方的文件表项都指向了相同的 v 节点,并且下方的文件表项已经加了排他锁,这时如果其他指向相同v 节点的文件表项再想尝试加上与原有锁类型不相容的文件锁时,操作系统会将此文件表项对应的进程阻塞。

调用dup 、 fork、execve 时的文件锁

如果要了解用 dup 复制文件描述符时和使用 fork 产生子进程时的文件锁表现,就要了解在调用这两个函数时,描述符对应的数据结构发生了哪些变化。

使用 dup 复制文件描述符

用 dup 复制文件描述符时,新的文件描述符和旧的文件描述符共享同一个文件表表项,示意图如下:
被遗忘的桃源——flock 文件锁

调用 dup 后,两个描述符指向了相同的文件表项,而flock 的文件锁是加在了文件表项上,因而如果对 fd0 加锁那么 fd1 就会自动持有同一把锁,释放锁时,可以使用这两个描述符中的任意一个。

通过 fork 产生子进程

通过fork 产生子进程时,子进程完全复制了父进程的数据段和堆栈段,父进程已经打开的文件描述符也会被复制,但是文件表项所在的文件表是由操作系统统一维护的,并不会由于子进程的产生而发生变化,因而如果父进程打开了一个文件,假设对应 fd1,那么在调用 fork 后,两个进程对应的打开文件的数据结构如下:

被遗忘的桃源——flock 文件锁

这时父子进程中各有一个打开的fd,但是它们指向了同一个文件表项。如果在父进程中已经对 fd0 加了文件锁,由于文件锁作用于父子进程共享的文件表项上,这时相当于子进程的fd0 自动拥有了文件锁,父进程中的 fd0 和子进程中的fd0 持有的是同一个锁,因而在释放锁时,可以使用父进程或子进程中的任意一个fd。

子进程重复加锁

上面已经说明,子进程会复制父进程已经打开的文件描述符,并且子进程和父进程中的文件描述符会指向文件表中的同一个文件表项。考虑如下情形:在fork 之前,父进程已经对某一文件描述符加了文件锁,fork 产生子进程后,如果子进程对复制的文件描述符再次用 flock 加锁,并且锁的类型与父进程加的并不相同,这里会发生什么?

答案就是子进程中新加的锁会生效,所有指向同一文件表项的fd 持有的锁都会变为子进程中新加的锁。可以认为,子进程新加的锁起到了修改锁类型的作用。

execve 函数族中的文件锁

在fork 产生子进程后,一般会调用 execve 函数族在子进程中执行新的程序。如果在调用 execve 之前,子进程中某些打开的文件描述符已经持有了文件锁,那么在执行execve 时,如果没有设置 close-on-exec 标志,那么在新的程序中,原本打开的文件描述符依然会保持打开,原本持有的文件锁还会继续持有。

文件锁的解除

用 LOCK_UN 解锁

文件锁的解除可以通过将 flock 的 operation 参数设置为 LOCK_UN 常量来实现。这时如果有多个fd 指向同一文件表项,例如给 fd0 加文件锁后,用 dup 复制了fd0 的情况下,用 LOCK_UN 对fd0 解锁后,所有和 fd0 指向同一文件表项的 fd 都不再持有文件锁。fork 子进程复制父进程文件描述符的情形也是如此。

关闭文件时自动解解锁

对描述符fd加了文件锁后,如果没有显式使用LOCK_UN 解锁,在关闭 fd 时,会自动解除其持有的文件锁。但是在为 fd 加锁后如果调用 了dup 复制了文件描述符,这时关闭fd 时的表现和调用 LOCK_UN 是不一样的。

如果未显式使用 LOCK_UN 解锁,在关闭文件描述符后,如果还有其他的fd 指向同一文件表项,比如之前调用了dup 的情形,这时加在文件表项上的文件锁并不会解除,其他指向此文件表项的文件描述符依然持有锁,并且锁的类型也不会发生变化。

使用fork 产生子进程时同样如此。父进程和子进程的描述符指向同一文件表项且已经加了文件锁时,如果用 LOCK_UN 将其中一个fd 解锁,那么指向同一表项的所有其他fd 都会自动解锁。但是如果未使用 LOCK_UN 解锁,只是通过 close(fd) 关闭了某个文件描述符,那么指向同一文件表项的其他描述符,依然会持有原有的锁。

出于方便考虑,在没有出现多个fd 指向现一文件表项的情况下,可以直接使用close(fd) 的默认解锁功能,而不用显式的使用LOCK_UN。在有多个 fd 指向同一文件表项的情形下,如果要完全解锁,一定要使用 LOCK_UN 解锁,不能再使用 close(fd) 的默认解锁功能。

用 fork 验证以上结论的代码如下,读者可以将下面程序稍作改动,并与本文后面的python 程序配合使用,观察交替加锁的情形:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>

int fd = 0;
int main() {

  int i;
  char path[]="./file.txt";
  extern int errno;
  fd=open(path,O_WRONLY|O_CREAT);
  if(fd!=-1) {
    printf("open file %s. fd:%d.\n",path, fd);
    printf("please input a number to lock the file.\n");
    scanf("%d",&i);
    printf("try lock file:%s...\n", path);

    if(flock(fd,LOCK_EX)==0) {
      printf("the file was locked.\n");
    } else { 
      printf("the file was not locked.\n");
    }

    int pid;
    if ((pid = fork()) < 0) {
      printf("fork failed\n");
      exit(0);
    } else if (pid == 0) { // child
      sleep(5);

      //if(flock(fd,LOCK_SH)==0) {
      // printf("child add ex success\n");
      //} else { 
      // printf("child add ex failed\n");
      //}

      if(flock(fd,LOCK_UN)==0) {
        printf("child unlock success.\n");
      } else {
        printf("child unlock fail.\n");
      }
      sleep(5);
    } else { // parent
      int pid2=wait(NULL);  
      printf("end\n");
    }

  } else {
    printf("cannot open file %s/n",path);
    printf("errno:%d\n",errno);
    printf("errMsg:%s\n",strerror(errno));
  }

  return 0;
}

用 dup 验证以上结论的代码如下,也可以与 python 示例配合使用,观察交替加锁现象:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>

int main() {
  int test_close = 1;
  int fd,i;
  char path[]="/weiboad/work/py_work/file.txt";
  extern int errno;
  fd = open(path,O_WRONLY|O_CREAT);
  if(fd!=-1) {
    printf("open file %s .\n",path);
    printf("please input a number to lock the file.\n");
    scanf("%d",&i);
    printf("try lock file:%s...\n", path);

    if(flock(fd,LOCK_EX)==0) {
      printf("the file was locked.\n");
    } else { 
      printf("the file was not locked.\n");
    }

    int fd1 = dup(fd);
    printf("fd:%d has dup, new fd:%d\n", fd, fd1);
    sleep(5);
    if (!test_close) {
      if(flock(fd,LOCK_UN)==0) {
        printf("unlock success\n");
      } else {
        printf("unlock fail\n");
      }
    } else {
      close(fd);
      printf("fd:%d has closed\n", fd);
    }

    sleep(5);
  } else {
    printf("cannot open file %s/n",path);
    printf("errno:%d\n",errno);
    printf("errMsg:%s\n",strerror(errno));
  }

  printf("end\n");
  return 0;
}

常规使用示例

从文件读写示意图中可以看出,对同一文件的读写即使是完全不相关的两个进程,也可以使用flock 进行文件的读写保护。因而可以认为 flock 是操作系统级别的锁。为了验证这一观点,可以分别用不同的语言对同一文件进行了锁定和释放测试。测试表明,用 flock 对同一文件进行锁定,确实是在操作系统层面上全局生效的。下面分别是C 语言、python、php 对同一文件的锁定测试代码,可以在同一系统上同时运行其中的任意两个来验证文件锁对文件读写的全局保护特性。

C 语言中通过用户两次输入任意整数来控制对文件的加锁和解锁,具体测试代码如下:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>

int main() {
  int fd,i;
  char path[]="./file.txt";
  extern int errno;
  fd=open(path,O_WRONLY|O_CREAT);
  if(fd!=-1) {
    printf("open file %s .\n",path);
    printf("please input a number to lock the file.\n");
    scanf("%d",&i);
    printf("try lock file:%s...\n", path);

    if(flock(fd,LOCK_EX)==0) {
      printf("the file was locked.\n");
    } else { 
      printf("the file was not locked.\n");
    }
    printf("please input a number to unlock the file.\n");
    scanf("%d",&i);
    if(flock(fd,LOCK_UN)==0) {
      printf("the file was unlocked.\n");
    } else {
      printf("the file was not unlocked.\n");
    }
    close(fd);
  } else {
    printf("cannot open file %s/n",path);
    printf("errno:%d\n",errno);
    printf("errMsg:%s\n",strerror(errno));
  }

  return 0;

python 中对文件的不断加锁和解锁,当运行其他进程锁定文件后,可以看到python 的输出停止了, 说明文件锁被阻塞,具体测试代码如下:

import fcntl 
import os, time 

FILE = "./file.txt" 
if not os.path.exists(FILE): # create the counter file if it doesn't exist 
  file = open(FILE, "w") 
  file.write("0") 
  file.close() 

for i in range(20): 
  file = open(FILE, "r+")
  print("\ntry acquire lock...")
  fcntl.flock(file.fileno(), fcntl.LOCK_EX)
  print 'acquire lock success' 

  counter = int(file.readline()) + 1 

  file.seek(0) 
  file.write(str(counter)) 
  print os.getpid(), "=>", counter 

  time.sleep(2) 
  file.close() # unlocks the file 

  print 'release lock' 

php 的文件锁测试代码如下:

<?php
$file_path = "./file.txt";
$fp = fopen($file_path, "r+");

printf("try lock file:$file_path\n");
if (flock($fp, LOCK_EX)) {
  printf("acquire lock success\n");
  sleep(10);

  flock($fp, LOCK_UN);    // 释放锁定
  printf("release lock\n");

} else {
  echo "Couldn't get the lock!";
}
fclose($fp);
?>

flock 命令

除了多种语言提供 flock 系统调用或函数,linux shell 中也提供了 flock 命令。

flock 命令最大的用途就是实现对 crontab 任务的串行化。在 crontab 任务中,有可能出现某个任务的执行时间超过了 crontab 中为此任务设定的执行周期,这就导致了当前的任务实例还未执行完成,crontab 又启动了同一任务的另外一个实例,这通常不是用户所期望的行为。极端情况下,如果某个任务执行异常一直未返回,crontab 不会处理这种情形,会继续启动新的实例,而新的实例很可能又会异常,这样就导致 crontab 对同一任务不断的启动新的实例,最终导致系统内存被耗尽,影响到整个操作系统的运行。为了防止crontab 任务出现多实例的情况,可以使用 flock 命令将crontab 中任务的周期性执行串行化。

在将corntab 中任务串行化时,flock 通过对一个中间文件加文件锁来间接实现同一时刻某个任务只有一个实例运行的目标。对应的 crontab 中任务的描述形式如下:

* * * * * flock -xn /tmp/mytest.lock -c 'php /home/fdipzone/php/test.php'

这里的定时任务是每分钟执行一次,但是任务中并未直接执行目标命令 ‘php /home/fdipzone/php/test.php’ ,而是将命令作为 flock 的 -c 选项的参数。flock 命令中,-x 表示对文件加上排他锁,-n 表示文件使用非阻塞模式,-c 选项指明加锁成功后要执行的命令。因而上面flock 命令的整体含义就是:如果对 /tmp/mytest.lock 文件(如果文件不存在, flock 命令会自动创建)加锁成功就执行后面的命令,否则不执行。

假如上面 php 命令要执行2分钟,而crontab 任务每分钟就会执行一次,如果当前 php 命令正在执行,说明 flock 已经锁定了文件 /tmp/mytest.lock,crontab 到了再次执行任务的时间时,会发现文件已经被加了锁。由于设置的是非阻塞模式的文件锁,flock 会在加锁失败时直接返回,并不执行php 命令,这样就使 php 命令得以顺序执行,crontab 任务就不会出现同时有两个实例运行的情况了,达到了串行化目的。

结语

flock 在多进程共享文件时非常有用,避免了用各种“奇技淫巧”来模拟多线程中的读写锁,这样模拟出来的锁通常会有缺陷。在多进程共享文件时,强烈建议使用flock 文件件锁。一言以蔽之:简单、易用、高效。

初次使用 flock,如履薄冰,直到把 man page 的每句话都分析一遍并用多种语言尝试之后,豁然开朗,虽然可能依然有理解不到位的地方,但对于文件锁的使用,已经基本熟悉。

每次对技术细节的探索都会有新的收获,记得一位前同事对我说过:”每次接触未知的东西,都是你成长最快的时候“。今日感觉,古人诚不我欺!

我就是我,疾驰中的企鹅。
我就是我,不一样的焰火。

被遗忘的桃源——flock 文件锁