Linux文件锁实现之flock(2)与fcntl(2)

服务器

浏览数:57

2020-6-8


1. 基本概念

github项目地址:https://github.com/superwujc

尊重原创,欢迎转载,注明出处:https://my.oschina.net/superwjc/blog/1809753

Linux文件锁用于同步多个进程对同一文件执行的IO操作,防止出现竞争状态。
文件锁分为建议性锁与强制性锁:

  • 建议性锁用于协同多进程,即多个已知进程间的同步;每个进程都按照加锁,读写文件,解锁的步骤对同一文件执行IO操作;若文件已被其他进程锁定,则当前进程将等待或以失败返回;建议性锁并不能阻止其他进程在文件已加锁的情况下,不获得锁而强制执行与锁的类型相冲突的IO操作。
  • 强制性锁除可用于协同多进程外,还可用于保护文件内容,以防止其他进程强制读写已被当前进程加锁的文件。

2. 实现详解

Linux用户空间文件锁主要通过flock(2)或fcntl(2)系统调用实现:

2.1 – flock(2) 

#include <sys/file.h>

int flock(int fd, int operation);

fd指定用于引用文件的文件描述符

operation指定对该文件执行的相关锁操作

  • LOCK_SH:设置共享(读)锁
  • LOCK_EX:设置独占(写)锁
  • LOCK_UN:解锁
  • 默认情况下,若其他进程已对fd指定的文件加锁,则当前进程对该文件加锁时将被阻塞,直到对该文件加锁的进程执行解锁;若LOCK_SH或LOCK_EX与该标志进行按位或操作,则当前进程立即以失败返回,并将errno设置为EWOULDBLOCK/EAGAIN

进程对未加锁的文件执行解锁操作,或对已解锁的文件再次执行解锁操作,都不会产生错误。

对于同一文件,多个进程都可以设置共享锁,但在任一时间点,仅单一进程可以对该文件设置独占锁,且其他进程无法对该文件设置共享锁与独占锁,否则将以EWOULDBLOCK/EAGAIN错误失败,即同一文件的独占锁排斥所有其他类型的锁。

2.2 – fcntl(2)

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

int fcntl(int fd, int cmd, struct flock *flockstr);

fd指定用于引用文件的文件描述符

flockstr指定锁的属性

struct flock {
    short l_type;
    short l_whence;
    off_t l_start;
    off_t l_len;
    pid_t l_pid;
};

l_type指定锁的类型,可以设置为F_RDLCK/F_WRLCK/F_UNLCK,含义分别与flock(2)的LOCK_SH/LOCK_EX/LOCK_UN一致,且加锁的规则与flock(2)相同,即共享锁数量任意,独占锁单一且排他。

l_whence,l_start,l_len共同设置锁定区域:
l_whence与lseek(2)的whence参数含义相同,可以设置为SEEK_SET/SEEK_CUR/SEEK_END分别表示文件起始/文件当前偏移/文件末尾
l_start指定相对于l_whence的起始字节偏移数;l_whence为SEEK_CUR或SEEK_END时,l_start可以指定为负值
l_len指定从l_start与l_whence计算得出的偏移值开始,锁定区域的字节长度

  • 值为0,表示从l_start与l_whence计算得出的偏移值开始,至文件末尾,而不论文件长度的变化
  • 值为正数,表示[l_start, l_start+l_len-1]
  • 值为负数,表示[l_start+l_len, l_start-1]

锁定的区域可以超过文件末尾,但l_start与l_len为负数时,与l_whence计算得出的偏移值不能超过文件起始位置,即字节0。

cmd指定对文件区域设置锁的方式

  • F_SETLK:加锁(F_RDLCK/F_WRLCK)或解锁(F_UNLCK);若该操作与其他进程对该文件区域的锁相冲突,则返回-1,并将errno设置为EACCES或EAGAIN。
  • F_SETLKW:与F_SETLK相同,但与其他进程对该文件区域的锁相冲突时将阻塞,等待解锁;等待过程中若被信号中断,则返回-1,并将errno设置为EINTR。
  • F_GETLK:检查是否可对文件指定区域加锁,但并不实际执行锁定操作,此时l_type值必须为F_RDLCK或F_WRLCK;若当前进程可以对文件内的指定区域加锁,则通过l_type返回F_UNLCK;若与其他进程的锁相冲突,则分别通过l_type返回锁的类型,l_whence,l_start,l_len返回锁定区域,l_pid返回锁定该文件区域的进程PID。

对文件区域解锁将立即返回,对并未加锁的区域解锁不会产生错误。

由于独占锁排斥所有其他类型的锁,因此若某进程已对某文件设置了共享锁,而其他进程请求对该文件设置独占锁时,将出现锁饥饿而可能被无限阻塞。

共享锁与独占锁之间没有优先级关系,对于多个对同一文件请求设置锁的进程,内核将按进程调度的顺序而非请求锁的顺序处理。

2.3 – flock(2)与fcntl(2)设置文件锁的语义区别

1. flock(2)仅可对整个文件加锁;fcntl(2)可对从单一字节到整个文件范围内的任意区域加锁。

2. 通过flock(2)设置文件锁不受文件的打开访问模式标志影响;通过fcntl(2)设置文件锁时,锁的类型必须与文件的打开访问模式标志一致,即F_RDLCK/F_WRLCK分别对应于O_RDONLY/O_WRONLY;先后设置读写锁时,访问模式标志应为O_RDWR。

3. 同一进程可以通过再次调用flock(2)或fcntl(2)的方式,对同一文件的共享锁与独占锁之间进行相互转换;内核保证fcntl(2)的原子性,但不保证flock(2)的原子性。

4. 通过flock(2)设置的文件锁与系统文件表项相关联,而非进程的文件描述符或文件(inode)自身;通过fcntl(2)设置的文件锁与进程的文件描述符表项与系统的inode表项相关联,而非系统文件表项。

对同一文件执行多次open(2)将产生多个不同的文件描述符,每个文件描述符各自指向一个独立的文件表项,但这些文件表项都指向相同的inode表项;对文件描述符调用dup(2)/dup2(2)/fcntl(2)等执行复制操作,或对进程调用fork(2),将产生多个不同的文件描述符,但这些文件描述符都指向相同的文件表项。

  • 对于flock(2),调用dup(2)等复制的文件描述符,以及fork(2)继承的文件描述符,以及未设置close-on-exec标志位的文件描述符,都与原fd引用相同的文件表项;仅当所有引用该文件表项的文件描述符都关闭后,相应的文件锁才会自动释放。
  • 对于fcntl(2),由于文件描述符表是每进程属性,因此通过fork(2)创建的子进程与其父进程各自具有独立的文件描述符表,文件锁不会被继承;关闭dup(2)等调用复制的文件描述符,以及设置了close-on-exec标志而执行exec(3)的情况,都将导致与文件描述符相关联的文件锁被释放。
  • 同一进程对同一文件多次调用open(2)获得引用该文件的多个文件描述符时,flock(2)将区分对待这些文件描述符,而fcntl(2)将视为同一文件(inode);由于这些文件描述符引用不同的文件表项,对不同的文件描述符先后多次调用flock(2)可能导致进程锁定自身对文件的IO操作,最终将阻塞或以失败返回;fcntl(2)不会出现该情况。

5. 进程的加锁操作即将导致多个进程死锁的情况出现时,内核将对fcntl(2)执行检测,选择一个进程使其以EDEADLK错误返回;flock(2)不会被执行检测。

6. flock(2)仅支持建议性锁,而fcntl(2)同时支持建议性锁与强制性锁。

2.4 – 强制性锁

2.4.1 – 启用方式

对于设置了强制性锁的文件,内核将在进程尝试对文件执行IO时,检查该文件是否已被其他进程设置了与IO请求类型相冲突的锁。
启用强制性锁需要文件系统属性与文件自身属性的支持:

  • 文件系统需要开启mount(2)的MS_MANDLOCK挂载标志
  • 文件需要开启set-group-ID(S_ISGID/02000)标志位,并关闭组的可执行/搜索标志位(S_IXGRP/00010)

2.4.2 – 问题与限制

  • 对文件设置强制性共享锁并不能阻止其他读取该文件
  • 对文件设置强制性锁并不能阻止其他进程删除该文件
  • 非特权进程可以对文件设置强制性锁,可能导致其他访问该文件区域的进程拒绝服务
  • 对于设置了强制性锁的文件,内核对每一个尝试对该文件区域执行IO操作的系统调用都将执行检查;系统中包含大量强制性锁时,将额外消耗较多的系统资源
  • 使用强制性锁可能导致内核竞争条件,受影响的系统调用包括read(2)/write(2)/readv(2)/writev(2)/open(2)/creat(2)/mmap(2)/truncate(2)/ftruncate(2)等

2.5 – stdio库函数与文件锁

包含用户空间缓冲区的stdio库函数与flock(2)/fcntl(2)设置的文件锁共同使用时,可能出现文件加锁前输入缓冲区已被填满而仍可写入,或文件解锁后输出缓冲区已被清空而无法读取的情况,可以通过以下方式避免该问题:

  • 使用read(2)/write(2)等IO系统调用替代stdio库函数
  • 加锁前对文件流执行fflush(3),解锁后再次对文件流执行fflush(3)
  • 调用setbuf(3)等禁用stdio缓冲区

2.6 – 查看文件锁

Linux下可以通过/proc/locks文件与lslocks(8)命令查看系统中的文件锁;/proc/locks文件的每一列表示的含义分别为:
1. 文件锁的序号
2. 加锁的方式,POSIX表示fcntl(2),FLOCK表示flock(2)
3. 锁的模式,ADVISORY表示建议性锁,MANDATORY表示强制性锁
4. 锁的类型,READ表示共享锁,WRITE表示独占锁
5. 加锁进程的PID
6. 由冒号分隔的加锁文件所在的文件系统标识,主设备号:次设备号:锁文件inode号
7. 锁定区域的起始字节偏移,对于flock(2),该字段总为0
8. 锁定区域的末尾字节偏移,对于flock(2),该字段总为EOF

2.7 – 文件锁的其他设置方式(不推荐)

1. open(file, O_CREAT | O_EXCL, …)与unlink(file)

  通过指定O_CREAT | O_EXCL标志而原子性创建锁文件的方式加锁,通过删除文件的方式解锁。

  若open(2)调用以EEXIST失败,则表示其他进程已独占性创建并打开该文件;该方式具有以下限制:

  • 若open(2)调用失败,则需继续调用以获得文件锁;可以通过fcntl(2)的F_SETLKW操作解决该问题
  • 若当前进程在删除文件之前异常终止,则其他进程对已存在的锁文件调用open(2)将失败而无法获得锁
  • 该加锁方式需要文件系统操作,速度慢于fcntl(2)提供的记录锁
  • 无死锁检测,若通过不同的锁文件加锁,则可能出现无限死锁的情况

2. link(file, lockfile)与unlink(lockfile) 

  通过为现有文件创建硬链接的方式加锁,通过删除硬链接的方式解锁;该方式的限制为:创建硬链接需要原路径名与链接路径名位于同一文件系统,且每个进程在创建的锁文件名称需要通过某种协定保持唯一性,且link(2)调用失败时,需继续调用以获得文件锁。

3. open(file, O_CREAT | O_TRUNC | O_WRONLY, 0)与unlink(file)

  对已存在的文件调用open(2),若指定了O_TRUNC标志且当前进程对文件没有写入权限,则调用将失败。

  该方式的限制为,若调用失败而无法加锁,则需继续调用,且特权用户不受文件访问权限的影响。

2.8 – 杂项

glibc基于fcntl(2)实现了lockf(3)函数:

#include <unistd.h>

int lockf(int fd, int cmd, off_t len);

该函数的加锁类型仅支持独占锁,且分别设置默认值l_whence为SEEK_CUR,以及l_start为0,len参数一致。

util-linux工具包基于flock(2)实现了flock(1)工具。

flock(2)与fcntl(2)对文件锁的内部实现在Linux下并无交互,对同一文件混合使用这两种方式可能出现未定义的行为。

3 – 示例程序:单实例进程

操作系统与内核版本

# lsb_release -a
LSB Version:	:core-4.1-amd64:core-4.1-noarch:cxx-4.1-amd64:cxx-4.1-noarch:desktop-4.1-amd64:desktop-4.1-noarch:languages-4.1-amd64:languages-4.1-noarch:printing-4.1-amd64:printing-4.1-noarch
Distributor ID:	CentOS
Description:	CentOS Linux release 7.4.1708 (Core)
Release:	7.4.1708
Codename:	Core
# uname -r
3.10.0-693.21.1.el7.x86_64

gcc与glibc版本

# gcc -v
Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/lto-wrapper
Target: x86_64-redhat-linux
Configured with: ../configure --prefix=/usr --mandir=/usr/share/man --infodir=/usr/share/info --with-bugurl=http://bugzilla.redhat.com/bugzilla --enable-bootstrap --enable-shared --enable-threads=posix --enable-checking=release --with-system-zlib --enable-__cxa_atexit --disable-libunwind-exceptions --enable-gnu-unique-object --enable-linker-build-id --with-linker-hash-style=gnu --enable-languages=c,c++,objc,obj-c++,java,fortran,ada,go,lto --enable-plugin --enable-initfini-array --disable-libgcj --with-isl=/builddir/build/BUILD/gcc-4.8.5-20150702/obj-x86_64-redhat-linux/isl-install --with-cloog=/builddir/build/BUILD/gcc-4.8.5-20150702/obj-x86_64-redhat-linux/cloog-install --enable-gnu-indirect-function --with-tune=generic --with-arch_32=x86-64 --build=x86_64-redhat-linux
Thread model: posix
gcc version 4.8.5 20150623 (Red Hat 4.8.5-16) (GCC)
# ldd /bin/ls
	linux-vdso.so.1 =>  (0x00007ffd230a3000)
	libselinux.so.1 => /lib64/libselinux.so.1 (0x00007ff394398000)
	libcap.so.2 => /lib64/libcap.so.2 (0x00007ff394193000)
	libacl.so.1 => /lib64/libacl.so.1 (0x00007ff393f89000)
	libc.so.6 => /lib64/libc.so.6 (0x00007ff393bc6000)
	libpcre.so.1 => /lib64/libpcre.so.1 (0x00007ff393964000)
	libdl.so.2 => /lib64/libdl.so.2 (0x00007ff39375f000)
	/lib64/ld-linux-x86-64.so.2 (0x00005591acf14000)
	libattr.so.1 => /lib64/libattr.so.1 (0x00007ff39355a000)
	libpthread.so.0 => /lib64/libpthread.so.0 (0x00007ff39333e000)
# /lib64/libc.so.6
GNU C Library (GNU libc) stable release version 2.17, by Roland McGrath et al.
Copyright (C) 2012 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 4.8.5 20150623 (Red Hat 4.8.5-16).
Compiled on a Linux 3.10.0 system on 2017-11-30.
Available extensions:
	The C stubs add-on version 2.1.2.
	crypt add-on version 2.1 by Michael Glad and others
	GNU Libidn by Simon Josefsson
	Native POSIX Threads Library by Ulrich Drepper et al
	BIND-8.2.3-T5B
	RT using linux kernel aio
libc ABIs: UNIQUE IFUNC
For bug reporting instructions, please see:
<http://www.gnu.org/software/libc/bugs.html>.

程序代码:single_instance.c

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

#define BUF_SIZE 16
#define lockfile "/var/run/daemon.pid"
#define ERR_EXIT(m) do { perror(m); exit(EXIT_FAILURE); } while (0)

static void addlock(void);

int main(void)
{
	setbuf(stdout, NULL);
	addlock();
	printf("OK\n");

	for ( ; ; )
		sleep(1);
}

static void addlock(void)
{
	int lockfd, flags;
	struct flock fl_w;
	char buf[BUF_SIZE];

	lockfd = open(lockfile, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
	if (lockfd == -1)
		ERR_EXIT("open() failed");

	flags = fcntl(lockfd, F_GETFD);
	if (flags == -1)
		ERR_EXIT("fcntl() to get flags failed");

	flags |= FD_CLOEXEC;
	if (fcntl(lockfd, F_SETFD, flags) == -1)
		ERR_EXIT("fcntl() to set flags failed");

	fl_w.l_type = F_WRLCK;
	fl_w.l_whence = SEEK_SET;
	fl_w.l_start = 0;
	fl_w.l_len = 0;

	if (fcntl(lockfd, F_SETLK, &fl_w) == -1)
		ERR_EXIT("fcntl() to set lock failed");
	
	if (ftruncate(lockfd, 0) == -1)
		ERR_EXIT("ftruncate() failed");
	
	snprintf(buf, BUF_SIZE, "%ld\n", (long)getpid());
	if (write(lockfd, buf, strlen(buf)) != strlen(buf))
		ERR_EXIT("write() failed");
	
	return ;
}

守护进程通常仅允许运行程序的单个实例,加锁文件通常位于/var/run/目录下,且加锁文件中包含锁定文件的进程PID。

对于通过执行exec(3)重启自身的进程,需要设置引用加锁文件的文件描述符的close-on-exec标志位,以防止文件描述符未随exec(3)关闭导致文件锁未被释放而无法启动程序。

编译程序并运行

# gcc single_instance.c -o single_instance
# ./single_instance &
[2] 3953
# OK
# cat /var/run/daemon.pid
3953
# ./single_instance
fcntl() to set lock failed: Resource temporarily unavailable

4. 参考

flock(1)

flock(2)

fcntl(2)

flockfile(3)

lslocks(8)

《UNIX环境高级编程》13.5 14.3

《The Linux Programming Interface》Chapter 55

https://lwn.net/Articles/667210/
Documentation/filesystems/mandatory-locking.txt

作者:摘下-满天星